Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Allow users to manage emails #2613

Merged
merged 15 commits into from
Aug 9, 2024
5 changes: 5 additions & 0 deletions packages/frontend-2/components/settings/Dialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import SettingsServerProjects from '~/components/settings/server/Projects.vue'
import SettingsServerActiveUsers from '~/components/settings/server/ActiveUsers.vue'
import SettingsServerPendingInvitations from '~/components/settings/server/PendingInvitations.vue'
import SettingsWorkspacesMembers from '~/components/settings/workspaces/Members.vue'
import SettingsUserEmails from '~/components/settings/user/Emails.vue'
import { useBreakpoints } from '@vueuse/core'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { UserIcon, ServerStackIcon } from '@heroicons/vue/24/outline'
Expand Down Expand Up @@ -154,6 +155,10 @@ const menuItemConfig = shallowRef<{ [key: string]: { [key: string]: MenuItem } }
[settingsQueries.user.developerSettings]: {
title: 'Developer settings',
component: SettingsUserDeveloper
},
[settingsQueries.user.emails]: {
title: 'Email addresses',
component: SettingsUserEmails
}
},
server: {
Expand Down
82 changes: 82 additions & 0 deletions packages/frontend-2/components/settings/user/Emails.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<template>
<section>
<div class="md:max-w-xl md:mx-auto">
<SettingsSectionHeader
title="Emails addresses"
text="Manage your email addresses"
/>
<SettingsSectionHeader title="Your emails" subheading />
<SettingsUserEmailList class="pt-6" :email-data="emailItems" />
<hr class="my-6 md:my-10" />
<SettingsSectionHeader title="Add new email" subheading />
<div class="flex flex-col md:flex-row w-full pt-4 md:pt-6 pb-6">
<div class="flex flex-col md:flex-row gap-x-2 w-full">
<FormTextInput
v-model="email"
color="foundation"
label-position="left"
label="Email address"
name="email"
:rules="[isEmail, isRequired]"
placeholder="Email address"
show-label
wrapper-classes="flex-1 py-3 md:py-0 w-full"
/>
<FormButton @click="onAddEmailSubmit">Add</FormButton>
</div>
</div>
</div>
</section>
</template>

<script setup lang="ts">
import { orderBy } from 'lodash-es'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { isEmail, isRequired } from '~~/lib/common/helpers/validation'
import { useForm } from 'vee-validate'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { settingsUserEmailsQuery } from '~/lib/settings/graphql/queries'
import { settingsCreateUserEmailMutation } from '~/lib/settings/graphql/mutations'
import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'

type FormValues = { email: string }

const { handleSubmit } = useForm<FormValues>()
const { triggerNotification } = useGlobalToast()
const { result: userEmailsResult } = useQuery(settingsUserEmailsQuery)
const { mutate: createMutation } = useMutation(settingsCreateUserEmailMutation)

const email = ref('')

// Mak sure primary email is always on top, followed by verified emails
const emailItems = computed(() =>
userEmailsResult.value?.activeUser?.emails
? orderBy(
userEmailsResult.value?.activeUser.emails,
['primary', 'verified'],
['desc', 'desc']
)
: []
)

const onAddEmailSubmit = handleSubmit(async () => {
const result = await createMutation({ input: { email: email.value } }).catch(
convertThrowIntoFetchResult
)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: `${email.value} added`
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: errorMessage
})
}
})
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<template>
<LayoutDialog
v-model:open="isOpen"
title="Delete email address"
max-width="sm"
:buttons="dialogButtons"
>
<p class="text-body-xs text-foreground">
Are you sure you want to delete
<span class="font-medium">{{ email }}</span>
from your account?
</p>
</LayoutDialog>
</template>

