diff --git a/.changeset/mosaic-user-profile-email-wireup.md b/.changeset/mosaic-user-profile-email-wireup.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-email-wireup.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 4d99091666f..b23b793d4c0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -15,6 +15,11 @@ interface FakeAttribute { used_for_second_factor: boolean; } +interface FakeVerification { + status: string | null; + expireAt?: Date | null; +} + let isUserLoaded: boolean; let user: { firstName: string | null; @@ -29,8 +34,13 @@ let user: { }[]; primaryEmailAddressId: string | null; primaryPhoneNumberId: string | null; - emailAddresses: { id: string; emailAddress: string; verification: { status: string | null } }[]; - phoneNumbers: { id: string; phoneNumber: string; verification: { status: string | null } }[]; + emailAddresses: { + id: string; + emailAddress: string; + verification: FakeVerification; + destroy?: ReturnType; + }[]; + phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[]; setProfileImage: ReturnType; update: ReturnType; } | null; @@ -109,8 +119,18 @@ beforeEach(() => { primaryEmailAddressId: 'email_1', primaryPhoneNumberId: null, emailAddresses: [ - { id: 'email_2', emailAddress: 'other@clerk.dev', verification: { status: null } }, - { id: 'email_1', emailAddress: 'preston@clerk.dev', verification: { status: 'verified' } }, + { + id: 'email_2', + emailAddress: 'other@clerk.dev', + verification: { status: null }, + destroy: vi.fn(() => Promise.resolve()), + }, + { + id: 'email_1', + emailAddress: 'preston@clerk.dev', + verification: { status: 'verified' }, + destroy: vi.fn(() => Promise.resolve()), + }, ], phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }], setProfileImage: vi.fn(() => Promise.resolve({})), @@ -153,6 +173,50 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('orders contacts primary first, then verified, then pending by expiry, then never started', () => { + if (!user) { + throw new Error('expected a user'); + } + user.primaryEmailAddressId = 'email_primary'; + user.primaryPhoneNumberId = 'phone_primary'; + user.emailAddresses = [ + { id: 'email_unstarted', emailAddress: 'd@clerk.dev', verification: { status: null } }, + { + id: 'email_late', + emailAddress: 'e@clerk.dev', + verification: { status: 'unverified', expireAt: new Date(2000) }, + }, + { id: 'email_verified_b', emailAddress: 'b@clerk.dev', verification: { status: 'verified' } }, + { + id: 'email_primary', + emailAddress: 'a@clerk.dev', + verification: { status: 'unverified', expireAt: new Date(3000) }, + }, + { + id: 'email_early', + emailAddress: 'f@clerk.dev', + verification: { status: 'unverified', expireAt: new Date(1000) }, + }, + { id: 'email_verified_a', emailAddress: 'c@clerk.dev', verification: { status: 'verified' } }, + ]; + user.phoneNumbers = [ + { id: 'phone_unstarted', phoneNumber: '+18018888183', verification: { status: null } }, + { id: 'phone_verified', phoneNumber: '+18018888182', verification: { status: 'verified' } }, + { id: 'phone_primary', phoneNumber: '+18018888181', verification: { status: 'verified' } }, + ]; + const model = ready(); + + expect(model.emails?.map(email => email.id)).toEqual([ + 'email_primary', + 'email_verified_a', + 'email_verified_b', + 'email_early', + 'email_late', + 'email_unstarted', + ]); + expect(model.phones?.map(phone => phone.id)).toEqual(['phone_primary', 'phone_verified', 'phone_unstarted']); + }); + it('leaves out the contacts the instance does not collect', () => { attributes.email_address = attribute({ enabled: false }); attributes.phone_number = attribute({ enabled: false }); @@ -162,6 +226,40 @@ describe('useUserProfileAccountSectionModel', () => { expect(model.phones).toBeUndefined(); }); + describe('emails', () => { + it('sets the chosen email as primary', async () => { + await ready().onSetPrimaryEmail?.('email_2'); + expect(user?.update).toHaveBeenCalledWith({ primaryEmailAddressId: 'email_2' }); + }); + + it('returns the API message when setting the primary fails', async () => { + user?.update.mockRejectedValue(apiError()); + await expect(rejection(ready().onSetPrimaryEmail?.('email_2'))).resolves.toEqual({ + global: { code: 'form_param_invalid', message: 'That value is invalid.' }, + }); + }); + + it('removes the chosen email', async () => { + await ready().onRemoveEmail?.('email_2'); + expect(user?.emailAddresses[0]?.destroy).toHaveBeenCalled(); + expect(user?.emailAddresses[1]?.destroy).not.toHaveBeenCalled(); + }); + + it('returns the API message when removing fails', async () => { + user?.emailAddresses[0]?.destroy?.mockRejectedValue(apiError()); + await expect(rejection(ready().onRemoveEmail?.('email_2'))).resolves.toEqual({ + global: { code: 'form_param_invalid', message: 'That value is invalid.' }, + }); + }); + + it('still offers a new primary but no removal when the email address is immutable', () => { + attributes.email_address = attribute({ immutable: true }); + const model = ready(); + expect(model.onSetPrimaryEmail).toBeDefined(); + expect(model.onRemoveEmail).toBeUndefined(); + }); + }); + describe('profile picture', () => { it('uploads the picked file', async () => { const file = new File(['x'], 'me.png', { type: 'image/png' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx index ba9098b25f9..87680d6dc7d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx @@ -8,8 +8,7 @@ describe('UserProfileContactListRowView', () => { it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => { const onVerify = vi.fn(); const onSetPrimary = vi.fn(); - const onRemove = vi.fn(); - const item = { id: 'contact_1', value: 'Contact', isDefault: true, isVerified: true, canRemove: false }; + const item = { id: 'contact_1', value: 'Contact', isDefault: true, isVerified: true }; render( { items={[item]} onVerify={onVerify} onSetPrimary={onSetPrimary} - onRemove={onRemove} />, ); expect(screen.queryByRole('button', { name: 'Manage Contact' })).not.toBeInTheDocument(); }); + it.each(['email', 'phone'] as const)('marks an unverified %s', kind => { + render( + , + ); + + expect(screen.getAllByText('Unverified')).toHaveLength(1); + expect(screen.getByText('Pending contact').parentElement).toHaveTextContent('Unverified'); + }); + it('offers removal when it applies', async () => { const user = userEvent.setup(); const onRemove = vi.fn(); @@ -31,7 +45,7 @@ describe('UserProfileContactListRowView', () => { , ); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx index 40335a5ee1e..8004597b13c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx @@ -14,7 +14,9 @@ describe('contact removal focus', () => { const labels = kind === 'email' ? values : ['+1 (801) 555-0100', '+1 (801) 555-0200']; function Example() { - const [items, setItems] = useState(values.map(value => ({ id: value, value }))); + const [items, setItems] = useState( + values.map(value => ({ id: value, value, isDefault: false, isVerified: true })), + ); const remove = (id: string) => setItems(current => current.filter(item => item.id !== id)); return ( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 0666505cbce..8301014f25c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -16,7 +16,7 @@ function renderEmail(overrides: Partial = {} name='Test' username='test' phones={[]} - emails={[{ id: 'email_1', value: 'test@example.com', isVerified: true }]} + emails={[{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified: true }]} {...overrides} /> , @@ -45,7 +45,9 @@ describe('email actions', () => { it('focuses Add email after removing the last email', async () => { const user = userEvent.setup(); function Example() { - const [emails, setEmails] = useState([{ id: 'email_1', value: 'test@example.com', isVerified: true }]); + const [emails, setEmails] = useState([ + { id: 'email_1', value: 'test@example.com', isDefault: false, isVerified: true }, + ]); return ( { expect(onRemoveEmail).toHaveBeenNthCalledWith(1, 'email_1'); expect(onRemoveEmail).toHaveBeenNthCalledWith(2, 'email_1'); }); + it.each([ + [true, 'You won’t be able to use it to sign in.'], + [false, undefined], + ])('warns about signing in only when removing a verified email (verified: %s)', async (isVerified, warning) => { + const user = userEvent.setup(); + renderEmail({ + emails: [{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified }], + onRemoveEmail: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); + const dialog = screen.getByRole('alertdialog'); + if (warning) { + expect(dialog).toHaveTextContent(warning); + } else { + expect(dialog).not.toHaveTextContent('sign in'); + } + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx index 84745a2cd9c..3c9c8dfb4d3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -15,7 +15,7 @@ function renderPhone(overrides: Partial = {} name='Test' username='test' emails={[]} - phones={[{ id: 'phone_1', value: '+18015550100', isVerified: true }]} + phones={[{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }]} {...overrides} /> , @@ -78,19 +78,19 @@ describe('phone actions', () => { finish(); await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument()); }); - it.each([{ isDefault: true, isVerified: true }, { isDefault: false, isVerified: false }, { isDefault: false }])( - 'hides set primary for an ineligible phone: %j', - async flags => { - const user = userEvent.setup(); - renderPhone({ - phones: [{ id: 'phone_1', value: '+18015550100', ...flags }], - onSetPrimaryPhone: vi.fn(), - onRemovePhone: vi.fn(), - }); - await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); - expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); - }, - ); + it.each([ + { isDefault: true, isVerified: true }, + { isDefault: false, isVerified: false }, + ])('hides set primary for an ineligible phone: %j', async flags => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', ...flags }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + }); it('updates the primary badge immediately without confirmation', async () => { const user = userEvent.setup(); @@ -154,7 +154,9 @@ describe('phone actions', () => { it('focuses Add phone number after removing the last phone', async () => { const user = userEvent.setup(); function Example() { - const [phones, setPhones] = useState([{ id: 'phone_1', value: '+18015550100', isVerified: true }]); + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }, + ]); return ( { it('does not offer removal when it is forbidden', async () => { const user = userEvent.setup(); renderPhone({ - phones: [{ id: 'phone_1', value: '+18015550100', isVerified: true, canRemove: false }], + phones: [{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }], onSetPrimaryPhone: vi.fn(), - onRemovePhone: vi.fn(), }); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); @@ -227,7 +228,7 @@ describe('phone actions', () => { name='Test' username='test' emails={[]} - phones={[{ id: 'phone_1', value: '+18015550100', isVerified: true }]} + phones={[{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }]} onRemovePhone={onRemovePhone} /> , @@ -241,4 +242,22 @@ describe('phone actions', () => { expect(onRemovePhone).toHaveBeenCalledExactlyOnceWith('phone_1'); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); + it.each([ + [true, 'You won’t be able to use it to sign in.'], + [false, undefined], + ])('warns about signing in only when removing a verified phone (verified: %s)', async (isVerified, warning) => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified }], + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + const dialog = screen.getByRole('alertdialog'); + if (warning) { + expect(dialog).toHaveTextContent(warning); + } else { + expect(dialog).not.toHaveTextContent('sign in'); + } + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 764892b2055..680b24ab5e0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -12,10 +12,10 @@ const props: UserProfileProfilePanelViewProps = { name: 'Preston Booth', username: 'prestonxyz', emails: [ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true }, - { id: 'email_2', value: 'item2@clerk.dev' }, + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true }, ], - phones: [{ id: 'phone_1', value: '+1 801-888-8181' }], + phones: [{ id: 'phone_1', value: '+1 801-888-8181', isDefault: false, isVerified: true }], }; function renderView(overrides: Partial = {}) { @@ -118,7 +118,7 @@ describe('UserProfileProfilePanelView', () => { it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { renderView({ allowMultipleAccounts, - phones: [{ id: 'phone_added', value: '+18015558181' }], + phones: [{ id: 'phone_added', value: '+18015558181', isDefault: false, isVerified: true }], onRemovePhone: vi.fn(), }); @@ -218,7 +218,7 @@ describe('UserProfileProfilePanelView', () => { it('breaks out both contact types when multiple accounts are allowed', () => { renderView({ - emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], onAddEmail: vi.fn(), onSendPhoneCode: () => Promise.resolve(), onVerifyPhoneCode: () => Promise.resolve(), @@ -239,7 +239,7 @@ describe('UserProfileProfilePanelView', () => { it('keeps both contact types inside Account when multiple accounts are not allowed', () => { renderView({ allowMultipleAccounts: false, - emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], onManageEmail: vi.fn(), onManagePhone: vi.fn(), }); @@ -440,12 +440,12 @@ describe('UserProfileProfilePanelView', () => { renderView({ emails: [ { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: false }, - { id: 'email_secondary', value: 'secondary@clerk.dev', isVerified: true }, - { id: 'email_unverified', value: 'unverified@clerk.dev', isVerified: false }, + { id: 'email_secondary', value: 'secondary@clerk.dev', isDefault: false, isVerified: true }, + { id: 'email_unverified', value: 'unverified@clerk.dev', isDefault: false, isVerified: false }, ], phones: [ - { id: 'phone_unverified', value: '+1 801-555-0100', isVerified: false }, - { id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true }, + { id: 'phone_unverified', value: '+1 801-555-0100', isDefault: false, isVerified: false }, + { id: 'phone_secondary', value: '+1 801-555-0101', isDefault: false, isVerified: true }, ], connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz' }], onVerifyEmail, @@ -520,14 +520,12 @@ describe('UserProfileProfilePanelView', () => { value: 'immutable@clerk.dev', isDefault: true, isVerified: true, - canRemove: false, }, ], phones: [], connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }], onVerifyEmail: vi.fn(), onSetPrimaryEmail: vi.fn(), - onRemoveEmail: vi.fn(), onRemoveConnectedAccount: vi.fn(), }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 97ef3318fbf..e030e57cf39 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -40,6 +40,7 @@ export const userProfileAccountSectionMessages = { save: 'Save changes', }, primary: 'Primary', + unverified: 'Unverified', add: 'Add', manage: 'Manage', setPrimary: 'Set as primary', @@ -56,7 +57,8 @@ export const userProfileAccountSectionMessages = { primaryError: 'Unable to set the primary email address. Try again.', removeDialog: { title: 'Remove email address?', - description: '{emailAddress} will be removed from your account. You won’t be able to use it to sign in.', + description: '{emailAddress} will be removed from your account.', + verifiedDescription: '{emailAddress} will be removed from your account. You won’t be able to use it to sign in.', confirm: 'Remove', cancel: 'Cancel', }, @@ -71,7 +73,8 @@ export const userProfileAccountSectionMessages = { primaryError: 'Unable to set the primary phone number. Try again.', removeDialog: { title: 'Remove phone number?', - description: '{phoneNumber} will be removed from your account. You won’t be able to use it to sign in.', + description: '{phoneNumber} will be removed from your account.', + verifiedDescription: '{phoneNumber} will be removed from your account. You won’t be able to use it to sign in.', confirm: 'Remove', cancel: 'Cancel', }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index b2525e2e238..35ab98d2f47 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -1,6 +1,6 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; import { useUser } from '@clerk/shared/react'; -import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; +import type { AttributeData, EmailAddressResource, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import { save } from '../../../utils/form-error'; @@ -10,7 +10,7 @@ import type { UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; -import { isAttributeAvailable } from './user-profile-account-section.utils'; +import { isAttributeAvailable, sortByVerification } from './user-profile-account-section.utils'; import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -29,6 +29,8 @@ type UserProfileAccountSectionData = Pick< | 'username' | 'emails' | 'phones' + | 'onSetPrimaryEmail' + | 'onRemoveEmail' | 'onProfilePictureChange' | 'onRemoveProfilePicture' | 'onSubmitName' @@ -43,6 +45,14 @@ export type UserProfileAccountSectionModel = const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; +function emailById(user: UserResource, id: string): EmailAddressResource { + const email = user.emailAddresses.find(email => email.id === id); + if (!email) { + throw new Error(`No email address with id ${id}`); + } + return email; +} + function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined { if (!account) { return undefined; @@ -58,12 +68,8 @@ function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameA return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; } -function primaryFirst(items: T[], primaryId: string | null): T[] { - return [...items.filter(item => item.id === primaryId), ...items.filter(item => item.id !== primaryId)]; -} - function toEmails(user: UserResource): UserProfileEmail[] { - return primaryFirst(user.emailAddresses, user.primaryEmailAddressId).map(email => ({ + return sortByVerification(user.emailAddresses, user.primaryEmailAddressId).map(email => ({ id: email.id, value: email.emailAddress, isDefault: email.id === user.primaryEmailAddressId, @@ -72,7 +78,7 @@ function toEmails(user: UserResource): UserProfileEmail[] { } function toPhones(user: UserResource): UserProfilePhone[] { - return primaryFirst(user.phoneNumbers, user.primaryPhoneNumberId).map(phone => ({ + return sortByVerification(user.phoneNumbers, user.primaryPhoneNumberId).map(phone => ({ id: phone.id, value: phone.phoneNumber, isDefault: phone.id === user.primaryPhoneNumberId, @@ -98,6 +104,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); const showEmails = isAttributeAvailable(attributes.email_address); + const emailsImmutable = Boolean(attributes.email_address?.immutable); const showPhones = isAttributeAvailable(attributes.phone_number); return { @@ -114,6 +121,8 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo username: showUsername ? (user.username ?? '') : undefined, emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, + onSetPrimaryEmail: showEmails ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, + onRemoveEmail: showEmails && !emailsImmutable ? id => save(() => emailById(user, id).destroy()) : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameManagedBy diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts index 7defa07f149..d7afb176a53 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -6,18 +6,13 @@ export interface UserProfileNameAttribute { required?: boolean; } -export interface UserProfilePhone { +export interface UserProfileContact { id: string; value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; + isDefault: boolean; + isVerified: boolean; } -export interface UserProfileEmail { - id: string; - value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; -} +export type UserProfileEmail = UserProfileContact; + +export type UserProfilePhone = UserProfileContact; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts index fa2aabc8e4f..9a085302c44 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts @@ -1,5 +1,23 @@ -import type { AttributeData } from '@clerk/shared/types'; +import type { AttributeData, VerificationResource } from '@clerk/shared/types'; export function isAttributeAvailable(attribute: AttributeData | undefined): boolean { return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); } + +type Identification = { id: string; verification: Pick }; + +export function sortByVerification(items: T[], primaryId: string | null): T[] { + const others = items.filter(item => item.id !== primaryId); + const verified = others.filter(item => item.verification.status === 'verified'); + const pending = others.filter(item => item.verification.status && item.verification.status !== 'verified'); + const unstarted = others.filter(item => !item.verification.status); + + verified.sort((a, b) => a.id.localeCompare(b.id)); + pending.sort((a, b) => { + const aExpireAt = a.verification.expireAt; + const bExpireAt = b.verification.expireAt; + return aExpireAt && bExpireAt ? aExpireAt.getTime() - bExpireAt.getTime() : 0; + }); + + return [...items.filter(item => item.id === primaryId), ...verified, ...pending, ...unstarted]; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx index d21c5abd97b..907c765bf40 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -8,6 +8,7 @@ import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; +import type { UserProfileContact } from './user-profile-account-section.types'; export interface UserProfileContactListRowViewProps { rowRef?: Ref; @@ -15,7 +16,7 @@ export interface UserProfileContactListRowViewProps { addAction?: ReactNode; kind: 'email' | 'phone'; label: string; - items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; + items: UserProfileContact[]; onAdd?: () => void; onVerify?: (id: string) => void; onSetPrimary?: (id: string) => void; @@ -80,16 +81,16 @@ export function UserProfileContactListRowView({ items.map(item => { const actions: ActionMenuAction[] = []; - if (item.isVerified === false && onVerify) { + if (!item.isVerified && onVerify) { actions.push({ label: item.isDefault ? m.completeVerification : m[kind].verify, onClick: () => onVerify(item.id), }); - } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + } else if (item.isVerified && !item.isDefault && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); } - if (onRemove && item.canRemove !== false) { + if (onRemove) { actions.push({ label: m[kind].remove, color: 'negative', @@ -103,6 +104,7 @@ export function UserProfileContactListRowView({ {item.value} {item.isDefault ? {m.primary} : null} + {!item.isVerified ? {m.unverified} : null} {actions.length > 0 ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx index 471492e5a03..9db44fe83cf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; @@ -12,6 +12,7 @@ import { useUserProfileAddEmailController } from './user-profile-add-email.contr import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; import { UserProfileContactRowView } from './user-profile-contact-row.view'; +import { useUserProfileSetPrimaryController } from './user-profile-set-primary.controller'; export interface UserProfileEmailRowViewProps { emails: UserProfileEmail[]; @@ -68,31 +69,15 @@ export function UserProfileEmailRowView({ ) : undefined; const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []); - const [isSettingPrimary, setIsSettingPrimary] = useState(false); - const [primaryError, setPrimaryError] = useState(); - const settingPrimary = useRef(false); - - const setPrimaryEmail = async (id: string) => { - const email = emails.find(email => email.id === id); - if (!onSetPrimaryEmail || !email?.isVerified || email.isDefault || settingPrimary.current) { - return; - } - settingPrimary.current = true; - setIsSettingPrimary(true); - setPrimaryError(undefined); - try { - await onSetPrimaryEmail(id); - } catch (error) { - setPrimaryError(error instanceof Error ? error.message : m.email.primaryError); - } finally { - settingPrimary.current = false; - setIsSettingPrimary(false); - } - }; + const primary = useUserProfileSetPrimaryController({ + items: emails, + onSetPrimary: onSetPrimaryEmail, + fallbackError: m.email.primaryError, + }); const removeEmail = (id: string) => { const email = emails.find(email => email.id === id); - if (email && email.canRemove !== false && onRemoveEmail) { + if (email && onRemoveEmail) { removeEmailConfirmation.open(email); } }; @@ -119,22 +104,26 @@ export function UserProfileEmailRowView({ label={m.email.label} addAction={addEmailAction} onRemove={onRemoveEmail ? removeEmail : undefined} - onSetPrimary={onSetPrimaryEmail && !isSettingPrimary ? id => void setPrimaryEmail(id) : undefined} + onSetPrimary={primary.onSetPrimary} onVerify={onVerifyEmail} /> - {primaryError ? ( + {primary.error ? ( - {primaryError} + {primary.error} ) : null} {onRemoveEmail ? ( fill(m.email.removeDialog.description, { emailAddress: email.value })} + description={email => + fill(email.isVerified ? m.email.removeDialog.verifiedDescription : m.email.removeDialog.description, { + emailAddress: email.value, + }) + } actionLabel={m.email.removeDialog.confirm} cancelLabel={m.email.removeDialog.cancel} finalFocus={removalFocus.finalFocus} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx index 110e754bd45..62f26346248 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -1,5 +1,5 @@ import { stringToFormattedPhoneString } from '@clerk/shared/phone'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; @@ -13,6 +13,7 @@ import { useUserProfileAddPhoneController } from './user-profile-add-phone.contr import { UserProfileAddPhoneDialog } from './user-profile-add-phone.dialog'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; import { UserProfileContactRowView } from './user-profile-contact-row.view'; +import { useUserProfileSetPrimaryController } from './user-profile-set-primary.controller'; export interface UserProfilePhoneRowViewProps { phones: UserProfilePhone[]; @@ -50,31 +51,15 @@ export function UserProfilePhoneRowView({ /> ) : undefined; const removePhoneConfirmation = useMemo(() => Confirmation.createHandle(), []); - const [isSettingPrimary, setIsSettingPrimary] = useState(false); - const [primaryError, setPrimaryError] = useState(); - const settingPrimary = useRef(false); - - const setPrimaryPhone = async (id: string) => { - const phone = phones.find(phone => phone.id === id); - if (!onSetPrimaryPhone || !phone?.isVerified || phone.isDefault || settingPrimary.current) { - return; - } - settingPrimary.current = true; - setIsSettingPrimary(true); - setPrimaryError(undefined); - try { - await onSetPrimaryPhone(id); - } catch (error) { - setPrimaryError(error instanceof Error ? error.message : m.phone.primaryError); - } finally { - settingPrimary.current = false; - setIsSettingPrimary(false); - } - }; + const primary = useUserProfileSetPrimaryController({ + items: phones, + onSetPrimary: onSetPrimaryPhone, + fallbackError: m.phone.primaryError, + }); const removePhone = (id: string) => { const phone = phones.find(phone => phone.id === id); - if (phone && phone.canRemove !== false && onRemovePhone) { + if (phone && onRemovePhone) { removePhoneConfirmation.open(phone); } }; @@ -105,15 +90,15 @@ export function UserProfilePhoneRowView({ label={m.phone.label} addAction={addPhoneAction} onRemove={onRemovePhone ? removePhone : undefined} - onSetPrimary={onSetPrimaryPhone && !isSettingPrimary ? id => void setPrimaryPhone(id) : undefined} + onSetPrimary={primary.onSetPrimary} onVerify={onVerifyPhone} /> - {primaryError ? ( + {primary.error ? ( - {primaryError} + {primary.error} ) : null} {onRemovePhone ? ( @@ -121,7 +106,9 @@ export function UserProfilePhoneRowView({ handle={removePhoneConfirmation} title={m.phone.removeDialog.title} description={phone => - fill(m.phone.removeDialog.description, { phoneNumber: stringToFormattedPhoneString(phone.value) }) + fill(phone.isVerified ? m.phone.removeDialog.verifiedDescription : m.phone.removeDialog.description, { + phoneNumber: stringToFormattedPhoneString(phone.value), + }) } actionLabel={m.phone.removeDialog.confirm} cancelLabel={m.phone.removeDialog.cancel} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts new file mode 100644 index 00000000000..a0d71966e02 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts @@ -0,0 +1,47 @@ +import { useRef, useState } from 'react'; + +import type { UserProfileContact } from './user-profile-account-section.types'; + +export interface UserProfileSetPrimaryControllerOptions { + items: UserProfileContact[]; + onSetPrimary?: (id: string) => void | Promise; + fallbackError: string; +} + +export interface UserProfileSetPrimaryController { + onSetPrimary: ((id: string) => void) | undefined; + error: string | undefined; +} + +export function useUserProfileSetPrimaryController({ + items, + onSetPrimary, + fallbackError, +}: UserProfileSetPrimaryControllerOptions): UserProfileSetPrimaryController { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(); + const pending = useRef(false); + + const setPrimary = async (id: string) => { + const item = items.find(item => item.id === id); + if (!onSetPrimary || !item?.isVerified || item.isDefault || pending.current) { + return; + } + pending.current = true; + setIsPending(true); + setError(undefined); + try { + await onSetPrimary(id); + } catch (error) { + setError(error instanceof Error ? error.message : fallbackError); + } finally { + pending.current = false; + setIsPending(false); + } + }; + + return { + onSetPrimary: onSetPrimary && !isPending ? id => void setPrimary(id) : undefined, + error, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 85b44a03420..1019e574e6d 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -41,7 +41,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const [activePage, setActivePage] = useState('account'); const [emails, setEmails] = useState([ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'preston.booth@gmail.com', isVerified: true }, + { id: 'email_2', value: 'preston.booth@gmail.com', isDefault: false, isVerified: true }, ]); const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, @@ -62,9 +62,10 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const apiKeys = useUserProfileAPIKeysFixture(); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); const addEmail = (value: string) => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: false }]); const emailFlow = createUserProfileAddEmailFixture({ - onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); const pages: UserProfileViewProps['pages'] = { @@ -90,7 +91,8 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions onSendEmailCode: onAddEmail ? undefined : emailFlow.onSendEmailCode, onVerifyEmailCode: onAddEmail ? undefined : emailFlow.onVerifyEmailCode, ...createUserProfileAddPhoneFixture({ - onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), }), onDeleteAccount: () => Promise.resolve(), onManageEmail: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 3b53cdbebd0..56d77d311b6 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -59,22 +59,24 @@ function AccountSection({ allowMultipleAccounts ? [ { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true }, ] : [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], ); const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, - ...(allowMultipleAccounts ? [{ id: 'phone_2', value: '+18015550100', isVerified: true }] : []), + ...(allowMultipleAccounts ? [{ id: 'phone_2', value: '+18015550100', isDefault: false, isVerified: true }] : []), ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); const addPhone = createUserProfileAddPhoneFixture({ failAt, - onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); const emailFlow = createUserProfileAddEmailFixture({ failAt: failEmailVerification ? 'verify' : undefined, - onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); return ( diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index c0fb9ce4bf4..96400c4ec62 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -31,7 +31,7 @@ export const meta: StoryMeta = { export function Default(_args: Record) { const [emails, setEmails] = useState([ { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true }, ]); const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, @@ -42,7 +42,8 @@ export function Default(_args: Record) { const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const emailFlow = createUserProfileAddEmailFixture({ - onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); return ( @@ -61,7 +62,8 @@ export function Default(_args: Record) { imageUrl={imageUrl} phones={phones} {...createUserProfileAddPhoneFixture({ - onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), })} onConnectAccount={connections.onConnect} onDeleteAccount={() => Promise.resolve()}