Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/calm-mfa-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/contact-verify-pending.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/friendly-identifier-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| Problem | Use |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) |
| Direct actions and confirmation dialogs that must exclude each other | `useActionLock` injected into `usePendingAction` and `useConfirmController` (`src/hooks/`, `src/blocks/confirm/`) |
| A form: values, validation, submit, field errors | `useForm` (`src/components/form/`), with `validateAsync` for a server-side check |
| A `<form>` element, without the browser's validation popup | `Form` (`src/primitives/form/`) |
| A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` |
Expand Down
17 changes: 17 additions & 0 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ return {
};
```

When a direct action and a confirmation must not run together, create one
`useActionLock` (`src/hooks/use-action-lock.ts`) and inject it into both owners:

```ts
const actionLock = useActionLock();
const primary = usePendingAction({ actionLock });
const remove = useConfirmController({
actionLock,
onConfirm: contact => removalFocus.remove(contact.id),
});
```

Pass `actionLock.isPending` to the section controls' `disabled` props, including
`Confirmation.disabled`. The lock prevents overlapping requests even before React
renders the disabled controls, and stays held until the action settles. Each owner
keeps its existing error handling. `actionLock` is a hook option, not a per-run option.

`run` resolves `true` on success, so the caller can close a dialog or move
focus. To hold pending through an OAuth redirect, await a delay inside the
action (`user-profile-connected-accounts-section.controller.ts`). When the user
Expand Down
20 changes: 14 additions & 6 deletions packages/mosaic/src/blocks/confirm/confirm.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMemo } from 'react';
import type { DialogHandle } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
import type { ReverificationController } from '../../features/reverification';
import type { ActionLock } from '../../hooks/use-action-lock';
import type { ErrorDescription } from '../../localization';
import { useErrorText } from '../../localization';
import { setup } from '../../machine/setup';
Expand Down Expand Up @@ -85,6 +86,7 @@ export interface ConfirmController<Payload = void> {
}

