diff --git a/.changeset/quiet-managed-password.md b/.changeset/quiet-managed-password.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-managed-password.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index b71c2b9f5f1..a7a4ba2dea0 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -39,6 +39,7 @@ export interface FakeFapiState { suggestions: OrganizationSuggestionJSON[]; apiKeys: ApiKeyJSON[]; verification: FakeVerificationState; + passwordUpdates: URLSearchParams[]; } export type FakeFapiSeed = Partial> & { @@ -100,6 +101,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { invitations: [], suggestions: [], apiKeys: [], + passwordUpdates: [], ...rest, verification: createVerificationState(verification), }; @@ -144,6 +146,19 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null }; return envelope({ ...session, status: 'removed' }, state.client); }), + http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => { + const session = findSession(state, state.client.last_active_session_id); + if (!session) { + return missing(); + } + state.passwordUpdates.push(new URLSearchParams(await request.text())); + const updatedUser = { ...session.user, password_enabled: true }; + state.client = { + ...state.client, + sessions: state.client.sessions.map(item => (item.id === session.id ? { ...item, user: updatedUser } : item)), + }; + return envelope(updatedUser, state.client); + }), http.post(fapiUrl('/v1/client/sessions'), ({ request }) => { if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { return undefined; @@ -224,7 +239,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { export interface HeldRequests { requests: Request[]; release: () => void; - fail: (code?: string) => void; + fail: (code?: string, longMessage?: string, paramName?: string) => void; } interface Hold { @@ -266,6 +281,21 @@ export function holdRequests(method: 'get' | 'post', path: string): HeldRequests return { requests, release: () => settle(undefined), - fail: (code = 'form_param_invalid') => settle(error(code)), + fail: (code = 'form_param_invalid', longMessage = code, paramName?: string) => + settle( + HttpResponse.json( + { + errors: [ + { + code, + message: code, + long_message: longMessage, + ...(paramName ? { meta: { param_name: paramName } } : {}), + }, + ], + }, + { status: 400 }, + ), + ), }; } diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index b8575289d0e..81faadddb17 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -7,6 +7,7 @@ import type { ClientJSON, DisplayConfigJSON, EmailAddressJSON, + EnterpriseAccountJSON, EnvironmentJSON, OAuthProviders, OrganizationJSON, @@ -271,6 +272,44 @@ export function fapiUser(overrides: Partial & Pick): U }; } +export function fapiEnterpriseAccount( + overrides: Partial & Pick, +): EnterpriseAccountJSON { + return { + object: 'enterprise_account', + active: true, + email_address: 'sso@example.com', + enterprise_connection: { + object: 'enterprise_connection', + id: 'sso_1', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: true, + domain: 'example.com', + logo_public_url: null, + name: 'Company SSO', + protocol: 'saml', + provider: 'saml_okta', + sync_user_attributes: false, + allow_organization_account_linking: false, + created_at: createdAt, + updated_at: createdAt, + enterprise_connection_id: 'sso_1', + }, + first_name: null, + last_name: null, + protocol: 'saml', + provider: 'saml_okta', + provider_user_id: null, + public_metadata: {}, + verification: null, + last_authenticated_at: null, + enterprise_connection_id: 'sso_1', + ...overrides, + }; +} + export function fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); diff --git a/packages/mosaic/src/components/field/field.test.tsx b/packages/mosaic/src/components/field/field.test.tsx index 22572b7b628..4700f6fae86 100644 --- a/packages/mosaic/src/components/field/field.test.tsx +++ b/packages/mosaic/src/components/field/field.test.tsx @@ -7,6 +7,7 @@ import { renderToString } from 'react-dom/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Checkbox } from '../checkbox'; +import type { FieldFeedback } from '../form'; import { Input } from '../input'; import { Select } from '../select'; import { Field } from './field'; @@ -37,6 +38,41 @@ function stubPrototype(target: object, name: string, descriptor: PropertyDescrip } describe('Mosaic Field', () => { + it.each<{ type: FieldFeedback['type']; slot: string }>([ + { type: 'error', slot: 'cl-field-error' }, + { type: 'success', slot: 'cl-field-success' }, + { type: 'info', slot: 'cl-field-info' }, + { type: 'warning', slot: 'cl-field-info' }, + ])('associates $type feedback with its control', ({ type, slot }) => { + render( + + Password + + + , + ); + expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Password feedback'); + expect(screen.getByText('Password feedback').closest('p')).toHaveClass(slot); + expect(screen.getByRole('status')).toHaveTextContent('Password feedback'); + }); + it('associates text-only info feedback with the control in a live region', () => { + render( + + Password + + + Try a longer password. + + , + ); + + expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Try a longer password.'); + expect(screen.getByRole('status')).toHaveTextContent('Try a longer password.'); + const info = screen.getByText('Try a longer password.').closest('p'); + expect(info).toHaveClass('cl-field-info'); + expect(info?.querySelector('svg')).toBeNull(); + }); + afterEach(() => { restores.splice(0).forEach(restore => restore()); }); diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index 6f792681cda..ad790ab6f51 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -11,6 +11,7 @@ import { feedbackHeight, feedbackStyles } from '../../utils/feedback.styles'; import { reset } from '../../utils/reset.styles'; import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import { visuallyHidden } from '../../utils/visually-hidden.styles'; +import type { FieldFeedback as FormFieldFeedback } from '../form'; import type { FieldOrientation } from './field.context'; import { FieldMessageProvider, @@ -202,18 +203,19 @@ const Message = React.forwardRef(function Mos return {rendered}; }); -type FieldFeedbackKind = 'error' | 'success'; +type FieldFeedbackKind = 'error' | 'success' | 'info'; -const FEEDBACK: Record = { +const FEEDBACK: Record = { error: { slot: 'field-error', icon: 'exclamation-circle', color: feedbackStyles.error }, success: { slot: 'field-success', icon: 'checkmark', color: feedbackStyles.success }, + info: { slot: 'field-info', color: feedbackStyles.info }, }; -interface FieldFeedbackProps extends MosaicComponentProps<'p'> { +interface FieldFeedbackPartProps extends MosaicComponentProps<'p'> { kind: FieldFeedbackKind; } -const FieldFeedback = React.forwardRef(function MosaicFieldFeedback( +const FieldFeedback = React.forwardRef(function MosaicFieldFeedback( { render, xstyle, id: idProp, children, kind, ...rest }, ref, ) { @@ -240,7 +242,7 @@ const FieldFeedback = React.forwardRef rest, ), id, - children: {message}, + children: icon ? {message} : message, }, }); }); @@ -271,5 +273,47 @@ const FieldSuccess = React.forwardRef(f ); }); +export type FieldHintProps = MosaicComponentProps<'p'>; + +const FieldHint = React.forwardRef(function MosaicFieldHint(props, ref) { + return ( + + ); +}); + +export interface FieldFeedbackProps extends Omit { + feedback?: FormFieldFeedback; +} + +const Feedback = React.forwardRef(function MosaicFieldFeedbackValue( + { feedback, ...props }, + ref, +) { + return ( + + {feedback?.type === 'error' ? feedback.message : null} + {feedback?.type === 'info' || feedback?.type === 'warning' ? feedback.message : null} + {feedback?.type === 'success' ? feedback.message : null} + + ); +}); + /** Styled parts for composing an automatically associated single-control field. */ -export const Field = { Root, Label, Content, Description, Message, Error: FieldError, Success: FieldSuccess }; +export const Field = { + Root, + Label, + Content, + Description, + Message, + Feedback, + Error: FieldError, + Success: FieldSuccess, + Hint: FieldHint, +}; diff --git a/packages/mosaic/src/components/field/index.ts b/packages/mosaic/src/components/field/index.ts index 95609821d13..9daf83fd93f 100644 --- a/packages/mosaic/src/components/field/index.ts +++ b/packages/mosaic/src/components/field/index.ts @@ -2,6 +2,8 @@ export { Field } from './field'; export type { FieldDescriptionProps, FieldErrorProps, + FieldFeedbackProps, + FieldHintProps, FieldLabelProps, FieldMessageProps, FieldRootProps, diff --git a/packages/mosaic/src/components/form/use-form.edit-password.test.ts b/packages/mosaic/src/components/form/use-form.edit-password.test.ts index 64f9c021936..a5ec2bedf5f 100644 --- a/packages/mosaic/src/components/form/use-form.edit-password.test.ts +++ b/packages/mosaic/src/components/form/use-form.edit-password.test.ts @@ -45,6 +45,24 @@ function useEditPasswordForm(onSubmit: (values: EditPasswordValues) => Promise { + it('reports submission immediately within the submit event', async () => { + let finish = () => {}; + const onSubmit = () => + new Promise(resolve => { + finish = resolve; + }); + const { result } = renderHook(() => useForm({ initialValues: { password: 'secret' }, onSubmit })); + const form = result.current; + act(() => { + form.submit(); + expect(form.isSubmitting).toBe(true); + }); + await act(async () => { + finish(); + await flush(); + }); + expect(form.isSubmitting).toBe(false); + }); it('walks a user from a weak password to a saved one', async () => { const onSubmit = vi.fn(() => Promise.resolve()); const { result } = renderHook(() => useEditPasswordForm(onSubmit)); diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index cdb82fd408f..4bb89209265 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -191,7 +191,10 @@ export function useForm(options: UseFormOptions values, fields, error: context.error?.message, - isSubmitting, + get isSubmitting() { + const current = actor.getSnapshot(); + return current.value === 'submitting' || current.context.submitQueued; + }, isDirty: keysOf(values).some(name => fields[name].isDirty), canSubmit: !isSubmitting && isValid(context), register, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx deleted file mode 100644 index c4bbc9a52c1..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import type { FormField, UseFormResult } from '../../../components/form'; -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileEditPasswordDialogProps } from '../user-profile-password-section/user-profile-edit-password.dialog'; -import { UserProfileEditPasswordDialog } from '../user-profile-password-section/user-profile-edit-password.dialog'; -import type { UserProfileEditPasswordValues } from '../user-profile-password-section/user-profile-password-section.types'; - -type Form = UseFormResult; - -const untouched: FormField = { feedback: undefined, isValidating: false, touched: false, isDirty: false }; - -function stubForm(overrides: Partial
= {}): Form { - const form: Form = { - id: 'edit-password', - values: { currentPassword: '', newPassword: '', confirmPassword: '', signOutOfOtherSessions: true }, - fields: { - currentPassword: untouched, - newPassword: untouched, - confirmPassword: untouched, - signOutOfOtherSessions: untouched, - }, - error: undefined, - isSubmitting: false, - isDirty: false, - canSubmit: true, - register: name => ({ - name, - value: form.values[name], - onChange: event => form.setValue(name, event.target.value), - onBlur: () => form.touch(name), - ref: () => undefined, - }), - setValue: vi.fn(), - touch: vi.fn(), - submit: vi.fn(), - handleSubmit: vi.fn((event: { preventDefault: () => void }) => event.preventDefault()), - reset: vi.fn(), - ...overrides, - }; - return form; -} - -function renderView(overrides: Partial = {}, form: Partial = {}) { - const props: UserProfileEditPasswordDialogProps = { - open: true, - onOpenChange: vi.fn(), - hasPassword: true, - requiresCurrentPassword: true, - form: stubForm(form), - ...overrides, - }; - return { - props, - ...render( - - - , - ), - }; -} - -const currentPasswordField = () => screen.getByLabelText('Current password'); -const newPasswordField = () => screen.getByLabelText('New password'); -const confirmPasswordField = () => screen.getByLabelText('Confirm password'); -const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of all other devices' }); -const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); - -describe('UserProfileEditPasswordDialog', () => { - it('names the dialog for a change and masks every field', () => { - renderView( - {}, - { values: { currentPassword: 'old', newPassword: 'new', confirmPassword: 'new', signOutOfOtherSessions: true } }, - ); - - expect(screen.getByRole('dialog', { name: 'Change password' })).toBeInTheDocument(); - expect(currentPasswordField()).toHaveAttribute('type', 'password'); - expect(currentPasswordField()).toHaveValue('old'); - expect(newPasswordField()).toHaveAttribute('type', 'password'); - expect(newPasswordField()).toHaveAttribute('autocomplete', 'new-password'); - expect(confirmPasswordField()).toHaveAttribute('type', 'password'); - expect(signOutCheckbox()).toBeChecked(); - expect(signOutCheckbox()).toHaveAccessibleDescription( - 'It is recommended to sign out of all other devices which may have used your old password.', - ); - }); - - it('writes typing and leaving a field back to the form by name', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - await user.type(newPasswordField(), 'a'); - await user.tab(); - await user.click(signOutCheckbox()); - - expect(props.form.setValue).toHaveBeenCalledWith('newPassword', 'a'); - expect(props.form.touch).toHaveBeenCalledWith('newPassword'); - expect(props.form.setValue).toHaveBeenCalledWith('signOutOfOtherSessions', false); - }); - - it('reveals a password from its own eye toggle and hides it again', async () => { - const user = userEvent.setup(); - renderView( - {}, - { - values: { - currentPassword: '', - newPassword: 'new-secret-123', - confirmPassword: '', - signOutOfOtherSessions: true, - }, - }, - ); - const [, newPasswordToggle] = screen.getAllByRole('button', { name: 'Show password' }); - if (!newPasswordToggle) { - throw new Error('New password visibility toggle is missing'); - } - - await user.click(newPasswordToggle); - - expect(newPasswordField()).toHaveAttribute('type', 'text'); - expect(newPasswordField()).toHaveValue('new-secret-123'); - expect(currentPasswordField()).toHaveAttribute('type', 'password'); - expect(confirmPasswordField()).toHaveAttribute('type', 'password'); - - await user.click(screen.getByRole('button', { name: 'Hide password' })); - - expect(newPasswordField()).toHaveAttribute('type', 'password'); - }); - - it('names the dialog for a first password and skips the current one', () => { - renderView({ hasPassword: false }); - - expect(screen.getByRole('dialog', { name: 'Set password' })).toBeInTheDocument(); - expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); - }); - - it('skips the current password when reverification stands in for it', async () => { - renderView({ requiresCurrentPassword: false }); - - expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); - await waitFor(() => expect(newPasswordField()).toHaveFocus()); - }); - - it('opens on the current password rather than the corner dismiss', async () => { - renderView(); - - await waitFor(() => expect(currentPasswordField()).toHaveFocus()); - }); - - it('announces the failure in a negative banner', () => { - renderView({}, { error: 'Your password could not be updated.' }); - - const banner = screen.getByRole('alert'); - expect(banner).toHaveTextContent('Your password could not be updated.'); - expect(newPasswordField()).not.toHaveAttribute('aria-invalid', 'true'); - }); - - it('renders field errors under their controls with no banner', () => { - const errored = (message: string): FormField => ({ - feedback: { type: 'error', message }, - isValidating: false, - touched: true, - }); - renderView( - {}, - { - fields: { - currentPassword: errored('Incorrect password.'), - newPassword: errored('Your password must contain 8 or more characters.'), - confirmPassword: errored("Passwords don't match."), - signOutOfOtherSessions: untouched, - }, - }, - ); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(currentPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(currentPasswordField()).toHaveAccessibleDescription('Incorrect password.'); - expect(newPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(newPasswordField()).toHaveAccessibleDescription('Your password must contain 8 or more characters.'); - expect(confirmPasswordField()).toHaveAttribute('aria-invalid', 'true'); - expect(confirmPasswordField()).toHaveAccessibleDescription("Passwords don't match."); - }); - - it('withholds the save while the form says it cannot submit', async () => { - const user = userEvent.setup(); - const { props } = renderView({}, { canSubmit: false }); - - expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); - await user.click(saveButton()); - - expect(props.form.handleSubmit).not.toHaveBeenCalled(); - }); - - it('submits the form from the save button once every required field is filled', async () => { - const user = userEvent.setup(); - const { props } = renderView( - {}, - { - values: { - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - confirmPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }, - }, - ); - - await user.click(saveButton()); - - expect(props.form.handleSubmit).toHaveBeenCalledTimes(1); - }); - - it('stays inert while the save runs', async () => { - const user = userEvent.setup(); - const { props } = renderView({}, { isSubmitting: true }); - - await user.type(newPasswordField(), 'abc'); - - expect(currentPasswordField()).toBeDisabled(); - expect(newPasswordField()).toBeDisabled(); - expect(confirmPasswordField()).toBeDisabled(); - expect(signOutCheckbox()).toBeDisabled(); - screen.getAllByRole('button', { name: 'Show password' }).forEach(toggle => expect(toggle).toBeDisabled()); - expect(props.form.setValue).not.toHaveBeenCalled(); - expect(saveButton()).toHaveAttribute('aria-busy', 'true'); - await user.click(saveButton()); - expect(props.form.handleSubmit).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx deleted file mode 100644 index 2a9b023d80a..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import type { UserProfilePasswordSectionViewProps } from '../user-profile-password-section/user-profile-password-section.types'; -import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; - -function renderView(props: UserProfilePasswordSectionViewProps = {}) { - return render( - - - , - ); -} - -describe('UserProfilePasswordSectionView', () => { - it('changes a password and closes the dialog after saving', async () => { - const onSubmitPassword = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ hasPassword: true, requiresCurrentPassword: true, onSubmitPassword }); - - expect(screen.getByText('••••••••••••••••••')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Change password' })); - const dialog = screen.getByRole('dialog', { name: 'Change password' }); - await user.type(within(dialog).getByLabelText('Current password'), 'old-secret'); - await user.type(within(dialog).getByLabelText('New password'), 'new-secret-123'); - await user.type(within(dialog).getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(within(dialog).getByRole('checkbox', { name: 'Sign out of all other devices' })); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitPassword).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Change password' })).not.toBeInTheDocument()); - }); - - it('offers to set a password when the instance takes one but the account has none', async () => { - const onSubmitPassword = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ hasPassword: false, onSubmitPassword }); - - expect(screen.getByRole('group', { name: 'Password' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 2, name: 'Password' })).toHaveClass('cl-section-title'); - expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); - expect(screen.getByText('No password set')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Set password' })); - const dialog = screen.getByRole('dialog', { name: 'Set password' }); - expect(within(dialog).queryByLabelText('Current password')).not.toBeInTheDocument(); - await user.type(within(dialog).getByLabelText('New password'), 'new-secret-123'); - await user.type(within(dialog).getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitPassword).toHaveBeenCalledWith({ - currentPassword: undefined, - newPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }); - }); - - it('keeps entered values after a failure and closes after a corrected retry', async () => { - const user = userEvent.setup(); - const onSubmitPassword = vi - .fn() - .mockRejectedValueOnce( - new UserProfileSaveError('Your password could not be updated.', { - currentPassword: 'Incorrect password.', - }), - ) - .mockResolvedValue(undefined); - renderView({ hasPassword: true, requiresCurrentPassword: true, onSubmitPassword }); - - await user.click(screen.getByRole('button', { name: 'Change password' })); - await user.type(screen.getByLabelText('Current password'), 'incorrect-password'); - await user.type(screen.getByLabelText('New password'), 'new-secret-123'); - await user.type(screen.getByLabelText('Confirm password'), 'new-secret-123'); - await user.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('Your password could not be updated.'); - expect(screen.getByLabelText('Current password')).toHaveAccessibleDescription('Incorrect password.'); - expect(screen.getByLabelText('New password')).toHaveValue('new-secret-123'); - expect(screen.getByLabelText('Confirm password')).toHaveValue('new-secret-123'); - - await user.clear(screen.getByLabelText('Current password')); - await user.type(screen.getByLabelText('Current password'), 'correct-password'); - await user.click(screen.getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Change password' })).toHaveFocus(); - }); - - it('hides the entire section when there is no password, manager, or action', () => { - const { container } = render( - - - , - ); - - expect(container).toBeEmptyDOMElement(); - }); - - it('shows an existing password without requiring an edit action', () => { - render( - - - , - ); - - expect(screen.getByRole('group', { name: 'Password' })).toBeVisible(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('shows the enterprise manager instead of password actions', () => { - render( - - Promise.resolve())} - /> - , - ); - - expect(screen.getByText('Managed by Okta')).toBeVisible(); - expect(screen.getByText('Okta')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /password/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx new file mode 100644 index 00000000000..f28ecf6a9a3 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password.feature.test.tsx @@ -0,0 +1,344 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnterpriseAccount, + fapiEnvironment, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { + UserProfilePasswordSection, + useUserProfilePasswordSlot, +} from '../user-profile-password-section/user-profile-password-section'; +import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; + +const email = fapiEmailAddress({ id: 'idn_1', email_address: 'person@example.com' }); +const alice = fapiUser({ id: 'user_1', email_addresses: [email] }); + +function PasswordSecurityPanel({ fallback }: { fallback?: ReactNode }) { + const passwordSlot = useUserProfilePasswordSlot({ fallback }); + return ; +} + +async function renderPassword(user = alice, environment = fapiEnvironment()) { + const fapi = serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + return fapi; +} + +async function fillPassword() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + return user; +} + +describe('Changing a password', () => { + it('omits Authentication while the only method loads without a fallback', async () => { + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); + const loading = renderWithClerk(); + try { + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + } finally { + await loading; + } + expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); + }); + + it('keeps Authentication around a visible loading fallback', async () => { + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); + const loading = renderWithClerk(Loading password section} />); + try { + expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Loading password section'); + } finally { + await loading; + } + expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); + expect(screen.queryByText('Loading password section')).toBeNull(); + }); + + it('shows no password action when nobody is signed in', async () => { + serveFapi({ client: fapiClient() }); + await renderWithClerk(); + + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + expect(screen.queryByText('Password')).toBeNull(); + }); + + it('sends the update to Clerk and closes after it succeeds', async () => { + const fapi = await renderPassword(); + const user = await fillPassword(); + await user.click(screen.getByRole('checkbox', { name: 'Sign out of all other devices' })); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates[0]?.get('current_password')).toBe('old-secret'); + expect(fapi.passwordUpdates[0]?.get('new_password')).toBe('new-password-123'); + expect(fapi.passwordUpdates[0]?.get('sign_out_of_other_sessions')).toBe('false'); + }); + + it('stays busy until the update finishes and prevents duplicate saves', async () => { + await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + expect(screen.getByLabelText('New password')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); + await user.keyboard('{Enter}'); + expect(update.requests).toHaveLength(1); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + update.release(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('shows a direct API error and keeps the draft without retrying automatically', async () => { + const fapi = await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + update.fail('session_reverification_required'); + + expect(await screen.findByRole('alert')).toHaveTextContent('session_reverification_required'); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.queryByText('Verification required')).toBeNull(); + expect(update.requests).toHaveLength(1); + serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates).toHaveLength(1); + }); + + it('shows a password error at the field and keeps the draft', async () => { + await renderPassword(); + const user = await fillPassword(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + update.fail('form_password_pwned', undefined, 'new_password'); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'This password has been found as part of a breach and can not be used, please try another password instead.', + ), + ); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('sets a first password without asking for the current one', async () => { + const fapi = await renderPassword(fapiUser({ ...alice, password_enabled: false })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Set password' })); + expect(screen.queryByLabelText('Current password')).toBeNull(); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates[0]?.has('current_password')).toBe(false); + await waitFor(() => expect(screen.getByRole('button', { name: 'Change password' })).toBeInTheDocument()); + }); + + it('hides the section when instance passwords are disabled', async () => { + const environment = fapiEnvironment(); + environment.user_settings.attributes.password.enabled = false; + await renderPassword(alice, environment); + + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + }); + + it.each(['disabled', 'editable', 'managed'])('resolves the Authentication section for %s passwords', async policy => { + const environment = fapiEnvironment(); + environment.user_settings.attributes.password.enabled = policy !== 'disabled'; + const user = fapiUser({ + ...alice, + enterprise_accounts: policy === 'managed' ? [fapiEnterpriseAccount({ id: 'ent_1' })] : [], + }); + serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + if (policy === 'disabled') { + expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + } else { + expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); + } + }); + + it.each([true, false])('shows the managed view when passwordEnabled is %s', async passwordEnabled => { + await renderPassword( + fapiUser({ + ...alice, + password_enabled: passwordEnabled, + enterprise_accounts: [ + fapiEnterpriseAccount({ id: 'inactive', active: false, enterprise_connection: null }), + fapiEnterpriseAccount({ id: 'ent_1' }), + ], + }), + ); + + expect(screen.getByText(/Managed by|Your password can currently/)).toHaveTextContent('Managed by Company SSO'); + if (!passwordEnabled) { + expect(screen.getByText('No password set')).toBeVisible(); + } + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Set password' })).toBeNull(); + }); + + it('keeps the managed view when connection details are unavailable', async () => { + await renderPassword( + fapiUser({ + ...alice, + enterprise_accounts: [fapiEnterpriseAccount({ id: 'ent_1', enterprise_connection: null })], + }), + ); + + expect(screen.getByText('Managed by your enterprise connection')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Change password' })).toBeNull(); + }); + + it('focuses the current password and clears the draft after cancellation', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + await user.type(screen.getByLabelText('New password'), 'draft-secret'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Change password' })); + expect(screen.getByLabelText('New password')).toHaveValue(''); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + }); + + it('keeps a confirmation mismatch visible after its field is cleared', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-12'); + await user.click(screen.getByLabelText('New password')); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); + await user.clear(screen.getByLabelText('Confirm password')); + + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Confirm password')).toHaveValue(''); + await waitFor(() => + expect(screen.getByLabelText('Confirm password')).toHaveAccessibleDescription("Passwords don't match."), + ); + expect(screen.getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('reveals and hides the current password on request', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + const current = screen.getByLabelText('Current password'); + expect(current).toHaveAttribute('type', 'password'); + + await user.click(screen.getAllByRole('button', { name: 'Show password' })[0]); + expect(current).toHaveAttribute('type', 'text'); + await user.click(screen.getByRole('button', { name: 'Hide password' })); + expect(current).toHaveAttribute('type', 'password'); + }); + + it('shows the password rule and leaves matching passwords eligible for a server check', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.min_length = 8; + const fapi = await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'short'); + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + await user.type(screen.getByLabelText('Confirm password'), 'short'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(fapi.passwordUpdates).toHaveLength(1)); + expect(fapi.passwordUpdates[0]?.get('new_password')).toBe('short'); + }); + + it('shows the minimum length as an error after an empty new password is left', async () => { + await renderPassword(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + await user.click(screen.getByLabelText('New password')); + expect(screen.getByLabelText('New password')).not.toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ); + + await user.click(screen.getByLabelText('Confirm password')); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain 8 or more characters.', + ), + ); + expect(screen.getByLabelText('New password')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('lists the configured complexity requirements beside the new password', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.require_uppercase = true; + environment.user_settings.password_settings.require_numbers = true; + await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveFocus()); + await user.type(screen.getByLabelText('New password'), 'longpassword'); + + await waitFor( + () => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription( + 'Your password must contain a number and an uppercase letter.', + ), + { timeout: 2500 }, + ); + }); + + it('advises a weak password without blocking a server check', async () => { + const environment = fapiEnvironment(); + environment.user_settings.password_settings.show_zxcvbn = true; + environment.user_settings.password_settings.min_zxcvbn_strength = 3; + const fapi = await renderPassword(alice, environment); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('Current password'), 'old-secret'); + await user.type(screen.getByLabelText('New password'), 'password123'); + await user.type(screen.getByLabelText('Confirm password'), 'password123'); + + await waitFor(() => + expect(screen.getByLabelText('New password')).toHaveAccessibleDescription(/Your password is not strong enough/), + ); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(fapi.passwordUpdates).toHaveLength(1)); + }); +}); + +describe('Deferred password behavior', () => { + it.todo('reverifies the session and retries the password update when Clerk requires verification'); + it.todo('omits the current password when session reverification is enabled'); + it.todo('shows a password section skeleton while loading without a custom fallback'); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index f7035959062..39e78180fcb 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -9,13 +9,12 @@ import { UserProfileDeleteSectionView } from '../user-profile-delete-section/use import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; -function DeleteAccount() { - const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); +function DeleteAccount({ onDelete = () => Promise.resolve() }: { onDelete?: () => Promise }) { + const controller = useDestructiveController({ onDelete }); return ; } const props: UserProfileSecurityPanelViewProps = { - hasPassword: true, passkeys: [ { id: 'passkey_1', @@ -72,8 +71,6 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Active devices' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Danger zone' })).toBeInTheDocument(); - expect(within(authentication).getByRole('heading', { level: 3, name: 'Password' })).toBeInTheDocument(); - expect(within(authentication).getByRole('group', { name: 'Password' })).toBeInTheDocument(); expect(within(authentication).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeInTheDocument(); expect(within(authentication).getByRole('heading', { level: 3, name: '2-step verification' })).toBeInTheDocument(); expect(within(authentication).getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); @@ -113,6 +110,7 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(); const onSignOutDevice = vi.fn(); const onSignOutAllOtherDevices = vi.fn(); + const onDeleteAccount = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ @@ -121,6 +119,7 @@ describe('UserProfileSecurityPanelView', () => { onRemovePasskey, onSignOutDevice, onSignOutAllOtherDevices, + deleteAccountSlot: , }); await user.click(screen.getByRole('button', { name: 'Add passkey' })); @@ -151,16 +150,22 @@ describe('UserProfileSecurityPanelView', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + // The danger zone confirms in a modal, so it goes last: nothing else is clickable while it is open. + await user.click(screen.getByRole('button', { name: 'Delete account' })); + const deleteDialog = screen.getByRole('dialog'); + await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); + await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); + expect(onAddPasskey).toHaveBeenCalledOnce(); expect(onRenamePasskey).toHaveBeenCalledWith('passkey_1', 'Work laptop'); expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1'); expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); + expect(onDeleteAccount).toHaveBeenCalledOnce(); }); it('keeps supported empty authentication methods actionable', () => { renderView({ - hasPassword: false, passkeys: [], mfaMethods: [], devices: [], @@ -174,7 +179,6 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.getByRole('button', { name: 'Add passkey' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add verification method' })).toBeInTheDocument(); expect(screen.getByText('No current device available')).toBeInTheDocument(); - expect(screen.queryByText('Password')).not.toBeInTheDocument(); }); it('withholds sign out from the current device', async () => { @@ -188,7 +192,6 @@ describe('UserProfileSecurityPanelView', () => { it('keeps the authentication section on MFA when existing passkeys are hidden', () => { renderView({ - hasPassword: false, passkeysVisible: false, onAddPasskey: vi.fn(), onRenamePasskey: vi.fn(), @@ -203,7 +206,7 @@ describe('UserProfileSecurityPanelView', () => { }); it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => { - renderView({ hasPassword: false, passkeys: [], onAddPasskey: undefined }); + renderView({ passkeys: [], onAddPasskey: undefined }); const section = screen.getByRole('region', { name: 'Authentication' }); expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible(); @@ -218,7 +221,7 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(async () => { await removal.promise; }); - const { rerender } = renderView({ hasPassword: false, mfaMethods: undefined, onRemovePasskey }); + const { rerender } = renderView({ mfaMethods: undefined, onRemovePasskey }); await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index d3a041d5292..6a0ca5e1f85 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -9,7 +9,7 @@ import { UserProfileView } from '../user-profile.view'; const pages: UserProfileViewProps['pages'] = { account: { name: 'Preston Booth', username: 'prestonxyz' }, - security: { hasPassword: true }, + security: {}, billing: { subscription: { planName: 'Basic Plan', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index b82f79e1d5a..a05b6232abd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,215 +1,132 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; -import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; -function deferred() { - let resolve: () => void = () => {}; - const promise = new Promise(r => { +function deferred() { + let resolve: (result: T) => void = () => {}; + const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; } -function renderController( - onSubmit: (value: UserProfileEditPasswordValue) => Promise = () => Promise.resolve(), - requiresCurrentPassword = true, -) { - return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); -} - -type Controller = ReturnType['result']; - -function open(result: Controller) { - act(() => result.current.onOpenChange(true)); -} - -function fill(result: Controller, { current = 'old-secret', next = 'new-secret-123' } = {}) { - act(() => result.current.form.setValue('currentPassword', current)); - act(() => result.current.form.setValue('newPassword', next)); - act(() => result.current.form.setValue('confirmPassword', next)); -} - -describe('useUserProfileEditPasswordController', () => { - it('opens with empty fields and sign-out of other devices on', () => { - const { result } = renderController(); - expect(result.current.isOpen).toBe(false); - - open(result); - - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values).toEqual({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - signOutOfOtherSessions: true, - }); - expect(result.current.form.error).toBeUndefined(); - expect(result.current.form.isSubmitting).toBe(false); - }); - - it('saves the current password alongside the new one when it is required, then closes', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - - act(() => result.current.form.submit()); - - expect(result.current.isOpen).toBe(true); - expect(result.current.form.isSubmitting).toBe(true); - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); +describe('useUserProfileEditPasswordController timing', () => { + it('shows a validation failure instead of silently dropping it', async () => { + vi.useFakeTimers(); + try { + const { result } = renderHook(() => + useUserProfileEditPasswordController({ + onSubmit: () => Promise.resolve(), + validatePassword: () => Promise.reject(new Error('Failed to load strength checker')), + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'new password')); + await act(() => vi.advanceTimersByTimeAsync(350)); + expect(result.current.passwordFeedback).toMatchObject({ type: 'error', message: expect.any(String) }); + } finally { + vi.useRealTimers(); + } }); - - it('leaves the current password out when reverification stands in for it', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit, false); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.submit()); - - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: undefined, - newPassword: 'new-secret-123', - signOutOfOtherSessions: true, - }); - await waitFor(() => expect(result.current.isOpen).toBe(false)); + it('holds feedback while the next password is being checked', async () => { + vi.useFakeTimers(); + try { + const feedback: FieldFeedback = { type: 'warning', message: 'Could be stronger.' }; + const validatePassword = vi.fn(() => Promise.resolve(feedback)); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ + onSubmit: () => Promise.resolve(), + validatePassword, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'first password')); + await act(() => vi.advanceTimersByTimeAsync(350)); + expect(result.current.passwordFeedback).toEqual(feedback); + act(() => result.current.form.setValue('newPassword', 'second password')); + expect(result.current.passwordFeedback).toEqual(feedback); + act(() => result.current.onOpenChange(false)); + expect(result.current.passwordFeedback).toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); - - it('withholds the save until the halves match and the current password is in', () => { - const { result } = renderController(); - open(result); - expect(result.current.form.canSubmit).toBe(false); - - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.canSubmit).toBe(false); - - act(() => result.current.form.setValue('currentPassword', 'old-secret')); - expect(result.current.form.canSubmit).toBe(true); - - act(() => result.current.form.setValue('confirmPassword', 'new-secret-124')); - expect(result.current.form.canSubmit).toBe(false); + it('debounces changes and cancels pending validation when closed', async () => { + vi.useFakeTimers(); + try { + const validatePassword = vi.fn(() => Promise.resolve(undefined)); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'first password')); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(validatePassword).not.toHaveBeenCalled(); + act(() => result.current.form.setValue('newPassword', 'latest password')); + await act(() => vi.advanceTimersByTimeAsync(349)); + expect(validatePassword).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(validatePassword).toHaveBeenCalledExactlyOnceWith('latest password'); + act(() => result.current.form.setValue('newPassword', 'cancelled password')); + act(() => result.current.onOpenChange(false)); + await act(() => vi.advanceTimersByTimeAsync(350)); + expect(validatePassword).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } }); + it('ignores older password feedback and clears it when the editor closes', async () => { + const older = deferred(); + const newer = deferred(); + const validatePassword = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ onSubmit: () => Promise.resolve(), validatePassword }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('newPassword', 'first password')); + await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('first password')); + act(() => result.current.form.setValue('newPassword', 'second password')); + await waitFor(() => expect(validatePassword).toHaveBeenCalledWith('second password')); - it('names the mismatch under the confirmation once it has been left', () => { - const { result } = renderController(); - open(result); - act(() => result.current.form.setValue('newPassword', 'new-secret-123')); - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - - act(() => result.current.form.touch('confirmPassword')); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", + await act(async () => { + newer.resolve({ type: 'success', message: 'Strong password.' }); + await newer.promise; }); - - act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - expect(result.current.form.fields.confirmPassword.feedback).toBeUndefined(); - }); - - it('forgets what was typed when the dialog is cancelled', () => { - const { result } = renderController(); - open(result); - fill(result); - act(() => result.current.form.setValue('signOutOfOtherSessions', false)); - act(() => result.current.form.touch('confirmPassword')); - - act(() => result.current.onOpenChange(false)); - - expect(result.current.isOpen).toBe(false); - open(result); - expect(result.current.form.values).toEqual({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - signOutOfOtherSessions: true, + await act(async () => { + older.resolve({ type: 'warning', message: 'Weak password.' }); + await older.promise; }); - expect(result.current.form.fields.confirmPassword.touched).toBe(false); - }); - - it('starts fresh when opened again after a save', async () => { - const { result } = renderController(); - open(result); - fill(result); - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.isOpen).toBe(false)); - - open(result); - expect(result.current.form.values.newPassword).toBe(''); + expect(result.current.passwordFeedback).toEqual({ type: 'success', message: 'Strong password.' }); + act(() => result.current.onOpenChange(false)); + expect(result.current.passwordFeedback).toBeUndefined(); }); - it('stays open while the save runs', async () => { + it('ignores dismissal and duplicate saves in the same event as submission', async () => { const save = deferred(); - const { result } = renderController(() => save.promise); - open(result); - fill(result); - act(() => result.current.form.submit()); + const onSubmit = vi.fn(() => save.promise); + const { result } = renderHook(() => + useUserProfileEditPasswordController({ requiresCurrentPassword: true, onSubmit }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.form.setValue('currentPassword', 'old-secret')); + act(() => result.current.form.setValue('newPassword', 'new-secret-123')); + act(() => result.current.form.setValue('confirmPassword', 'new-secret-123')); - act(() => result.current.onOpenChange(false)); + act(() => { + result.current.form.submit(); + result.current.onOpenChange(false); + result.current.form.submit(); + }); expect(result.current.isOpen).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); await act(async () => { - save.resolve(); + save.resolve(undefined); await save.promise; }); await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - - it('keeps what was typed when the save fails, and carries field copy when the rejection names the control', async () => { - const failure = new UserProfileSaveError('Your password could not be updated.', { - newPassword: 'Your password must contain 8 or more characters.', - }); - const { result } = renderController(() => Promise.reject(failure)); - open(result); - fill(result); - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Your password could not be updated.')); - expect(result.current.isOpen).toBe(true); - expect(result.current.form.values.newPassword).toBe('new-secret-123'); - expect(result.current.form.fields.newPassword.feedback).toEqual({ - type: 'error', - message: 'Your password must contain 8 or more characters.', - }); - }); - - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const { result } = renderController(() => Promise.reject('nope')); - open(result); - fill(result); - act(() => result.current.form.submit()); - - await waitFor(() => expect(result.current.form.error).toBe('Something went wrong. Please try again.')); - }); - - it('keeps a failed save visible next to a fresh mismatch', async () => { - const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); - open(result); - fill(result); - act(() => result.current.form.submit()); - await waitFor(() => expect(result.current.form.error).toBe('Incorrect password.')); - - act(() => result.current.form.setValue('confirmPassword', 'new-secret-12')); - - expect(result.current.form.error).toBe('Incorrect password.'); - expect(result.current.form.fields.confirmPassword.feedback).toEqual({ - type: 'error', - message: "Passwords don't match.", - }); - }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index eeb5ab7c25d..f28707b9884 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,7 +1,9 @@ -import { useState } from 'react'; +import { DEBOUNCE_MS } from '@clerk/shared/internal/clerk-js/constants'; +import { useEffect, useState } from 'react'; import type { UseFormResult } from '../../../components/form'; import { useForm } from '../../../components/form'; +import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useMessages } from '../../../localization'; import type { UserProfileEditPasswordValue, @@ -17,48 +19,90 @@ const initialValues: UserProfileEditPasswordValues = { export interface UserProfileEditPasswordControllerOptions { requiresCurrentPassword?: boolean; - onSubmit: (value: UserProfileEditPasswordValue) => Promise; + onSubmit: (value: UserProfileEditPasswordValue) => Promise; + validatePassword?: (password: string) => Promise; } export interface UserProfileEditPasswordController { isOpen: boolean; onOpenChange: (open: boolean) => void; form: UseFormResult; + passwordFeedback: FieldFeedback | undefined; } export function useUserProfileEditPasswordController({ requiresCurrentPassword = false, onSubmit, + validatePassword, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { + const validationError = useMessages('errors').generic; const m = useMessages('userProfilePasswordSection'); const [isOpen, setIsOpen] = useState(false); + const [passwordFeedback, setPasswordFeedback] = useState(); const form = useForm({ initialValues, fields: { confirmPassword: { + // TODO: Discuss showing success feedback when the confirmation matches, as legacy does. https://github.com/clerk/javascript/pull/9930#discussion_r4150406791 validate: (value, values) => - value !== '' && value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, + value !== values.newPassword ? { type: 'error', message: m.errors.mismatch } : undefined, }, }, - canSubmit: values => values.newPassword !== '' && (!requiresCurrentPassword || values.currentPassword !== ''), + canSubmit: values => + values.newPassword !== '' && + values.confirmPassword === values.newPassword && + (!requiresCurrentPassword || values.currentPassword !== ''), onSubmit: async values => { await onSubmit({ currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, newPassword: values.newPassword, signOutOfOtherSessions: values.signOutOfOtherSessions, }); + // TODO: Discuss confirming the password was set or updated and other devices were signed out with a success page or toast. https://github.com/clerk/javascript/pull/9930#discussion_r4151641473 setIsOpen(false); }, }); + const password = form.values.newPassword; + const passwordLeft = form.fields.newPassword.touched; + useEffect(() => { + // TODO: Discuss keeping the password hint hidden on open or showing it immediately when the field autofocuses. https://github.com/clerk/javascript/pull/9930#discussion_r4150863181 + if (!isOpen || (password === '' && !passwordLeft) || !validatePassword) { + setPasswordFeedback(undefined); + return; + } + + let active = true; + const timeout = setTimeout(() => { + void Promise.resolve() + .then(() => validatePassword(password)) + .then( + feedback => { + if (active) { + setPasswordFeedback(feedback); + } + }, + () => { + if (active) { + setPasswordFeedback({ type: 'error', message: validationError }); + } + }, + ); + }, DEBOUNCE_MS); + return () => { + active = false; + clearTimeout(timeout); + }; + }, [isOpen, password, passwordLeft, validatePassword, validationError]); + const onOpenChange = (open: boolean) => { - if (!open && form.isSubmitting) { + if (form.isSubmitting) { return; } form.reset(); setIsOpen(open); }; - return { isOpen, onOpenChange, form }; + return { isOpen, onOpenChange, form, passwordFeedback }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index f0fd1f94040..072c8788a23 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -9,7 +9,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import type { UseFormResult } from '../../../components/form'; +import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; @@ -21,6 +21,8 @@ import type { } from './user-profile-password-section.types'; export interface UserProfileEditPasswordDialogProps { + passwordFeedback?: FieldFeedback; + identifier?: string; open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -30,6 +32,8 @@ export interface UserProfileEditPasswordDialogProps { } export function UserProfileEditPasswordDialog({ + passwordFeedback, + identifier = '', open, onOpenChange, trigger, @@ -68,6 +72,13 @@ export function UserProfileEditPasswordDialog({ /> } > + {form.error ? ( ; inputRef?: RefObject; name: UserProfileEditPasswordField; + advisoryFeedback?: FieldFeedback; }) { const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); + const [focused, setFocused] = useState(false); const { feedback } = form.fields[name]; - const error = feedback?.type === 'error' ? feedback.message : undefined; + const message = feedback?.type === 'error' ? feedback : advisoryFeedback; + const feedbackType = message?.type === 'info' && !focused ? 'error' : message?.type; const { ref, ...control } = form.register(name); const mergedRef = useMergeRefs([ref, inputRef]); + // TODO: Discuss enforcing the configured minimum length on the new password input or keeping the hint advisory and letting the server validate. https://github.com/clerk/javascript/pull/9930#discussion_r4151734254 return ( {label} @@ -187,6 +204,11 @@ function PasswordField({ autoComplete={autoComplete} type={visible ? 'text' : 'password'} {...control} + onFocus={() => setFocused(true)} + onBlur={() => { + setFocused(false); + control.onBlur(); + }} /> - } - /> - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index ebf73640adf..b9055babc0b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -3,6 +3,7 @@ export const userProfilePasswordSectionMessages = { masked: '••••••••••••••••••', noPasswordSet: 'No password set', managedBy: 'Managed by {name}', + enterpriseConnection: 'your enterprise connection', change: 'Change password', set: 'Set password', @@ -20,8 +21,41 @@ export const userProfilePasswordSectionMessages = { 'It is recommended to sign out of all other devices which may have used your old password.', cancel: 'Cancel', save: 'Save changes', + rules: { + weak: 'Your password is not strong enough.', + stronger: 'Your password works, but could be stronger. Try adding more characters.', + strong: 'Your password meets all the necessary requirements.', + }, + + complexity: { + sentence: 'Your password must contain {requirements}.', + minimumLength: '{length} or more characters', + maximumLength: 'less than {length} characters', + lowercase: 'a lowercase letter', + uppercase: 'an uppercase letter', + number: 'a number', + special: 'a special character', + }, + suggestions: { + allUppercase: 'Capitalize some, but not all letters.', + anotherWord: 'Add more words that are less common.', + associatedYears: 'Avoid years that are associated with you.', + capitalization: 'Capitalize more than the first letter.', + dates: 'Avoid dates and years that are associated with you.', + l33t: "Avoid predictable letter substitutions like '@' for 'a'.", + longerKeyboardPattern: 'Use longer keyboard patterns and change typing direction multiple times.', + noNeed: 'You can create strong passwords without using symbols, numbers, or uppercase letters.', + pwned: 'If you use this password elsewhere, you should change it.', + recentYears: 'Avoid recent years.', + repeated: 'Avoid repeated words and characters.', + reverseWords: 'Avoid reversed spellings of common words.', + sequences: 'Avoid common character sequences.', + useWords: 'Use multiple words, but avoid common phrases.', + }, errors: { + unavailable: 'Password update is no longer available.', + currentPasswordRequired: 'Current password is required.', mismatch: "Passwords don't match.", }, } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts new file mode 100644 index 00000000000..bca8f2fc05b --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.test.ts @@ -0,0 +1,189 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { PasswordSettingsData } from '@clerk/shared/types'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FormSubmitError } from '../../../components/form'; +import { useUserProfilePasswordModel } from './user-profile-password-section.model'; + +type TestUser = { + id: string; + passwordEnabled: boolean; + enterpriseAccounts: { active: boolean; enterpriseConnection?: { name: string; logoPublicUrl: string | null } }[]; + updatePassword: ReturnType; +}; + +type TestSession = { id: string; publicUserData: { identifier: string | null } }; + +const passwordSettings: PasswordSettingsData = { + min_length: 8, + max_length: 72, + require_numbers: false, + require_uppercase: false, + require_lowercase: false, + require_special_char: false, + allowed_special_characters: '', + disable_hibp: false, + show_zxcvbn: true, + min_zxcvbn_strength: 2, +}; + +function createEnvironment() { + return { userSettings: { instanceIsPasswordBased: true, passwordSettings } }; +} + +let user: TestUser | null; +let session: TestSession | null; +let environment: ReturnType; + +const clerk = { + get user() { + return user; + }, + get session() { + return session; + }, + get __internal_environment() { + return environment; + }, +}; + +vi.mock('@clerk/shared/react', () => ({ + useClerk: () => clerk, + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ isLoaded: true, session }), +})); + +beforeEach(() => { + user = { id: 'user_1', passwordEnabled: true, enterpriseAccounts: [], updatePassword: vi.fn() }; + session = { id: 'session_1', publicUserData: { identifier: 'person@example.com' } }; + environment = createEnvironment(); +}); + +afterEach(cleanup); + +describe('useUserProfilePasswordModel update errors', () => { + it('reports an unavailable strength checker instead of silently skipping it', async () => { + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect(ready(result.current).validatePassword('long password with 123')).rejects.toBeInstanceOf( + FormSubmitError, + ); + }); + it('translates API errors into form field errors before leaving the model', async () => { + if (!user) { + throw new Error('expected user'); + } + user.updatePassword.mockRejectedValue( + new ClerkAPIResponseError('Invalid', { + status: 422, + data: [{ code: 'form_password_incorrect', message: 'raw', meta: { param_name: 'current_password' } }], + }), + ); + const { result } = renderHook(() => useUserProfilePasswordModel()); + const action = ready(result.current).updatePassword; + const input = { currentPassword: 'wrong', newPassword: 'new password', signOutOfOtherSessions: true }; + await expect(action(input)).rejects.toBeInstanceOf(FormSubmitError); + await expect(action(input)).rejects.toMatchObject({ + fields: { currentPassword: 'Your current password is incorrect.' }, + }); + }); + + it('keeps unexpected failures behind the form error contract', async () => { + if (!user) { + throw new Error('expected user'); + } + user.updatePassword.mockRejectedValue(new Error('Connection interrupted')); + const { result } = renderHook(() => useUserProfilePasswordModel()); + await expect( + ready(result.current).updatePassword({ + currentPassword: 'old password', + newPassword: 'new password', + signOutOfOtherSessions: true, + }), + ).rejects.toBeInstanceOf(FormSubmitError); + }); +}); + +function ready(model: ReturnType) { + if (model.status !== 'ready') { + throw new Error('expected ready model'); + } + return model; +} + +describe('useUserProfilePasswordModel context changes', () => { + it.each(['signed out', 'different user', 'different session', 'no session', 'disabled', 'enterprise', 'mode'])( + 'rejects a captured action after %s', + async change => { + if (!user || !session) { + throw new Error('expected loaded fixtures'); + } + const updatePassword = user.updatePassword; + const { result, rerender } = renderHook(() => useUserProfilePasswordModel()); + const action = ready(result.current).updatePassword; + + switch (change) { + case 'signed out': + user = null; + break; + case 'different user': + user = { ...user, id: 'user_2' }; + break; + case 'different session': + session = { ...session, id: 'session_2' }; + break; + case 'no session': + session = null; + break; + case 'disabled': + environment.userSettings.instanceIsPasswordBased = false; + break; + case 'enterprise': + user.enterpriseAccounts = [{ active: true }]; + break; + case 'mode': + user.passwordEnabled = false; + break; + } + + const input = { currentPassword: 'old password', newPassword: 'new password', signOutOfOtherSessions: true }; + await expect(action(input)).rejects.toMatchObject({ banner: 'Password update is no longer available.' }); + rerender(); + await expect(action(input)).rejects.toMatchObject({ banner: 'Password update is no longer available.' }); + expect(updatePassword).not.toHaveBeenCalled(); + }, + ); + + it('hides the section when a loaded user has no active session', () => { + session = null; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ status: 'hidden' }); + }); +}); + +describe('useUserProfilePasswordModel enterprise accounts', () => { + it('describes the managing connection as plain data', () => { + if (!user) { + throw new Error('expected user'); + } + user.enterpriseAccounts = [ + { active: false, enterpriseConnection: { name: 'Inactive', logoPublicUrl: null } }, + { active: true, enterpriseConnection: { name: 'Acme SSO', logoPublicUrl: 'https://example.com/acme.png' } }, + ]; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toEqual({ + status: 'readonly', + mode: 'change', + managedBy: { name: 'Acme SSO' }, + }); + }); + + it('leaves a blank connection name undefined', () => { + if (!user) { + throw new Error('expected user'); + } + user.enterpriseAccounts = [{ active: true, enterpriseConnection: { name: '', logoPublicUrl: null } }]; + const { result } = renderHook(() => useUserProfilePasswordModel()); + expect(result.current).toMatchObject({ managedBy: { name: undefined } }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts new file mode 100644 index 00000000000..93be9526e9f --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -0,0 +1,153 @@ +import { validate as validateComplexity } from '@clerk/shared/internal/clerk-js/passwords/complexity'; +import { createLoadZxcvbn } from '@clerk/shared/internal/clerk-js/passwords/loadZxcvbn'; +import { createValidatePasswordStrength } from '@clerk/shared/internal/clerk-js/passwords/strength'; +import { useClerk, useSession, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, UserResource } from '@clerk/shared/types'; +import { useMemo } from 'react'; + +import type { FieldFeedback } from '../../../components/form'; +import { FormSubmitError } from '../../../components/form'; +import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { useErrorText, useLocale, useMessages } from '../../../localization'; +import { passwordFormError } from './user-profile-password-errors'; +import { passwordFieldFeedback } from './user-profile-password-feedback'; +import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; + +type EditablePasswordPolicy = + | { mode: 'set'; requiresCurrentPassword: false } + | { mode: 'change'; requiresCurrentPassword: boolean }; + +type UnavailablePasswordModel = + | { status: 'hidden' } + | { + status: 'readonly'; + mode: 'set' | 'change'; + managedBy: { name?: string }; + }; + +export type UserProfilePasswordModel = + | { status: 'loading' } + | UnavailablePasswordModel + | (EditablePasswordPolicy & { + status: 'ready'; + userId: string; + sessionId: string; + identifier: string; + validatePassword: (password: string) => Promise; + updatePassword: (input: UserProfileEditPasswordValue) => Promise; + }); + +function getPasswordPolicy( + user: UserResource | null | undefined, + environment: EnvironmentResource, +): UnavailablePasswordModel | (EditablePasswordPolicy & { status: 'ready'; userId: string }) { + if (!user) { + return { status: 'hidden' }; + } + + if (!environment.userSettings.instanceIsPasswordBased) { + return { status: 'hidden' }; + } + + // TODO: When session reverification is supported, require the current password only when reverification is disabled. + const policy: EditablePasswordPolicy = user.passwordEnabled + ? { mode: 'change', requiresCurrentPassword: true } + : { mode: 'set', requiresCurrentPassword: false }; + + const enterpriseAccount = user.enterpriseAccounts.find(account => account.active); + if (enterpriseAccount) { + return { + status: 'readonly', + mode: policy.mode, + managedBy: { name: enterpriseAccount.enterpriseConnection?.name || undefined }, + }; + } + + return { status: 'ready', userId: user.id, ...policy }; +} + +export function useUserProfilePasswordModel(): UserProfilePasswordModel { + const clerk = useClerk(); + const m = useMessages('userProfilePasswordSection'); + const locale = useLocale(); + const errorText = useErrorText(); + const errorMessages = useMessages('errors'); + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + const environment = useMosaicEnvironment(); + const passwordSettings = environment?.userSettings.passwordSettings; + const moduleManager = clerk.__internal_moduleManager; + const validatePassword = useMemo( + () => + async (password: string): Promise => { + if (!passwordSettings) { + throw new FormSubmitError({ message: m.errors.unavailable }); + } + const complexity = validateComplexity(password, passwordSettings); + if (Object.keys(complexity).length > 0 || !passwordSettings.show_zxcvbn) { + return passwordFieldFeedback({ complexity }, passwordSettings, m, locale); + } + if (!moduleManager) { + throw new FormSubmitError({ message: errorMessages.generic }); + } + const { loadZxcvbn } = createLoadZxcvbn(moduleManager); + const strength = createValidatePasswordStrength(passwordSettings)(await loadZxcvbn())(password); + return passwordFieldFeedback({ complexity, strength }, passwordSettings, m, locale); + }, + [passwordSettings, moduleManager, m, locale, errorMessages], + ); + + if (!isUserLoaded || !isSessionLoaded || !environment) { + return { status: 'loading' }; + } + + if (!session) { + return { status: 'hidden' }; + } + + const policy = getPasswordPolicy(user, environment); + if (policy.status !== 'ready') { + return policy; + } + + const userId = policy.userId; + const sessionId = session.id; + + return { + ...policy, + sessionId, + identifier: session.publicUserData.identifier ?? '', + validatePassword, + // TODO: Add session reverification for password updates; surface API errors until then. + updatePassword: async ({ currentPassword, newPassword, signOutOfOtherSessions }) => { + const currentUser = clerk.user; + if ( + !currentUser || + currentUser.id !== userId || + clerk.session?.id !== sessionId || + !environment.userSettings.instanceIsPasswordBased || + currentUser.enterpriseAccounts.some(account => account.active) || + currentUser.passwordEnabled !== (policy.mode === 'change') + ) { + throw new FormSubmitError({ message: m.errors.unavailable }); + } + + try { + await currentUser.updatePassword({ + newPassword, + signOutOfOtherSessions, + ...(policy.requiresCurrentPassword ? { currentPassword } : {}), + }); + } catch (error) { + throw passwordFormError( + error, + policy.requiresCurrentPassword, + environment.userSettings.passwordSettings, + m, + locale, + errorText, + ); + } + }, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx new file mode 100644 index 00000000000..a89862fc1dd --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react'; + +import { Button } from '../../../components/button'; +import { useMessages } from '../../../localization'; +import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; +import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; +import type { UserProfilePasswordModel } from './user-profile-password-section.model'; +import { useUserProfilePasswordModel } from './user-profile-password-section.model'; +import type { UserProfilePasswordSlot } from './user-profile-password-section.types'; +import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; + +export interface UserProfilePasswordSectionProps { + fallback?: ReactNode; +} + +export function UserProfilePasswordSection(props: UserProfilePasswordSectionProps) { + return useUserProfilePasswordSlot(props)?.content ?? null; +} + +export function useUserProfilePasswordSlot({ + fallback = null, +}: UserProfilePasswordSectionProps = {}): UserProfilePasswordSlot | null { + const model = useUserProfilePasswordModel(); + const m = useMessages('userProfilePasswordSection'); + if (model.status === 'loading') { + // TODO: Add a password section skeleton as the default loading fallback. + return fallback ? { content: fallback } : null; + } + if (model.status === 'hidden') { + return null; + } + if (model.status === 'readonly') { + return { + content: ( + + ), + }; + } + return { + content: ( + + ), + }; +} + +function PasswordEditor({ model }: { model: Extract }) { + const m = useMessages('userProfilePasswordSection'); + const controller = useUserProfileEditPasswordController({ + validatePassword: model.validatePassword, + requiresCurrentPassword: model.requiresCurrentPassword, + onSubmit: model.updatePassword, + }); + + return ( + + {model.mode === 'change' ? m.change : m.set} + + } + /> + } + /> + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index 44fc4da20c6..c286af81533 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { @@ -18,9 +20,12 @@ export interface UserProfilePasswordManagedBy { } export interface UserProfilePasswordSectionViewProps { + action?: ReactNode; hasPassword?: boolean; - requiresCurrentPassword?: boolean; /** Replaces the edit action with the enterprise provider’s name. */ managedBy?: UserProfilePasswordManagedBy; - onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; +} + +export interface UserProfilePasswordSlot { + content: ReactNode; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index f941a18e8f4..96024384f37 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -12,13 +12,12 @@ export type { } from './user-profile-password-section.types'; export function UserProfilePasswordSectionView({ + action, hasPassword = false, - requiresCurrentPassword = false, managedBy, - onSubmitPassword, }: UserProfilePasswordSectionViewProps) { const m = useMessages('userProfilePasswordSection'); - if (!hasPassword && !managedBy && !onSubmitPassword) { + if (!hasPassword && !managedBy && !action) { return null; } @@ -29,10 +28,9 @@ export function UserProfilePasswordSectionView({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 9f1fe8b237d..4c1d9143a9c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -12,29 +12,12 @@ import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-p import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; -import type { - UserProfileEditPasswordValue, - UserProfilePasswordManagedBy, - UserProfilePasswordSectionViewProps, -} from './user-profile-password-section/user-profile-password-section.view'; -import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; +import type { UserProfilePasswordSlot } from './user-profile-password-section/user-profile-password-section.types'; -export type { - UserProfileDevice, - UserProfileEditPasswordValue, - UserProfileMfaAddableMethod, - UserProfileMfaMethod, - UserProfilePasskey, - UserProfilePasswordManagedBy, -}; +export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; -export interface UserProfileSecurityPanelViewProps - extends - Omit, - Pick< - UserProfilePasswordSectionViewProps, - 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' - > { +export interface UserProfileSecurityPanelViewProps extends Omit { + passwordSlot?: UserProfilePasswordSlot | null; passkeys?: UserProfilePasskey[]; passkeysVisible?: boolean; mfaMethods?: UserProfileMfaMethod[]; @@ -54,16 +37,13 @@ export interface UserProfileSecurityPanelViewProps } export function UserProfileSecurityPanelView({ - hasPassword = false, - requiresCurrentPassword, - managedBy, + passwordSlot, passkeys, passkeysVisible = true, mfaMethods, addableMfaMethods, mfaAddControl, devices, - onSubmitPassword, onAddPasskey, addPasskeyError, onRenamePasskey, @@ -76,7 +56,7 @@ export function UserProfileSecurityPanelView({ onSignOutAllOtherDevices, deleteAccountSlot, }: UserProfileSecurityPanelViewProps): ReactElement { - const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); + const showPassword = Boolean(passwordSlot); const showPasskeys = passkeys !== undefined && passkeysVisible; const hasAuthentication = showPassword || showPasskeys || mfaMethods !== undefined; @@ -86,14 +66,7 @@ export function UserProfileSecurityPanelView({ {hasAuthentication ? ( - {showPassword ? ( - - ) : null} + {passwordSlot?.content} {showPasskeys ? ( > = { + generic: 'Something went wrong. Please try again.', + form_new_password_matches_current: 'New password cannot be the same as the current password.', + form_password_incorrect: 'Your current password is incorrect.', + form_password_matches_identifier: + 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', + form_password_pwned: + 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_password_size_in_bytes_exceeded: + 'Your password has exceeded the maximum number of bytes allowed, please shorten it or remove some special characters.', + form_password_validation_failed: 'Incorrect Password', +}; diff --git a/packages/mosaic/src/localization/errors.test.tsx b/packages/mosaic/src/localization/errors.test.tsx new file mode 100644 index 00000000000..475439a4e5e --- /dev/null +++ b/packages/mosaic/src/localization/errors.test.tsx @@ -0,0 +1,62 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicCatalog } from './catalog'; +import { MosaicLocalizationProvider, resolveLocalization } from './context'; +import { useErrorText } from './errors'; + +function errorText(overrides?: MosaicCatalog) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useErrorText(), { wrapper }).result.current; +} + +describe('useErrorText', () => { + it('prefers the message for the code on that field', () => { + const text = errorText({ + 'errors.form_identifier_exists__username': 'Nombre de usuario en uso.', + 'errors.form_identifier_exists': 'Ya existe.', + }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe( + 'Nombre de usuario en uso.', + ); + }); + + it('falls back to the message for the code', () => { + const text = errorText({ 'errors.form_identifier_exists': 'Ya existe.' }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe('Ya existe.'); + }); + + it('ships English for known codes', () => { + expect(errorText()({ code: 'form_password_incorrect', message: 'api text' })).toBe( + 'Your current password is incorrect.', + ); + }); + + it('falls back to the message Clerk sent for a code it has no text for', () => { + expect(errorText()({ code: 'form_identifier_exists', message: 'That username is taken.' })).toBe( + 'That username is taken.', + ); + }); + + it('fills the values the error carries into its message', () => { + const text = errorText({ + 'errors.test_length': 'Your value must be between {min_length} and {max_length} characters long.', + }); + expect(text({ code: 'test_length', params: { min_length: 4, max_length: 64 } })).toBe( + 'Your value must be between 4 and 64 characters long.', + ); + }); + + it('fills an override the same way', () => { + const text = errorText({ 'errors.test_length': 'Entre {min_length} y {max_length} caracteres.' }); + expect(text({ code: 'test_length', params: { min_length: 4, max_length: 64 } })).toBe('Entre 4 y 64 caracteres.'); + }); + + it('falls back to the generic message when there is nothing else', () => { + expect(errorText({ 'errors.generic': 'Algo salió mal.' })({})).toBe('Algo salió mal.'); + expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); + }); +}); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts new file mode 100644 index 00000000000..172628b9e7c --- /dev/null +++ b/packages/mosaic/src/localization/errors.ts @@ -0,0 +1,20 @@ +import { useMessages } from './context'; +import type { MessageValues } from './messages'; +import { fill } from './messages'; + +export interface LocalizableError { + code?: string; + paramName?: string; + message?: string; + params?: MessageValues; +} + +export function useErrorText(): (error: LocalizableError) => string { + const messages = useMessages('errors'); + const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); + return ({ code, paramName, message, params }) => { + const template = + lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; + return params ? fill(template, params) : template; + }; +} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index fbfc32e732c..8d5633e4bac 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -1,5 +1,7 @@ export type { MosaicCatalog, MosaicLocalization } from './catalog'; export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; +export type { LocalizableError } from './errors'; +export { useErrorText } from './errors'; export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 34a933c8880..6e4a6cae586 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -27,8 +27,10 @@ import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/ import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; +import { errorMessages } from './errors.messages'; export const mosaicMessages = { + errors: errorMessages, form: formMessages, membersTableTab: membersTableTabMessages, invitationsTableTab: invitationsTableTabMessages, diff --git a/packages/mosaic/src/utils/feedback.styles.ts b/packages/mosaic/src/utils/feedback.styles.ts index 92bcd4fbd4c..52af213cff4 100644 --- a/packages/mosaic/src/utils/feedback.styles.ts +++ b/packages/mosaic/src/utils/feedback.styles.ts @@ -46,6 +46,9 @@ export const feedbackStyles = stylex.create({ error: { color: colorVars['--cl-color-negative'], }, + info: { + color: colorVars['--cl-color-foreground-secondary'], + }, success: { color: colorVars['--cl-color-positive'], }, diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index e428bb89584..5ab40cfaa2e 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -20,6 +20,7 @@ import { const flows = [ { title: 'API keys', href: '/live/api-keys' }, + { title: 'Password', href: '/live/password' }, { title: 'Delete account', href: '/live/delete-account' }, { title: 'Reverification', href: '/live/reverification' }, ]; diff --git a/packages/swingset/src/app/(clerk)/live/password/page.tsx b/packages/swingset/src/app/(clerk)/live/password/page.tsx new file mode 100644 index 00000000000..7cb0e53e3b6 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/password/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { UserProfilePasswordSection } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function PasswordLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Password

+

Set or change the password for your signed-in account.

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to manage your password. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx similarity index 54% rename from packages/swingset/src/stories/fixtures/user-profile-edit-password.ts rename to packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx index a00865cc05e..6d2fd3c4f61 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx @@ -1,6 +1,10 @@ +import { Button } from '@clerk/mosaic/components/button'; import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import { useUserProfileEditPasswordController } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller'; +import { UserProfileEditPasswordDialog } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog'; import type { UserProfileEditPasswordValue } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import { useMessages } from '@clerk/mosaic/localization'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -17,13 +21,12 @@ export function useUserProfileEditPasswordFixture({ latency = 800, failWith, }: UserProfileEditPasswordFixtureOptions = {}) { + const m = useMessages('userProfilePasswordSection'); const [hasPassword, setHasPassword] = useState(initialHasPassword); const [hasFailed, setHasFailed] = useState(false); - - return { - hasPassword, - requiresCurrentPassword, - onSubmitPassword: async (_value: UserProfileEditPasswordValue) => { + const controller = useUserProfileEditPasswordController({ + requiresCurrentPassword: hasPassword && requiresCurrentPassword, + onSubmit: async (_value: UserProfileEditPasswordValue) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith && !hasFailed) { setHasFailed(true); @@ -31,5 +34,27 @@ export function useUserProfileEditPasswordFixture({ } setHasPassword(true); }, + }); + + return { + hasPassword, + action: ( + + {hasPassword ? m.change : m.set} + + } + /> + ), }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 7221cff9cba..de99d9b055b 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -6,6 +6,7 @@ import type { UserProfileSubscription, } from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view'; import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; +import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; import type { UserProfileEmail, UserProfilePhone, @@ -149,7 +150,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), }, security: { - ...editPassword, + passwordSlot: { content: }, passkeys: passkeys.passkeys, addPasskeyError: passkeys.addError, onRenamePasskey: passkeys.onRename, diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 1aa43a70301..471efaaaba4 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -2,9 +2,9 @@ import * as Stories from './user-profile-password-section.stories'; # UserProfilePasswordSection -Password management composes a section row and an edit dialog. The row coordinates local form state; -display data and the save callback come from the caller. Enterprise-managed passwords name their -provider instead of an edit action. +Password management composes a section row and an edit dialog. The live section reads Clerk state +and translates save errors into form feedback. The view takes display data and a dialog through +`action`. Enterprise-managed passwords name their provider instead of an edit action.