Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-profile-add-email-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ let user: {
enterpriseAccounts: {
active: boolean;
provider: string;
enterpriseConnection: { name: string; logoPublicUrl: string | null } | null;
enterpriseConnection: {
name: string;
logoPublicUrl: string | null;
disableAdditionalIdentifications?: boolean;
} | null;
}[];
primaryEmailAddressId: string | null;
primaryPhoneNumberId: string | null;
Expand All @@ -39,14 +43,18 @@ let user: {
emailAddress: string;
verification: FakeVerification;
destroy?: ReturnType<typeof vi.fn>;
prepareVerification?: ReturnType<typeof vi.fn>;
attemptVerification?: ReturnType<typeof vi.fn>;
}[];
phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[];
setProfileImage: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
createEmailAddress: ReturnType<typeof vi.fn>;
} | null;
let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>;
let usernameSettings: { min_length: number; max_length: number };
let environmentHydrated: boolean;
let enterpriseSSOEnabled: boolean;

function attribute(overrides: Partial<FakeAttribute> = {}): FakeAttribute {
return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides };
Expand All @@ -58,7 +66,9 @@ vi.mock('@clerk/shared/react', async importOriginal => {
...actual,
useUser: () => ({ isLoaded: isUserLoaded, user }),
useClerk: () => ({
__internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null,
__internal_environment: environmentHydrated
? { userSettings: { attributes, usernameSettings, enterpriseSSO: { enabled: enterpriseSSOEnabled } } }
: null,
}),
};
});
Expand All @@ -67,7 +77,7 @@ function renderModel() {
return renderHook(() => useUserProfileAccountSectionModel()).result.current;
}