<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { settingsDeleteUserEmailMutation } from '~/lib/settings/graphql/mutations'
import { useMutation } from '@vue/apollo-composable'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'
const props = defineProps<{
emailId: string
email: string
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
const { triggerNotification } = useGlobalToast()
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline', fullWidth: true },
onClick: () => {
isOpen.value = false
}
},
{
text: 'Delete',
props: { color: 'primary', fullWidth: true },
onClick: () => {
onDeleteEmail()
}
}
])
const onDeleteEmail = async () => {
const result = await deleteMutation({ input: { id: props.emailId } }).catch(
convertThrowIntoFetchResult
)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: `${props.email} deleted`
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: errorMessage
})
}
isOpen.value = false
}
</script>
17 changes: 17 additions & 0 deletions packages/frontend-2/components/settings/user/email/List.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<template>
<ul class="flex flex-col">
<SettingsUserEmailListItem
v-for="email in emailData"
:key="email.id"
:email-data="email"
/>
</ul>
</template>

<script setup lang="ts">
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
defineProps<{
emailData: SettingsUserEmailCards_UserEmailFragment[]
}>()
</script>
138 changes: 138 additions & 0 deletions packages/frontend-2/components/settings/user/email/ListItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<template>
<li class="border-x border-b first:border-t first:rounded-t-lg last:rounded-b-lg p-6">
<div
v-if="emailData.primary || !emailData.verified"
class="flex w-full gap-x-2 pb-4 md:pb-3"
>
<CommonBadge
v-if="emailData.primary"
rounded
color-classes="bg-info-lighter text-outline-4"
>
Primary
</CommonBadge>
<CommonBadge
v-if="!emailData.verified"
rounded
color-classes="bg-outline-3 text-foreground-3"
>
Unverified
</CommonBadge>
<FormButton
v-if="!emailData.verified"
color="outline"
size="sm"
@click="resendVerificationEmail"
>
Resend verification email
</FormButton>
</div>
<div class="flex flex-col md:flex-row">
<div class="flex-1">
<p class="text-body-xs font-medium text-foreground md:pt-0.5">
{{ emailData.email }}
</p>
<p v-if="description" class="text-body-2xs pt-1 text-foreground-2">
{{ description }}
</p>
</div>
<div class="flex gap-x-2 pt-4 md:pt-0">
<FormButton
:disabled="!emailData.verified || emailData.primary"
color="outline"
size="sm"
@click="toggleSetPrimaryDialog"
>
Set as primary
</FormButton>
<FormButton
:disabled="emailData.primary"
color="outline"
size="sm"
@click="toggleDeleteDialog"
>
Delete
</FormButton>
</div>
</div>

<SettingsUserEmailSetPrimaryDialog
v-model:open="showSetPrimaryDialog"
:email-id="emailData.id"
:email="emailData.email"
/>

<SettingsUserEmailDeleteDialog
v-model:open="showDeleteDialog"
:email-id="emailData.id"
:email="emailData.email"
/>
</li>
</template>

<script setup lang="ts">
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import { graphql } from '~~/lib/common/generated/gql'
import { useMutation } from '@vue/apollo-composable'
import { settingsNewEmailVerificationMutation } from '~~/lib/settings/graphql/mutations'
import {
getFirstErrorMessage,
convertThrowIntoFetchResult
} from '~~/lib/common/helpers/graphql'
graphql(`
fragment SettingsUserEmailCards_UserEmail on UserEmail {
email
id
primary
verified
}
`)
const props = defineProps<{
emailData: SettingsUserEmailCards_UserEmailFragment
}>()
const { triggerNotification } = useGlobalToast()
const { mutate: resendMutation } = useMutation(settingsNewEmailVerificationMutation)
const showDeleteDialog = ref(false)
const showSetPrimaryDialog = ref(false)
const description = computed(() => {
if (props.emailData.primary) {
return 'Used for sign in and notifications'
} else if (!props.emailData.verified) {
return 'Unverified email cannot be set as primary'
}
return null
})
const toggleSetPrimaryDialog = () => {
showSetPrimaryDialog.value = true
}
const toggleDeleteDialog = () => {
showDeleteDialog.value = true
}
const resendVerificationEmail = async () => {
const result = await resendMutation({ input: { id: props.emailData.id } }).catch(
convertThrowIntoFetchResult
)
if (result?.data) {
triggerNotification({
type: ToastNotificationType.Success,
title: `Verification mail sent to ${props.emailData.email}`
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: errorMessage
})
}
}
</script>
Loading