Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
8e91fcd
feat(mosaic): add account password model and policy tests
austincalvelage Sep 24, 2026
29480c3
feat(mosaic): support password feedback and verification in the editor
austincalvelage Sep 24, 2026
ccc8e70
feat(mosaic): connect password editing to account updates and reverif…
austincalvelage Sep 24, 2026
80d8f62
feat(swingset): add live password editing demo
austincalvelage Sep 24, 2026
7d738b5
refactor(mosaic): rename Field.Info to Field.Hint
austincalvelage Sep 25, 2026
b3ed23f
fix(mosaic): restore password API error formatting
austincalvelage Sep 25, 2026
de8d8d3
fix(mosaic): show password strength suggestions
austincalvelage Sep 25, 2026
31e5a64
fix(mosaic): match legacy password hint wording
austincalvelage Sep 25, 2026
c24bf64
refactor(mosaic): take the password section as a security panel slot
austincalvelage Sep 25, 2026
cf939ba
fix(mosaic): confirm met password requirements when strength checks a…
austincalvelage Sep 25, 2026
125ea1a
fix(mosaic): use the legacy read-only password copy
austincalvelage Sep 25, 2026
98d6501
fix(mosaic): localize password update errors raised by the model
austincalvelage Sep 25, 2026
bec2644
fix(mosaic): use the legacy copy when the new password matches the cu…
austincalvelage Sep 25, 2026
a4e8f84
refactor(mosaic): move password reverification into the editor contro…
austincalvelage Sep 25, 2026
7d4c33b
fix(mosaic): show password requirements after the new password is lef…
austincalvelage Sep 25, 2026
e5209e8
fix(mosaic): keep the password mismatch after the confirmation is cle…
austincalvelage Sep 25, 2026
251da77
fix(mosaic): align password reverification with controller status
austincalvelage Sep 29, 2026
c62ab33
refactor(mosaic): remove password reverification flow
austincalvelage Sep 29, 2026
a9b8078
test(mosaic): cover password editing with real Clerk
austincalvelage Sep 29, 2026
0ef6cb7
fix(swingset): parse password fixture as JSX and trim duplicate tests
austincalvelage Sep 29, 2026
3b5eab5
fix(mosaic): use managed password row for enterprise accounts
austincalvelage Sep 29, 2026
9967dff
test(mosaic): reproduce managed password label overflow
austincalvelage Sep 29, 2026
6816ca9
fix(mosaic): wrap managed password provider names
austincalvelage Sep 29, 2026
98c1f24
docs(swingset): show long managed password provider
austincalvelage Sep 29, 2026
0531bec
fix(mosaic): place managed provider below password
austincalvelage Sep 29, 2026
a7736ed
revert(mosaic): leave section layout for follow-up
austincalvelage Sep 29, 2026
bde1a0d
test(mosaic): await password dialog focus before typing
austincalvelage Sep 29, 2026
1083ac8
chore(mosaic): mark session reverification as deferred
austincalvelage Sep 30, 2026
6bce495
chore: make password section changeset empty
austincalvelage Sep 30, 2026
f91503a
chore: remove duplicate empty changeset
austincalvelage Sep 30, 2026
2e68e35
refactor(mosaic): simplify password loading
austincalvelage Sep 30, 2026
7601f00
fix(mosaic): localize user profile password errors
austincalvelage Oct 1, 2026
5829c87
fix(mosaic): debounce user profile password validation
austincalvelage Oct 1, 2026
d73f3a2
chore: consolidate password changesets
austincalvelage Oct 1, 2026
20cb44f
chore(mosaic): note password loading skeleton follow-up
austincalvelage Oct 1, 2026
e67e809
refactor(mosaic): extract password field feedback mapping
austincalvelage Oct 1, 2026
ac42876
refactor(mosaic): check hidden before readonly password status
austincalvelage Oct 1, 2026
87d8be0
refactor(mosaic): require password settings before validating
austincalvelage Oct 1, 2026
d3ac749
refactor(mosaic): return plain data from password model
austincalvelage Oct 1, 2026
f3177c0
chore(mosaic): note current password reverification parity
austincalvelage Oct 1, 2026
dcb61fd
refactor(mosaic): hide password section without a session
austincalvelage Oct 1, 2026
3b119b5
fix(mosaic): only report strong passwords after a strength check
austincalvelage Oct 1, 2026
d03939e
fix(mosaic): drop managed-by logo removed on main
austincalvelage Oct 1, 2026
4a550bd
test(mosaic): remove synthetic password loading test
austincalvelage Oct 1, 2026
e6a6153
chore: remove redundant password changeset
austincalvelage Oct 1, 2026
f0e6f94
test(mosaic): remove password setup from profile view tests
austincalvelage Oct 1, 2026
8d43be0
feat(mosaic): add account password model and policy tests
austincalvelage Sep 24, 2026
5278a9f
feat(mosaic): support password feedback and verification in the editor
austincalvelage Sep 24, 2026
9755e99
feat(mosaic): connect password editing to account updates and reverif…
austincalvelage Sep 24, 2026
8f38846
feat(swingset): add live password editing demo
austincalvelage Sep 24, 2026
fb3a26d
refactor(mosaic): rename Field.Info to Field.Hint
austincalvelage Sep 25, 2026
dae0a21
fix(mosaic): restore password API error formatting
austincalvelage Sep 25, 2026
a3e951c
fix(mosaic): show password strength suggestions
austincalvelage Sep 25, 2026
d316fc4
fix(mosaic): match legacy password hint wording
austincalvelage Sep 25, 2026
5a607dc
refactor(mosaic): take the password section as a security panel slot
austincalvelage Sep 25, 2026
6a541c3
fix(mosaic): confirm met password requirements when strength checks a…
austincalvelage Sep 25, 2026
b7d15e8
fix(mosaic): use the legacy read-only password copy
austincalvelage Sep 25, 2026
b9f3181
fix(mosaic): localize password update errors raised by the model
austincalvelage Sep 25, 2026
6465ca0
fix(mosaic): use the legacy copy when the new password matches the cu…
austincalvelage Sep 25, 2026
f4588d0
refactor(mosaic): move password reverification into the editor contro…
austincalvelage Sep 25, 2026
949b26f
fix(mosaic): show password requirements after the new password is lef…
austincalvelage Sep 25, 2026
c180e8f
fix(mosaic): keep the password mismatch after the confirmation is cle…
austincalvelage Sep 25, 2026
d162913
fix(mosaic): align password reverification with controller status
austincalvelage Sep 29, 2026
a1f1ca2
refactor(mosaic): remove password reverification flow
austincalvelage Sep 29, 2026
fe251e5
test(mosaic): cover password editing with real Clerk
austincalvelage Sep 29, 2026
5603fde
fix(swingset): parse password fixture as JSX and trim duplicate tests
austincalvelage Sep 29, 2026
c99eb12
fix(mosaic): use managed password row for enterprise accounts
austincalvelage Sep 29, 2026
0d49e80
test(mosaic): reproduce managed password label overflow
austincalvelage Sep 29, 2026
07d1d43
fix(mosaic): wrap managed password provider names
austincalvelage Sep 29, 2026
5afc13b
docs(swingset): show long managed password provider
austincalvelage Sep 29, 2026
889efe1
fix(mosaic): place managed provider below password
austincalvelage Sep 29, 2026
ef21259
revert(mosaic): leave section layout for follow-up
austincalvelage Sep 29, 2026
ef6fbe9
test(mosaic): await password dialog focus before typing
austincalvelage Sep 29, 2026
b1e7ec8
chore(mosaic): mark session reverification as deferred
austincalvelage Sep 30, 2026
24cbdc7
chore: make password section changeset empty
austincalvelage Sep 30, 2026
4f13837
chore: remove duplicate empty changeset
austincalvelage Sep 30, 2026
505bcd3
refactor(mosaic): simplify password loading
austincalvelage Sep 30, 2026
e3c77c5
fix(mosaic): localize user profile password errors
austincalvelage Oct 1, 2026
f027035
fix(mosaic): debounce user profile password validation
austincalvelage Oct 1, 2026
3a89371
chore: consolidate password changesets
austincalvelage Oct 1, 2026
15726d6
chore(mosaic): note password loading skeleton follow-up
austincalvelage Oct 1, 2026
2aca946
refactor(mosaic): extract password field feedback mapping
austincalvelage Oct 1, 2026
d06c22b
refactor(mosaic): check hidden before readonly password status
austincalvelage Oct 1, 2026
9f0b3b5
refactor(mosaic): require password settings before validating
austincalvelage Oct 1, 2026
6475bc6
refactor(mosaic): return plain data from password model
austincalvelage Oct 1, 2026
94dae09
chore(mosaic): note current password reverification parity
austincalvelage Oct 1, 2026
00f60d7
refactor(mosaic): hide password section without a session
austincalvelage Oct 1, 2026
b1d57a9
fix(mosaic): only report strong passwords after a strength check
austincalvelage Oct 1, 2026
c7d8253
fix(mosaic): drop managed-by logo removed on main
austincalvelage Oct 1, 2026
053902d
Delete .changeset/quiet-password-messages.md
austincalvelage Oct 1, 2026
37f4226
fix(mosaic): simplify password section wiring
austincalvelage Oct 1, 2026
4d25762
chore(mosaic): preserve remote password test cleanup
austincalvelage Oct 1, 2026
e664a41
test(mosaic): document deferred password behavior
austincalvelage Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/quiet-managed-password.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
34 changes: 32 additions & 2 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface FakeFapiState {
suggestions: OrganizationSuggestionJSON[];
apiKeys: ApiKeyJSON[];
verification: FakeVerificationState;
passwordUpdates: URLSearchParams[];
}

