From ef02c42cd9f88cdd0a46234417e887dd74dfbc59 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 13 Oct 2026 11:13:58 -0400 Subject: [PATCH 01/19] feat(mosaic): decouple dialog dismissal from its role (#10207) --- .changeset/mosaic-dialog-outside-press.md | 2 + packages/mosaic/src/components/card/card.tsx | 5 +- .../dialog/__tests__/alert-dialog.test.tsx | 86 ++++++++++++++++++- .../dialog/__tests__/dialog.test.tsx | 51 +++++++++++ .../mosaic/src/components/dialog/dialog.tsx | 75 +++++++++++----- .../src/components/profile/profile.test.tsx | 17 ++++ .../mosaic/src/components/profile/profile.tsx | 2 +- .../swingset/src/stories/dialog.component.mdx | 20 +++-- 8 files changed, 222 insertions(+), 36 deletions(-) create mode 100644 .changeset/mosaic-dialog-outside-press.md diff --git a/.changeset/mosaic-dialog-outside-press.md b/.changeset/mosaic-dialog-outside-press.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-dialog-outside-press.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/card/card.tsx b/packages/mosaic/src/components/card/card.tsx index bf9e6c7b04a..1f934cf60e5 100644 --- a/packages/mosaic/src/components/card/card.tsx +++ b/packages/mosaic/src/components/card/card.tsx @@ -124,10 +124,11 @@ const Header = React.forwardRef>(fun <> {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening focus — the same reason `Dialog.CloseButton` is a part rather than a popup flag. - Not outside a dialog, where there is nothing to close, and not in an alert dialog. */} + Not outside a dialog, where there is nothing to close, and not where the dialog's + `showCloseButton` is off, which is the default for an alert dialog. */} {/* An alert dialog interrupts to ask for a decision, and a corner X is a way out without answering one. The cancel action in the footer is the way out. */} - {isInDialog(dialog) && dialog.role !== 'alertdialog' ? : null} + {isInDialog(dialog) && dialog.showCloseButton ? : null}
{children}
diff --git a/packages/mosaic/src/components/dialog/__tests__/alert-dialog.test.tsx b/packages/mosaic/src/components/dialog/__tests__/alert-dialog.test.tsx index 80d6b7ad992..8532a1677aa 100644 --- a/packages/mosaic/src/components/dialog/__tests__/alert-dialog.test.tsx +++ b/packages/mosaic/src/components/dialog/__tests__/alert-dialog.test.tsx @@ -15,11 +15,10 @@ const settle = () => await new Promise(resolve => setTimeout(resolve, 0)); }); -function Confirm({ onOpenChange, ...rest }: Partial = {}) { +function Confirm({ onOpenChange }: Pick = {}) { return ( @@ -375,3 +374,86 @@ describe('stacked on another dialog', () => { warn.mockRestore(); }); }); + +describe('showCloseButton', () => { + function Verify({ showCloseButton }: { showCloseButton?: boolean }) { + return ( + + + + + Verify it is you + Enter the code sent to your email. + + + + + ); + } + + it('renders the card header dismiss in an alert dialog that opts in', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('shows and hides the dismiss as the prop changes while open', () => { + const { rerender } = render(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + + rerender(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + }); + + it('hides the card header dismiss in a plain dialog that opts out', () => { + render( + + + + + Add email address + + + + , + ); + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + }); + + it('does not warn about Dialog.CloseButton in an alert dialog that opts in', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + + + Verify it is you + Enter the code sent to your email. + + + , + ); + await settle(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx index 0612e91a4c5..ae180f4542b 100644 --- a/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx +++ b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx @@ -164,6 +164,57 @@ describe('Mosaic Dialog', () => { }); }); +describe('shouldCloseOnInteractOutside', () => { + function Verify({ shouldCloseOnInteractOutside }: { shouldCloseOnInteractOutside?: boolean }) { + return ( + + + + + + ); + } + + it('closes on an outside press by default', async () => { + const user = userEvent.setup(); + render(); + + await user.click(document.querySelector('.cl-dialog-backdrop') as HTMLElement); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('stays open on an outside press when false', async () => { + const user = userEvent.setup(); + render(); + + await user.click(document.querySelector('.cl-dialog-backdrop') as HTMLElement); + + expect(screen.getByRole('dialog', { name: 'Verify it is you' })).toBeInTheDocument(); + }); + + it('still closes on Escape when false', async () => { + const user = userEvent.setup(); + render(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('keeps the corner dismiss when false', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + // A `profile` dialog (account profile) opening a `card` dialog (add an email address) is a real // shape, so the `FloatingTree` nesting the headless README claims is exercised here rather than // assumed. Dismissal must reach the topmost dialog only, and the body must stay locked until the diff --git a/packages/mosaic/src/components/dialog/dialog.tsx b/packages/mosaic/src/components/dialog/dialog.tsx index 586a71ec11b..6ce8f0808bf 100644 --- a/packages/mosaic/src/components/dialog/dialog.tsx +++ b/packages/mosaic/src/components/dialog/dialog.tsx @@ -74,10 +74,14 @@ export interface DialogContextValue { * the one way out an alert must not offer. */ role: DialogRole; + /** Whether `Card.Header` renders its dismiss. From `showCloseButton` on the root. */ + showCloseButton: boolean; } export const DialogContext = React.createContext(null); +const ShowCloseButtonContext = React.createContext(true); + /** * In a dialog, as opposed to standalone. A surface that can be rendered either way — `Card`, * `Profile` — reads this to take the dialog's dismiss and its overlay treatment, without knowing @@ -136,32 +140,55 @@ export interface DialogPopupProps extends MosaicComponentProps<'div'> { type DialogRootBaseProps = Omit, 'role' | 'closedBy'>; /** - * `role` is the whole dismissal API: it decides how the dialog announces itself AND what closes it, - * with no prop to contradict either. + * `role` decides how the dialog announces itself; `shouldCloseOnInteractOutside` decides whether + * an outside press closes it; `showCloseButton` decides whether the surface renders its dismiss. + * Escape always closes. * * - `alertdialog` announces as an interruption rather than as a surface the user navigated to, and - * cannot be dismissed by an outside press. A dialog asking a question it needs an answer to must - * not be answerable by clicking next to it. Escape still closes, which is the keyboard's - * equivalent of the cancel button that is always present; - * - `dialog` dismisses on an outside press as well. + * never closes on an outside press. A dialog asking a question it needs an answer to must not be + * answerable by clicking next to it. Escape is the keyboard's equivalent of the cancel button + * that is always present; + * - `dialog` closes on an outside press unless `shouldCloseOnInteractOutside` is `false`, for a + * task an accidental press would throw away, such as entering a verification code. * * It says nothing about the variant: an alert is a `Card` like every other dialog. */ -export type DialogRootProps = DialogRootBaseProps & { - /** The popup's ARIA role, and with it what dismisses the dialog. @default 'dialog' */ - role?: DialogRole; -}; +export type DialogRootProps = DialogRootBaseProps & + ( + | { + /** The popup's ARIA role. @default 'dialog' */ + role?: 'dialog'; + /** Whether an outside press closes the dialog. @default true */ + shouldCloseOnInteractOutside?: boolean; + /** Whether the surface renders its dismiss. @default true */ + showCloseButton?: boolean; + } + | { + role: 'alertdialog'; + shouldCloseOnInteractOutside?: false; + /** Whether the surface renders its dismiss, for a step past the question. @default false */ + showCloseButton?: boolean; + } + ); /** Owns the open state and the decisions — role, dismissal — every part reads. */ -function Root({ role = 'dialog', children, ...rest }: DialogRootProps) { +function Root({ + role = 'dialog', + shouldCloseOnInteractOutside = role === 'dialog', + showCloseButton = role === 'dialog', + children, + ...rest +}: DialogRootProps) { return ( - - {...rest} - role={role} - closedBy={role === 'alertdialog' ? 'closerequest' : 'any'} - > - {children} - + + + {...rest} + role={role} + closedBy={shouldCloseOnInteractOutside ? 'any' : 'closerequest'} + > + {children} + + ); } @@ -191,8 +218,8 @@ const Close = React.forwardRef(function Dia }); /** - * Warns when the corner dismiss is rendered inside an alert dialog, where a corner X is a way out - * without answering the question. + * Warns when the corner dismiss is rendered inside an alert dialog that has not opted in with + * `showCloseButton`, where a corner X is a way out without answering the question. */ function useCloseButtonWarning(isAlert: boolean) { React.useEffect(() => { @@ -219,9 +246,10 @@ const CloseButton = React.forwardRef( ref, ) { const surface = React.useContext(DialogContext); + const showCloseButton = React.useContext(ShowCloseButtonContext); const { role } = useHeadlessDialogContext(); const variant = surface?.variant ?? 'card'; - useCloseButtonWarning(role === 'alertdialog'); + useCloseButtonWarning(role === 'alertdialog' && !showCloseButton); return ( (function Dialog // The headless flag, not the stack check below — the variant rule is about opening a dialog inside // ANY dialog, which is broader than the card-on-card case the stacking styles cover. const { role, isStacked: isNestedInDialog, labelId, descriptionId } = useHeadlessDialogContext(); + const showCloseButton = React.useContext(ShowCloseButtonContext); const isAlert = role === 'alertdialog'; // A profile has its own compact-band treatment and takes no placement; the warning says so. const compactPlacement: DialogCompactPlacement = variant === 'profile' ? 'center' : (compactPlacementProp ?? 'sheet'); @@ -369,8 +398,8 @@ const Popup = React.forwardRef(function Dialog useNestedVariantWarning(isNestedInDialog, variant); const surface = React.useMemo( - () => ({ labelId, descriptionId, variant, role }), - [labelId, descriptionId, variant, role], + () => ({ labelId, descriptionId, variant, role, showCloseButton }), + [labelId, descriptionId, variant, role, showCloseButton], ); // Observed through state rather than a plain ref, because the warnings have to re-run when the // node arrives and a ref mutation does not re-render. diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index e2d2325c6c6..0b591fa905e 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -596,6 +596,23 @@ describe('Profile', () => { expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); + it('follows showCloseButton on the dialog', () => { + render( + + + + + + + , + ); + + expect(screen.queryByRole('button', { name: 'Close', exact: true })).not.toBeInTheDocument(); + }); + it('carries no dismiss standalone', () => { renderSurface(); diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 7678462555f..058f7305bb6 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -150,7 +150,7 @@ const Root = React.forwardRef(function Profile {...mergeStyleProps(themeProps('profile-sentinel'), stylex.props(reset.base, styles.sentinel))} /> {/* First in the DOM so it takes the dialog's opening focus, as in `Card.Header`. */} - {isInDialog(dialog) && dialog.role !== 'alertdialog' ? : null} + {isInDialog(dialog) && dialog.showCloseButton ? : null}
void' }, @@ -138,7 +140,8 @@ What follows from the role: - No outside-press dismissal; Escape still cancels. - A name **and** a description are required — `Card.Title` and `Card.Description`; both warn in dev when missing. -- `Card.Header` withholds its corner dismiss, and `Dialog.CloseButton` warns. +- `Card.Header` withholds its corner dismiss, and `Dialog.CloseButton` warns, unless the root sets + `showCloseButton`. ### Confirming a discard @@ -173,14 +176,15 @@ the DOM. Point `initialFocus` on `Dialog.Popup` at the field that should have it ### Dismissal -`role` decides what closes the dialog. There is no prop for it. +Escape always closes. `shouldCloseOnInteractOutside={false}` keeps a `dialog` open on an outside +press, for a task an accidental press would throw away, such as entering a verification code. An +`alertdialog` never closes on an outside press. -| Role | Escape | Outside press | Programmatic | -| ------------- | ------ | ------------- | ------------ | -| `dialog` | ✅ | ✅ | ✅ | -| `alertdialog` | ✅ | ❌ | ✅ | - -The two map to the native [``](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy) values `any` and `closerequest`. +| Root | Escape | Outside press | Programmatic | +| ---------------------------------------------------- | ------ | ------------- | ------------ | +| `role='dialog'` | ✅ | ✅ | ✅ | +| `role='dialog' shouldCloseOnInteractOutside={false}` | ✅ | ❌ | ✅ | +| `role='alertdialog'` | ✅ | ❌ | ✅ | ### Keyboard From 3b4ceb677056fb20dc1f44b7657edc5aeeb5e1cb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 13 Oct 2026 12:01:45 -0400 Subject: [PATCH 02/19] fix(ui): keep quotes, commas and parentheses inside quoted localization modifier params (#10091) --- .../localization-modifier-quoted-params.md | 5 ++++ .../__tests__/applyTokensToString.test.ts | 27 ++++++++++++++++++ .../src/localization/applyTokensToString.ts | 28 ++++++++++++------- 3 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 .changeset/localization-modifier-quoted-params.md diff --git a/.changeset/localization-modifier-quoted-params.md b/.changeset/localization-modifier-quoted-params.md new file mode 100644 index 00000000000..642d25cf5cc --- /dev/null +++ b/.changeset/localization-modifier-quoted-params.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix localization strings whose `link("…")` labels contain apostrophes, quotes, commas or parentheses. Previously, a custom label such as `link("Conditions d'utilisation")` rendered as "Conditions dutilisation"; quoted labels now render exactly as written. diff --git a/packages/ui/src/localization/__tests__/applyTokensToString.test.ts b/packages/ui/src/localization/__tests__/applyTokensToString.test.ts index 49ec57a07c8..9dca73ad376 100644 --- a/packages/ui/src/localization/__tests__/applyTokensToString.test.ts +++ b/packages/ui/src/localization/__tests__/applyTokensToString.test.ts @@ -129,6 +129,33 @@ describe('applyTokensToString', function () { expect(applyTokensToString(input, tokens as any)).toEqual(expected); }); }); + + describe('with quoted params', () => { + const linkTokens = { url: 'https://example.com', date: new Date('2021-12-31T22:00:00.000Z') }; + const cases = [ + [`{{ url | link("Conditions d'utilisation") }}`, `[Conditions d'utilisation](https://example.com)`], + [`{{ url | link("Members'") }}`, `[Members'](https://example.com)`], + [`{{ url | link('Say "hi"') }}`, `[Say "hi"](https://example.com)`], + [`{{ url | link("Terms, Conditions") }}`, `[Terms, Conditions](https://example.com)`], + [`{{ url | link("Terms (EU)") }}`, `[Terms (EU)](https://example.com)`], + [`{{ url | link( "Terms, Conditions" ) }}`, `[Terms, Conditions](https://example.com)`], + [`{{ url | link( "Conditions d'utilisation" ) }}`, `[Conditions d'utilisation](https://example.com)`], + [`{{ date | weekday('en-US','long') }}`, 'Friday'], + [`{{ date | weekday ( "en-US" , "short" ) }}`, 'Fri'], + [`{{ date | weekday('en-US",) }}`, 'Friday'], + [`{{ url | link(Conditions d'utilisation) }}`, `[Conditions d'utilisation](https://example.com)`], + [`{{ url | link("Terms) }}`, `[Terms](https://example.com)`], + [`{{ date | weekday(en-US, narrow) }}`, 'F'], + [`{{ date | weekday('', 'short') }}`, 'Fri'], + [`{{ url || link("Terms") }}`, `[Terms](https://example.com)`], + [`{{ url | link('Conditions d'utilisation') }}`, `[Conditions d'utilisation](https://example.com)`], + [`{{ date | weekday('en-US", 'short') }}`, 'Fri'], + ]; + + it.each(cases)('.applyTokensToString(%s, tokens) => %s', (input, expected) => { + expect(applyTokensToString(input, linkTokens as any)).toEqual(expected); + }); + }); }); }); }); diff --git a/packages/ui/src/localization/applyTokensToString.ts b/packages/ui/src/localization/applyTokensToString.ts index 9097b7efe8c..4d8bd1e8144 100644 --- a/packages/ui/src/localization/applyTokensToString.ts +++ b/packages/ui/src/localization/applyTokensToString.ts @@ -85,16 +85,24 @@ const applyTokenExpressions = (s: string, expressions: TokenExpression[], tokens const assertKnownModifier = (s: any): s is Modifier => Object.prototype.hasOwnProperty.call(MODIFIERS, s); +const PARAM_REGEX = /\s*(?:"([^"]*)"|'([^']*)'|([^,]+))\s*(?=,|$)/g; + const getModifierWithParams = (modifierExpression: string) => { - const parts = modifierExpression - .split(/[(,)]/g) - .map(m => m.trim()) - .filter(m => !!m); - if (parts.length === 1) { - const [modifierName] = parts; - return { modifierName, params: [] }; - } else { - const [modifierName, ...params] = parts; - return { modifierName, params: params.map(p => p.replace(/['"]+/g, '')) }; + const open = modifierExpression.indexOf('('); + if (open === -1) { + return { modifierName: modifierExpression.trim(), params: [] }; } + const close = modifierExpression.lastIndexOf(')'); + const paramsExpression = modifierExpression.slice(open + 1, close > open ? close : undefined); + const params = [...paramsExpression.matchAll(PARAM_REGEX)].flatMap( + ([, doubleQuoted, singleQuoted, unquoted = '']) => { + const quoted = doubleQuoted ?? singleQuoted; + if (quoted !== undefined) { + return [quoted]; + } + const value = unquoted.trim().replace(/^['"]+|['"]+$/g, ''); + return value ? [value] : []; + }, + ); + return { modifierName: modifierExpression.slice(0, open).trim(), params }; }; From f5ba9b03c3887be389f9f3c414923380bedf42a5 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 13 Oct 2026 09:52:36 -0700 Subject: [PATCH 03/19] fix(nextjs): evaluate auth.protect() authorization params for machine tokens (#10198) --- .changeset/quiet-keys-protect.md | 5 + integration/tests/next-machine.test.ts | 97 ++++++++++++++++++- .../server/__tests__/clerkMiddleware.test.ts | 79 ++++++++++++++- packages/nextjs/src/server/protect.ts | 32 +++--- 4 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 .changeset/quiet-keys-protect.md diff --git a/.changeset/quiet-keys-protect.md b/.changeset/quiet-keys-protect.md new file mode 100644 index 00000000000..0dd77df6eed --- /dev/null +++ b/.changeset/quiet-keys-protect.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Fix `auth.protect()` skipping authorization checks for machine tokens. The `role`, `permission`, `feature`, `plan`, and `reverification` params and the predicate callback are now evaluated for API key, OAuth token, and M2M token requests. Param-based checks respond with a 401 for those requests. diff --git a/integration/tests/next-machine.test.ts b/integration/tests/next-machine.test.ts index 0a753cce476..80b9cbc878d 100644 --- a/integration/tests/next-machine.test.ts +++ b/integration/tests/next-machine.test.ts @@ -1,8 +1,13 @@ -import { test } from '@playwright/test'; +import type { User } from '@clerk/backend'; +import { TokenType } from '@clerk/backend/internal'; +import { expect, test } from '@playwright/test'; +import type { Application } from '../models/application'; import { appConfigs } from '../presets'; +import { createTestUtils } from '../testUtils'; import type { MachineAuthTestAdapter } from '../testUtils/machineAuthHelpers'; import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../testUtils/machineAuthHelpers'; +import type { FakeAPIKey, FakeUser } from '../testUtils/usersService'; const adapter: MachineAuthTestAdapter = { baseConfig: appConfigs.next.appRouter, @@ -94,4 +99,94 @@ test.describe('Next.js machine authentication @machine', () => { registerApiKeyAuthTests(adapter); registerM2MAuthTests(adapter); registerOAuthAuthTests(adapter); + + test.describe('auth.protect() authorization with API keys', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + let fakeBapiUser: User; + let fakeAPIKey: FakeAPIKey; + + test.beforeAll(async () => { + test.setTimeout(120_000); + + app = await appConfigs.next.appRouter + .clone() + .addFile( + 'src/app/api/protect/route.ts', + () => ` + import { auth } from '@clerk/nextjs/server'; + + export async function GET() { + const { userId, tokenType } = await auth.protect({ token: 'api_key' }); + + return Response.json({ userId, tokenType }); + } + + export async function POST() { + const { userId } = await auth.protect({ role: 'org:admin' }, { token: ['session_token', 'api_key'] }); + + return Response.json({ userId }); + } + + export async function PUT() { + await auth.protect(() => false, { token: 'api_key' }); + + return Response.json({ protected: true }); + } + + export async function PATCH() { + const { userId, tokenType } = await auth.protect(() => true, { token: 'api_key' }); + + return Response.json({ userId, tokenType }); + } + `, + ) + .commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withAPIKeys.clone()); + await app.dev(); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + fakeBapiUser = await u.services.users.createBapiUser(fakeUser); + fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id); + }); + + test.afterAll(async () => { + await fakeAPIKey?.revoke(); + await fakeUser?.deleteIfExists(); + await app?.teardown(); + }); + + test('should return 200 if API key is valid and no authorization params are passed', async ({ request }) => { + const res = await request.get(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ userId: fakeBapiUser.id, tokenType: TokenType.ApiKey }); + }); + + test('should return 401 if API key is valid but a role is required', async ({ request }) => { + const res = await request.post(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(401); + }); + + test('should return 401 if API key is valid but the predicate returns false', async ({ request }) => { + const res = await request.put(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(401); + }); + + test('should return 200 if API key is valid and the predicate returns true', async ({ request }) => { + const res = await request.patch(new URL('/api/protect', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ userId: fakeBapiUser.id, tokenType: TokenType.ApiKey }); + }); + }); }); diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 31757419d0f..4b3e6754dfd 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1,6 +1,6 @@ // There is no need to execute the complete authenticateRequest to test clerkMiddleware // This mock SHOULD exist before the import of authenticateRequest -import { AuthStatus, constants, TokenType } from '@clerk/backend/internal'; +import { authenticatedMachineObject, AuthStatus, constants, TokenType } from '@clerk/backend/internal'; import { MalformedURLError } from '@clerk/shared/pathMatcher'; // used to assert the mock import assert from 'assert'; @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { clerkClient } from '../clerkClient'; +import type { ClerkMiddlewareAuth } from '../clerkMiddleware'; import { clerkMiddleware } from '../clerkMiddleware'; import { createRouteMatcher } from '../routeMatcher'; import { decryptClerkRequestData } from '../utils'; @@ -881,6 +882,82 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(401); expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + + describe.each([ + [TokenType.ApiKey, 'ak_123', 'user_123'], + [TokenType.M2MToken, 'mt_123', 'mch_123'], + [TokenType.OAuthToken, 'oat_123', 'user_123'], + ] as const)('when the request is authenticated with a valid %s', (tokenType, token, subject) => { + const protectWith = async (protect: (auth: ClerkMiddlewareAuth) => Promise) => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ [constants.Headers.Authorization]: `Bearer ${token}` }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => authenticatedMachineObject(tokenType, token, { id: token, subject, scopes: [] } as any), + }); + + return clerkMiddleware(async auth => { + await protect(auth); + })(req, {} as NextFetchEvent); + }; + + it.each([ + { role: 'org:admin' }, + { permission: 'org:settings:manage' }, + { feature: 'premium' }, + { plan: 'pro' }, + { reverification: 'strict' }, + ] as const)('returns 401 when protect is called with %o', async params => { + const resp = await protectWith(auth => auth.protect(params, { token: 'any' })); + + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with a permission and an array of token types', async () => { + const resp = await protectWith(auth => + auth.protect({ permission: 'org:settings:manage' }, { token: [TokenType.SessionToken, tokenType] }), + ); + + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with authorization params and token in a single argument', async () => { + const resp = await protectWith(auth => auth.protect({ feature: 'premium', token: tokenType } as any)); + + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with a predicate that returns false', async () => { + const predicate = vi.fn().mockReturnValue(false); + const resp = await protectWith(auth => auth.protect(predicate, { token: tokenType })); + + expect(predicate).toHaveBeenCalledOnce(); + expect(resp?.status).toEqual(401); + }); + + it('returns 401 when protect is called with a predicate that relies on has()', async () => { + const resp = await protectWith(auth => auth.protect(has => has({ role: 'org:admin' }), { token: tokenType })); + + expect(resp?.status).toEqual(401); + }); + + it('does not throw when protect is called with a predicate that returns true', async () => { + const resp = await protectWith(auth => auth.protect(() => true, { token: tokenType })); + + expect(resp?.status).toEqual(200); + }); + + it('does not throw when protect is called without authorization params', async () => { + const resp = await protectWith(auth => auth.protect({ token: tokenType })); + + expect(resp?.status).toEqual(200); + }); + }); }); describe('auth().redirectToSignIn()', () => { diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 1e72f7128fd..73b4cf74f9d 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -154,30 +154,28 @@ export function createProtect(opts: { } if (authObject.tokenType !== TokenType.SessionToken) { - // For machine tokens, we only check if they're authenticated - // They don't have session status or organization permissions + // Machine tokens don't have session status if (!authObject.isAuthenticated) { return handleUnauthorized(); } - return authObject; - } - - /** - * Redirects the user back to the tasks URL if their session status is pending - */ - if (authObject.sessionStatus === 'pending') { - return handleUnauthenticated(); - } + } else { + /** + * Redirects the user back to the tasks URL if their session status is pending + */ + if (authObject.sessionStatus === 'pending') { + return handleUnauthenticated(); + } - /** - * User is not authenticated - */ - if (!authObject.userId) { - return handleUnauthenticated(); + /** + * User is not authenticated + */ + if (!authObject.userId) { + return handleUnauthenticated(); + } } /** - * User is authenticated + * User or machine is authenticated */ if (!paramsOrFunction) { return authObject; From 7ac1a3c561e3f600c4a633dfd1e3ce20d722510e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 13 Oct 2026 13:09:10 -0400 Subject: [PATCH 04/19] docs(swingset): add useServerDataTable page (#10212) --- .../swingset-use-server-data-table-docs.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 7 + .../src/stories/use-server-data-table.mdx | 103 ++++++++++++ .../stories/use-server-data-table.stories.tsx | 147 ++++++++++++++++++ 5 files changed, 260 insertions(+) create mode 100644 .changeset/swingset-use-server-data-table-docs.md create mode 100644 packages/swingset/src/stories/use-server-data-table.mdx create mode 100644 packages/swingset/src/stories/use-server-data-table.stories.tsx diff --git a/.changeset/swingset-use-server-data-table-docs.md b/.changeset/swingset-use-server-data-table-docs.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/swingset-use-server-data-table-docs.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 55d1553b36f..24a57c4a7ea 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -129,6 +129,7 @@ const docModules: Record> = { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), 'use-form': dynamic(() => import('../stories/use-form.mdx')), + 'use-server-data-table': dynamic(() => import('../stories/use-server-data-table.mdx')), }, localization: { localization: dynamic(() => import('../stories/localization.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 1de0aac484f..8825ab578d9 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -309,6 +309,10 @@ import { import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; import { Default as UseFormDefault, meta as useFormMeta } from '../stories/use-form.stories'; +import { + Default as UseServerDataTableDefault, + meta as useServerDataTableMeta, +} from '../stories/use-server-data-table.stories'; import { Combined as UserButtonCombined, meta as userButtonMeta, @@ -736,6 +740,8 @@ const useDataTableModule: StoryModule = { meta: useDataTableMeta }; const useFormModule: StoryModule = { meta: useFormMeta, Default: UseFormDefault }; +const useServerDataTableModule: StoryModule = { meta: useServerDataTableMeta, Default: UseServerDataTableDefault }; + const localizationModule: StoryModule = { meta: localizationMeta, Overrides: LocalizationOverrides, @@ -1052,6 +1058,7 @@ export const registry: StoryModule[] = [ // Hooks useDataTableModule, useFormModule, + useServerDataTableModule, // Localization localizationModule, ]; diff --git a/packages/swingset/src/stories/use-server-data-table.mdx b/packages/swingset/src/stories/use-server-data-table.mdx new file mode 100644 index 00000000000..28778e64729 --- /dev/null +++ b/packages/swingset/src/stories/use-server-data-table.mdx @@ -0,0 +1,103 @@ +import * as UseServerDataTableStories from './use-server-data-table.stories'; + +# useServerDataTable + +Adapter from server query state to the [Table](/components/table) and [Pagination](/components/pagination) components. You own the query (search, sort, 1-based page, page size) and pass in the page of rows it returned; the hook wraps [useDataTable](/hooks/use-data-table) and hands back the table state, the sort props for each header cell, and the props for `Pagination`. Any change to the search, sort, page, or page size clears the row selection. + +## Example + +Search, sort by a column, select rows, and change pages. The story filters, sorts, and slices the rows itself to stand in for the server. + + + +## Usage + +```tsx +import { Pagination } from '@clerk/mosaic/components/pagination'; +import { Table } from '@clerk/mosaic/components/table'; +import { useServerDataTable } from '@clerk/mosaic/hooks/use-server-data-table'; + +const { table, sortHeader, pagination } = useServerDataTable({ + data: response.data, + totalCount: response.totalCount, + getRowId: row => row.id, + sortableColumns: ['name', 'createdAt'], + sort, + onSortChange: setSort, + page, + pageSize, + onPageChange: setPage, + onPageSizeChange: setPageSize, + searchValue: query, + onSearchChange: setQuery, +}); + + + + + + Name + Created + + + + {table.rows.map(row => ( + … + ))} + + +{pagination ? : null} +``` + +## Options + +| Prop | Type | Default | Description | +| ------------------ | ------------------------------------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------- | +| `data` | `TData[]` | — (required) | Current page of rows | +| `totalCount` | `number` | — (required) | Total matching rows across all pages | +| `getRowId` | `(row: TData) => string` | — (required) | Stable row identifier | +| `isRowSelectable` | `(row: TData) => boolean` | every row | Rows that return `false` are skipped by selection | +| `sortableColumns` | `readonly TColumn[]` | — (required) | Columns the server can sort by | +| `sort` | `ServerDataTableSort \| null` | unsorted | Active sort | +| `onSortChange` | `(sort: ServerDataTableSort \| null) => void` | — | Called with the next sort. Without it, header cells are not sortable | +| `page` | `number` | — (required) | Current page, 1-based | +| `pageSize` | `number` | — (required) | Rows per page | +| `onPageChange` | `(page: number) => void` | — (required) | Called with the next 1-based page | +| `onPageSizeChange` | `(pageSize: number) => void` | — | Called with the next page size, followed by `onPageChange(1)`. Without it, `Pagination` has no page size control | +| `searchValue` | `string` | — (required) | Current search query | +| `onSearchChange` | `(value: string) => void` | — (required) | Called with the next search query | + +## Return + +| Value | Type | Description | +| ------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `table` | `UseDataTableReturn` | The [useDataTable](/hooks/use-data-table) return: rows, selection, and the search value to bind to `Table.Search` | +| `sortHeader` | `(column: TColumn) => Pick` | Props to spread on a `Table.HeaderCell`. Clicking cycles ascending, descending, unsorted | +| `pagination` | `ServerDataTablePagination \| null` | Props to spread on `Pagination`, or `null` when there is nothing to paginate | + +`pagination` is non-null when there is more than one page, or when there is at least one row and `onPageSizeChange` is set. + +## Types + +```ts +interface ServerDataTableSort { + column: TColumn; + direction: 'ascending' | 'descending'; +} + +type ServerDataTablePagination = { + page: number; + pageSize: number; + totalItems: number; + onChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; +}; +``` diff --git a/packages/swingset/src/stories/use-server-data-table.stories.tsx b/packages/swingset/src/stories/use-server-data-table.stories.tsx new file mode 100644 index 00000000000..97ee7120d75 --- /dev/null +++ b/packages/swingset/src/stories/use-server-data-table.stories.tsx @@ -0,0 +1,147 @@ +import { Pagination } from '@clerk/mosaic/components/pagination'; +import { Table } from '@clerk/mosaic/components/table'; +import type { ServerDataTableSort } from '@clerk/mosaic/hooks/use-server-data-table'; +import { useServerDataTable } from '@clerk/mosaic/hooks/use-server-data-table'; +import * as React from 'react'; + +import { useChaosFixture } from '@/components/ChaosProvider'; +import { chaosName, chaosRows } from '@/lib/chaos'; +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Hooks', + status: 'stable', + title: 'useServerDataTable', + layout: 'wide', + source: 'packages/mosaic/src/hooks/use-server-data-table.ts', +}; + +type Member = { id: string; name: string; role: string }; +type Column = 'name' | 'role'; + +const members: Member[] = [ + { id: 'kyle', name: 'Kyle Mac', role: 'Member' }, + { id: 'austin', name: 'Austin Calvelage', role: 'Member' }, + { id: 'colin', name: 'Colin Sidoti', role: 'Admin' }, + { id: 'max', name: 'Max Yinger', role: 'Member' }, + { id: 'preston', name: 'Preston Booth', role: 'Member' }, + { id: 'richard', name: 'Richard Hendricks', role: 'Member' }, + { id: 'monica', name: 'Monica Hall', role: 'Admin' }, + { id: 'jared', name: 'Jared Dunn', role: 'Member' }, + { id: 'dinesh', name: 'Dinesh Chugtai', role: 'Member' }, + { id: 'steve', name: 'Steve Hayes', role: 'Admin' }, + { id: 'gavin', name: 'Gavin Belson', role: 'Admin' }, + { id: 'laurie', name: 'Laurie Bream', role: 'Member' }, + { id: 'erlich', name: 'Erlich Bachman', role: 'Member' }, + { id: 'big-head', name: 'Nelson Bighetti', role: 'Member' }, + { id: 'carla', name: 'Carla Walton', role: 'Member' }, + { id: 'russ', name: 'Russ Hanneman', role: 'Admin' }, + { id: 'peter', name: 'Peter Gregory', role: 'Member' }, + { id: 'ron', name: 'Ron LaFlamme', role: 'Member' }, +]; + +function queryMembers({ + rows, + query, + sort, + page, + pageSize, +}: { + rows: Member[]; + query: string; + sort: ServerDataTableSort | null; + page: number; + pageSize: number; +}) { + const needle = query.trim().toLowerCase(); + const matches = rows.filter(member => member.name.toLowerCase().includes(needle)); + const sorted = sort + ? [...matches].sort( + (a, b) => a[sort.column].localeCompare(b[sort.column]) * (sort.direction === 'ascending' ? 1 : -1), + ) + : matches; + const start = (page - 1) * pageSize; + return { data: sorted.slice(start, start + pageSize), totalCount: matches.length }; +} + +export function Default() { + const fixture = useChaosFixture(members, rows => + chaosRows(rows).map((row, index) => ({ ...row, name: chaosName(index) })), + ); + const [query, setQuery] = React.useState(''); + const [sort, setSort] = React.useState | null>(null); + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(5); + const { data, totalCount } = queryMembers({ rows: fixture, query, sort, page, pageSize }); + + const { table, sortHeader, pagination } = useServerDataTable({ + data, + totalCount, + getRowId: row => row.id, + sortableColumns: ['name', 'role'], + sort, + onSortChange: setSort, + page, + pageSize, + onPageChange: setPage, + onPageSizeChange: setPageSize, + searchValue: query, + onSearchChange: next => { + setQuery(next); + setPage(1); + }, + }); + + return ( +
+ + + + + + + table.toggleAllRowsSelected()} + /> + Name + Role + + + + {table.rows.length === 0 ? ( + No members match “{query}”. + ) : ( + table.rows.map(row => ( + + + {row.original.name} + {row.original.role} + + )) + )} + + + {pagination ? ( + + ) : null} +
+ ); +} From 5573042d3d80dca3e38d05d496f171ff721318e0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 13 Oct 2026 13:14:00 -0400 Subject: [PATCH 05/19] fix(mosaic): move the combobox highlight with arrow keys right after typing (#10205) --- .changeset/mosaic-combobox-arrow-keys.md | 2 + .../primitives/combobox/combobox-context.ts | 1 + .../primitives/combobox/combobox-option.tsx | 15 ++++- .../src/primitives/combobox/combobox-root.tsx | 17 ++++- .../src/primitives/combobox/combobox.test.tsx | 64 +++++++++++++++++++ 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 .changeset/mosaic-combobox-arrow-keys.md diff --git a/.changeset/mosaic-combobox-arrow-keys.md b/.changeset/mosaic-combobox-arrow-keys.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-combobox-arrow-keys.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/primitives/combobox/combobox-context.ts b/packages/mosaic/src/primitives/combobox/combobox-context.ts index d81770fb503..fa1be72ca63 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-context.ts +++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts @@ -36,6 +36,7 @@ export interface ComboboxContextValue { focusInput: () => void; popupId: string | undefined; registerSelectedIndex: (index: number, value: string, label: string) => (() => void) | undefined; + registerOption: (index: number) => void; mounted: boolean; transitionProps: TransitionProps; } diff --git a/packages/mosaic/src/primitives/combobox/combobox-option.tsx b/packages/mosaic/src/primitives/combobox/combobox-option.tsx index 4c62c130fdf..96191114995 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-option.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-option.tsx @@ -16,8 +16,16 @@ export interface ComboboxOptionProps extends ComponentProps<'div'> { export const ComboboxOption = React.forwardRef( function ComboboxOption(props, ref) { const { render, value, label, disabled, ...otherProps } = props; - const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = - useComboboxContext(); + const { + activeIndex, + selectedValue, + getItemProps, + handleSelect, + valuesByIndexRef, + registerSelectedIndex, + registerOption, + refs, + } = useComboboxContext(); const id = useId(); const displayLabel = label ?? value; @@ -32,11 +40,12 @@ export const ComboboxOption = React.forwardRef { map.delete(index); unregisterSelectedIndex?.(); }; - }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex]); + }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex, registerOption]); const state = { selected: isSelected, diff --git a/packages/mosaic/src/primitives/combobox/combobox-root.tsx b/packages/mosaic/src/primitives/combobox/combobox-root.tsx index 60cb537d907..e41883e3653 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx @@ -86,6 +86,7 @@ function ComboboxInner(props: ComboboxProps) { useEffect(() => { if (!open) { setQueryChanged(false); + highlightFirstRef.current = false; } if (open) { return; @@ -106,6 +107,13 @@ function ComboboxInner(props: ComboboxProps) { const popupRef = useRef(null); const triggerRef = useRef(null); const valuesByIndexRef = useRef>(new Map()); + const highlightFirstRef = useRef(false); + const registerOption = useCallback((index: number) => { + if (index === 0 && highlightFirstRef.current) { + highlightFirstRef.current = false; + setActiveIndex(0); + } + }, []); const registerSelectedIndex = useCallback( (index: number, value: string, label: string) => { labelsByValueRef.current.set(value, label); @@ -219,7 +227,12 @@ function ComboboxInner(props: ComboboxProps) { setSelectedIndex(null); } setOpenState(true); - setActiveIndex(0); + if (elementsRef.current[0]) { + setActiveIndex(0); + } else { + highlightFirstRef.current = true; + setActiveIndex(null); + } }, [props.inline, setInputValue, setOpenState, setSelectedValue], ); @@ -252,6 +265,7 @@ function ComboboxInner(props: ComboboxProps) { focusInput, popupId, registerSelectedIndex, + registerOption, mounted, transitionProps, }), @@ -275,6 +289,7 @@ function ComboboxInner(props: ComboboxProps) { focusInput, popupId, registerSelectedIndex, + registerOption, mounted, transitionProps, ], diff --git a/packages/mosaic/src/primitives/combobox/combobox.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.test.tsx index 6892f2203c8..4cc7d331f20 100644 --- a/packages/mosaic/src/primitives/combobox/combobox.test.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox.test.tsx @@ -262,6 +262,70 @@ describe('Combobox', () => { expect(screen.getByRole('option', { name: 'Banana' })).toBeInTheDocument(); expect(popup).not.toHaveTextContent('Apple'); }); + + it('moves the highlight with the arrow keys right after typing opens a popup', async () => { + const user = userEvent.setup(); + render( + + + + + {fruits.map(fruit => ( + + {fruit.label} + + ))} + + + , + ); + + await user.type(screen.getByRole('combobox', { name: 'Fruit' }), 'a'); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'); + }); + + it('moves the highlight with the arrow keys right after typing shows an inline list', async () => { + const user = userEvent.setup(); + function Fixture() { + const [query, setQuery] = useState(''); + return ( + + + {query ? ( + + {fruits.map(fruit => ( + + {fruit.label} + + ))} + + ) : null} + + ); + } + render(); + + await user.type(screen.getByRole('combobox', { name: 'Fruit' }), 'a'); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'); + }); + describe('selection-only input', () => { it('keeps the selected option when hover and keyboard highlight move', async () => { const user = userEvent.setup(); From e0cdbf289aace583524e08df8a5c432147fe6bc9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 9 Oct 2026 17:49:16 -0600 Subject: [PATCH 06/19] feat(mosaic): add reverification to password updates --- .changeset/mosaic-password-reverification.md | 2 + ...-profile-password-section.feature.test.tsx | 61 ++++- .../user-profile-edit-password.controller.ts | 41 +++- .../user-profile-edit-password.dialog.tsx | 210 +++++++++++------- .../user-profile-password-section.messages.ts | 1 + .../user-profile-password-section.model.ts | 39 ++-- .../user-profile-password-section.tsx | 8 +- 7 files changed, 245 insertions(+), 117 deletions(-) create mode 100644 .changeset/mosaic-password-reverification.md diff --git a/.changeset/mosaic-password-reverification.md b/.changeset/mosaic-password-reverification.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-password-reverification.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx index 632d9aef0b0..ff4bc8b5c9e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx @@ -1,8 +1,9 @@ import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; -import { holdRequests, serveFapi } from '../../../../__tests__/feature/fake-fapi'; +import { fapiUrl, holdRequests, serveFapi, worker } from '../../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEmailAddress, @@ -23,6 +24,34 @@ async function renderPassword(user = alice, environment = fapiEnvironment()) { return fapi; } +async function renderWithReverification() { + const fapi = serveFapi({ + environment: fapiEnvironment({ auth_config: { reverification: true } }), + client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]), + verification: { secrets: { password: 'hunter2' }, firstFactors: [{ strategy: 'password' }] }, + }); + let required = false; + worker.use( + http.post(fapiUrl('/v1/me/change_password'), () => { + if (required) { + return undefined; + } + required = true; + return HttpResponse.json( + { errors: [{ code: 'session_reverification_required', message: 'Verification required' }] }, + { status: 403 }, + ); + }), + ); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.type(screen.getByLabelText('New password'), 'new-password-123'); + await user.type(screen.getByLabelText('Confirm password'), 'new-password-123'); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + return { fapi, user }; +} + async function fillPassword() { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); @@ -197,6 +226,31 @@ describe('Changing a password', () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); }); + it('skips the current password and asks to verify before saving when reverification is on', async () => { + const { fapi, user } = await renderWithReverification(); + + expect(screen.queryByLabelText('Current password')).toBeNull(); + const field = await screen.findByPlaceholderText('Enter your password'); + expect(fapi.passwordUpdates).toHaveLength(0); + await user.type(field, 'hunter2{Enter}'); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull(), { timeout: 3000 }); + expect(fapi.passwordUpdates).toHaveLength(1); + }); + + it('closes without saving when verification is dismissed, and reopens on an empty form', async () => { + const { fapi, user } = await renderWithReverification(); + + await screen.findByPlaceholderText('Enter your password'); + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates).toHaveLength(0); + await user.click(screen.getByRole('button', { name: 'Change password' })); + expect(screen.getByLabelText('New password')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeVisible(); + }); + it('shows a direct API error and keeps the draft without retrying automatically', async () => { const fapi = await renderPassword(); const user = await fillPassword(); @@ -204,11 +258,10 @@ describe('Changing a password', () => { await user.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(update.requests).toHaveLength(1)); - update.fail('session_reverification_required'); + update.fail('form_param_invalid'); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('session_reverification_required')); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('form_param_invalid')); expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); - expect(screen.queryByText('Verification required')).toBeNull(); expect(update.requests).toHaveLength(1); serveFapi(fapi); await user.click(screen.getByRole('button', { name: 'Save changes' })); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index f7c18a1dc99..bb3db92134a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,11 +1,14 @@ +import { isReverificationHint } from '@clerk/shared/authorization-errors'; +import { isReverificationCancelledError } from '@clerk/shared/error'; import { DEBOUNCE_MS } from '@clerk/shared/internal/clerk-js/constants'; import { useState } from 'react'; import type { UseFormResult } from '../../../components/form'; -import { useForm } from '../../../components/form'; +import { FormSubmitError, useForm } from '../../../components/form'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useDebouncedAsync } from '../../../hooks/use-debounced-async'; import { useMessages } from '../../../localization'; +import type { ReverificationController } from '../../reverification'; import type { UserProfileEditPasswordValue, UserProfileEditPasswordValues, @@ -23,6 +26,8 @@ export interface UserProfileEditPasswordControllerOptions { policy: UserProfilePasswordPolicy; onSubmit: (value: UserProfileEditPasswordValue) => Promise; validatePassword?: (password: string) => Promise; + formatError?: (error: unknown) => unknown; + reverification?: ReverificationController; } export interface UserProfileEditPasswordController { @@ -30,12 +35,16 @@ export interface UserProfileEditPasswordController { onOpenChange: (open: boolean) => void; form: UseFormResult; passwordFeedback: FieldFeedback | undefined; + reverification?: ReverificationController; + step: 'edit' | 'verify'; } export function useUserProfileEditPasswordController({ policy, onSubmit, validatePassword, + formatError = error => error, + reverification, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const requiresCurrentPassword = policy.requiresCurrentPassword; const validationError = useMessages('errors').generic; @@ -56,11 +65,22 @@ export function useUserProfileEditPasswordController({ values.confirmPassword === values.newPassword && (!requiresCurrentPassword || values.currentPassword !== ''), onSubmit: async values => { - await onSubmit({ - currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, - newPassword: values.newPassword, - signOutOfOtherSessions: values.signOutOfOtherSessions, - }); + reverification?.reset(); + try { + const result = await onSubmit({ + currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, + newPassword: values.newPassword, + signOutOfOtherSessions: values.signOutOfOtherSessions, + }); + if (isReverificationHint(result)) { + throw new FormSubmitError({ message: m.errors.verificationIncomplete }); + } + } catch (error) { + if (isReverificationCancelledError(error)) { + return; + } + throw formatError(error); + } // TODO: Discuss confirming the password was set or updated and other devices were signed out with a success page or toast. https://github.com/clerk/javascript/pull/9930#discussion_r4151641473 setIsOpen(false); }, @@ -78,13 +98,18 @@ export function useUserProfileEditPasswordController({ ? { type: 'error', message: validationError } : strength.data; + const step = form.isSubmitting && reverification?.visible ? 'verify' : 'edit'; + const onOpenChange = (open: boolean) => { if (form.isSubmitting) { - return; + if (open || !reverification?.onCancel) { + return; + } + reverification.onCancel(); } form.reset(); setIsOpen(open); }; - return { isOpen, onOpenChange, form, passwordFeedback }; + return { isOpen, onOpenChange, form, passwordFeedback, reverification, step }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index fb42feb1068..3d22196c125 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,6 +1,6 @@ import { useMergeRefs } from '@floating-ui/react'; import type { RefObject } from 'react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; @@ -8,10 +8,13 @@ import { Checkbox } from '../../../components/checkbox'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import { Flow } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { useMessages } from '../../../localization'; +import type { ReverificationController } from '../../reverification'; +import { Reverification } from '../../reverification'; import type { UserProfileEditPasswordField, UserProfileEditPasswordValues, @@ -26,6 +29,8 @@ export interface UserProfileEditPasswordDialogProps { hasPassword?: boolean; requiresCurrentPassword?: boolean; form: UseFormResult; + reverification?: ReverificationController; + step?: 'edit' | 'verify'; } export function UserProfileEditPasswordDialog({ @@ -37,10 +42,129 @@ export function UserProfileEditPasswordDialog({ hasPassword = false, requiresCurrentPassword = false, form, + reverification, + step = 'edit', }: UserProfileEditPasswordDialogProps) { const m = useMessages('userProfilePasswordSection'); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; + const returnFocus = useRef(false); + + useEffect(() => { + if (step === 'verify') { + returnFocus.current = true; + } else if (returnFocus.current) { + returnFocus.current = false; + initialFocusRef.current?.focus({ preventScroll: true }); + } + }, [step]); + + const edit = ( + <> + + {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} + + + {form.error} + + + } + > + + {showCurrentPassword ? ( + + ) : null} + + + + form.setValue('signOutOfOtherSessions', event.target.checked)} + /> + + {m.signOutOfOtherSessionsLabel} + {m.signOutOfOtherSessionsDescription} + + + + + + {m.cancel} + + } + /> + + {m.save} + + + + ); + + const content = reverification ? ( + + {() => ( + <> + {edit} + + + + + )} + + ) : ( + edit + ); return ( - - {hasPassword ? m.dialogTitle.change : m.dialogTitle.set} - - - {form.error} - - - } - > - - {showCurrentPassword ? ( - - ) : null} - - - - form.setValue('signOutOfOtherSessions', event.target.checked)} - /> - - {m.signOutOfOtherSessionsLabel} - {m.signOutOfOtherSessionsDescription} - - - - - - {m.cancel} - - } - /> - - {m.save} - - + {content} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index b9055babc0b..9a55023be80 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -54,6 +54,7 @@ export const userProfilePasswordSectionMessages = { }, errors: { + verificationIncomplete: 'Your password was not saved. Please try verifying again.', unavailable: 'Password update is no longer available.', currentPasswordRequired: 'Current password is required.', mismatch: "Passwords don't match.", diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 1c6f10716a3..780b2ed216b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -30,7 +30,8 @@ export type UserProfilePasswordModel = sessionId: string; identifier: string; validatePassword: (password: string) => Promise; - updatePassword: (input: UserProfileEditPasswordValue) => Promise; + updatePassword: (input: UserProfileEditPasswordValue) => Promise; + formatError: (error: unknown) => unknown; }); type PasswordPolicyResult = @@ -50,9 +51,8 @@ function getPasswordPolicy( return { status: 'hidden' }; } - // TODO: When session reverification is supported, require the current password only when reverification is disabled. const policy: UserProfilePasswordPolicy = user.passwordEnabled - ? { mode: 'change', requiresCurrentPassword: true } + ? { mode: 'change', requiresCurrentPassword: !environment.authConfig.reverification } : { mode: 'set', requiresCurrentPassword: false }; const enterpriseAccount = user.enterpriseAccounts.find(account => account.active); @@ -126,7 +126,6 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { sessionId, identifier: session.publicUserData.identifier ?? '', validatePassword, - // TODO: Add session reverification for password updates; surface API errors until then. updatePassword: async ({ currentPassword, newPassword, signOutOfOtherSessions }) => { const currentUser = clerk.user; if ( @@ -140,22 +139,22 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { throw new FormSubmitError({ message: m.errors.unavailable }); } - try { - await currentUser.updatePassword({ - newPassword, - signOutOfOtherSessions, - ...(policy.requiresCurrentPassword ? { currentPassword } : {}), - }); - } catch (error) { - throw passwordFormError( - error, - policy.requiresCurrentPassword, - environment.userSettings.passwordSettings, - m, - locale, - errorText, - ); - } + return currentUser.updatePassword({ + newPassword, + signOutOfOtherSessions, + ...(policy.requiresCurrentPassword ? { currentPassword } : {}), + }); }, + formatError: error => + error instanceof FormSubmitError + ? error + : passwordFormError( + error, + policy.requiresCurrentPassword, + environment.userSettings.passwordSettings, + m, + locale, + errorText, + ), }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 379431a53f3..ab5fc7c5429 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { Button } from '../../../components/button'; import { useMessages } from '../../../localization'; +import { useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; import type { UserProfilePasswordModel } from './user-profile-password-section.model'; @@ -43,10 +44,13 @@ export function renderPasswordSection(model: UserProfilePasswordModel, fallback: function PasswordEditor({ model }: { model: Extract }) { const m = useMessages('userProfilePasswordSection'); const hasPassword = model.mode === 'change'; + const [updatePassword, reverification] = useReverificationFlow(model.updatePassword); const controller = useUserProfileEditPasswordController({ policy: model, validatePassword: model.validatePassword, - onSubmit: model.updatePassword, + onSubmit: updatePassword, + formatError: model.formatError, + reverification, }); return ( @@ -61,6 +65,8 @@ function PasswordEditor({ model }: { model: Extract Date: Tue, 13 Oct 2026 11:08:28 -0600 Subject: [PATCH 07/19] chore(mosaic): note the open question on repeated reverification hints --- .../user-profile-edit-password.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index bb3db92134a..454e346ba4c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -72,6 +72,8 @@ export function useUserProfileEditPasswordController({ newPassword: values.newPassword, signOutOfOtherSessions: values.signOutOfOtherSessions, }); + // TODO: Should we handle repeated reverification hints in useReverificationWithState + // so every Mosaic consumer rejects incomplete verification? if (isReverificationHint(result)) { throw new FormSubmitError({ message: m.errors.verificationIncomplete }); } From c57951b99dccc531aeb942dc12d789e426fa47d4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 11:08:30 -0600 Subject: [PATCH 08/19] refactor(mosaic): format password update errors in the model The model catches again and lets reverification errors through, as save() does, so the controller and section no longer carry a formatError callback. --- packages/mosaic/docs/models.md | 2 +- .../user-profile-edit-password.controller.ts | 4 +- .../user-profile-password-section.model.ts | 39 ++++++++++--------- .../user-profile-password-section.tsx | 1 - 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/mosaic/docs/models.md b/packages/mosaic/docs/models.md index 3b5c5beb09d..21e55cfe910 100644 --- a/packages/mosaic/docs/models.md +++ b/packages/mosaic/docs/models.md @@ -108,7 +108,7 @@ drops the fallback instead of holding the space open. Keep the two apart. (`user-profile-profile-section.model.ts`). `save` lets reverification errors through, so the callback can be wrapped in `useReverificationFlow`. A model that formats its own errors does the same with `isReverificationError` from - the same file. + the same file (`user-profile-password-section.model.ts`). - No local UI state. What is open and what is in flight belong to the controller. - No React state machinery beyond the Clerk hooks themselves; the model is a derivation of what Clerk currently says. The exception is what to fetch: a diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 454e346ba4c..ad0cc21b702 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -26,7 +26,6 @@ export interface UserProfileEditPasswordControllerOptions { policy: UserProfilePasswordPolicy; onSubmit: (value: UserProfileEditPasswordValue) => Promise; validatePassword?: (password: string) => Promise; - formatError?: (error: unknown) => unknown; reverification?: ReverificationController; } @@ -43,7 +42,6 @@ export function useUserProfileEditPasswordController({ policy, onSubmit, validatePassword, - formatError = error => error, reverification, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { const requiresCurrentPassword = policy.requiresCurrentPassword; @@ -81,7 +79,7 @@ export function useUserProfileEditPasswordController({ if (isReverificationCancelledError(error)) { return; } - throw formatError(error); + throw error; } // TODO: Discuss confirming the password was set or updated and other devices were signed out with a success page or toast. https://github.com/clerk/javascript/pull/9930#discussion_r4151641473 setIsOpen(false); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts index 780b2ed216b..66fda0b34fd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.model.ts @@ -9,6 +9,7 @@ import type { FieldFeedback } from '../../../components/form'; import { FormSubmitError } from '../../../components/form'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useErrorText, useLocale, useMessages } from '../../../localization'; +import { isReverificationError } from '../../../utils/errors'; import { passwordFormError } from './user-profile-password-errors'; import { passwordFieldFeedback } from './user-profile-password-feedback'; import type { UserProfileEditPasswordValue, UserProfilePasswordPolicy } from './user-profile-password-section.types'; @@ -30,8 +31,7 @@ export type UserProfilePasswordModel = sessionId: string; identifier: string; validatePassword: (password: string) => Promise; - updatePassword: (input: UserProfileEditPasswordValue) => Promise; - formatError: (error: unknown) => unknown; + updatePassword: (input: UserProfileEditPasswordValue) => Promise; }); type PasswordPolicyResult = @@ -139,22 +139,25 @@ export function useUserProfilePasswordModel(): UserProfilePasswordModel { throw new FormSubmitError({ message: m.errors.unavailable }); } - return currentUser.updatePassword({ - newPassword, - signOutOfOtherSessions, - ...(policy.requiresCurrentPassword ? { currentPassword } : {}), - }); + try { + await currentUser.updatePassword({ + newPassword, + signOutOfOtherSessions, + ...(policy.requiresCurrentPassword ? { currentPassword } : {}), + }); + } catch (error) { + if (isReverificationError(error)) { + throw error; + } + throw passwordFormError( + error, + policy.requiresCurrentPassword, + environment.userSettings.passwordSettings, + m, + locale, + errorText, + ); + } }, - formatError: error => - error instanceof FormSubmitError - ? error - : passwordFormError( - error, - policy.requiresCurrentPassword, - environment.userSettings.passwordSettings, - m, - locale, - errorText, - ), }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index ab5fc7c5429..77cb22c80fb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -49,7 +49,6 @@ function PasswordEditor({ model }: { model: Extract Date: Tue, 13 Oct 2026 11:08:31 -0600 Subject: [PATCH 09/19] refactor(mosaic): pass reverification to the password dialog directly --- .../user-profile-edit-password.controller.ts | 3 +-- .../user-profile-password-section.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index ad0cc21b702..e6e03a929a6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -34,7 +34,6 @@ export interface UserProfileEditPasswordController { onOpenChange: (open: boolean) => void; form: UseFormResult; passwordFeedback: FieldFeedback | undefined; - reverification?: ReverificationController; step: 'edit' | 'verify'; } @@ -111,5 +110,5 @@ export function useUserProfileEditPasswordController({ setIsOpen(open); }; - return { isOpen, onOpenChange, form, passwordFeedback, reverification, step }; + return { isOpen, onOpenChange, form, passwordFeedback, step }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 77cb22c80fb..08b4652e350 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -64,7 +64,7 @@ function PasswordEditor({ model }: { model: Extract Date: Tue, 13 Oct 2026 11:08:32 -0600 Subject: [PATCH 10/19] fix(mosaic): close the password dialog from the verification error card --- .../user-profile-edit-password.dialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 3d22196c125..584929de4da 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -157,7 +157,10 @@ export function UserProfileEditPasswordDialog({ <> {edit} - + onOpenChange(false)} + /> )} From 99e751e384a411ed79f93c9fa81a94b91ed317b3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 11:08:33 -0600 Subject: [PATCH 11/19] test(mosaic): cover returning to the password form from verification --- ...-profile-password-section.feature.test.tsx | 65 ++++++++++++++----- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx index ff4bc8b5c9e..080c486ede7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx @@ -1,9 +1,8 @@ import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; -import { fapiUrl, holdRequests, serveFapi, worker } from '../../../../__tests__/feature/fake-fapi'; +import { demandReverificationOnce, holdRequests, serveFapi } from '../../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEmailAddress, @@ -24,25 +23,14 @@ async function renderPassword(user = alice, environment = fapiEnvironment()) { return fapi; } -async function renderWithReverification() { +async function renderWithReverification(prepare?: () => void) { const fapi = serveFapi({ environment: fapiEnvironment({ auth_config: { reverification: true } }), client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]), verification: { secrets: { password: 'hunter2' }, firstFactors: [{ strategy: 'password' }] }, }); - let required = false; - worker.use( - http.post(fapiUrl('/v1/me/change_password'), () => { - if (required) { - return undefined; - } - required = true; - return HttpResponse.json( - { errors: [{ code: 'session_reverification_required', message: 'Verification required' }] }, - { status: 403 }, - ); - }), - ); + prepare?.(); + demandReverificationOnce('/v1/me/change_password'); await renderWithClerk(); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Change password' })); @@ -251,6 +239,51 @@ describe('Changing a password', () => { expect(screen.getByRole('button', { name: 'Save changes' })).toBeVisible(); }); + it('returns to the form with the field error when the save fails after verifying', async () => { + const updates: Array> = []; + const { fapi, user } = await renderWithReverification(() => { + updates.push(holdRequests('post', '/v1/me/change_password')); + }); + const update = updates[0]; + + await user.type(await screen.findByPlaceholderText('Enter your password'), 'hunter2{Enter}'); + await waitFor(() => expect(update.requests).toHaveLength(1)); + update.fail('form_password_pwned', undefined, 'new_password'); + + const field = await screen.findByLabelText('New password', undefined, { timeout: 3000 }); + expect(field).toHaveValue('new-password-123'); + await waitFor(() => expect(field).toHaveAccessibleDescription(/found as part of a breach/)); + await waitFor(() => expect(field).toHaveFocus()); + expect(fapi.passwordUpdates).toHaveLength(0); + }); + + it('says the password was not saved when Clerk asks to verify again after verifying', async () => { + const { fapi, user } = await renderWithReverification(() => demandReverificationOnce('/v1/me/change_password')); + + await user.type(await screen.findByPlaceholderText('Enter your password'), 'hunter2{Enter}'); + + await waitFor( + () => + expect(screen.getByRole('alert')).toHaveTextContent('Your password was not saved. Please try verifying again.'), + { timeout: 3000 }, + ); + expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); + expect(fapi.passwordUpdates).toHaveLength(0); + }); + + it('closes the dialog from the verification error card', async () => { + const { fapi, user } = await renderWithReverification(() => + holdRequests('post', '/v1/client/sessions/sess_1/verify').fail('some_new_error', 'Nope'), + ); + + expect(await screen.findByText('Something went wrong')).toBeVisible(); + const buttons = screen.getAllByRole('button', { name: 'Close' }); + await user.click(buttons[buttons.length - 1]); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(fapi.passwordUpdates).toHaveLength(0); + }); + it('shows a direct API error and keeps the draft without retrying automatically', async () => { const fapi = await renderPassword(); const user = await fillPassword(); From e0c54c0adc60af55693c05d5a155133cc2f10578 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 11:40:15 -0600 Subject: [PATCH 12/19] fix(mosaic): treat a cancelled reverification as no form error A submit that rejects because the user cancelled a reverification now returns the form to editing with its values and no message, instead of showing the generic error and logging the cancellation. --- .../mosaic-form-cancelled-reverification.md | 2 ++ packages/mosaic/docs/controllers.md | 3 +- .../form/__tests__/use-form.test.ts | 32 +++++++++++++++++++ .../src/components/form/form.machine.ts | 17 +++++++--- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 .changeset/mosaic-form-cancelled-reverification.md diff --git a/.changeset/mosaic-form-cancelled-reverification.md b/.changeset/mosaic-form-cancelled-reverification.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-form-cancelled-reverification.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index c64f2589c4b..1baa6342e92 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -233,7 +233,8 @@ One path, so every error a user sees goes through the `errors.*` catalog: error as a `SaveError` with each error routed to the field it names. The form machine turns a `SaveError` into field and global errors. A controller can also reject a submit with `FormSubmitError` (`components/form`) to set field errors - itself. + itself. A submit that rejects because the user cancelled a reverification + is not an error: the form returns to editing with its values and no message. - **Owners hold an `ErrorDescription`.** It is either a `LocalizableError` (Clerk gave a `code`) or an `UnlocalizableError` (`{ cause }`, the original failure). `toLocalizableError` logs the second as diff --git a/packages/mosaic/src/components/form/__tests__/use-form.test.ts b/packages/mosaic/src/components/form/__tests__/use-form.test.ts index 777feb20dc3..310d4e84542 100644 --- a/packages/mosaic/src/components/form/__tests__/use-form.test.ts +++ b/packages/mosaic/src/components/form/__tests__/use-form.test.ts @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; @@ -151,6 +152,37 @@ describe('useForm', () => { log.mockRestore(); }); + it('returns to editing with the draft and no message when a reverification is cancelled', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const outcomes: Array<() => Promise> = [ + () => Promise.reject(new FormSubmitError({ message: 'Username is taken.' })), + () => + Promise.reject( + new ClerkRuntimeError('User cancelled attempted verification', { code: 'reverification_cancelled' }), + ), + ]; + const { result } = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => (outcomes.shift() ?? resolved)() }), + ); + act(() => result.current.setValue('username', 'alex')); + await act(async () => { + result.current.submit(); + await tick(); + }); + expect(result.current.error).toBe('Username is taken.'); + + await act(async () => { + result.current.submit(); + await tick(); + }); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.values).toEqual({ username: 'alex' }); + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); + it('shows errorFallback instead of the generic message', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); const rejections = [new Error('Nope'), new SaveError({})]; diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 679221b5a7a..366f0e75ad3 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,3 +1,5 @@ +import { isReverificationCancelledError } from '@clerk/shared/error'; + import type { LocalizableError } from '../../localization'; import { setup } from '../../machine/setup'; import type { TransitionResult } from '../../machine/types'; @@ -251,10 +253,17 @@ export function createFormMachine(deps: FormDeps ({ error: undefined })), }, - onError: { - target: 'editing', - actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), - }, + onError: [ + { + guard: (_, e) => isReverificationCancelledError(e.error), + target: 'editing', + actions: assign(() => ({ error: undefined })), + }, + { + target: 'editing', + actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), + }, + ], }), }, }, From 4c948686dec6d6478dcc40ce9557c86fef009a2e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 11:46:29 -0600 Subject: [PATCH 13/19] fix(mosaic): reject when a retry is asked to reverify again useReverificationWithState resolved with the reverification hint when the retried action still required verification, so callers saw a success. It now rejects with a ClerkRuntimeError coded reverification_incomplete, which has copy in the errors catalog. --- .../mosaic-reverification-incomplete.md | 2 ++ .../use-reverification-with-state.test.tsx | 27 +++++++++++++++++++ .../use-reverification-with-state.ts | 21 ++++++++++++++- .../src/localization/errors.messages.ts | 1 + 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .changeset/mosaic-reverification-incomplete.md diff --git a/.changeset/mosaic-reverification-incomplete.md b/.changeset/mosaic-reverification-incomplete.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-reverification-incomplete.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx index 7a0760ee2eb..7b474c53828 100644 --- a/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/use-reverification-with-state.test.tsx @@ -1,3 +1,4 @@ +import { reverificationError } from '@clerk/shared/authorization-errors'; import { ClerkRuntimeError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import type * as SharedReact from '@clerk/shared/react'; import type { SessionVerificationLevel } from '@clerk/shared/types'; @@ -122,6 +123,32 @@ describe('useReverificationWithState', () => { expect(challengeCancel).not.toHaveBeenCalled(); }); + it('rejects when the retry is asked to reverify again', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ reverificationLevel: 'first_factor' } satisfies Hint) + .mockResolvedValueOnce(reverificationError()); + const { result } = renderHook(() => useReverificationWithState(fetcher)); + + let pending!: Promise; + act(() => { + pending = result.current[0](); + }); + await waitFor(() => expect(result.current[1].phase).toBe('active')); + act(() => { + assertActive(result.current[1]); + result.current[1].complete(); + }); + + await act(async () => { + await expect(pending).rejects.toSatisfy( + error => isClerkRuntimeError(error) && error.code === 'reverification_incomplete', + ); + }); + await waitFor(() => expect(result.current[1]).toEqual({ phase: 'settled' })); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('settles when the delayed retry fails', async () => { const retry = deferred(); const fetcher = vi diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index b332f98f39d..ac6cfb4ccb2 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -1,3 +1,4 @@ +import { isReverificationHint } from '@clerk/shared/authorization-errors'; import { ClerkRuntimeError } from '@clerk/shared/error'; import { useReverification, useSafeLayoutEffect, useSession } from '@clerk/shared/react'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -42,6 +43,14 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { }); } +const REVERIFICATION_INCOMPLETE_CODE = 'reverification_incomplete'; + +function reverificationIncompleteError(): ClerkRuntimeError { + return new ClerkRuntimeError('The action still requires verification after it was verified.', { + code: REVERIFICATION_INCOMPLETE_CODE, + }); +} + /** * Wraps useReverification without the default UI. Returns [handler, state]: call handler * to run the action, and render your own UI from state.phase ('inactive' | 'active' | @@ -52,6 +61,9 @@ function requestAlreadyInProgressError(): ClerkRuntimeError { * is called again, or when the third return value, reset, is called. A run that was never * interrupted stays 'inactive' throughout. * + * A retry that is asked to reverify again rejects with a ClerkRuntimeError with code + * 'reverification_incomplete', so the handler never resolves with a reverification hint. + * * The handler runs one call at a time. A call made while another is pending rejects with a * ClerkRuntimeError with code 'request_already_in_progress'. */ @@ -132,7 +144,14 @@ export function useReverificationWithState( setReverificationState(releaseSettled); - const invocation = Promise.resolve().then(() => wrapped(...args)); + const invocation = Promise.resolve() + .then(() => wrapped(...args)) + .then(result => { + if (isReverificationHint(result)) { + throw reverificationIncompleteError(); + } + return result; + }); runtimeRef.current.operation = { status: 'requesting', promise: invocation }; void invocation .finally(() => { diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index d4dff125961..40a6728b68b 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -78,6 +78,7 @@ export const errorMessages: { readonly generic: string } & Readonly Date: Tue, 13 Oct 2026 11:49:35 -0600 Subject: [PATCH 14/19] fix(mosaic): keep empty submit rejections out of the cancellation check isReverificationCancelledError throws on a falsy value, which left the form stuck submitting when onSubmit rejected with no reason. --- packages/mosaic/src/components/form/__tests__/use-form.test.ts | 2 +- packages/mosaic/src/components/form/form.machine.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/components/form/__tests__/use-form.test.ts b/packages/mosaic/src/components/form/__tests__/use-form.test.ts index 310d4e84542..7a785cffe8d 100644 --- a/packages/mosaic/src/components/form/__tests__/use-form.test.ts +++ b/packages/mosaic/src/components/form/__tests__/use-form.test.ts @@ -131,7 +131,7 @@ describe('useForm', () => { it('shows the generic message and logs the cause for an unrecognized rejection', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const causes: unknown[] = [new Error('Nope'), 'boom']; + const causes: unknown[] = [new Error('Nope'), 'boom', undefined, null, 0]; for (const cause of causes) { const { result } = renderHook(() => useForm({ diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 366f0e75ad3..8124e23e1f9 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -255,7 +255,7 @@ export function createFormMachine(deps: FormDeps isReverificationCancelledError(e.error), + guard: (_, e) => Boolean(e.error) && isReverificationCancelledError(e.error), target: 'editing', actions: assign(() => ({ error: undefined })), }, From cb40c3ba0e0b62c5d4daa344a3eaf7d396ba9bd0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 12:28:38 -0600 Subject: [PATCH 15/19] test(mosaic): reproduce reverification review regressions --- ...onnected-accounts.removal.feature.test.tsx | 21 +++++++++++++++++++ ...-profile-password-section.feature.test.tsx | 19 +++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx index 356aea4c2a2..f95c924d945 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx @@ -84,6 +84,27 @@ describe('connected accounts', () => { expect(fapi.client.sessions[0]?.user.external_accounts).toHaveLength(0); }); + it('keeps the account and confirmation open when removal asks to verify again after verifying', async () => { + const { fapi } = await renderSection([google], { verification: { secrets: { password: 'hunter2' } } }); + demandReverificationOnce('/v1/me/external_accounts/idn_google'); + demandReverificationOnce('/v1/me/external_accounts/idn_google'); + const user = userEvent.setup(); + const dialog = await openRemoval(user, 'Google'); + + await user.click(within(dialog).getByRole('button', { name: 'Remove' })); + expect(await within(dialog).findByText('Enter your current password to continue')).toBeVisible(); + await user.type(within(dialog).getByLabelText('Password'), 'hunter2{Enter}'); + + await waitFor(() => + expect(within(dialog).getByRole('alert')).toHaveTextContent( + 'Your request was not completed. Please try verifying again.', + ), + ); + expect(screen.getByRole('dialog')).toBe(dialog); + expect(within(dialog).getByRole('button', { name: 'Remove' })).toBeVisible(); + expect(fapi.client.sessions[0]?.user.external_accounts).toEqual([google]); + }); + it('targets the account selected after canceling another removal', async () => { const { fapi } = await renderSection([google, github]); const user = userEvent.setup(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx index 080c486ede7..3b33ac84ca3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx @@ -257,6 +257,25 @@ describe('Changing a password', () => { expect(fapi.passwordUpdates).toHaveLength(0); }); + it('keeps focus on the popup close button when the save fails after verifying', async () => { + const { fapi, user } = await renderWithReverification(); + const update = holdRequests('post', '/v1/me/change_password'); + + await user.type(await screen.findByPlaceholderText('Enter your password'), 'hunter2{Enter}'); + await waitFor(() => expect(update.requests).toHaveLength(1)); + const close = screen.getByRole('button', { name: 'Close' }); + await act(() => close.focus()); + expect(close).toHaveFocus(); + update.fail('form_password_pwned', undefined, 'new_password'); + + const field = await screen.findByLabelText('New password'); + expect(field).toHaveValue('new-password-123'); + expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); + await waitFor(() => expect(field).toHaveAccessibleDescription(/found as part of a breach/)); + expect(close).toHaveFocus(); + expect(fapi.passwordUpdates).toHaveLength(0); + }); + it('says the password was not saved when Clerk asks to verify again after verifying', async () => { const { fapi, user } = await renderWithReverification(() => demandReverificationOnce('/v1/me/change_password')); From 358908d8ec4e85df6e9f7b920dd39e2ed29d2692 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 12:32:23 -0600 Subject: [PATCH 16/19] test(mosaic): expect focus to leave the removed verification header --- .../__tests__/user-profile-password-section.feature.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx index 3b33ac84ca3..1032a0b3f58 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx @@ -257,7 +257,7 @@ describe('Changing a password', () => { expect(fapi.passwordUpdates).toHaveLength(0); }); - it('keeps focus on the popup close button when the save fails after verifying', async () => { + it('returns focus to the form when the verification header is removed after a save error', async () => { const { fapi, user } = await renderWithReverification(); const update = holdRequests('post', '/v1/me/change_password'); @@ -272,7 +272,7 @@ describe('Changing a password', () => { expect(field).toHaveValue('new-password-123'); expect(screen.getByLabelText('Confirm password')).toHaveValue('new-password-123'); await waitFor(() => expect(field).toHaveAccessibleDescription(/found as part of a breach/)); - expect(close).toHaveFocus(); + await waitFor(() => expect(field).toHaveFocus()); expect(fapi.passwordUpdates).toHaveLength(0); }); From a08a08e88b5bbe82ff5ed1bd9969e021acdbb0b9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 12:33:02 -0600 Subject: [PATCH 17/19] refactor(mosaic): share reverification dialog transitions --- packages/mosaic/AGENTS.md | 2 + packages/mosaic/docs/views.md | 8 +++ .../src/blocks/confirm/confirm-shell.tsx | 32 ++++------- .../src/features/reverification/index.ts | 1 + .../reverification-flow.view.tsx | 47 ++++++++++++++++ .../user-profile-device-details.dialog.tsx | 32 ++++------- .../user-profile-edit-password.dialog.tsx | 53 +++++-------------- .../fixtures/user-profile-edit-password.tsx | 1 + 8 files changed, 91 insertions(+), 85 deletions(-) create mode 100644 packages/mosaic/src/features/reverification/reverification-flow.view.tsx diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index b7a2f1f13b0..aadb6db743a 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -38,6 +38,8 @@ Most problems a feature hits already have a shared answer. Find the row for your | A confirm step before an action | `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) | | A delete that needs the user to type a phrase | `useConfirmController` (`src/blocks/confirm/`) with `Destructive` (`src/blocks/destructive/`) | | An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`) in the wrapper, passed to `useConfirmController` | +| Switching dialog content to a step-up challenge | `ReverificationFlow` (`src/features/reverification/`); the host owns its dialog and close callback | +| Preserving step-up and cancellation errors in a custom model mapper | Rethrow errors matched by `isReverificationError` (`src/utils/errors.ts`) before mapping user errors | | Showing any error to the user | One of the owners above. They call `toLocalizableError` and resolve copy with `useErrorText` | | Moving focus after a list row is removed | `useListRemovalFocus` (`src/hooks/`) | | A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) | diff --git a/packages/mosaic/docs/views.md b/packages/mosaic/docs/views.md index 9b58f75ec1e..250d4593f0b 100644 --- a/packages/mosaic/docs/views.md +++ b/packages/mosaic/docs/views.md @@ -42,6 +42,14 @@ Two shapes, chosen by whether the slice fetches its own data: (`UserProfileUsernameRowView` calls `useUserProfileEditUsernameController` with its `onSubmit`). Still no Clerk — the effect arrives from above. +For a dialog that swaps its content for step-up verification, use +`ReverificationFlow` from `src/features/reverification/`. The host keeps its +`Dialog`, `Card.Root`, controller and close behavior. Pass the controller's +verification state and an `onClose` callback. See the password dialog for an +example. Fields inside the flow register with `useFlowAutoFocus` from +`components/flow` so focus follows step entry without taking focus from a +control outside the flow. + ## Where the strings live Every string a view renders comes from the feature's `*.messages.ts`, shaped the diff --git a/packages/mosaic/src/blocks/confirm/confirm-shell.tsx b/packages/mosaic/src/blocks/confirm/confirm-shell.tsx index 8023c17d98e..c07775097fe 100644 --- a/packages/mosaic/src/blocks/confirm/confirm-shell.tsx +++ b/packages/mosaic/src/blocks/confirm/confirm-shell.tsx @@ -3,8 +3,7 @@ import type { ReactNode } from 'react'; import { Card } from '../../components/card'; import type { DialogFocusTarget, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; -import { Flow } from '../../components/flow'; -import { Reverification } from '../../features/reverification'; +import { ReverificationFlow } from '../../features/reverification'; import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload'; import type { ConfirmController } from './confirm.controller'; @@ -77,27 +76,14 @@ function ConfirmCard({ elevation='overlay' renderBranding={false} > - {reverification ? ( - - {() => ( - <> - {confirmStep} - - controller.onOpenChange(false)} - /> - - - )} - - ) : ( - confirmStep - )} + controller.onOpenChange(false)} + > + {confirmStep} + ); } diff --git a/packages/mosaic/src/features/reverification/index.ts b/packages/mosaic/src/features/reverification/index.ts index 1c56d70fda6..12c9808a6ec 100644 --- a/packages/mosaic/src/features/reverification/index.ts +++ b/packages/mosaic/src/features/reverification/index.ts @@ -1,3 +1,4 @@ +export { ReverificationFlow } from './reverification-flow.view'; export { Reverification, useReverificationFlow } from './reverification'; export type { ReverificationController } from './reverification.controller'; export type { UseReverificationFlowResult } from './reverification'; diff --git a/packages/mosaic/src/features/reverification/reverification-flow.view.tsx b/packages/mosaic/src/features/reverification/reverification-flow.view.tsx new file mode 100644 index 00000000000..ac27adf350f --- /dev/null +++ b/packages/mosaic/src/features/reverification/reverification-flow.view.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; + +import { Flow } from '../../components/flow'; +import { Reverification } from './reverification'; +import type { ReverificationController } from './reverification.controller'; + +interface ReverificationFlowProps { + contentId: 'edit' | 'confirm'; + isVerifying: boolean; + reverification: ReverificationController | undefined; + onClose: () => void; + children: ReactNode; +} + +export function ReverificationFlow({ + contentId, + isVerifying, + reverification, + onClose, + children, +}: ReverificationFlowProps) { + if (!reverification) { + return <>{children}; + } + + const step = isVerifying ? 'verify' : contentId; + + return ( + + {() => ( + <> + {children} + + + + + )} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx index 5bfe752bc9f..88fc667deb6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-device-details.dialog.tsx @@ -4,8 +4,7 @@ import { Card } from '../../../components/card'; import { DataList } from '../../../components/data-list'; import type { DialogFocusTarget } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; -import { Flow } from '../../../components/flow'; -import { Reverification } from '../../../features/reverification'; +import { ReverificationFlow } from '../../../features/reverification'; import { fill, useMessages } from '../../../localization'; import type { UserProfileDevice } from './user-profile-active-devices.types'; @@ -54,27 +53,14 @@ export function UserProfileDeviceDetailsDialog({ elevation='overlay' renderBranding={false} > - {reverification ? ( - - {() => ( - <> - {details} - - controller.onOpenChange(false)} - /> - - - )} - - ) : ( - details - )} + controller.onOpenChange(false)} + > + {details} + ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 584929de4da..2b24a44d680 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -1,6 +1,6 @@ import { useMergeRefs } from '@floating-ui/react'; import type { RefObject } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; @@ -8,13 +8,13 @@ import { Checkbox } from '../../../components/checkbox'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import { Flow } from '../../../components/flow'; +import { useFlowAutoFocus } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { useMessages } from '../../../localization'; import type { ReverificationController } from '../../reverification'; -import { Reverification } from '../../reverification'; +import { ReverificationFlow } from '../../reverification'; import type { UserProfileEditPasswordField, UserProfileEditPasswordValues, @@ -30,7 +30,7 @@ export interface UserProfileEditPasswordDialogProps { requiresCurrentPassword?: boolean; form: UseFormResult; reverification?: ReverificationController; - step?: 'edit' | 'verify'; + step: 'edit' | 'verify'; } export function UserProfileEditPasswordDialog({ @@ -43,21 +43,11 @@ export function UserProfileEditPasswordDialog({ requiresCurrentPassword = false, form, reverification, - step = 'edit', + step, }: UserProfileEditPasswordDialogProps) { const m = useMessages('userProfilePasswordSection'); const initialFocusRef = useRef(null); const showCurrentPassword = hasPassword && requiresCurrentPassword; - const returnFocus = useRef(false); - - useEffect(() => { - if (step === 'verify') { - returnFocus.current = true; - } else if (returnFocus.current) { - returnFocus.current = false; - initialFocusRef.current?.focus({ preventScroll: true }); - } - }, [step]); const edit = ( <> @@ -147,28 +137,6 @@ export function UserProfileEditPasswordDialog({ ); - const content = reverification ? ( - - {() => ( - <> - {edit} - - onOpenChange(false)} - /> - - - )} - - ) : ( - edit - ); - return ( - {content} + onOpenChange(false)} + > + {edit} + @@ -212,7 +187,7 @@ function PasswordField({ const message = feedback?.type === 'error' ? feedback : advisoryFeedback; const feedbackType = message?.type === 'info' && !focused ? 'error' : message?.type; const { ref, ...control } = form.register(name); - const mergedRef = useMergeRefs([ref, inputRef]); + const mergedRef = useMergeRefs([ref, inputRef, useFlowAutoFocus()]); // TODO: Discuss enforcing the configured minimum length on the new password input or keeping the hint advisory and letting the server validate. https://github.com/clerk/javascript/pull/9930#discussion_r4151734254 return ( diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx b/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx index 41a0d509c00..81052919c89 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx @@ -47,6 +47,7 @@ export function useUserProfileEditPasswordFixture({ action: ( Date: Tue, 13 Oct 2026 12:38:51 -0600 Subject: [PATCH 18/19] fix(mosaic): use shared password reverification errors --- ...-profile-password-section.feature.test.tsx | 4 ++- .../user-profile-edit-password.controller.ts | 26 +++++-------------- .../user-profile-password-section.messages.ts | 1 - .../user-profile-password-section.tsx | 3 ++- 4 files changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx index 1032a0b3f58..40e029eb6cb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx @@ -283,7 +283,9 @@ describe('Changing a password', () => { await waitFor( () => - expect(screen.getByRole('alert')).toHaveTextContent('Your password was not saved. Please try verifying again.'), + expect(screen.getByRole('alert')).toHaveTextContent( + 'Your request was not completed. Please try verifying again.', + ), { timeout: 3000 }, ); expect(screen.getByLabelText('New password')).toHaveValue('new-password-123'); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index e6e03a929a6..13370fa0076 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,10 +1,8 @@ -import { isReverificationHint } from '@clerk/shared/authorization-errors'; -import { isReverificationCancelledError } from '@clerk/shared/error'; import { DEBOUNCE_MS } from '@clerk/shared/internal/clerk-js/constants'; import { useState } from 'react'; import type { UseFormResult } from '../../../components/form'; -import { FormSubmitError, useForm } from '../../../components/form'; +import { useForm } from '../../../components/form'; import type { FieldFeedback } from '../../../components/form/form-submit-error'; import { useDebouncedAsync } from '../../../hooks/use-debounced-async'; import { useMessages } from '../../../localization'; @@ -63,23 +61,11 @@ export function useUserProfileEditPasswordController({ (!requiresCurrentPassword || values.currentPassword !== ''), onSubmit: async values => { reverification?.reset(); - try { - const result = await onSubmit({ - currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, - newPassword: values.newPassword, - signOutOfOtherSessions: values.signOutOfOtherSessions, - }); - // TODO: Should we handle repeated reverification hints in useReverificationWithState - // so every Mosaic consumer rejects incomplete verification? - if (isReverificationHint(result)) { - throw new FormSubmitError({ message: m.errors.verificationIncomplete }); - } - } catch (error) { - if (isReverificationCancelledError(error)) { - return; - } - throw error; - } + await onSubmit({ + currentPassword: requiresCurrentPassword ? values.currentPassword : undefined, + newPassword: values.newPassword, + signOutOfOtherSessions: values.signOutOfOtherSessions, + }); // TODO: Discuss confirming the password was set or updated and other devices were signed out with a success page or toast. https://github.com/clerk/javascript/pull/9930#discussion_r4151641473 setIsOpen(false); }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 9a55023be80..b9055babc0b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -54,7 +54,6 @@ export const userProfilePasswordSectionMessages = { }, errors: { - verificationIncomplete: 'Your password was not saved. Please try verifying again.', unavailable: 'Password update is no longer available.', currentPasswordRequired: 'Current password is required.', mismatch: "Passwords don't match.", diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx index 08b4652e350..97fdbe8e3a6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { Button } from '../../../components/button'; import { useMessages } from '../../../localization'; +import { save } from '../../../utils/errors'; import { useReverificationFlow } from '../../reverification'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; @@ -48,7 +49,7 @@ function PasswordEditor({ model }: { model: Extract save(() => updatePassword(value)), reverification, }); From 64040fc48d38b6442b7f6759f92038bb4867dd93 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 13 Oct 2026 13:07:50 -0600 Subject: [PATCH 19/19] test(mosaic): remove covered password reverification todos --- .../__tests__/user-profile-password-section.feature.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx index 40e029eb6cb..cf05817d756 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/__tests__/user-profile-password-section.feature.test.tsx @@ -579,6 +579,4 @@ describe('Changing a password', () => { describe('Deferred password behavior', () => { it.todo('shows a password section skeleton while loading without a custom fallback'); - it.todo('reverifies the session and retries the password update when Clerk requires verification'); - it.todo('omits the current password when session reverification is enabled'); });