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/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 e905833036c..d7a2fea1761 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, + ManagedByEnterprise as UserProfilePasswordSectionManagedByEnterprise, meta as userProfilePasswordSectionMeta, + 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, + ManagedByEnterprise: UserProfilePasswordSectionManagedByEnterprise, + 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..2305b0f94ea --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -0,0 +1,35 @@ +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-password-section.types'; +import { useState } from 'react'; + +export interface UserProfileEditPasswordFixtureOptions { + hasPassword?: boolean; + requiresCurrentPassword?: boolean; + latency?: number; + /** Rejects the first save so the next attempt can succeed. */ + failWith?: UserProfileFormError; +} + +export function useUserProfileEditPasswordFixture({ + hasPassword: initialHasPassword = true, + requiresCurrentPassword = true, + latency = 800, + 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 && !hasFailed) { + setHasFailed(true); + 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..69e861c14bd 100644 --- a/packages/swingset/src/stories/user-profile-password-section.mdx +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -2,10 +2,57 @@ import * as Stories from './user-profile-password-section.stories'; # UserProfilePasswordSection -Password management composed with `Section`. +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. + +## Set password + + + +## Without current password + + + +## 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. + + + +## 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. + + 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..2a70194d300 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,70 @@ 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, + failWith, +}: { + hasPassword?: boolean; + requiresCurrentPassword?: boolean; + failWith?: UserProfileFormError; +}) { + const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); + + 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 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 ; +} + +/** + * 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 ( + + + + + ); +} + +/** The first save shows field and form errors; retrying succeeds. */ +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.dialog.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx new file mode 100644 index 00000000000..d120baaf33b --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-edit-password.dialog.test.tsx @@ -0,0 +1,161 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +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'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileEditPasswordDialogProps = { + 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('UserProfileEditPasswordDialog', () => { + 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 [, newPasswordToggle] = screen.getAllByRole('button', { name: 'Show password' }); + if (!newPasswordToggle) { + throw new Error('New password visibility toggle is missing'); + } + + await user.click(newPasswordToggle); + + expect(newPasswordField()).toHaveAttribute('type', 'text'); + expect(newPasswordField()).toHaveValue('new-secret-123'); + expect(currentPasswordField()).toHaveAttribute('type', 'password'); + expect(confirmPasswordField()).toHaveAttribute('type', 'password'); + + await user.click(screen.getByRole('button', { name: 'Hide password' })); + + expect(newPasswordField()).toHaveAttribute('type', 'password'); + }); + + it('names the dialog for a first password and skips the current one', () => { + renderView({ hasPassword: false }); + + expect(screen.getByRole('dialog', { name: 'Set password' })).toBeInTheDocument(); + expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); + }); + + it('skips the current password when reverification stands in for it', async () => { + renderView({ requiresCurrentPassword: false }); + + expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument(); + await waitFor(() => expect(newPasswordField()).toHaveFocus()); + }); + + it('opens on the current password rather than the corner dismiss', async () => { + renderView(); + + await waitFor(() => expect(currentPasswordField()).toHaveFocus()); + }); + + it('announces the failure in a negative banner', () => { + renderView({ error: { message: 'Your password could not be updated.' } }); + + const banner = screen.getByRole('alert'); + expect(banner).toHaveTextContent('Your password could not be updated.'); + expect(newPasswordField()).not.toHaveAttribute('aria-invalid', 'true'); + }); + + it('renders field-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(); + }); +}); 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..21a49b497e9 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -0,0 +1,130 @@ +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( + + + , + ); + + 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('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 e3a0d051f5a..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 @@ -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(); @@ -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'); 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 ? ( - - - Change password - - - ) : 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..2c15f9fc9fb --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -0,0 +1,254 @@ +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-password-section.types'; + +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('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..1e21d851184 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -0,0 +1,153 @@ +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 { 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; + 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 { + requiresCurrentPassword?: boolean; + 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.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx new file mode 100644 index 00000000000..b97a50dbf64 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -0,0 +1,236 @@ +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'; +import type { UserProfileEditPasswordField } from './user-profile-password-section.types'; + +export interface UserProfileEditPasswordDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + hasPassword?: boolean; + requiresCurrentPassword?: 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; +} + +export function UserProfileEditPasswordDialog({ + open, + onOpenChange, + trigger, + hasPassword = false, + requiresCurrentPassword = false, + currentPassword, + newPassword, + confirmPassword, + signOutOfOtherSessions, + onCurrentPasswordChange, + onNewPasswordChange, + onConfirmPasswordChange, + onSignOutOfOtherSessionsChange, + canSave = true, + isSaving = false, + error, + onSubmit, +}: UserProfileEditPasswordDialogProps) { + const formId = useId(); + const signOutId = useId(); + const signOutDescriptionId = useId(); + const initialFocusRef = useRef(null); + const showCurrentPassword = hasPassword && requiresCurrentPassword; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSave && !isSaving) { + onSubmit(); + } + }; + + return ( + + {trigger ? : null} + + + + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + } + > + {error?.message ? ( + + {error.message} + + ) : null} + {showCurrentPassword ? ( + + ) : null} + + + + onSignOutOfOtherSessionsChange(event.target.checked)} + /> + + } + size='sm' + xstyle={styles.checkboxLabel} + > + {m.signOutOfOtherSessionsLabel} + + + {m.signOutOfOtherSessionsDescription} + + + + + + + {m.cancel} + + } + /> + + {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)} + /> + + setVisible(current => !current)} + > + + + + + {error ? {error} : null} + + ); +} 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.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..d393984c5a5 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -0,0 +1,29 @@ +export const userProfilePasswordSectionBase = { + sectionTitle: 'Authentication', + label: 'Password', + masked: '••••••••••••••••••', + noPasswordSet: 'No password set', + managedBy: 'Managed by {name}', + change: 'Change password', + set: 'Set password', + + dialogTitle: { + change: 'Change password', + set: 'Set password', + }, + 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..dbc4e473700 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts @@ -0,0 +1,44 @@ +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'], + }, + 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.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..4e8a3b4c40f --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -0,0 +1,21 @@ +export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; + +export interface UserProfileEditPasswordValue { + currentPassword?: string; + newPassword: string; + signOutOfOtherSessions: boolean; +} + +export interface UserProfilePasswordManagedBy { + name: string; + iconUrl?: string; +} + +export interface UserProfilePasswordSectionViewProps { + sectionTitle?: string; + hasPassword?: boolean; + requiresCurrentPassword?: boolean; + /** Replaces the edit action with the enterprise provider’s name. */ + managedBy?: UserProfilePasswordManagedBy; + 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 new file mode 100644 index 00000000000..949fa4f3cb7 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -0,0 +1,37 @@ +import { Section } from '../../../components/section'; +import { UserProfilePasswordRowView } from './user-profile-password-row.view'; +import { userProfilePasswordSectionBase as m } from './user-profile-password-section.messages'; +import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; + +export type { + UserProfileEditPasswordField, + UserProfileEditPasswordValue, + UserProfilePasswordManagedBy, + UserProfilePasswordSectionViewProps, +} from './user-profile-password-section.types'; + +export function UserProfilePasswordSectionView({ + sectionTitle = m.sectionTitle, + hasPassword = false, + requiresCurrentPassword = false, + managedBy, + onSubmitPassword, +}: UserProfilePasswordSectionViewProps) { + if (!hasPassword && !managedBy && !onSubmitPassword) { + return null; + } + + return ( + + {sectionTitle ? {sectionTitle} : null} + + + + + ); +} 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..2a652d3941c 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,33 @@ 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, + UserProfilePasswordManagedBy, + UserProfilePasswordSectionViewProps, +} from './user-profile-password-section/user-profile-password-section.view'; +import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; import { styles } from './user-profile-security-panel.styles'; -export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; +export type { + UserProfileDevice, + UserProfileEditPasswordValue, + UserProfileMfaAddableMethod, + UserProfileMfaMethod, + UserProfilePasskey, + UserProfilePasswordManagedBy, +}; -export interface UserProfileSecurityPanelViewProps extends Omit { - hasPassword?: boolean; +export interface UserProfileSecurityPanelViewProps + extends + Omit, + Pick< + UserProfilePasswordSectionViewProps, + 'hasPassword' | 'requiresCurrentPassword' | 'managedBy' | 'onSubmitPassword' + > { passkeys?: UserProfilePasskey[]; mfaMethods?: UserProfileMfaMethod[]; devices?: UserProfileDevice[]; - onChangePassword?: () => void; onAddPasskey?: () => void; onManagePasskey?: (id: string) => void; onRemovePasskey?: (id: string) => void; @@ -36,10 +52,12 @@ export interface UserProfileSecurityPanelViewProps extends Omit @@ -59,11 +78,18 @@ export function UserProfileSecurityPanelView({ {hasAuthentication ? ( - {hasPassword ? : null} + {showPassword ? ( + + ) : null} {passkeys !== undefined ? ( , +); + const DevicePhone = glyph( <>