export type FakeFapiSeed = Partial<Omit<FakeFapiState, 'verification'>> & {
Expand Down Expand Up @@ -100,6 +101,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
invitations: [],
suggestions: [],
apiKeys: [],
passwordUpdates: [],
...rest,
verification: createVerificationState(verification),
};
Expand Down Expand Up @@ -144,6 +146,19 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null };
return envelope({ ...session, status: 'removed' }, state.client);
}),
http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => {
const session = findSession(state, state.client.last_active_session_id);
if (!session) {
return missing();
}
state.passwordUpdates.push(new URLSearchParams(await request.text()));
const updatedUser = { ...session.user, password_enabled: true };
state.client = {
...state.client,
sessions: state.client.sessions.map(item => (item.id === session.id ? { ...item, user: updatedUser } : item)),
};
return envelope(updatedUser, state.client);
}),
http.post(fapiUrl('/v1/client/sessions'), ({ request }) => {
if (new URL(request.url).searchParams.get('_method') !== 'DELETE') {
return undefined;
Expand Down Expand Up @@ -224,7 +239,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
export interface HeldRequests {
requests: Request[];
release: () => void;
fail: (code?: string) => void;
fail: (code?: string, longMessage?: string, paramName?: string) => void;
}

interface Hold {
Expand Down Expand Up @@ -266,6 +281,21 @@ export function holdRequests(method: 'get' | 'post', path: string): HeldRequests
return {
requests,
release: () => settle(undefined),
fail: (code = 'form_param_invalid') => settle(error(code)),
fail: (code = 'form_param_invalid', longMessage = code, paramName?: string) =>
settle(
HttpResponse.json(
{
errors: [
{
code,
message: code,
long_message: longMessage,
...(paramName ? { meta: { param_name: paramName } } : {}),
},
],
},
{ status: 400 },
),
),
};
}
39 changes: 39 additions & 0 deletions packages/mosaic/src/__tests__/feature/fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ClientJSON,
DisplayConfigJSON,
EmailAddressJSON,
EnterpriseAccountJSON,
EnvironmentJSON,
OAuthProviders,
OrganizationJSON,
Expand Down Expand Up @@ -271,6 +272,44 @@ export function fapiUser(overrides: Partial<UserJSON> & Pick<UserJSON, 'id'>): U
};
}

export function fapiEnterpriseAccount(
overrides: Partial<EnterpriseAccountJSON> & Pick<EnterpriseAccountJSON, 'id'>,
): EnterpriseAccountJSON {
return {
object: 'enterprise_account',
active: true,
email_address: 'sso@example.com',
enterprise_connection: {
object: 'enterprise_connection',
id: 'sso_1',
active: true,
allow_idp_initiated: false,
allow_subdomains: false,
disable_additional_identifications: true,
domain: 'example.com',
logo_public_url: null,
name: 'Company SSO',
protocol: 'saml',
provider: 'saml_okta',
sync_user_attributes: false,
allow_organization_account_linking: false,
created_at: createdAt,
updated_at: createdAt,
enterprise_connection_id: 'sso_1',
},
first_name: null,
last_name: null,
protocol: 'saml',
provider: 'saml_okta',
provider_user_id: null,
public_metadata: {},
verification: null,
last_authenticated_at: null,
enterprise_connection_id: 'sso_1',
...overrides,
};
}

export function fapiSession(overrides: Partial<SessionJSON> & Pick<SessionJSON, 'id' | 'user'>): SessionJSON {
const { user } = overrides;
const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id);
Expand Down
36 changes: 36 additions & 0 deletions packages/mosaic/src/components/field/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { renderToString } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Checkbox } from '../checkbox';
import type { FieldFeedback } from '../form';
import { Input } from '../input';
import { Select } from '../select';
import { Field } from './field';
Expand Down Expand Up @@ -37,6 +38,41 @@ function stubPrototype(target: object, name: string, descriptor: PropertyDescrip
}