export interface ConfirmControllerOptions<Payload = void> {
actionLock?: ActionLock;
/** The action the dialog confirms. Resolve to close; reject to keep the dialog open showing why. */
onConfirm: (payload: Payload) => Promise<unknown> | void;
/** The flow `onConfirm` runs under, when the action may need step-up verification. */
Expand All @@ -107,6 +109,7 @@ export function useConfirmController<Payload = void>({
onConfirm,
reverification,
errorFallback,
actionLock,
}: ConfirmControllerOptions<Payload>): ConfirmController<Payload> {
const handle = useMemo(() => Dialog.createHandle<Payload>(), []);
const [snapshot, send] = useMachine(confirmMachine);
Expand Down Expand Up @@ -139,12 +142,17 @@ export function useConfirmController<Payload = void>({
if (snapshot.value !== 'confirming') {
return;
}
send({
type: 'CONFIRM',
run: async () => {
await onConfirm(payload);
},
});
const run = async () => {
await onConfirm(payload);
};
if (actionLock) {
const accepted = actionLock.tryRun(run);
if (accepted) {
send({ type: 'CONFIRM', run: () => accepted });
}
return;
}
send({ type: 'CONFIRM', run });
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ function Host<Payload>({

async function renderAccount(
options: ConfirmControllerOptions<void>,
{ localization, description }: { localization?: MosaicLocalization; description?: ReactNode } = {},
{
localization,
description,
disabled,
}: { localization?: MosaicLocalization; description?: ReactNode; disabled?: boolean } = {},
) {
const ref: { current: ConfirmController | undefined } = { current: undefined };
serveFapi({ client: fapiClient([session]) });
Expand All @@ -53,6 +57,7 @@ async function renderAccount(
title={TITLE}
description={description ?? 'Google will be removed from this account.'}
actionLabel='Remove'
disabled={disabled}
/>
);
}}
Expand All @@ -70,6 +75,19 @@ async function renderAccount(
const confirmButton = () => screen.getByRole('button', { name: 'Remove' });

describe('Confirmation', () => {
it('disables confirmation while leaving Cancel usable', async () => {
const onConfirm = vi.fn(() => Promise.resolve());
const { user, open, closed } = await renderAccount({ onConfirm }, { disabled: true });
await open();

expect(confirmButton()).toBeDisabled();
await user.click(confirmButton());
expect(onConfirm).not.toHaveBeenCalled();

await user.click(screen.getByRole('button', { name: 'Cancel' }));
await closed();
});

it('opens from its trigger, confirms, closes once the action lands, and hands focus back', async () => {
const run = deferred<void>();
const onConfirm = vi.fn(() => run.promise);
Expand All @@ -82,6 +100,7 @@ describe('Confirmation', () => {
await user.click(confirmButton());
expect(onConfirm).toHaveBeenCalledOnce();
expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
expect(confirmButton()).toHaveFocus();

run.resolve();
await closed();
Expand Down
4 changes: 3 additions & 1 deletion packages/mosaic/src/blocks/confirmation/confirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ConfirmationColor = 'negative' | 'primary';
export interface ConfirmationProps<Payload = void> extends ConfirmBlockProps<Payload> {
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
color?: ConfirmationColor;
disabled?: boolean;
}

/**
Expand All @@ -36,7 +37,7 @@ export interface ConfirmationProps<Payload = void> extends ConfirmBlockProps<Pay
* actionLabel='Remove'
* />
*/
export function Confirmation<Payload = void>({ color = 'negative', ...props }: ConfirmationProps<Payload>) {
export function Confirmation<Payload = void>({ color = 'negative', disabled, ...props }: ConfirmationProps<Payload>) {
return (
<ConfirmShell {...props}>
{({ title, description, actionLabel, cancelLabel, isPending, errorMessage, confirm }) => (
Expand Down Expand Up @@ -67,6 +68,7 @@ export function Confirmation<Payload = void>({ color = 'negative', ...props }: C
fullWidth
color={color}
isPending={isPending}
disabled={disabled && !isPending}
onClick={confirm}
>
{actionLabel}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,30 @@ function contact(id: string, status: Contact['verification']['status'], expireAt
}

describe('toContacts', () => {
it('orders primary first, then verified, then pending by expiry, then never started', () => {
it('orders verified, then pending by expiry, then never started regardless of primary', () => {
const items = [
contact('idn_unstarted', null),
contact('idn_pending_late', 'unverified', new Date(2026, 0, 2)),
contact('idn_verified_b', 'verified'),
contact('idn_primary', 'verified'),
contact('idn_verified_z', 'verified'),
contact('idn_pending_soon', 'unverified', new Date(2026, 0, 1)),
contact('idn_verified_a', 'verified'),
];

const contacts = toContacts(items, 'idn_primary', item => item.id);
const contacts = toContacts(items, 'idn_verified_z', item => item.id);
const changedPrimary = toContacts(items, 'idn_verified_b', item => item.id);

expect(contacts.map(item => item.id)).toEqual([
'idn_primary',
'idn_verified_a',
'idn_verified_b',
'idn_verified_z',
'idn_pending_soon',
'idn_pending_late',
'idn_unstarted',
]);
expect(changedPrimary.map(item => item.id)).toEqual(contacts.map(item => item.id));
expect(changedPrimary.find(item => item.id === 'idn_verified_b')?.isDefault).toBe(true);
expect(changedPrimary.find(item => item.id === 'idn_verified_z')?.isDefault).toBe(false);
});

it('marks which contact is the primary one and which are verified', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export interface UserProfileContactListRowViewProps {
items: UserProfileContact[];
onAdd?: () => void;
onVerify?: (id: string) => void;
pendingVerifyId?: string;
pendingPrimaryId?: string;
isDisabled?: boolean;
onSetPrimary?: (id: string) => void;
onRemove?: (item: UserProfileContact) => void;
children?: ReactNode;
Expand All @@ -33,6 +36,9 @@ export function UserProfileContactListRowView({
items,
onAdd,
onVerify,
pendingVerifyId,
pendingPrimaryId,
isDisabled = false,
onSetPrimary,
onRemove,
addAction,
Expand Down Expand Up @@ -62,6 +68,7 @@ export function UserProfileContactListRowView({
size='sm'
variant='outline'
onClick={onAdd}
disabled={isDisabled}
>
<Icon
glyph={Plus}
Expand Down Expand Up @@ -89,9 +96,14 @@ export function UserProfileContactListRowView({
actions.push({
label: item.isDefault ? m.completeVerification : m[kind].verify,
onClick: () => onVerify(item.id),
status: pendingVerifyId === item.id ? 'pending' : 'idle',
});
} else if (item.isVerified && !item.isDefault && onSetPrimary) {
actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) });
actions.push({
label: m.setPrimary,
onClick: () => onSetPrimary(item.id),
status: pendingPrimaryId === item.id ? 'pending' : 'idle',
});
}

if (onRemove) {
Expand All @@ -116,6 +128,8 @@ export function UserProfileContactListRowView({
<ActionMenu
triggerRef={triggerRef?.(item.id)}
actions={actions}
disabled={isDisabled}
focusableWhenDisabled
label={fill(m.manageValue, { value: item.value })}
/>
</Section.Actions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,10 @@ export function byId<T extends { id: string }>(items: T[], id: string, kind: str

type Identification = { id: string; verification: Pick<VerificationResource, 'status' | 'expireAt'> };

export function sortByVerification<T extends Identification>(items: T[], primaryId: string | null): T[] {
const others = items.filter(item => item.id !== primaryId);
const verified = others.filter(item => item.verification.status === 'verified');
const pending = others.filter(item => item.verification.status && item.verification.status !== 'verified');
const unstarted = others.filter(item => !item.verification.status);
export function sortByVerification<T extends Identification>(items: T[]): T[] {
const verified = items.filter(item => item.verification.status === 'verified');
const pending = items.filter(item => item.verification.status && item.verification.status !== 'verified');
const unstarted = items.filter(item => !item.verification.status);

verified.sort((a, b) => a.id.localeCompare(b.id));
pending.sort((a, b) => {
Expand All @@ -52,15 +51,15 @@ export function sortByVerification<T extends Identification>(items: T[], primary
return aExpireAt && bExpireAt ? aExpireAt.getTime() - bExpireAt.getTime() : 0;
});

return [...items.filter(item => item.id === primaryId), ...verified, ...pending, ...unstarted];
return [...verified, ...pending, ...unstarted];
}

export function toContacts<T extends Identification>(
items: T[],
primaryId: string | null,
value: (item: T) => string,
): UserProfileContact[] {
return sortByVerification(items, primaryId).map(item => ({
return sortByVerification(items).map(item => ({
id: item.id,
value: value(item),
isDefault: item.id === primaryId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,18 +192,27 @@ describe('useUserProfileAddEmailController', () => {
expect(email.verifyCode).toHaveBeenCalledExactlyOnceWith('123456');
});

it('verifies an existing email without creating one', async () => {
it('verifies an existing email without creating one, opening once its code is sent', async () => {
const onCreate = vi.fn(created);
const email = verifier();
const sent = createDeferredPromise();
const email = verifier(() => ({ method: 'code', sent: sent.promise.then(() => undefined) }));
const { result } = renderHook(() =>
useUserProfileAddEmailController({ reverification: idleReverification, onCreate }),
);

act(() => result.current.onVerifyEmail(other, email));
expect(email.start).toHaveBeenCalledOnce();
expect(result.current.open).toBe(false);
expect(result.current.pendingVerifyId).toBe('email_2');

await act(async () => {
sent.resolve();
await sent.promise;
});
expect(result.current.open).toBe(true);
expect(result.current.pendingVerifyId).toBeUndefined();
expect(result.current.step).toBe('verify');
expect(result.current.emailAddress).toBe('other@example.com');
await waitFor(() => expect(email.start).toHaveBeenCalledOnce());

act(() => result.current.onSubmit('123456'));
await waitFor(() => expect(result.current.open).toBe(false));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,36 @@ async function enterCode(actor: Actor, code: string) {
}

describe('the user profile email section', () => {
it('holds the menu and row order while setting an address as primary', async () => {
const other = fapiEmailAddress({ id: 'idn_other', email_address: 'bob@example.com', verification: VERIFIED });
const { actor } = await renderSection(signedInWithEmails([PRIMARY, other]));
const initialOrder = emailsListed();
const primary = holdRequests('post', '/v1/me');
try {
await manageEmail(actor, 'bob@example.com', 'Set as primary');
await waitFor(() => expect(primary.requests).toHaveLength(1));
const menu = screen.getByRole('menu');
const item = within(menu).getByRole('menuitem', { name: 'Set as primary' });
expect(item).toHaveAttribute('aria-busy', 'true');
const manage = screen.getByRole('button', { name: 'Manage bob@example.com' });
expect(manage).toHaveAttribute('aria-disabled', 'true');
expect(within(menu).getByRole('menuitem', { name: 'Remove email' })).toHaveAttribute('aria-disabled', 'true');
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(screen.getByRole('button', { name: 'Add email' })).toBeDisabled();
expect(await within(item).findByRole('progressbar', { name: 'Set as primary' })).toBeVisible();
expect(emailsListed()).toEqual(initialOrder);
} finally {
primary.release();
await waitFor(() =>
expect(within(emailRow()).getByText('bob@example.com').parentElement).toHaveTextContent('Primary'),
);
}
expect(screen.getByRole('button', { name: 'Manage bob@example.com' })).not.toHaveAttribute('aria-disabled', 'true');
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
expect(emailsListed()).toEqual(initialOrder);
expect(screen.queryByRole('progressbar', { name: 'Set as primary' })).toBeNull();
});

it('adds an address and verifies the code it was sent', async () => {
const { actor } = await renderSection(signedInWithEmails([]));
expect(emailRow()).toHaveTextContent('No email addresses added');
Expand Down Expand Up @@ -114,6 +144,27 @@ describe('the user profile email section', () => {
);
});

it('holds the menu open with a spinner on Verify until the code is sent', async () => {
const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' });
const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending]));
const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification');

try {
await manageEmail(actor, 'pending@example.com', 'Verify');
await waitFor(() => expect(prepare.requests).toHaveLength(1));
expect(screen.getByRole('menu')).toBeVisible();
expect(screen.getByRole('menuitem', { name: 'Verify' })).toHaveAttribute('aria-busy', 'true');
expect(await screen.findByRole('progressbar', { name: 'Verify' })).toBeVisible();
expect(screen.queryByRole('dialog')).toBeNull();
} finally {
prepare.release();
}

await enterCode(actor, VERIFICATION_CODE);
expect(screen.queryByRole('menu')).toBeNull();
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});

it('waits for the emailed link when the instance verifies by link, then closes once it is opened', async () => {
const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' });
const { actor, fapi } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink));
Expand Down Expand Up @@ -293,6 +344,7 @@ describe('email reverification', () => {
expect(within(emailRow()).queryByRole('alert')).toBeNull();
expect(within(emailRow()).getByRole('button', { name: 'Manage bob@example.com' })).toHaveFocus();

await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
await manageEmail(actor, 'bob@example.com', 'Set as primary');
await waitFor(() => expect(fapi.client.sessions[0]?.user?.primary_email_address_id).toBe('idn_second'));
});
Expand Down Expand Up @@ -406,7 +458,7 @@ describe('email reverification', () => {
await screen.findByRole('dialog', { name: 'Verify your email' });
await enterCode(actor, VERIFICATION_CODE);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(emailsListed()).toEqual(['alice@example.com', 'new@example.com']);
expect(emailsListed()).toEqual(['new@example.com', 'alice@example.com']);
});

it('closes quietly when confirmation is dismissed and starts clean on reopen', async () => {
Expand Down
Loading
Loading