Skip to content

Commit

Permalink
Feat: Allow users to manage emails (#2613)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mikehrn authored Aug 9, 2024
1 parent e9f7286 commit 3ac6741
Show file tree
Hide file tree
Showing 11 changed files with 551 additions and 1 deletion.
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
92 changes: 92 additions & 0 deletions packages/frontend-2/components/settings/user/Emails.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<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 { graphql } from '~~/lib/common/generated/gql'
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'
graphql(`
fragment SettingsUserEmails_User on User {
id
emails {
...SettingsUserEmailCards_UserEmail
}
}
`)
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

0 comments on commit 3ac6741

Please sign in to comment.