describe('Mosaic Field', () => {
it.each<{ type: FieldFeedback['type']; slot: string }>([
{ type: 'error', slot: 'cl-field-error' },
{ type: 'success', slot: 'cl-field-success' },
{ type: 'info', slot: 'cl-field-info' },
{ type: 'warning', slot: 'cl-field-info' },
])('associates $type feedback with its control', ({ type, slot }) => {
render(
<Field.Root>
<Field.Label>Password</Field.Label>
<Input />
<Field.Feedback feedback={{ type, message: 'Password feedback' }} />
</Field.Root>,
);
expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Password feedback');
expect(screen.getByText('Password feedback').closest('p')).toHaveClass(slot);
expect(screen.getByRole('status')).toHaveTextContent('Password feedback');
});
it('associates text-only info feedback with the control in a live region', () => {
render(
<Field.Root>
<Field.Label>Password</Field.Label>
<Input />
<Field.Message>
<Field.Hint>Try a longer password.</Field.Hint>
</Field.Message>
</Field.Root>,
);

expect(screen.getByRole('textbox', { name: 'Password' })).toHaveAccessibleDescription('Try a longer password.');
expect(screen.getByRole('status')).toHaveTextContent('Try a longer password.');
const info = screen.getByText('Try a longer password.').closest('p');
expect(info).toHaveClass('cl-field-info');
expect(info?.querySelector('svg')).toBeNull();
});

afterEach(() => {
restores.splice(0).forEach(restore => restore());
});
Expand Down
56 changes: 50 additions & 6 deletions packages/mosaic/src/components/field/field.tsx

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds variant to mirror legacy:

Legacy:
Image

Mosiac:
Image

Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { feedbackHeight, feedbackStyles } from '../../utils/feedback.styles';
import { reset } from '../../utils/reset.styles';
import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles';
import { visuallyHidden } from '../../utils/visually-hidden.styles';
import type { FieldFeedback as FormFieldFeedback } from '../form';
import type { FieldOrientation } from './field.context';
import {
FieldMessageProvider,
Expand Down Expand Up @@ -202,18 +203,19 @@ const Message = React.forwardRef<HTMLDivElement, FieldMessageProps>(function Mos
return <FieldMessageProvider register={register}>{rendered}</FieldMessageProvider>;
});

type FieldFeedbackKind = 'error' | 'success';
type FieldFeedbackKind = 'error' | 'success' | 'info';

const FEEDBACK: Record<FieldFeedbackKind, { slot: string; icon: IconName; color: stylex.StyleXStyles }> = {
const FEEDBACK: Record<FieldFeedbackKind, { slot: string; icon?: IconName; color: stylex.StyleXStyles }> = {
error: { slot: 'field-error', icon: 'exclamation-circle', color: feedbackStyles.error },
success: { slot: 'field-success', icon: 'checkmark', color: feedbackStyles.success },
info: { slot: 'field-info', color: feedbackStyles.info },
};

interface FieldFeedbackProps extends MosaicComponentProps<'p'> {
interface FieldFeedbackPartProps extends MosaicComponentProps<'p'> {
kind: FieldFeedbackKind;
}

const FieldFeedback = React.forwardRef<HTMLParagraphElement, FieldFeedbackProps>(function MosaicFieldFeedback(
const FieldFeedback = React.forwardRef<HTMLParagraphElement, FieldFeedbackPartProps>(function MosaicFieldFeedback(
{ render, xstyle, id: idProp, children, kind, ...rest },
ref,
) {
Expand All @@ -240,7 +242,7 @@ const FieldFeedback = React.forwardRef<HTMLParagraphElement, FieldFeedbackProps>
rest,
),
id,
children: <FeedbackBody icon={icon}>{message}</FeedbackBody>,
children: icon ? <FeedbackBody icon={icon}>{message}</FeedbackBody> : message,
},
});
});
Expand Down Expand Up @@ -271,5 +273,47 @@ const FieldSuccess = React.forwardRef<HTMLParagraphElement, FieldSuccessProps>(f
);
});