async function rejection(save: Promise<void> | undefined): Promise<FormError | undefined> {
async function rejection(save: void | Promise<unknown> | undefined): Promise<FormError | undefined> {
try {
await save;
return undefined;
Expand Down Expand Up @@ -101,6 +111,7 @@ function apiError(paramName?: string) {
beforeEach(() => {
isUserLoaded = true;
environmentHydrated = true;
enterpriseSSOEnabled = true;
usernameSettings = { min_length: 4, max_length: 64 };
attributes = {
first_name: attribute(),
Expand All @@ -124,6 +135,8 @@ beforeEach(() => {
emailAddress: 'other@clerk.dev',
verification: { status: null },
destroy: vi.fn(() => Promise.resolve()),
prepareVerification: vi.fn(() => Promise.resolve()),
attemptVerification: vi.fn(() => Promise.resolve()),
},
{
id: 'email_1',
Expand All @@ -135,6 +148,7 @@ beforeEach(() => {
phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }],
setProfileImage: vi.fn(() => Promise.resolve({})),
update: vi.fn(() => Promise.resolve(user)),
createEmailAddress: vi.fn(() => Promise.resolve({ id: 'email_new' })),
};
});

Expand Down Expand Up @@ -252,6 +266,73 @@ describe('useUserProfileAccountSectionModel', () => {
});
});

it('maps a create error onto the email field', async () => {
user?.createEmailAddress.mockRejectedValue(apiError('email_address'));
await expect(rejection(ready().onCreateEmail?.('taken@clerk.dev'))).resolves.toEqual({
fields: {
emailAddress: {
code: 'form_param_invalid',
paramName: 'email_address',
message: 'That value is invalid.',
},
},
});
});

it('sends a code to the chosen email', async () => {
const verification = ready().getEmailVerifier?.('email_2').start();
if (verification?.method !== 'code') {
throw new Error('expected a code verification');
}
await expect(verification.sent).resolves.toBeUndefined();
expect(user?.emailAddresses[0]?.prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' });
});

it('creates an email and verifies the created address before the user reloads', async () => {
const prepareVerification = vi.fn(() => Promise.resolve());
user?.createEmailAddress.mockResolvedValue({ id: 'email_new', prepareVerification });
const email = await ready().onCreateEmail?.('new@clerk.dev');
expect(user?.createEmailAddress).toHaveBeenCalledWith({ email: 'new@clerk.dev' });
email?.start();
expect(prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' });
});

it('verifies the code for the chosen email', async () => {
await ready().getEmailVerifier?.('email_2').verifyCode('123456');
expect(user?.emailAddresses[0]?.attemptVerification).toHaveBeenCalledWith({ code: '123456' });
});

it('maps a wrong code onto the code field', async () => {
user?.emailAddresses[0]?.attemptVerification?.mockRejectedValue(apiError('code'));
await expect(rejection(ready().getEmailVerifier?.('email_2').verifyCode('000000'))).resolves.toEqual({
fields: { code: { code: 'form_param_invalid', paramName: 'code', message: 'That value is invalid.' } },
});
});

it('offers no new email when the email address is immutable', () => {
attributes.email_address = attribute({ immutable: true });
expect(ready().onCreateEmail).toBeUndefined();
});

it('offers no new email when the active enterprise connection forbids more identifications', () => {
user?.enterpriseAccounts.push({
active: true,
provider: 'saml_okta',
enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true },
});
expect(ready().onCreateEmail).toBeUndefined();
});

it('ignores the enterprise restriction when the instance has enterprise SSO off', () => {
enterpriseSSOEnabled = false;
user?.enterpriseAccounts.push({
active: true,
provider: 'saml_okta',
enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true },
});
expect(ready().onCreateEmail).toBeDefined();
});

it('still offers a new primary but no removal when the email address is immutable', () => {
attributes.email_address = attribute({ immutable: true });
const model = ready();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('UserProfileAddEmailDialog', () => {
it.each(['', 'invalid-address'])('uses native email validation for %j', async emailAddress => {
const user = userEvent.setup();
const { props } = renderView({ emailAddress });
await user.click(screen.getByRole('button', { name: 'Send code' }));
await user.click(screen.getByRole('button', { name: 'Continue' }));
expect(props.onSubmit).not.toHaveBeenCalled();
expect(screen.getByRole('textbox', { name: 'Email' })).toBeInvalid();
});
Expand All @@ -63,8 +63,7 @@ describe('UserProfileAddEmailDialog', () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<VerificationExample onSubmit={onSubmit} />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus());
await user.click(screen.getByRole('textbox', { name: 'Verification code' }));
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus());

if (method === 'typing') {
await user.keyboard('12345');
Expand All @@ -77,7 +76,7 @@ describe('UserProfileAddEmailDialog', () => {
expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456');
});

it('focuses the email field and submits through the form or Send code', async () => {
it('focuses the email field and submits through the form or Continue', async () => {
const user = userEvent.setup();
const { props } = renderView();

Expand All @@ -91,7 +90,7 @@ describe('UserProfileAddEmailDialog', () => {
emailForm.requestSubmit();
expect(props.onSubmit).toHaveBeenCalledOnce();

await user.click(screen.getByRole('button', { name: 'Send code' }));
await user.click(screen.getByRole('button', { name: 'Continue' }));

expect(props.onSubmit).toHaveBeenCalledTimes(2);
});
Expand Down Expand Up @@ -181,6 +180,11 @@ describe('UserProfileAddEmailDialog', () => {
/>
</MosaicProvider>,
);
expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Sending code…' })).toBeDisabled();
});

it('disables Continue when the email cannot be submitted', () => {
renderView({ canSubmitEmail: false });
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ describe('profile add email', () => {
'owns the dialog and returns focus with multiple accounts = %s',
async allowMultipleAccounts => {
const user = userEvent.setup();
const onSend = vi.fn(() => Promise.resolve());
const onVerify = vi.fn(() => Promise.resolve());
const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const);
const verifyCode = vi.fn(() => Promise.resolve());
const onCreate = vi.fn(() => Promise.resolve({ start, verifyCode }));
render(
<MosaicProvider>
<UserProfileProfilePanelView
Expand All @@ -20,23 +21,55 @@ describe('profile add email', () => {
username='test'
emails={[]}
phones={[]}
onSendEmailCode={onSend}
onVerifyEmailCode={onVerify}
onCreateEmail={onCreate}
getEmailVerifier={() => ({ start, verifyCode })}
/>
</MosaicProvider>,
);
const trigger = screen.getByRole('button', { name: 'Add email' });
await user.click(trigger);
expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com');
await user.click(screen.getByRole('button', { name: 'Send code' }));
await user.click(screen.getByRole('button', { name: 'Continue' }));
const codeInput = await screen.findByRole('textbox', { name: 'Verification code' });
await waitFor(() => expect(codeInput).toHaveFocus());
await user.keyboard('123456');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com');
expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456');
expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com');
expect(start).toHaveBeenCalledOnce();
expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456');
await waitFor(() => expect(trigger).toHaveFocus());
},
);

it('verifies an unverified email from its menu and returns focus to the menu', async () => {
const user = userEvent.setup();
const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const);
const verifyCode = vi.fn(() => Promise.resolve());
const getEmailVerifier = vi.fn(() => ({ start, verifyCode }));
render(
<MosaicProvider>
<UserProfileProfilePanelView
allowMultipleAccounts
name='Test'
username='test'
emails={[{ id: 'email_2', value: 'other@example.com', isDefault: false, isVerified: false }]}
phones={[]}
getEmailVerifier={getEmailVerifier}
/>
</MosaicProvider>,
);
expect(screen.queryByRole('button', { name: 'Add email' })).not.toBeInTheDocument();
const trigger = screen.getByRole('button', { name: 'Manage other@example.com' });
await user.click(trigger);
await user.click(screen.getByRole('menuitem', { name: 'Verify' }));
const dialog = await screen.findByRole('dialog', { name: 'Verify your email' });
expect(dialog).toHaveTextContent('other@example.com');
expect(getEmailVerifier).toHaveBeenCalledExactlyOnceWith('email_2');
await waitFor(() => expect(start).toHaveBeenCalledOnce());
await user.keyboard('123456');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456');
await waitFor(() => expect(trigger).toHaveFocus());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { MosaicProvider } from '../../../MosaicProvider';
import type { UserProfileAccountSectionViewProps } from '../user-profile-account-section/user-profile-account-section.view';
import { UserProfileAccountSectionView } from '../user-profile-account-section/user-profile-account-section.view';

const codeVerifier = {
start: () => ({ method: 'code', sent: Promise.resolve() }) as const,
verifyCode: () => Promise.resolve(),
};

function renderEmail(overrides: Partial<UserProfileAccountSectionViewProps> = {}) {
return render(
<MosaicProvider>
Expand Down Expand Up @@ -56,8 +61,8 @@ describe('email actions', () => {
username='test'
phones={[]}
emails={emails}
onSendEmailCode={() => Promise.resolve()}
onVerifyEmailCode={() => Promise.resolve()}
onCreateEmail={() => Promise.resolve(codeVerifier)}
getEmailVerifier={() => codeVerifier}
onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))}
/>
</MosaicProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { save } from '../../../utils/form-error';
import type { UserProfileManagedBy } from '../user-profile-managed-by';
import type {
UserProfileEmail,
UserProfileEmailVerifier,
UserProfileNameAttribute,
UserProfilePhone,
} from './user-profile-account-section.types';
import { isAttributeAvailable, sortByVerification } from './user-profile-account-section.utils';
import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view';
import type { UserProfileAddEmailField } from './user-profile-add-email.controller';
import type { UserProfileEditNameField } from './user-profile-edit-name.dialog';
import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog';

Expand All @@ -29,6 +31,8 @@ type UserProfileAccountSectionData = Pick<
| 'username'
| 'emails'
| 'phones'
| 'onCreateEmail'
| 'getEmailVerifier'
| 'onSetPrimaryEmail'
| 'onRemoveEmail'
| 'onProfilePictureChange'
Expand All @@ -44,6 +48,7 @@ export type UserProfileAccountSectionModel =

const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName'];
const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username'];
const ADD_EMAIL_FIELDS: readonly UserProfileAddEmailField[] = ['emailAddress', 'code'];

function emailById(user: UserResource, id: string): EmailAddressResource {
const email = user.emailAddresses.find(email => email.id === id);
Expand All @@ -53,6 +58,22 @@ function emailById(user: UserResource, id: string): EmailAddressResource {
return email;
}

function toEmailVerifier(email: EmailAddressResource): UserProfileEmailVerifier {
return {
start: () => ({ method: 'code', sent: save(() => email.prepareVerification({ strategy: 'email_code' })) }),
verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS),
};
}

function canAddIdentifications(user: UserResource, enterpriseSSOEnabled: boolean): boolean {
return (
!enterpriseSSOEnabled ||
!user.enterpriseAccounts.some(
account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications,
)
);
}

function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined {
if (!account) {
return undefined;
Expand Down Expand Up @@ -98,13 +119,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo
return { status: 'hidden' };
}

const { attributes, usernameSettings } = environment.userSettings;
const { attributes, usernameSettings, enterpriseSSO } = environment.userSettings;
const usernameAttribute = attributes.username;
const usernameImmutable = Boolean(usernameAttribute?.immutable);
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 canCreateEmail = showEmails && !emailsImmutable && canAddIdentifications(user, enterpriseSSO.enabled);
const showPhones = isAttributeAvailable(attributes.phone_number);

return {
Expand All @@ -121,6 +143,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo
username: showUsername ? (user.username ?? '') : undefined,
emails: showEmails ? toEmails(user) : undefined,
phones: showPhones ? toPhones(user) : undefined,
onCreateEmail: canCreateEmail
? async emailAddress => {
const request = user.createEmailAddress({ email: emailAddress });
await save(() => request, ADD_EMAIL_FIELDS);
return toEmailVerifier(await request);
}
: undefined,
getEmailVerifier: showEmails ? id => toEmailVerifier(emailById(user, id)) : 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 })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ export interface UserProfileContact {
export type UserProfileEmail = UserProfileContact;

export type UserProfilePhone = UserProfileContact;

export interface UserProfileEmailVerification {
method: 'code';
sent: Promise<void>;
}

export interface UserProfileEmailVerifier {
start: () => UserProfileEmailVerification;
verifyCode: (code: string) => Promise<void>;
}
Loading
Loading