From 97c8bc80336bc3ac9a2d16ff218e7e76dd8e4bff Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Mon, 14 Sep 2026 12:12:20 -0400 Subject: [PATCH 01/10] feat(ui): add the Mosaic user profile password section --- .changeset/user-profile-edit-password.md | 2 + packages/swingset/src/lib/registry.ts | 8 + .../fixtures/user-profile-edit-password.ts | 33 +++ .../src/stories/fixtures/user-profile.ts | 5 +- .../stories/user-profile-password-section.mdx | 39 ++- .../user-profile-password-section.stories.tsx | 58 +++- .../user-profile-security-panel.stories.tsx | 6 +- .../user-profile-edit-password.view.test.tsx | 259 +++++++++++++++++ .../user-profile-security-panel.view.test.tsx | 62 +++- .../user-profile-password-section.view.tsx | 40 --- ...r-profile-edit-password.controller.test.ts | 274 ++++++++++++++++++ .../user-profile-edit-password.controller.ts | 155 ++++++++++ .../user-profile-edit-password.view.tsx | 266 +++++++++++++++++ .../user-profile-password-section.messages.ts | 34 +++ .../user-profile-password-section.styles.ts | 31 ++ .../user-profile-password-section.view.tsx | 90 ++++++ .../user-profile-security-panel.view.tsx | 42 ++- 17 files changed, 1340 insertions(+), 64 deletions(-) create mode 100644 .changeset/user-profile-edit-password.md create mode 100644 packages/swingset/src/stories/fixtures/user-profile-edit-password.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx delete mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section.view.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx diff --git a/.changeset/user-profile-edit-password.md b/.changeset/user-profile-edit-password.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-edit-password.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index e905833036c..cbb5260f283 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -257,7 +257,11 @@ import { } from '../stories/user-profile-passkeys-section.stories'; import { Default as UserProfilePasswordSectionDefault, + EditPasswordFails as UserProfilePasswordSectionEditPasswordFails, meta as userProfilePasswordSectionMeta, + ReadOnly as UserProfilePasswordSectionReadOnly, + SetPassword as UserProfilePasswordSectionSetPassword, + WithoutCurrentPassword as UserProfilePasswordSectionWithoutCurrentPassword, } from '../stories/user-profile-password-section.stories'; import { Default as UserProfilePaymentMethodsSectionDefault, @@ -527,6 +531,10 @@ const userProfileBillingHistorySectionModule: StoryModule = { const userProfilePasswordSectionModule: StoryModule = { meta: userProfilePasswordSectionMeta, Default: UserProfilePasswordSectionDefault, + SetPassword: UserProfilePasswordSectionSetPassword, + WithoutCurrentPassword: UserProfilePasswordSectionWithoutCurrentPassword, + ReadOnly: UserProfilePasswordSectionReadOnly, + EditPasswordFails: UserProfilePasswordSectionEditPasswordFails, }; const userProfilePasskeysSectionModule: StoryModule = { meta: userProfilePasskeysSectionMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts new file mode 100644 index 00000000000..b735cf3aa4a --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -0,0 +1,33 @@ +import type { UserProfileFormError } from '@clerk/ui/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import { UserProfileSaveError } from '@clerk/ui/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileEditPasswordValue } from '@clerk/ui/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view'; +import { useState } from 'react'; + +export interface UserProfileEditPasswordFixtureOptions { + hasPassword?: boolean; + requiresCurrentPassword?: boolean; + latency?: number; + /** Rejects every save instead of committing it. */ + failWith?: UserProfileFormError; +} + +export function useUserProfileEditPasswordFixture({ + hasPassword: initialHasPassword = true, + requiresCurrentPassword = true, + latency = 800, + failWith, +}: UserProfileEditPasswordFixtureOptions = {}) { + const [hasPassword, setHasPassword] = useState(initialHasPassword); + + return { + hasPassword, + requiresCurrentPassword, + onSubmitPassword: async (_value: UserProfileEditPasswordValue) => { + await new Promise(resolve => setTimeout(resolve, latency)); + if (failWith) { + throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + } + setHasPassword(true); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index bc1bdc1a66f..21f95ba6de8 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -20,6 +20,7 @@ import { createUserProfileAddEmailFixture } from './user-profile-add-email'; import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; +import { useUserProfileEditPasswordFixture } from './user-profile-edit-password'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; export interface UserProfileFixtureOptions { @@ -53,6 +54,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const connections = useConnectedAccountsFixture(); const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); + const editPassword = useUserProfileEditPasswordFixture(); const [activePage, setActivePage] = useState('account'); const [emails, setEmails] = useState([ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, @@ -156,7 +158,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), }, security: { - hasPassword: true, + ...editPassword, passkeys, mfaMethods, devices, @@ -170,7 +172,6 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions ...current, { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, ]), - onChangePassword: () => undefined, onDeleteAccount: () => Promise.resolve(), onManageDevice: () => undefined, onManagePasskey: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 3f36536dac4..777e82fb0f9 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -2,10 +2,45 @@ import * as Stories from './user-profile-password-section.stories'; # UserProfilePasswordSection -Password management composed with `Section`. +## Change password + +## Set password + + + +## Without current password + + + +## Read only + + + +## Save fails + + diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index bc9b0a27cf7..b5accc477ca 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,7 +1,10 @@ -import { UserProfilePasswordSectionView } from '@clerk/ui/mosaic/features/user-profile/user-profile-password-section.view'; +import type { UserProfileFormError } from '@clerk/ui/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import { UserProfilePasswordSectionView } from '@clerk/ui/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; import type { StoryMeta } from '@/lib/types'; +import { useUserProfileEditPasswordFixture } from './fixtures/user-profile-edit-password'; + export { default as __source } from './user-profile-password-section.stories?raw'; export const meta: StoryMeta = { @@ -10,9 +13,58 @@ export const meta: StoryMeta = { title: 'UserProfilePasswordSection', label: 'Password', navigation: { category: 'Sections' }, - source: 'packages/ui/src/mosaic/features/user-profile/user-profile-password-section.view.tsx', + source: + 'packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx', }; +function PasswordSection({ + hasPassword, + requiresCurrentPassword, + isReadOnly, + failWith, +}: { + hasPassword?: boolean; + requiresCurrentPassword?: boolean; + isReadOnly?: boolean; + failWith?: UserProfileFormError; +}) { + const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); + + return ( + + ); +} + export function Default() { - return undefined} />; + return ; +} + +/** The account has no password yet, so the row sets one instead of changing one. */ +export function SetPassword() { + return ; +} + +/** Reverification already proved the user, so the dialog skips asking for the current password. */ +export function WithoutCurrentPassword() { + return ; +} + +/** The account signs in only through an enterprise connection: the dialog opens to say so, and nothing else. */ +export function ReadOnly() { + return ; +} + +/** Every save is rejected, so the dialog shows both halves of a failure at once. */ +export function EditPasswordFails() { + return ( + + ); } diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx index ee6d83edfca..4536f8f51e8 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -8,6 +8,8 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { useUserProfileEditPasswordFixture } from './fixtures/user-profile-edit-password'; + export { default as __source } from './user-profile-security-panel.stories?raw'; export const meta: StoryMeta = { @@ -20,6 +22,7 @@ export const meta: StoryMeta = { }; export function Default() { + const editPassword = useUserProfileEditPasswordFixture(); const [passkeys, setPasskeys] = useState([ { id: 'passkey', @@ -56,8 +59,8 @@ export function Default() { return ( @@ -82,7 +85,6 @@ export function Default() { { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, ]) } - onChangePassword={() => undefined} onDeleteAccount={() => Promise.resolve()} onManageDevice={() => undefined} onManagePasskey={() => undefined} diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx new file mode 100644 index 00000000000..317664f884a --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx @@ -0,0 +1,259 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { Button } from '../../../components/button'; +import { MosaicProvider } from '../../../MosaicProvider'; +import type { UserProfileEditPasswordViewProps } from '../user-profile-password-section/user-profile-edit-password.view'; +import { UserProfileEditPasswordView } from '../user-profile-password-section/user-profile-edit-password.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileEditPasswordViewProps = { + open: true, + onOpenChange: vi.fn(), + hasPassword: true, + requiresCurrentPassword: true, + currentPassword: '', + newPassword: '', + confirmPassword: '', + signOutOfOtherSessions: true, + onCurrentPasswordChange: vi.fn(), + onNewPasswordChange: vi.fn(), + onConfirmPasswordChange: vi.fn(), + onSignOutOfOtherSessionsChange: vi.fn(), + onSubmit: vi.fn(), + ...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('UserProfileEditPasswordView', () => { + it('renders nothing until the caller opens it', () => { + renderView({ open: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('names the dialog for a change and masks every field', () => { + renderView({ currentPassword: 'old', newPassword: 'new', confirmPassword: 'new' }); + + 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('reveals a password from its own eye toggle and hides it again', async () => { + const user = userEvent.setup(); + renderView({ newPassword: 'new-secret-123' }); + const newPasswordGroup = newPasswordField().closest('.cl-input-group') as HTMLElement; + + await user.click(within(newPasswordGroup).getByRole('button', { name: 'Show password' })); + + expect(newPasswordField()).toHaveAttribute('type', 'text'); + expect(newPasswordField()).toHaveValue('new-secret-123'); + expect(currentPasswordField()).toHaveAttribute('type', 'password'); + expect(confirmPasswordField()).toHaveAttribute('type', 'password'); + + await user.click(within(newPasswordGroup).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('asks to open from the trigger', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderView({ open: false, onOpenChange, trigger: }); + + await user.click(screen.getByRole('button', { name: 'Change password' })); + + expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); + }); + + it('reports each keystroke to its own field, holding nothing itself', async () => { + const onCurrentPasswordChange = vi.fn(); + const onNewPasswordChange = vi.fn(); + const onConfirmPasswordChange = vi.fn(); + const onSignOutOfOtherSessionsChange = vi.fn(); + const user = userEvent.setup(); + renderView({ + onCurrentPasswordChange, + onNewPasswordChange, + onConfirmPasswordChange, + onSignOutOfOtherSessionsChange, + }); + + await user.type(currentPasswordField(), 'a'); + await user.type(newPasswordField(), 'b'); + await user.type(confirmPasswordField(), 'c'); + await user.click(signOutCheckbox()); + + expect(onCurrentPasswordChange).toHaveBeenCalledWith('a'); + expect(onNewPasswordChange).toHaveBeenCalledWith('b'); + expect(onConfirmPasswordChange).toHaveBeenCalledWith('c'); + expect(onSignOutOfOtherSessionsChange).toHaveBeenCalledWith(false); + expect(newPasswordField()).toHaveValue(''); + expect(signOutCheckbox()).toBeChecked(); + }); + + it('submits from the action once every field is filled', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderView({ currentPassword: 'old', newPassword: 'new-secret-123', confirmPassword: 'new-secret-123', onSubmit }); + + await user.click(saveButton()); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('lets the browser hold an empty required field back', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderView({ onSubmit }); + + expect(currentPasswordField()).toBeRequired(); + expect(newPasswordField()).toBeRequired(); + expect(confirmPasswordField()).toBeRequired(); + await user.click(saveButton()); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('asks to close from cancel', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('announces the failure in a negative banner', () => { + renderView({ error: { message: 'Your password could not be updated.' } }); + + const banner = screen.getByRole('alert'); + expect(banner).toHaveAttribute('data-color', 'negative'); + expect(banner).toHaveTextContent('Your password could not be updated.'); + expect(newPasswordField()).not.toHaveAttribute('aria-invalid', 'true'); + }); + + it('renders field-scoped failures under their controls with no banner', () => { + renderView({ + error: { + fields: { + currentPassword: 'Incorrect password.', + newPassword: 'Your password must contain 8 or more characters.', + confirmPassword: "Passwords don't match.", + }, + }, + }); + + 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 caller says the value is unacceptable', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderView({ + canSave: false, + currentPassword: 'old', + newPassword: 'new-secret-123', + confirmPassword: 'new-secret-123', + onSubmit, + }); + + expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); + await user.click(saveButton()); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('stays inert while the save runs', async () => { + const onSubmit = vi.fn(); + const onNewPasswordChange = vi.fn(); + const user = userEvent.setup(); + renderView({ isSaving: true, onSubmit, onNewPasswordChange }); + + 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(onNewPasswordChange).not.toHaveBeenCalled(); + expect(saveButton()).toHaveAttribute('aria-busy', 'true'); + await user.click(saveButton()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('explains itself and offers only cancel while the password is read-only', async () => { + const onSubmit = vi.fn(); + const onConfirmPasswordChange = vi.fn(); + const user = userEvent.setup(); + renderView({ isReadOnly: true, onSubmit, onConfirmPasswordChange }); + + expect( + screen.getByText( + 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + ), + ).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(currentPasswordField()).toBeDisabled(); + expect(newPasswordField()).toBeDisabled(); + expect(confirmPasswordField()).toBeDisabled(); + expect(signOutCheckbox()).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + + await user.type(confirmPasswordField(), 'abc'); + expect(onConfirmPasswordChange).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index e3a0d051f5a..7a94a740c82 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -76,7 +76,6 @@ describe('UserProfileSecurityPanelView', () => { }); it('forwards security actions', async () => { - const onChangePassword = vi.fn(); const onAddPasskey = vi.fn(); const onManagePasskey = vi.fn(); const onRemovePasskey = vi.fn(); @@ -91,7 +90,6 @@ describe('UserProfileSecurityPanelView', () => { { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, { id: 'backup_1', type: 'backup-codes' }, ], - onChangePassword, onAddPasskey, onManagePasskey, onRemovePasskey, @@ -101,7 +99,6 @@ describe('UserProfileSecurityPanelView', () => { onDeleteAccount, }); - await user.click(screen.getByRole('button', { name: 'Change password' })); await user.click(screen.getByRole('button', { name: 'Add passkey' })); await user.click(screen.getByRole('button', { name: 'Add verification method' })); expect(screen.queryByRole('menuitem', { name: 'SMS verification' })).not.toBeInTheDocument(); @@ -123,7 +120,6 @@ describe('UserProfileSecurityPanelView', () => { await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); - expect(onChangePassword).toHaveBeenCalledOnce(); expect(onAddPasskey).toHaveBeenCalledOnce(); expect(onManagePasskey).toHaveBeenCalledWith('passkey_1'); expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1'); @@ -133,6 +129,62 @@ describe('UserProfileSecurityPanelView', () => { expect(onDeleteAccount).toHaveBeenCalledOnce(); }); + it('drives the change-password dialog from the section', async () => { + const onSubmitPassword = vi.fn(() => Promise.resolve()); + const user = userEvent.setup(); + renderView({ requiresCurrentPassword: true, onSubmitPassword }); + + expect(screen.getByText('••••••••••••••••••')).toHaveClass('cl-section-description'); + 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, passkeys: undefined, mfaMethods: undefined, onSubmitPassword }); + + expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); + expect(screen.getByText('Password')).toHaveClass('cl-section-label'); + expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); + 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 the password row inert while it is read-only', async () => { + const user = userEvent.setup(); + renderView({ isPasswordReadOnly: true, onSubmitPassword: vi.fn(() => Promise.resolve()) }); + + await user.click(screen.getByRole('button', { name: 'Change password' })); + const dialog = screen.getByRole('dialog', { name: 'Change password' }); + + expect(within(dialog).getByText(/can sign in only via the enterprise connection/)).toBeInTheDocument(); + expect(within(dialog).getByLabelText('New password')).toBeDisabled(); + expect(within(dialog).queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); + }); + it('keeps supported empty authentication methods actionable', () => { renderView({ hasPassword: false, diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section.view.tsx deleted file mode 100644 index 34545c4cbd2..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section.view.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button } from '../../components/button'; -import { Section } from '../../components/section'; - -export interface UserProfilePasswordSectionViewProps { - sectionTitle?: string; - onChangePassword?: () => void; -} - -export function UserProfilePasswordSectionView({ - sectionTitle = 'Authentication', - onChangePassword, -}: UserProfilePasswordSectionViewProps) { - return ( - - {sectionTitle ? {sectionTitle} : null} - - - - - Password - •••••••••••••••••• - - {onChangePassword ? ( - - - - ) : null} - - - - - ); -} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts new file mode 100644 index 00000000000..56b665f344c --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -0,0 +1,274 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../../machine/createActor'; +import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import { + userProfileEditPasswordMachine, + useUserProfileEditPasswordController, +} from './user-profile-edit-password.controller'; +import type { UserProfileEditPasswordValue } from './user-profile-edit-password.view'; + +function start(savePassword: (value: UserProfileEditPasswordValue) => Promise, requiresCurrentPassword = true) { + const actor = createActor(userProfileEditPasswordMachine, { + context: { savePassword, requiresCurrentPassword }, + }).start(); + actor.send({ type: 'OPEN' }); + return actor; +} + +function fill(actor: ReturnType, { current = 'old-secret', next = 'new-secret-123' } = {}) { + actor.send({ type: 'TYPE', field: 'currentPassword', value: current }); + actor.send({ type: 'TYPE', field: 'newPassword', value: next }); + actor.send({ type: 'TYPE', field: 'confirmPassword', value: next }); +} + +describe('userProfileEditPasswordMachine', () => { + it('opens with empty fields and sign-out of other devices on', () => { + const actor = start(() => Promise.resolve()); + + expect(actor.getSnapshot().value).toBe('editing'); + expect(actor.getSnapshot().context).toMatchObject({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + signOutOfOtherSessions: true, + error: undefined, + }); + }); + + it('saves the current password alongside the new one when it is required', async () => { + const savePassword = vi.fn(() => Promise.resolve()); + const actor = start(savePassword); + fill(actor); + actor.send({ type: 'TOGGLE_SIGN_OUT', value: false }); + + actor.send({ type: 'SAVE' }); + + expect(actor.getSnapshot().value).toBe('saving'); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); + expect(savePassword).toHaveBeenCalledWith({ + currentPassword: 'old-secret', + newPassword: 'new-secret-123', + signOutOfOtherSessions: false, + }); + }); + + it('leaves the current password out when reverification stands in for it', async () => { + const savePassword = vi.fn(() => Promise.resolve()); + const actor = start(savePassword, false); + actor.send({ type: 'TYPE', field: 'newPassword', value: 'new-secret-123' }); + actor.send({ type: 'TYPE', field: 'confirmPassword', value: 'new-secret-123' }); + + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); + expect(savePassword).toHaveBeenCalledWith({ + currentPassword: undefined, + newPassword: 'new-secret-123', + signOutOfOtherSessions: true, + }); + }); + + it('forgets what was typed once the save lands, and can be opened again', async () => { + const actor = start(() => Promise.resolve()); + fill(actor); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); + expect(actor.getSnapshot().status).toBe('active'); + expect(actor.getSnapshot().context.newPassword).toBe(''); + + actor.send({ type: 'OPEN' }); + expect(actor.getSnapshot().value).toBe('editing'); + }); + + it('forgets what was typed when the dialog is cancelled', () => { + const actor = start(() => Promise.resolve()); + fill(actor); + actor.send({ type: 'TOGGLE_SIGN_OUT', value: false }); + + actor.send({ type: 'CANCEL' }); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context).toMatchObject({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + signOutOfOtherSessions: true, + }); + }); + + it('keeps what was typed when the save fails, so it can be corrected', async () => { + const actor = start(() => Promise.reject(new Error('Incorrect password.'))); + fill(actor); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); + expect(actor.getSnapshot().context.newPassword).toBe('new-secret-123'); + expect(actor.getSnapshot().context.error).toEqual({ message: 'Incorrect password.', fields: undefined }); + }); + + it('carries field copy through 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 actor = start(() => Promise.reject(failure)); + fill(actor); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => + expect(actor.getSnapshot().context.error?.fields).toEqual({ + newPassword: '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 actor = start(() => Promise.reject('nope')); + fill(actor); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => + expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), + ); + }); + + it('refuses to save until both halves match', () => { + const savePassword = vi.fn(() => Promise.resolve()); + const actor = start(savePassword); + fill(actor); + actor.send({ type: 'TYPE', field: 'confirmPassword', value: 'new-secret-124' }); + + actor.send({ type: 'SAVE' }); + + expect(actor.getSnapshot().value).toBe('editing'); + expect(savePassword).not.toHaveBeenCalled(); + }); + + it('refuses to save an empty password', () => { + const savePassword = vi.fn(() => Promise.resolve()); + const actor = start(savePassword); + actor.send({ type: 'TYPE', field: 'currentPassword', value: 'old-secret' }); + + actor.send({ type: 'SAVE' }); + + expect(actor.getSnapshot().value).toBe('editing'); + expect(savePassword).not.toHaveBeenCalled(); + }); + + it('refuses to save without the current password when it is required', () => { + const savePassword = vi.fn(() => Promise.resolve()); + const actor = start(savePassword); + fill(actor, { current: '' }); + + actor.send({ type: 'SAVE' }); + + expect(actor.getSnapshot().value).toBe('editing'); + expect(savePassword).not.toHaveBeenCalled(); + }); +}); + +describe('useUserProfileEditPasswordController', () => { + function renderController(onSubmit = () => Promise.resolve(), requiresCurrentPassword = true) { + return renderHook(() => useUserProfileEditPasswordController({ requiresCurrentPassword, onSubmit })); + } + + it('holds the dialog open across editing and saving, then closes on success', async () => { + const { result } = renderController(); + expect(result.current.isOpen).toBe(false); + + act(() => result.current.onOpenChange(true)); + expect(result.current.isOpen).toBe(true); + expect(result.current.signOutOfOtherSessions).toBe(true); + expect(result.current.isSaving).toBe(false); + + act(() => result.current.onCurrentPasswordChange('old-secret')); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + expect(result.current.newPassword).toBe('new-secret-123'); + + act(() => result.current.onSubmit()); + expect(result.current.isOpen).toBe(true); + expect(result.current.isSaving).toBe(true); + + await waitFor(() => expect(result.current.isOpen).toBe(false)); + }); + + it('saves the values it is currently holding', async () => { + const onSubmit = vi.fn(() => Promise.resolve()); + const { result } = renderController(onSubmit); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onCurrentPasswordChange('old-secret')); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + act(() => result.current.onSignOutOfOtherSessionsChange(false)); + act(() => result.current.onSubmit()); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + currentPassword: 'old-secret', + newPassword: 'new-secret-123', + signOutOfOtherSessions: false, + }), + ); + }); + + it('withholds the save until the halves match and the current password is in', () => { + const { result } = renderController(); + + act(() => result.current.onOpenChange(true)); + expect(result.current.canSave).toBe(false); + + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + expect(result.current.canSave).toBe(false); + + act(() => result.current.onCurrentPasswordChange('old-secret')); + expect(result.current.canSave).toBe(true); + }); + + it('does not ask for the current password when it is not required', () => { + const { result } = renderController(() => Promise.resolve(), false); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + + expect(result.current.canSave).toBe(true); + }); + + it('names the mismatch under the confirmation once it has a value', () => { + const { result } = renderController(); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onNewPasswordChange('new-secret-123')); + expect(result.current.error).toBeUndefined(); + + act(() => result.current.onConfirmPasswordChange('new-secret-12')); + expect(result.current.error).toEqual({ fields: { confirmPassword: "Passwords don't match." } }); + + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + expect(result.current.error).toBeUndefined(); + }); + + it('keeps a failed save visible next to a fresh mismatch', async () => { + const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onCurrentPasswordChange('old-secret')); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + act(() => result.current.onSubmit()); + await waitFor(() => expect(result.current.error?.message).toBe('Incorrect password.')); + + act(() => result.current.onConfirmPasswordChange('new-secret-12')); + + expect(result.current.error).toEqual({ + message: 'Incorrect password.', + fields: { confirmPassword: "Passwords don't match." }, + }); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts new file mode 100644 index 00000000000..78937fd2d51 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -0,0 +1,155 @@ +import { setup } from '../../../machine/setup'; +import { useMachine } from '../../../machine/useMachine'; +import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; +import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-edit-password.view'; +import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; + +export interface UserProfileEditPasswordContext { + savePassword: (value: UserProfileEditPasswordValue) => Promise; + requiresCurrentPassword: boolean; + currentPassword: string; + newPassword: string; + confirmPassword: string; + signOutOfOtherSessions: boolean; + error: UserProfileFormError | undefined; +} + +export type UserProfileEditPasswordEvent = + | { type: 'OPEN' } + | { type: 'TYPE'; field: UserProfileEditPasswordField; value: string } + | { type: 'TOGGLE_SIGN_OUT'; value: boolean } + | { type: 'SAVE' } + | { type: 'CANCEL' }; + +const { createMachine, assign, fromPromise } = setup(); + +function notSeated(): Promise { + return Promise.reject(new Error('edit-password deps are not seated')); +} + +const emptyFields = { + currentPassword: '', + newPassword: '', + confirmPassword: '', + signOutOfOtherSessions: true, + error: undefined, +}; + +export function passwordsMismatch(context: UserProfileEditPasswordContext): boolean { + return context.confirmPassword !== '' && context.confirmPassword !== context.newPassword; +} + +export function isSaveable(context: UserProfileEditPasswordContext): boolean { + return ( + context.newPassword !== '' && + context.confirmPassword === context.newPassword && + (!context.requiresCurrentPassword || context.currentPassword !== '') + ); +} + +function toFormError(cause: unknown): UserProfileFormError { + if (cause instanceof UserProfileSaveError) { + return { message: cause.message, fields: cause.fields }; + } + if (cause instanceof Error) { + return { message: cause.message }; + } + return { message: m.errors.generic }; +} + +export const userProfileEditPasswordMachine = createMachine({ + id: 'editPassword', + initial: 'idle', + context: { + savePassword: notSeated, + requiresCurrentPassword: false, + ...emptyFields, + }, + states: { + idle: { + on: { + OPEN: { target: 'editing', actions: assign(() => emptyFields) }, + }, + }, + editing: { + on: { + TYPE: { actions: assign((_, event) => ({ [event.field]: event.value })) }, + TOGGLE_SIGN_OUT: { actions: assign((_, event) => ({ signOutOfOtherSessions: event.value })) }, + SAVE: { target: 'saving', guard: isSaveable }, + CANCEL: { target: 'idle', actions: assign(() => emptyFields) }, + }, + }, + saving: { + invoke: fromPromise( + context => + context.savePassword({ + currentPassword: context.requiresCurrentPassword ? context.currentPassword : undefined, + newPassword: context.newPassword, + signOutOfOtherSessions: context.signOutOfOtherSessions, + }), + { + onDone: { target: 'idle', actions: assign(() => emptyFields) }, + onError: { + target: 'editing', + actions: assign((_, event) => ({ error: toFormError(event.error) })), + }, + }, + ), + }, + }, +}); + +export interface UserProfileEditPasswordControllerOptions { + /** Whether the save must carry the password being replaced. Off when reverification stands in for it. */ + requiresCurrentPassword?: boolean; + /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ + onSubmit: (value: UserProfileEditPasswordValue) => Promise; +} + +export interface UserProfileEditPasswordController { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + currentPassword: string; + newPassword: string; + confirmPassword: string; + signOutOfOtherSessions: boolean; + onCurrentPasswordChange: (value: string) => void; + onNewPasswordChange: (value: string) => void; + onConfirmPasswordChange: (value: string) => void; + onSignOutOfOtherSessionsChange: (value: boolean) => void; + onSubmit: () => void; + canSave: boolean; + isSaving: boolean; + error: UserProfileFormError | undefined; +} + +export function useUserProfileEditPasswordController({ + requiresCurrentPassword = false, + onSubmit, +}: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { + const [snapshot, send] = useMachine(userProfileEditPasswordMachine, { + context: { savePassword: onSubmit, requiresCurrentPassword }, + }); + const { context } = snapshot; + const error = passwordsMismatch(context) + ? { ...context.error, fields: { ...context.error?.fields, confirmPassword: m.errors.mismatch } } + : context.error; + + return { + isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + currentPassword: context.currentPassword, + newPassword: context.newPassword, + confirmPassword: context.confirmPassword, + signOutOfOtherSessions: context.signOutOfOtherSessions, + onCurrentPasswordChange: value => send({ type: 'TYPE', field: 'currentPassword', value }), + onNewPasswordChange: value => send({ type: 'TYPE', field: 'newPassword', value }), + onConfirmPasswordChange: value => send({ type: 'TYPE', field: 'confirmPassword', value }), + onSignOutOfOtherSessionsChange: value => send({ type: 'TOGGLE_SIGN_OUT', value }), + onSubmit: () => send({ type: 'SAVE' }), + canSave: isSaveable(context), + isSaving: snapshot.value === 'saving', + error, + }; +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx new file mode 100644 index 00000000000..c7586733ec5 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx @@ -0,0 +1,266 @@ +import * as stylex from '@stylexjs/stylex'; +import type { FormEvent, RefObject } from 'react'; +import { useId, useRef, useState } from 'react'; + +import { Banner } from '../../../components/banner'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import type { DialogTriggerProps } from '../../../components/dialog'; +import { Dialog } from '../../../components/dialog'; +import { Field } from '../../../components/field'; +import { Icon } from '../../../components/icon'; +import { InputGroup } from '../../../components/input-group'; +import { Text } from '../../../components/text'; +import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; +import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; +import { styles } from './user-profile-password-section.styles'; + +export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; + +export interface UserProfileEditPasswordValue { + /** Only carried when the flow asked for it. */ + currentPassword?: string; + newPassword: string; + signOutOfOtherSessions: boolean; +} + +export interface UserProfileEditPasswordViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Rendering the opener here is what returns focus to it on close. */ + trigger?: DialogTriggerProps['render']; + /** Whether a password is being replaced or set for the first time. Decides the copy. */ + hasPassword?: boolean; + /** Asks for the password being replaced. Off when reverification stands in for it. */ + requiresCurrentPassword?: boolean; + /** + * The password cannot change while the account signs in only through an enterprise connection. + * The dialog still opens so it can say so, but every field is inert and only cancel remains. + */ + isReadOnly?: boolean; + currentPassword: string; + newPassword: string; + confirmPassword: string; + signOutOfOtherSessions: boolean; + onCurrentPasswordChange: (value: string) => void; + onNewPasswordChange: (value: string) => void; + onConfirmPasswordChange: (value: string) => void; + onSignOutOfOtherSessionsChange: (value: boolean) => void; + canSave?: boolean; + isSaving?: boolean; + error?: UserProfileFormError; + onSubmit: () => void; +} + +/** + * Sets or replaces the user's password. Holds nothing, and validates nothing itself: whether the + * halves match and whether the new password is acceptable arrive as `canSave` and `error`, so the + * rules live in one place and a rejection from the API lands the same way a local one does. + */ +export function UserProfileEditPasswordView({ + open, + onOpenChange, + trigger, + hasPassword = false, + requiresCurrentPassword = false, + isReadOnly = false, + currentPassword, + newPassword, + confirmPassword, + signOutOfOtherSessions, + onCurrentPasswordChange, + onNewPasswordChange, + onConfirmPasswordChange, + onSignOutOfOtherSessionsChange, + canSave = true, + isSaving = false, + error, + onSubmit, +}: UserProfileEditPasswordViewProps) { + const formId = useId(); + const signOutId = useId(); + const signOutDescriptionId = useId(); + const initialFocusRef = useRef(null); + const showCurrentPassword = hasPassword && requiresCurrentPassword; + const inert = isSaving || isReadOnly; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSave && !inert) { + onSubmit(); + } + }; + + return ( + + {trigger ? : null} + + + + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } + > + {isReadOnly ? ( + + {m.readOnly} + + ) : null} + {error?.message ? ( + + {error.message} + + ) : null} + {showCurrentPassword ? ( + + ) : null} + + +
+ onSignOutOfOtherSessionsChange(event.target.checked)} + /> +
+ } + size='sm' + {...stylex.props(styles.checkboxLabel)} + > + {m.signOutOfOtherSessionsLabel} + + + {m.signOutOfOtherSessionsDescription} + +
+
+
+ + + {m.cancel} + + } + /> + {isReadOnly ? null : ( + + {m.save} + + )} + +
+
+
+ ); +} + +function PasswordField({ + label, + autoComplete, + disabled, + error, + inputRef, + value, + onChange, +}: { + label: string; + autoComplete: 'current-password' | 'new-password'; + disabled: boolean; + error?: string; + inputRef?: RefObject; + value: string; + onChange: (value: string) => void; +}) { + const [visible, setVisible] = useState(false); + + return ( + + {label} + + onChange(event.target.value)} + /> + + + + + {error ? {error} : null} + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts new file mode 100644 index 00000000000..7fed10d4ca1 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -0,0 +1,34 @@ +/** + * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so + * localizing this component is a matter of registering the namespace and swapping the reads for + * `useMessages('userProfilePasswordSection', userProfilePasswordSectionBase)`, not of hunting the + * literals down first. + */ +export const userProfilePasswordSectionBase = { + sectionTitle: 'Authentication', + label: 'Password', + masked: '••••••••••••••••••', + change: 'Change password', + set: 'Set password', + + dialogTitle: { + change: 'Change password', + set: 'Set password', + }, + readOnly: 'Your password can currently not be edited because you can sign in only via the enterprise connection.', + currentPasswordLabel: 'Current password', + newPasswordLabel: 'New password', + confirmPasswordLabel: 'Confirm password', + showPassword: 'Show password', + hidePassword: 'Hide password', + signOutOfOtherSessionsLabel: 'Sign out of all other devices', + signOutOfOtherSessionsDescription: + 'It is recommended to sign out of all other devices which may have used your old password.', + cancel: 'Cancel', + save: 'Save changes', + + errors: { + mismatch: "Passwords don't match.", + generic: 'Something went wrong. Please try again.', + }, +}; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts new file mode 100644 index 00000000000..a3a1bd5d72e --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts @@ -0,0 +1,31 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space } from '../../../tokens.stylex'; + +export const styles = stylex.create({ + checkboxField: { + gap: space['2'], + alignItems: 'flex-start', + display: 'flex', + }, + checkbox: { + accentColor: colorVars['--cl-color-primary'], + cursor: 'pointer', + flexShrink: 0, + marginBlockStart: '2px', + height: space['4'], + width: space['4'], + }, + checkboxCopy: { + gap: space['1'], + display: 'flex', + flexDirection: 'column', + }, + checkboxLabel: { + cursor: 'pointer', + fontWeight: fontWeightVars['--cl-font-medium'], + }, + checkboxDescription: { + color: colorVars['--cl-color-neutral-faded'], + }, +}); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx new file mode 100644 index 00000000000..1f761a24e7e --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -0,0 +1,90 @@ +import { Button } from '../../../components/button'; +import { Section } from '../../../components/section'; +import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; +import type { UserProfileEditPasswordValue } from './user-profile-edit-password.view'; +import { UserProfileEditPasswordView } from './user-profile-edit-password.view'; +import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; + +export type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-edit-password.view'; + +export interface UserProfilePasswordSectionViewProps { + sectionTitle?: string; + /** Whether the user has a password. Decides between replacing one and setting the first. */ + hasPassword?: boolean; + /** Whether the save must carry the password being replaced. Off when reverification stands in for it. */ + requiresCurrentPassword?: boolean; + /** The account signs in only through an enterprise connection, so the password cannot change. */ + isReadOnly?: boolean; + /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ + onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; +} + +export function UserProfilePasswordSectionView({ + sectionTitle = m.sectionTitle, + hasPassword = false, + requiresCurrentPassword = false, + isReadOnly = false, + onSubmitPassword, +}: UserProfilePasswordSectionViewProps) { + return ( + + {sectionTitle ? {sectionTitle} : null} + + + + + {m.label} + {hasPassword ? {m.masked} : null} + + {onSubmitPassword ? ( + + + + ) : null} + + + + + ); +} + +function EditPassword({ + hasPassword, + requiresCurrentPassword, + isReadOnly, + onSubmit, +}: { + hasPassword: boolean; + requiresCurrentPassword: boolean; + isReadOnly: boolean; + onSubmit: (value: UserProfileEditPasswordValue) => Promise; +}) { + const controller = useUserProfileEditPasswordController({ + requiresCurrentPassword: hasPassword && requiresCurrentPassword, + onSubmit, + }); + + return ( + + {hasPassword ? m.change : m.set} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-security-panel.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-security-panel.view.tsx index 1e72465437e..0d7c5fce703 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-security-panel.view.tsx @@ -13,17 +13,29 @@ 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 { UserProfilePasswordSectionView } from './user-profile-password-section.view'; +import type { + UserProfileEditPasswordValue, + UserProfilePasswordSectionViewProps, +} from './user-profile-password-section/user-profile-password-section.view'; +import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; import { styles } from './user-profile-security-panel.styles'; -export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; +export type { + UserProfileDevice, + UserProfileEditPasswordValue, + UserProfileMfaAddableMethod, + UserProfileMfaMethod, + UserProfilePasskey, +}; -export interface UserProfileSecurityPanelViewProps extends Omit { - hasPassword?: boolean; +export interface UserProfileSecurityPanelViewProps + extends + Omit, + Pick { + isPasswordReadOnly?: boolean; passkeys?: UserProfilePasskey[]; mfaMethods?: UserProfileMfaMethod[]; devices?: UserProfileDevice[]; - onChangePassword?: () => void; onAddPasskey?: () => void; onManagePasskey?: (id: string) => void; onRemovePasskey?: (id: string) => void; @@ -36,10 +48,12 @@ export interface UserProfileSecurityPanelViewProps extends Omit @@ -59,11 +74,18 @@ export function UserProfileSecurityPanelView({
{hasAuthentication ? (
- {hasPassword ? : null} + {showPassword ? ( + + ) : null} {passkeys !== undefined ? ( Date: Mon, 14 Sep 2026 12:23:27 -0400 Subject: [PATCH 02/10] refactor(ui): name the password section's enterprise gate for its cause --- packages/swingset/src/lib/registry.ts | 4 ++-- .../stories/user-profile-password-section.mdx | 4 ++-- .../user-profile-password-section.stories.tsx | 10 +++++----- .../user-profile-edit-password.view.test.tsx | 4 ++-- .../user-profile-security-panel.view.test.tsx | 4 ++-- .../user-profile-edit-password.view.tsx | 17 +++++++++-------- .../user-profile-password-section.messages.ts | 3 ++- .../user-profile-password-section.view.tsx | 14 +++++++------- .../user-profile-security-panel.view.tsx | 6 +++--- 9 files changed, 34 insertions(+), 32 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index cbb5260f283..fd793c905f2 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -256,10 +256,10 @@ import { meta as userProfilePasskeysSectionMeta, } from '../stories/user-profile-passkeys-section.stories'; import { + ActiveEnterpriseAccount as UserProfilePasswordSectionActiveEnterpriseAccount, Default as UserProfilePasswordSectionDefault, EditPasswordFails as UserProfilePasswordSectionEditPasswordFails, meta as userProfilePasswordSectionMeta, - ReadOnly as UserProfilePasswordSectionReadOnly, SetPassword as UserProfilePasswordSectionSetPassword, WithoutCurrentPassword as UserProfilePasswordSectionWithoutCurrentPassword, } from '../stories/user-profile-password-section.stories'; @@ -533,7 +533,7 @@ const userProfilePasswordSectionModule: StoryModule = { Default: UserProfilePasswordSectionDefault, SetPassword: UserProfilePasswordSectionSetPassword, WithoutCurrentPassword: UserProfilePasswordSectionWithoutCurrentPassword, - ReadOnly: UserProfilePasswordSectionReadOnly, + ActiveEnterpriseAccount: UserProfilePasswordSectionActiveEnterpriseAccount, EditPasswordFails: UserProfilePasswordSectionEditPasswordFails, }; const userProfilePasskeysSectionModule: StoryModule = { diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 777e82fb0f9..77834d821fc 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -31,10 +31,10 @@ import * as Stories from './user-profile-password-section.stories'; storyModule={Stories} /> -## Read only +## Active enterprise account diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index b5accc477ca..493c530e7da 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -20,12 +20,12 @@ export const meta: StoryMeta = { function PasswordSection({ hasPassword, requiresCurrentPassword, - isReadOnly, + hasActiveEnterpriseAccount, failWith, }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - isReadOnly?: boolean; + hasActiveEnterpriseAccount?: boolean; failWith?: UserProfileFormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); @@ -33,7 +33,7 @@ function PasswordSection({ return ( ); } @@ -53,8 +53,8 @@ export function WithoutCurrentPassword() { } /** The account signs in only through an enterprise connection: the dialog opens to say so, and nothing else. */ -export function ReadOnly() { - return ; +export function ActiveEnterpriseAccount() { + return ; } /** Every save is rejected, so the dialog shows both halves of a failure at once. */ diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx index 317664f884a..7e1d17e8f60 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx @@ -233,11 +233,11 @@ describe('UserProfileEditPasswordView', () => { expect(onSubmit).not.toHaveBeenCalled(); }); - it('explains itself and offers only cancel while the password is read-only', async () => { + it('explains itself and offers only cancel while an enterprise account is active', async () => { const onSubmit = vi.fn(); const onConfirmPasswordChange = vi.fn(); const user = userEvent.setup(); - renderView({ isReadOnly: true, onSubmit, onConfirmPasswordChange }); + renderView({ hasActiveEnterpriseAccount: true, onSubmit, onConfirmPasswordChange }); expect( screen.getByText( diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 7a94a740c82..a9c36157649 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -173,9 +173,9 @@ describe('UserProfileSecurityPanelView', () => { }); }); - it('keeps the password row inert while it is read-only', async () => { + it('keeps the password row inert while an enterprise account is active', async () => { const user = userEvent.setup(); - renderView({ isPasswordReadOnly: true, onSubmitPassword: vi.fn(() => Promise.resolve()) }); + renderView({ hasActiveEnterpriseAccount: true, onSubmitPassword: vi.fn(() => Promise.resolve()) }); await user.click(screen.getByRole('button', { name: 'Change password' })); const dialog = screen.getByRole('dialog', { name: 'Change password' }); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx index c7586733ec5..0fa20d24a2b 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx @@ -34,10 +34,11 @@ export interface UserProfileEditPasswordViewProps { /** Asks for the password being replaced. Off when reverification stands in for it. */ requiresCurrentPassword?: boolean; /** - * The password cannot change while the account signs in only through an enterprise connection. - * The dialog still opens so it can say so, but every field is inert and only cancel remains. + * Whether the account signs in through an active enterprise connection, which is what stops the + * password from changing. The dialog still opens so it can say so, but every field is inert and + * only cancel remains. */ - isReadOnly?: boolean; + hasActiveEnterpriseAccount?: boolean; currentPassword: string; newPassword: string; confirmPassword: string; @@ -63,7 +64,7 @@ export function UserProfileEditPasswordView({ trigger, hasPassword = false, requiresCurrentPassword = false, - isReadOnly = false, + hasActiveEnterpriseAccount = false, currentPassword, newPassword, confirmPassword, @@ -82,7 +83,7 @@ export function UserProfileEditPasswordView({ const signOutDescriptionId = useId(); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; - const inert = isSaving || isReadOnly; + const inert = isSaving || hasActiveEnterpriseAccount; const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -117,9 +118,9 @@ export function UserProfileEditPasswordView({ /> } > - {isReadOnly ? ( + {hasActiveEnterpriseAccount ? ( - {m.readOnly} + {m.enterpriseAccount} ) : null} {error?.message ? ( @@ -198,7 +199,7 @@ export function UserProfileEditPasswordView({ } /> - {isReadOnly ? null : ( + {hasActiveEnterpriseAccount ? null : ( Promise; } @@ -23,7 +23,7 @@ export function UserProfilePasswordSectionView({ sectionTitle = m.sectionTitle, hasPassword = false, requiresCurrentPassword = false, - isReadOnly = false, + hasActiveEnterpriseAccount = false, onSubmitPassword, }: UserProfilePasswordSectionViewProps) { return ( @@ -40,7 +40,7 @@ export function UserProfilePasswordSectionView({ @@ -56,12 +56,12 @@ export function UserProfilePasswordSectionView({ function EditPassword({ hasPassword, requiresCurrentPassword, - isReadOnly, + hasActiveEnterpriseAccount, onSubmit, }: { hasPassword: boolean; requiresCurrentPassword: boolean; - isReadOnly: boolean; + hasActiveEnterpriseAccount: boolean; onSubmit: (value: UserProfileEditPasswordValue) => Promise; }) { const controller = useUserProfileEditPasswordController({ @@ -73,7 +73,7 @@ function EditPassword({ , Pick { - isPasswordReadOnly?: boolean; + hasActiveEnterpriseAccount?: boolean; passkeys?: UserProfilePasskey[]; mfaMethods?: UserProfileMfaMethod[]; devices?: UserProfileDevice[]; @@ -49,7 +49,7 @@ export interface UserProfileSecurityPanelViewProps export function UserProfileSecurityPanelView({ hasPassword = false, requiresCurrentPassword, - isPasswordReadOnly, + hasActiveEnterpriseAccount, passkeys, mfaMethods, devices, @@ -77,7 +77,7 @@ export function UserProfileSecurityPanelView({ {showPassword ? ( From 0881853fd61388239b1d48ee8b130cf6d5dfd7e5 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Tue, 15 Sep 2026 11:40:41 -0600 Subject: [PATCH 03/10] feat(ui): add managed-by label and no-password copy to the Mosaic password section Show "No password set" under the Password label when the account has no password, and render a "Managed by {name}" label with the connection's logo (or a lock fallback) in place of the edit action when an enterprise connection owns the password, rather than opening the dialog. Adds a `security-lock` glyph and a swingset story/placeholder for the managed states. Co-Authored-By: Claude Opus 4.8 --- packages/swingset/public/okta-placeholder.svg | 3 + packages/swingset/src/lib/registry.ts | 4 +- .../stories/user-profile-password-section.mdx | 8 ++- .../user-profile-password-section.stories.tsx | 34 ++++++---- .../user-profile-security-panel.view.test.tsx | 15 ++--- .../user-profile-password-section.messages.ts | 2 + .../user-profile-password-section.styles.ts | 13 ++++ .../user-profile-password-section.view.tsx | 63 ++++++++++++++++--- .../user-profile-security-panel.view.tsx | 14 +++-- packages/ui/src/mosaic/icons/registry.tsx | 8 +++ 10 files changed, 126 insertions(+), 38 deletions(-) create mode 100644 packages/swingset/public/okta-placeholder.svg diff --git a/packages/swingset/public/okta-placeholder.svg b/packages/swingset/public/okta-placeholder.svg new file mode 100644 index 00000000000..b948b928493 --- /dev/null +++ b/packages/swingset/public/okta-placeholder.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index fd793c905f2..d7a2fea1761 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -256,9 +256,9 @@ import { meta as userProfilePasskeysSectionMeta, } from '../stories/user-profile-passkeys-section.stories'; import { - ActiveEnterpriseAccount as UserProfilePasswordSectionActiveEnterpriseAccount, Default as UserProfilePasswordSectionDefault, EditPasswordFails as UserProfilePasswordSectionEditPasswordFails, + ManagedByEnterprise as UserProfilePasswordSectionManagedByEnterprise, meta as userProfilePasswordSectionMeta, SetPassword as UserProfilePasswordSectionSetPassword, WithoutCurrentPassword as UserProfilePasswordSectionWithoutCurrentPassword, @@ -533,7 +533,7 @@ const userProfilePasswordSectionModule: StoryModule = { Default: UserProfilePasswordSectionDefault, SetPassword: UserProfilePasswordSectionSetPassword, WithoutCurrentPassword: UserProfilePasswordSectionWithoutCurrentPassword, - ActiveEnterpriseAccount: UserProfilePasswordSectionActiveEnterpriseAccount, + ManagedByEnterprise: UserProfilePasswordSectionManagedByEnterprise, EditPasswordFails: UserProfilePasswordSectionEditPasswordFails, }; const userProfilePasskeysSectionModule: StoryModule = { diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index 77834d821fc..a72063fded9 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -31,10 +31,14 @@ import * as Stories from './user-profile-password-section.stories'; storyModule={Stories} /> -## Active enterprise account +## Managed by an enterprise connection + +The connection's logo is loaded from the image URL passed as `managedBy.iconUrl` — in production +that's the enterprise account's `logoPublicUrl`. When a connection ships no logo, a lock icon stands +in. The Okta logo below is a placeholder standing in for that URL. diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index 493c530e7da..9059ae0091d 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -20,24 +20,21 @@ export const meta: StoryMeta = { function PasswordSection({ hasPassword, requiresCurrentPassword, - hasActiveEnterpriseAccount, failWith, }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - hasActiveEnterpriseAccount?: boolean; failWith?: UserProfileFormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); - return ( - - ); + return ; } +// Stands in for the enterprise account's `logoPublicUrl`: a real hosted image URL, served from +// swingset's `public/` the same way production serves the connection's logo. +const oktaIcon = '/okta-placeholder.svg'; + export function Default() { return ; } @@ -52,9 +49,24 @@ export function WithoutCurrentPassword() { return ; } -/** The account signs in only through an enterprise connection: the dialog opens to say so, and nothing else. */ -export function ActiveEnterpriseAccount() { - return ; +/** + * An enterprise connection owns the password, so the row names who manages it in place of an edit + * action and never opens the dialog. The connection's logo leads the label, or a generic lock when + * a custom IDP ships none. + */ +export function ManagedByEnterprise() { + return ( +
+ + +
+ ); } /** Every save is rejected, so the dialog shows both halves of a failure at once. */ diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index a9c36157649..fd127a3f8ab 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -159,6 +159,7 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); expect(screen.getByText('Password')).toHaveClass('cl-section-label'); expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); + expect(screen.getByText('No password set')).toHaveClass('cl-section-description'); 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(); @@ -173,16 +174,12 @@ describe('UserProfileSecurityPanelView', () => { }); }); - it('keeps the password row inert while an enterprise account is active', async () => { - const user = userEvent.setup(); - renderView({ hasActiveEnterpriseAccount: true, onSubmitPassword: vi.fn(() => Promise.resolve()) }); - - await user.click(screen.getByRole('button', { name: 'Change password' })); - const dialog = screen.getByRole('dialog', { name: 'Change password' }); + it('shows who manages the password in place of an edit action when an enterprise connection owns it', () => { + renderView({ managedBy: { name: 'Okta' }, onSubmitPassword: vi.fn(() => Promise.resolve()) }); - expect(within(dialog).getByText(/can sign in only via the enterprise connection/)).toBeInTheDocument(); - expect(within(dialog).getByLabelText('New password')).toBeDisabled(); - expect(within(dialog).queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); + expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Set password' })).not.toBeInTheDocument(); }); it('keeps supported empty authentication methods actionable', () => { diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 0aa03d530ba..ea5837ef0e2 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -8,6 +8,8 @@ export const userProfilePasswordSectionBase = { sectionTitle: 'Authentication', label: 'Password', masked: '••••••••••••••••••', + noPasswordSet: 'No password set', + managedBy: 'Managed by {name}', change: 'Change password', set: 'Set password', diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts index a3a1bd5d72e..dbc4e473700 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts @@ -28,4 +28,17 @@ export const styles = stylex.create({ checkboxDescription: { color: colorVars['--cl-color-neutral-faded'], }, + managedBy: { + gap: space['1.5'], + alignItems: 'center', + display: 'flex', + }, + managedByIcon: { + flexShrink: 0, + height: space['4'], + width: space['4'], + }, + managedByText: { + color: colorVars['--cl-color-neutral-faded'], + }, }); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index b0f421d6bdb..4c76e069bb4 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -1,20 +1,37 @@ +import * as stylex from '@stylexjs/stylex'; + import { Button } from '../../../components/button'; +import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; +import { Text } from '../../../components/text'; +import { fill } from '../user-profile-account-section/user-profile-account-section.messages'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-edit-password.view'; import { UserProfileEditPasswordView } from './user-profile-edit-password.view'; import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; +import { styles } from './user-profile-password-section.styles'; export type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-edit-password.view'; +/** The enterprise connection that owns the password, shown in place of the edit action. */ +export interface UserProfilePasswordManagedBy { + /** The connection's display name, e.g. `'Okta'`. Rendered as "Managed by {name}". */ + name: string; + /** The connection's logo. A generic lock stands in when absent (a custom IDP with no icon). */ + iconUrl?: string; +} + export interface UserProfilePasswordSectionViewProps { sectionTitle?: string; /** Whether the user has a password. Decides between replacing one and setting the first. */ hasPassword?: boolean; /** Whether the save must carry the password being replaced. Off when reverification stands in for it. */ requiresCurrentPassword?: boolean; - /** Supplied from `user.enterpriseAccounts`. An active one stops the password from changing. */ - hasActiveEnterpriseAccount?: boolean; + /** + * Supplied from `user.enterpriseAccounts`. When set, the connection owns the password: the row + * shows "Managed by {name}" in place of the edit action and never opens the dialog. + */ + managedBy?: UserProfilePasswordManagedBy; /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; } @@ -23,7 +40,7 @@ export function UserProfilePasswordSectionView({ sectionTitle = m.sectionTitle, hasPassword = false, requiresCurrentPassword = false, - hasActiveEnterpriseAccount = false, + managedBy, onSubmitPassword, }: UserProfilePasswordSectionViewProps) { return ( @@ -34,13 +51,16 @@ export function UserProfilePasswordSectionView({ {m.label} - {hasPassword ? {m.masked} : null} + {hasPassword ? m.masked : m.noPasswordSet} - {onSubmitPassword ? ( + {managedBy ? ( + + + + ) : onSubmitPassword ? ( @@ -53,15 +73,41 @@ export function UserProfilePasswordSectionView({ ); } +function ManagedByLabel({ name, iconUrl }: UserProfilePasswordManagedBy) { + return ( +
+ {iconUrl ? ( + + ) : ( + + )} + } + size='sm' + {...stylex.props(styles.managedByText)} + > + {fill(m.managedBy, { name })} + +
+ ); +} + function EditPassword({ hasPassword, requiresCurrentPassword, - hasActiveEnterpriseAccount, onSubmit, }: { hasPassword: boolean; requiresCurrentPassword: boolean; - hasActiveEnterpriseAccount: boolean; onSubmit: (value: UserProfileEditPasswordValue) => Promise; }) { const controller = useUserProfileEditPasswordController({ @@ -73,7 +119,6 @@ function EditPassword({ , - Pick { - hasActiveEnterpriseAccount?: boolean; + Pick< + UserProfilePasswordSectionViewProps, + 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' + > { passkeys?: UserProfilePasskey[]; mfaMethods?: UserProfileMfaMethod[]; devices?: UserProfileDevice[]; @@ -49,7 +53,7 @@ export interface UserProfileSecurityPanelViewProps export function UserProfileSecurityPanelView({ hasPassword = false, requiresCurrentPassword, - hasActiveEnterpriseAccount, + managedBy, passkeys, mfaMethods, devices, @@ -65,7 +69,7 @@ export function UserProfileSecurityPanelView({ onSignOutAllOtherDevices, onDeleteAccount, }: UserProfileSecurityPanelViewProps): ReactElement { - const showPassword = hasPassword || Boolean(onSubmitPassword); + const showPassword = hasPassword || Boolean(onSubmitPassword) || Boolean(managedBy); const hasAuthentication = showPassword || passkeys !== undefined || mfaMethods !== undefined; return ( @@ -77,7 +81,7 @@ export function UserProfileSecurityPanelView({ {showPassword ? ( diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 1afda204741..daaba70791a 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -218,6 +218,13 @@ const SecurityLockSquare = glyph( '0 0 18 18', ); +const SecurityLock = glyph( + , +); + const DevicePhone = glyph( <> Date: Tue, 15 Sep 2026 12:05:38 -0600 Subject: [PATCH 04/10] refactor(ui): remove obsolete enterprise password dialog state --- .../user-profile-edit-password.view.test.tsx | 24 ----------- ...ser-profile-password-section.view.test.tsx | 22 ++++++++++ .../user-profile-security-panel.view.test.tsx | 8 ---- .../user-profile-edit-password.view.tsx | 43 ++++++------------- .../user-profile-password-section.messages.ts | 2 - 5 files changed, 36 insertions(+), 63 deletions(-) create mode 100644 packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx index 7e1d17e8f60..436163690c7 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx @@ -232,28 +232,4 @@ describe('UserProfileEditPasswordView', () => { await user.click(saveButton()); expect(onSubmit).not.toHaveBeenCalled(); }); - - it('explains itself and offers only cancel while an enterprise account is active', async () => { - const onSubmit = vi.fn(); - const onConfirmPasswordChange = vi.fn(); - const user = userEvent.setup(); - renderView({ hasActiveEnterpriseAccount: true, onSubmit, onConfirmPasswordChange }); - - expect( - screen.getByText( - 'Your password can currently not be edited because you can sign in only via the enterprise connection.', - ), - ).toBeInTheDocument(); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(currentPasswordField()).toBeDisabled(); - expect(newPasswordField()).toBeDisabled(); - expect(confirmPasswordField()).toBeDisabled(); - expect(signOutCheckbox()).toBeDisabled(); - expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - - await user.type(confirmPasswordField(), 'abc'); - expect(onConfirmPasswordChange).not.toHaveBeenCalled(); - expect(onSubmit).not.toHaveBeenCalled(); - }); }); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx new file mode 100644 index 00000000000..57f1c3198e5 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; + +describe('UserProfilePasswordSectionView', () => { + it('shows the enterprise manager instead of password actions', () => { + render( + + Promise.resolve())} + /> + , + ); + + expect(screen.getByText('Managed by Okta')).toBeVisible(); + expect(screen.queryByRole('button', { name: /password/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index fd127a3f8ab..1d973cd0250 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -174,14 +174,6 @@ describe('UserProfileSecurityPanelView', () => { }); }); - it('shows who manages the password in place of an edit action when an enterprise connection owns it', () => { - renderView({ managedBy: { name: 'Okta' }, onSubmitPassword: vi.fn(() => Promise.resolve()) }); - - expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Change password' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Set password' })).not.toBeInTheDocument(); - }); - it('keeps supported empty authentication methods actionable', () => { renderView({ hasPassword: false, diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx index 0fa20d24a2b..c78e53db341 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx @@ -33,12 +33,6 @@ export interface UserProfileEditPasswordViewProps { hasPassword?: boolean; /** Asks for the password being replaced. Off when reverification stands in for it. */ requiresCurrentPassword?: boolean; - /** - * Whether the account signs in through an active enterprise connection, which is what stops the - * password from changing. The dialog still opens so it can say so, but every field is inert and - * only cancel remains. - */ - hasActiveEnterpriseAccount?: boolean; currentPassword: string; newPassword: string; confirmPassword: string; @@ -64,7 +58,6 @@ export function UserProfileEditPasswordView({ trigger, hasPassword = false, requiresCurrentPassword = false, - hasActiveEnterpriseAccount = false, currentPassword, newPassword, confirmPassword, @@ -83,11 +76,10 @@ export function UserProfileEditPasswordView({ const signOutDescriptionId = useId(); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; - const inert = isSaving || hasActiveEnterpriseAccount; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - if (canSave && !inert) { + if (canSave && !isSaving) { onSubmit(); } }; @@ -118,11 +110,6 @@ export function UserProfileEditPasswordView({ /> } > - {hasActiveEnterpriseAccount ? ( - - {m.enterpriseAccount} - - ) : null} {error?.message ? ( } /> - {hasActiveEnterpriseAccount ? null : ( - - {m.save} - - )} + + {m.save} + diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index ea5837ef0e2..ce0c47385b4 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -17,8 +17,6 @@ export const userProfilePasswordSectionBase = { change: 'Change password', set: 'Set password', }, - enterpriseAccount: - 'Your password can currently not be edited because you can sign in only via the enterprise connection.', currentPasswordLabel: 'Current password', newPasswordLabel: 'New password', confirmPasswordLabel: 'Confirm password', From af1bb06b0810c2106aac8ff0d04e474b023accb8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 12:07:03 -0600 Subject: [PATCH 05/10] fix(ui): hide unavailable standalone password sections --- ...ser-profile-password-section.view.test.tsx | 32 +++++++++++++++++++ .../user-profile-password-section.view.tsx | 4 +++ 2 files changed, 36 insertions(+) diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 57f1c3198e5..925b449e51a 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -5,6 +5,38 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; describe('UserProfilePasswordSectionView', () => { + 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('region', { name: 'Authentication' })).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('offers to set a password when only the action is available', () => { + render( + + Promise.resolve())} /> + , + ); + + expect(screen.getByText('No password set')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Set password' })).toBeEnabled(); + }); + it('shows the enterprise manager instead of password actions', () => { render( diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index 4c76e069bb4..65b39ebaf77 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -43,6 +43,10 @@ export function UserProfilePasswordSectionView({ managedBy, onSubmitPassword, }: UserProfilePasswordSectionViewProps) { + if (!hasPassword && !managedBy && !onSubmitPassword) { + return null; + } + return ( {sectionTitle ? {sectionTitle} : null} From fab0cc5c05199d24a678b98acd0951e9e14e3faa Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 12:13:32 -0600 Subject: [PATCH 06/10] fix(swingset): allow password error stories to recover on retry --- .../src/stories/fixtures/user-profile-edit-password.ts | 6 ++++-- .../src/stories/user-profile-password-section.stories.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index b735cf3aa4a..ed77f8cbc57 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -7,7 +7,7 @@ export interface UserProfileEditPasswordFixtureOptions { hasPassword?: boolean; requiresCurrentPassword?: boolean; latency?: number; - /** Rejects every save instead of committing it. */ + /** Rejects the first save so the next attempt can succeed. */ failWith?: UserProfileFormError; } @@ -18,13 +18,15 @@ export function useUserProfileEditPasswordFixture({ failWith, }: UserProfileEditPasswordFixtureOptions = {}) { const [hasPassword, setHasPassword] = useState(initialHasPassword); + const [hasFailed, setHasFailed] = useState(false); return { hasPassword, requiresCurrentPassword, onSubmitPassword: async (_value: UserProfileEditPasswordValue) => { await new Promise(resolve => setTimeout(resolve, latency)); - if (failWith) { + if (failWith && !hasFailed) { + setHasFailed(true); throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); } setHasPassword(true); diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index 9059ae0091d..2a70194d300 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -69,7 +69,7 @@ export function ManagedByEnterprise() { ); } -/** Every save is rejected, so the dialog shows both halves of a failure at once. */ +/** The first save shows field and form errors; retrying succeeds. */ export function EditPasswordFails() { return ( Date: Tue, 15 Sep 2026 12:15:03 -0600 Subject: [PATCH 07/10] refactor(ui): separate password row dialog and shared contracts --- .../fixtures/user-profile-edit-password.ts | 2 +- ...ser-profile-edit-password.dialog.test.tsx} | 12 +- ...r-profile-edit-password.controller.test.ts | 2 +- .../user-profile-edit-password.controller.ts | 2 +- ... => user-profile-edit-password.dialog.tsx} | 20 +-- .../user-profile-password-row.view.tsx | 108 +++++++++++++++ .../user-profile-password-section.types.ts | 31 +++++ .../user-profile-password-section.view.tsx | 130 ++---------------- 8 files changed, 168 insertions(+), 139 deletions(-) rename packages/ui/src/mosaic/features/user-profile/__tests__/{user-profile-edit-password.view.test.tsx => user-profile-edit-password.dialog.test.tsx} (94%) rename packages/ui/src/mosaic/features/user-profile/user-profile-password-section/{user-profile-edit-password.view.tsx => user-profile-edit-password.dialog.tsx} (93%) create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index ed77f8cbc57..2305b0f94ea 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,6 +1,6 @@ import type { UserProfileFormError } from '@clerk/ui/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '@clerk/ui/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditPasswordValue } from '@clerk/ui/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view'; +import type { UserProfileEditPasswordValue } from '@clerk/ui/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx similarity index 94% rename from packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx rename to packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx index 436163690c7..eaa26128da9 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx @@ -4,11 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../../components/button'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileEditPasswordViewProps } from '../user-profile-password-section/user-profile-edit-password.view'; -import { UserProfileEditPasswordView } from '../user-profile-password-section/user-profile-edit-password.view'; +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'; -function renderView(overrides: Partial = {}) { - const props: UserProfileEditPasswordViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileEditPasswordDialogProps = { open: true, onOpenChange: vi.fn(), hasPassword: true, @@ -28,7 +28,7 @@ function renderView(overrides: Partial = {}) { props, ...render( - + , ), }; @@ -40,7 +40,7 @@ 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('UserProfileEditPasswordView', () => { +describe('UserProfileEditPasswordDialog', () => { it('renders nothing until the caller opens it', () => { renderView({ open: false }); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 56b665f344c..8fbaede57a9 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -7,7 +7,7 @@ import { userProfileEditPasswordMachine, useUserProfileEditPasswordController, } from './user-profile-edit-password.controller'; -import type { UserProfileEditPasswordValue } from './user-profile-edit-password.view'; +import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; function start(savePassword: (value: UserProfileEditPasswordValue) => Promise, requiresCurrentPassword = true) { const actor = createActor(userProfileEditPasswordMachine, { diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 78937fd2d51..ba8ec431a68 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -2,8 +2,8 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-edit-password.view'; import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; +import type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-password-section.types'; export interface UserProfileEditPasswordContext { savePassword: (value: UserProfileEditPasswordValue) => Promise; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx similarity index 93% rename from packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx rename to packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index c78e53db341..b5393612bf9 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -14,17 +14,9 @@ import { Text } from '../../../components/text'; import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; import { styles } from './user-profile-password-section.styles'; +import type { UserProfileEditPasswordField } from './user-profile-password-section.types'; -export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; - -export interface UserProfileEditPasswordValue { - /** Only carried when the flow asked for it. */ - currentPassword?: string; - newPassword: string; - signOutOfOtherSessions: boolean; -} - -export interface UserProfileEditPasswordViewProps { +export interface UserProfileEditPasswordDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Rendering the opener here is what returns focus to it on close. */ @@ -52,7 +44,7 @@ export interface UserProfileEditPasswordViewProps { * halves match and whether the new password is acceptable arrive as `canSave` and `error`, so the * rules live in one place and a rejection from the API lands the same way a local one does. */ -export function UserProfileEditPasswordView({ +export function UserProfileEditPasswordDialog({ open, onOpenChange, trigger, @@ -70,7 +62,7 @@ export function UserProfileEditPasswordView({ isSaving = false, error, onSubmit, -}: UserProfileEditPasswordViewProps) { +}: UserProfileEditPasswordDialogProps) { const formId = useId(); const signOutId = useId(); const signOutDescriptionId = useId(); @@ -160,14 +152,14 @@ export function UserProfileEditPasswordView({ } size='sm' - {...stylex.props(styles.checkboxLabel)} + xstyle={styles.checkboxLabel} > {m.signOutOfOtherSessionsLabel} {m.signOutOfOtherSessionsDescription} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx new file mode 100644 index 00000000000..72b0f8a8638 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -0,0 +1,108 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../../components/button'; +import { Icon } from '../../../components/icon'; +import { Section } from '../../../components/section'; +import { Text } from '../../../components/text'; +import { fill } from '../user-profile-account-section/user-profile-account-section.messages'; +import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; +import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; +import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; +import { styles } from './user-profile-password-section.styles'; +import type { + UserProfileEditPasswordValue, + UserProfilePasswordManagedBy, + UserProfilePasswordSectionViewProps, +} from './user-profile-password-section.types'; + +export function UserProfilePasswordRowView({ + hasPassword = false, + requiresCurrentPassword = false, + managedBy, + onSubmitPassword, +}: Omit) { + return ( + + + + {m.label} + {hasPassword ? m.masked : m.noPasswordSet} + + {managedBy ? ( + + + + ) : onSubmitPassword ? ( + + + + ) : null} + + + ); +} + +function ManagedByLabel({ name, iconUrl }: UserProfilePasswordManagedBy) { + return ( +
+ {iconUrl ? ( + + ) : ( + + )} + } + size='sm' + xstyle={styles.managedByText} + > + {fill(m.managedBy, { name })} + +
+ ); +} + +function EditPassword({ + hasPassword, + requiresCurrentPassword, + onSubmit, +}: { + hasPassword: boolean; + requiresCurrentPassword: boolean; + onSubmit: (value: UserProfileEditPasswordValue) => Promise; +}) { + const controller = useUserProfileEditPasswordController({ + requiresCurrentPassword: hasPassword && requiresCurrentPassword, + onSubmit, + }); + + return ( + + {hasPassword ? m.change : m.set} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts new file mode 100644 index 00000000000..8381053eee7 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -0,0 +1,31 @@ +export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; + +export interface UserProfileEditPasswordValue { + /** Only carried when the flow asked for it. */ + currentPassword?: string; + newPassword: string; + signOutOfOtherSessions: boolean; +} + +/** The enterprise connection that owns the password, shown in place of the edit action. */ +export interface UserProfilePasswordManagedBy { + /** The connection's display name, e.g. `'Okta'`. Rendered as "Managed by {name}". */ + name: string; + /** The connection's logo. A generic lock stands in when absent (a custom IDP with no icon). */ + iconUrl?: string; +} + +export interface UserProfilePasswordSectionViewProps { + sectionTitle?: string; + /** Whether the user has a password. Decides between replacing one and setting the first. */ + hasPassword?: boolean; + /** Whether the save must carry the password being replaced. Off when reverification stands in for it. */ + requiresCurrentPassword?: boolean; + /** + * Supplied from `user.enterpriseAccounts`. When set, the connection owns the password: the row + * shows "Managed by {name}" in place of the edit action and never opens the dialog. + */ + managedBy?: UserProfilePasswordManagedBy; + /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ + onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index 65b39ebaf77..949fa4f3cb7 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -1,40 +1,14 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Button } from '../../../components/button'; -import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; -import { Text } from '../../../components/text'; -import { fill } from '../user-profile-account-section/user-profile-account-section.messages'; -import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; -import type { UserProfileEditPasswordValue } from './user-profile-edit-password.view'; -import { UserProfileEditPasswordView } from './user-profile-edit-password.view'; +import { UserProfilePasswordRowView } from './user-profile-password-row.view'; import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; -import { styles } from './user-profile-password-section.styles'; - -export type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-edit-password.view'; +import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; -/** The enterprise connection that owns the password, shown in place of the edit action. */ -export interface UserProfilePasswordManagedBy { - /** The connection's display name, e.g. `'Okta'`. Rendered as "Managed by {name}". */ - name: string; - /** The connection's logo. A generic lock stands in when absent (a custom IDP with no icon). */ - iconUrl?: string; -} - -export interface UserProfilePasswordSectionViewProps { - sectionTitle?: string; - /** Whether the user has a password. Decides between replacing one and setting the first. */ - hasPassword?: boolean; - /** Whether the save must carry the password being replaced. Off when reverification stands in for it. */ - requiresCurrentPassword?: boolean; - /** - * Supplied from `user.enterpriseAccounts`. When set, the connection owns the password: the row - * shows "Managed by {name}" in place of the edit action and never opens the dialog. - */ - managedBy?: UserProfilePasswordManagedBy; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ - onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; -} +export type { + UserProfileEditPasswordField, + UserProfileEditPasswordValue, + UserProfilePasswordManagedBy, + UserProfilePasswordSectionViewProps, +} from './user-profile-password-section.types'; export function UserProfilePasswordSectionView({ sectionTitle = m.sectionTitle, @@ -51,89 +25,13 @@ export function UserProfilePasswordSectionView({ {sectionTitle ? {sectionTitle} : null} - - - - {m.label} - {hasPassword ? m.masked : m.noPasswordSet} - - {managedBy ? ( - - - - ) : onSubmitPassword ? ( - - - - ) : null} - - + ); } - -function ManagedByLabel({ name, iconUrl }: UserProfilePasswordManagedBy) { - return ( -
- {iconUrl ? ( - - ) : ( - - )} - } - size='sm' - {...stylex.props(styles.managedByText)} - > - {fill(m.managedBy, { name })} - -
- ); -} - -function EditPassword({ - hasPassword, - requiresCurrentPassword, - onSubmit, -}: { - hasPassword: boolean; - requiresCurrentPassword: boolean; - onSubmit: (value: UserProfileEditPasswordValue) => Promise; -}) { - const controller = useUserProfileEditPasswordController({ - requiresCurrentPassword: hasPassword && requiresCurrentPassword, - onSubmit, - }); - - return ( - - {hasPassword ? m.change : m.set} - - } - /> - ); -} From dbfff4bb1396f0e2cbbe10e47674c40cb927bffb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 12:17:24 -0600 Subject: [PATCH 08/10] test(ui): focus password coverage on section behavior --- ...user-profile-edit-password.dialog.test.tsx | 88 ++------------- ...ser-profile-password-section.view.test.tsx | 100 +++++++++++++++--- .../user-profile-security-panel.view.test.tsx | 53 +--------- ...r-profile-edit-password.controller.test.ts | 20 ---- 4 files changed, 99 insertions(+), 162 deletions(-) diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx index eaa26128da9..d120baaf33b 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx @@ -1,8 +1,7 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { Button } from '../../../components/button'; 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'; @@ -41,12 +40,6 @@ const signOutCheckbox = () => screen.getByRole('checkbox', { name: 'Sign out of const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); describe('UserProfileEditPasswordDialog', () => { - it('renders nothing until the caller opens it', () => { - renderView({ open: false }); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - it('names the dialog for a change and masks every field', () => { renderView({ currentPassword: 'old', newPassword: 'new', confirmPassword: 'new' }); @@ -65,16 +58,19 @@ describe('UserProfileEditPasswordDialog', () => { it('reveals a password from its own eye toggle and hides it again', async () => { const user = userEvent.setup(); renderView({ newPassword: 'new-secret-123' }); - const newPasswordGroup = newPasswordField().closest('.cl-input-group') as HTMLElement; + const [, newPasswordToggle] = screen.getAllByRole('button', { name: 'Show password' }); + if (!newPasswordToggle) { + throw new Error('New password visibility toggle is missing'); + } - await user.click(within(newPasswordGroup).getByRole('button', { name: 'Show password' })); + 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(within(newPasswordGroup).getByRole('button', { name: 'Hide password' })); + await user.click(screen.getByRole('button', { name: 'Hide password' })); expect(newPasswordField()).toHaveAttribute('type', 'password'); }); @@ -99,80 +95,10 @@ describe('UserProfileEditPasswordDialog', () => { await waitFor(() => expect(currentPasswordField()).toHaveFocus()); }); - it('asks to open from the trigger', async () => { - const onOpenChange = vi.fn(); - const user = userEvent.setup(); - renderView({ open: false, onOpenChange, trigger: }); - - await user.click(screen.getByRole('button', { name: 'Change password' })); - - expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); - }); - - it('reports each keystroke to its own field, holding nothing itself', async () => { - const onCurrentPasswordChange = vi.fn(); - const onNewPasswordChange = vi.fn(); - const onConfirmPasswordChange = vi.fn(); - const onSignOutOfOtherSessionsChange = vi.fn(); - const user = userEvent.setup(); - renderView({ - onCurrentPasswordChange, - onNewPasswordChange, - onConfirmPasswordChange, - onSignOutOfOtherSessionsChange, - }); - - await user.type(currentPasswordField(), 'a'); - await user.type(newPasswordField(), 'b'); - await user.type(confirmPasswordField(), 'c'); - await user.click(signOutCheckbox()); - - expect(onCurrentPasswordChange).toHaveBeenCalledWith('a'); - expect(onNewPasswordChange).toHaveBeenCalledWith('b'); - expect(onConfirmPasswordChange).toHaveBeenCalledWith('c'); - expect(onSignOutOfOtherSessionsChange).toHaveBeenCalledWith(false); - expect(newPasswordField()).toHaveValue(''); - expect(signOutCheckbox()).toBeChecked(); - }); - - it('submits from the action once every field is filled', async () => { - const onSubmit = vi.fn(); - const user = userEvent.setup(); - renderView({ currentPassword: 'old', newPassword: 'new-secret-123', confirmPassword: 'new-secret-123', onSubmit }); - - await user.click(saveButton()); - - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - it('lets the browser hold an empty required field back', async () => { - const onSubmit = vi.fn(); - const user = userEvent.setup(); - renderView({ onSubmit }); - - expect(currentPasswordField()).toBeRequired(); - expect(newPasswordField()).toBeRequired(); - expect(confirmPasswordField()).toBeRequired(); - await user.click(saveButton()); - - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('asks to close from cancel', async () => { - const onOpenChange = vi.fn(); - const user = userEvent.setup(); - renderView({ onOpenChange }); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - }); - it('announces the failure in a negative banner', () => { renderView({ error: { message: 'Your password could not be updated.' } }); const banner = screen.getByRole('alert'); - expect(banner).toHaveAttribute('data-color', 'negative'); expect(banner).toHaveTextContent('Your password could not be updated.'); expect(newPasswordField()).not.toHaveAttribute('aria-invalid', 'true'); }); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 925b449e51a..21a49b497e9 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -1,10 +1,97 @@ -import { render, screen } from '@testing-library/react'; +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('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); + expect(screen.getByText('Password')).toBeVisible(); + 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( @@ -26,17 +113,6 @@ describe('UserProfilePasswordSectionView', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - it('offers to set a password when only the action is available', () => { - render( - - Promise.resolve())} /> - , - ); - - expect(screen.getByText('No password set')).toBeVisible(); - expect(screen.getByRole('button', { name: 'Set password' })).toBeEnabled(); - }); - it('shows the enterprise manager instead of password actions', () => { render( diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 1d973cd0250..5889a203db0 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -63,9 +63,9 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 4, name: 'Active devices' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); - expect(screen.getByText('Password')).toHaveClass('cl-section-label'); - expect(screen.getByText('Passkeys')).toHaveClass('cl-section-label'); - expect(screen.getByText('2-step verification')).toHaveClass('cl-section-label'); + expect(screen.getByText('Password')).toBeVisible(); + expect(screen.getByText('Passkeys')).toBeVisible(); + expect(screen.getByText('2-step verification')).toBeVisible(); expect(screen.getByRole('region', { name: 'Passkeys' })).toBeInTheDocument(); expect(screen.getByRole('region', { name: '2-step verification' })).toBeInTheDocument(); expect(screen.getByText('This device')).toBeInTheDocument(); @@ -129,51 +129,6 @@ describe('UserProfileSecurityPanelView', () => { expect(onDeleteAccount).toHaveBeenCalledOnce(); }); - it('drives the change-password dialog from the section', async () => { - const onSubmitPassword = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ requiresCurrentPassword: true, onSubmitPassword }); - - expect(screen.getByText('••••••••••••••••••')).toHaveClass('cl-section-description'); - 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, passkeys: undefined, mfaMethods: undefined, onSubmitPassword }); - - expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); - expect(screen.getByText('Password')).toHaveClass('cl-section-label'); - expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); - expect(screen.getByText('No password set')).toHaveClass('cl-section-description'); - 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 supported empty authentication methods actionable', () => { renderView({ hasPassword: false, diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 8fbaede57a9..2c15f9fc9fb 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -196,26 +196,6 @@ describe('useUserProfileEditPasswordController', () => { await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('saves the values it is currently holding', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderController(onSubmit); - - act(() => result.current.onOpenChange(true)); - act(() => result.current.onCurrentPasswordChange('old-secret')); - act(() => result.current.onNewPasswordChange('new-secret-123')); - act(() => result.current.onConfirmPasswordChange('new-secret-123')); - act(() => result.current.onSignOutOfOtherSessionsChange(false)); - act(() => result.current.onSubmit()); - - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith({ - currentPassword: 'old-secret', - newPassword: 'new-secret-123', - signOutOfOtherSessions: false, - }), - ); - }); - it('withholds the save until the halves match and the current password is in', () => { const { result } = renderController(); From fd8776a9798728a2b6b794f45533d2be293ce8a8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 12:18:25 -0600 Subject: [PATCH 09/10] docs(swingset): describe password composition and retry states --- .../src/stories/user-profile-password-section.mdx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx index a72063fded9..69e861c14bd 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -2,7 +2,9 @@ import * as Stories from './user-profile-password-section.stories'; # UserProfilePasswordSection -## Change password +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 show their +provider instead of an edit action. @@ -42,7 +47,10 @@ in. The Okta logo below is a placeholder standing in for that URL. storyModule={Stories} /> -## Save fails +## Save error and retry + +The first save shows a form error and a current-password error. Correct the value and retry to +complete the simulated save and close the dialog. Date: Tue, 15 Sep 2026 13:06:43 -0600 Subject: [PATCH 10/10] docs(ui): trim redundant password section comments --- .../user-profile-edit-password.controller.ts | 2 -- .../user-profile-edit-password.dialog.tsx | 8 -------- .../user-profile-password-section.messages.ts | 6 ------ .../user-profile-password-section.types.ts | 12 +----------- 4 files changed, 1 insertion(+), 27 deletions(-) diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index ba8ec431a68..1e21d851184 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -101,9 +101,7 @@ export const userProfileEditPasswordMachine = createMachine({ }); export interface UserProfileEditPasswordControllerOptions { - /** Whether the save must carry the password being replaced. Off when reverification stands in for it. */ requiresCurrentPassword?: boolean; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ onSubmit: (value: UserProfileEditPasswordValue) => Promise; } diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index b5393612bf9..b97a50dbf64 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -19,11 +19,8 @@ import type { UserProfileEditPasswordField } from './user-profile-password-secti export interface UserProfileEditPasswordDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Rendering the opener here is what returns focus to it on close. */ trigger?: DialogTriggerProps['render']; - /** Whether a password is being replaced or set for the first time. Decides the copy. */ hasPassword?: boolean; - /** Asks for the password being replaced. Off when reverification stands in for it. */ requiresCurrentPassword?: boolean; currentPassword: string; newPassword: string; @@ -39,11 +36,6 @@ export interface UserProfileEditPasswordDialogProps { onSubmit: () => void; } -/** - * Sets or replaces the user's password. Holds nothing, and validates nothing itself: whether the - * halves match and whether the new password is acceptable arrive as `canSave` and `error`, so the - * rules live in one place and a rejection from the API lands the same way a local one does. - */ export function UserProfileEditPasswordDialog({ open, onOpenChange, diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index ce0c47385b4..d393984c5a5 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -1,9 +1,3 @@ -/** - * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so - * localizing this component is a matter of registering the namespace and swapping the reads for - * `useMessages('userProfilePasswordSection', userProfilePasswordSectionBase)`, not of hunting the - * literals down first. - */ export const userProfilePasswordSectionBase = { sectionTitle: 'Authentication', label: 'Password', diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index 8381053eee7..4e8a3b4c40f 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,31 +1,21 @@ export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValue { - /** Only carried when the flow asked for it. */ currentPassword?: string; newPassword: string; signOutOfOtherSessions: boolean; } -/** The enterprise connection that owns the password, shown in place of the edit action. */ export interface UserProfilePasswordManagedBy { - /** The connection's display name, e.g. `'Okta'`. Rendered as "Managed by {name}". */ name: string; - /** The connection's logo. A generic lock stands in when absent (a custom IDP with no icon). */ iconUrl?: string; } export interface UserProfilePasswordSectionViewProps { sectionTitle?: string; - /** Whether the user has a password. Decides between replacing one and setting the first. */ hasPassword?: boolean; - /** Whether the save must carry the password being replaced. Off when reverification stands in for it. */ requiresCurrentPassword?: boolean; - /** - * Supplied from `user.enterpriseAccounts`. When set, the connection owns the password: the row - * shows "Managed by {name}" in place of the edit action and never opens the dialog. - */ + /** Replaces the edit action with the enterprise provider’s name. */ managedBy?: UserProfilePasswordManagedBy; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; }