export type FieldHintProps = MosaicComponentProps<'p'>;

const FieldHint = React.forwardRef<HTMLParagraphElement, FieldHintProps>(function MosaicFieldHint(props, ref) {
return (
<FieldFeedback
ref={ref}
kind='info'
{...props}
/>
);
});

export interface FieldFeedbackProps extends Omit<FieldMessageProps, 'children'> {
feedback?: FormFieldFeedback;
}

const Feedback = React.forwardRef<HTMLDivElement, FieldFeedbackProps>(function MosaicFieldFeedbackValue(
{ feedback, ...props },
ref,
) {
return (
<Message
ref={ref}
{...props}
>
<FieldError>{feedback?.type === 'error' ? feedback.message : null}</FieldError>
<FieldHint>{feedback?.type === 'info' || feedback?.type === 'warning' ? feedback.message : null}</FieldHint>
<FieldSuccess>{feedback?.type === 'success' ? feedback.message : null}</FieldSuccess>
</Message>
);
});

/** Styled parts for composing an automatically associated single-control field. */
export const Field = { Root, Label, Content, Description, Message, Error: FieldError, Success: FieldSuccess };
export const Field = {
Root,
Label,
Content,
Description,
Message,
Feedback,
Error: FieldError,
Success: FieldSuccess,
Hint: FieldHint,
};
2 changes: 2 additions & 0 deletions packages/mosaic/src/components/field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export { Field } from './field';
export type {
FieldDescriptionProps,
FieldErrorProps,
FieldFeedbackProps,
FieldHintProps,
FieldLabelProps,
FieldMessageProps,
FieldRootProps,
Expand Down
18 changes: 18 additions & 0 deletions packages/mosaic/src/components/form/use-form.edit-password.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ function useEditPasswordForm(onSubmit: (values: EditPasswordValues) => Promise<v
}

describe('useForm: edit password', () => {
it('reports submission immediately within the submit event', async () => {
let finish = () => {};
const onSubmit = () =>
new Promise<void>(resolve => {
finish = resolve;
});
const { result } = renderHook(() => useForm({ initialValues: { password: 'secret' }, onSubmit }));
const form = result.current;
act(() => {
form.submit();
expect(form.isSubmitting).toBe(true);
});
await act(async () => {
finish();
await flush();
});
expect(form.isSubmitting).toBe(false);
});
it('walks a user from a weak password to a saved one', async () => {
const onSubmit = vi.fn(() => Promise.resolve());
const { result } = renderHook(() => useEditPasswordForm(onSubmit));
Expand Down
5 changes: 4 additions & 1 deletion packages/mosaic/src/components/form/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,10 @@ export function useForm<TValues extends object>(options: UseFormOptions<TValues>
values,
fields,
error: context.error?.message,
isSubmitting,
get isSubmitting() {
const current = actor.getSnapshot();
return current.value === 'submitting' || current.context.submitQueued;
},
isDirty: keysOf(values).some(name => fields[name].isDirty),
canSubmit: !isSubmitting && isValid(context),
register,
Expand Down
Loading
Loading