diff --git a/.changeset/wild-mangos-clap.md b/.changeset/wild-mangos-clap.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/wild-mangos-clap.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md
index 6cde265d985..e930daa2b79 100644
--- a/.claude/skills/mosaic/references/views.md
+++ b/.claude/skills/mosaic/references/views.md
@@ -9,16 +9,41 @@ The view renders a snapshot and emits events. Nothing else.
- **Take derived booleans from the controller.** `actor.can(...)` results (e.g.
`canSubmit`) are passed in — the view never re-implements a machine guard.
+```tsx
+
+```
+
+A **block** takes the flow's state as props. It owns only what nothing outside
+it can use. `Destructive` is the example: it holds the half-typed confirmation
+phrase and compares it, while `open`, `isDeleting`, and `errorMessage` come from
+the machine, because those are what decide whether the dialog closes or explains
+itself.
+
```tsx
send({ type: 'TYPE_CONFIRMATION', value })}
+ onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })}
+ trigger={}
+ title='Delete organization?'
+ description="All of this organization's data will be permanently deleted."
+ fieldLabel='Type the organization name below to continue'
+ confirmationValue={organizationName}
+ actionLabel='Delete organization'
onDelete={() => send({ type: 'CONFIRM' })}
- canSubmit={canSubmit}
isDeleting={snapshot.value === 'deleting'}
- error={snapshot.context.error}
+ errorMessage={snapshot.context.errorMessage}
/>
```
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 3a52fbae376..c144dfd18db 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -37,6 +37,9 @@ const docModules: Record> = {
'user-profile-web3wallets-section': dynamic(() => import('../stories/user-profile-web3-wallets-section.mdx')),
'user-profile-delete-section': dynamic(() => import('../stories/user-profile-delete-section.mdx')),
},
+ blocks: {
+ destructive: dynamic(() => import('../stories/destructive.mdx')),
+ },
components: {
avatar: dynamic(() => import('../stories/avatar.mdx')),
badge: dynamic(() => import('../stories/badge.mdx')),
diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx
index 00cc791f6c2..c8a9cbcf053 100644
--- a/packages/swingset/src/components/app-sidebar.tsx
+++ b/packages/swingset/src/components/app-sidebar.tsx
@@ -24,7 +24,7 @@ import { getSidebarGroups } from '@/lib/registry';
const groups = getSidebarGroups();
-const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);
+const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks']);
type SidebarEntry = ReturnType[number]['components'][number];
@@ -167,7 +167,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
{groups.map(({ group, groupSlug, components }) => (
- {group === 'Components' && }
+ {group === 'Blocks' && }
+
+## Usage
+
+The block holds one thing: the phrase the user types. Nothing outside the dialog can use a half-typed string, so keeping it inside removes the keystroke plumbing a caller would otherwise write.
+
+Everything that decides what the dialog does next belongs to the caller. `open` closes it, `isDeleting` marks it busy, `errorMessage` explains a failure.
+
+```tsx
+import { Destructive } from '@clerk/ui/mosaic/blocks/destructive';
+import { Button } from '@clerk/ui/mosaic/components/button';
+
+const [open, setOpen] = useState(false);
+const [isDeleting, setIsDeleting] = useState(false);
+
+const handleDelete = async () => {
+ setIsDeleting(true);
+ await deleteAccount();
+ setIsDeleting(false);
+ setOpen(false);
+};
+
+Delete account}
+ title='Delete account?'
+ description='Are you sure you want to delete your account? All of your data will be permanently deleted.'
+ fieldLabel='Type “Delete account” below to continue'
+ confirmationValue='Delete account'
+ actionLabel='Delete account'
+ onDelete={() => void handleDelete()}
+ isDeleting={isDeleting}
+/>;
+```
+
+## Failure
+
+A failed attempt leaves the dialog up. Pass the sentence the user should read as `errorMessage`, and clear it when the next attempt starts. The field is marked invalid for as long as a message is set.
+
+
+
+## Props
+
+| Prop | Type | Description |
+| ------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `open` | `boolean` | Whether the confirmation is showing. Controlled, the way any dialog is. |
+| `onOpenChange` | `(open: boolean) => void` | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. |
+| `trigger` | `ReactNode` | Optional. The button that asks to open the dialog. |
+| `title` | `string` | Names what is about to be destroyed. |
+| `description` | `string` | Spells out what is lost. Sits above the confirmation field. |
+| `fieldLabel` | `string` | Labels the confirmation field. |
+| `confirmationValue` | `string` | The phrase the user has to type back. Also the field's placeholder. |
+| `actionLabel` | `string` | The destructive button's label. |
+| `cancelLabel` | `string` | Optional. Defaults to `Cancel`. |
+| `onDelete` | `() => void` | Asks the caller to run the action. Reached by the button or by Enter in the field, once the typed phrase matches. |
+| `isDeleting` | `boolean` | Optional. Disables the field and renders the action pending. |
+| `errorMessage` | `string` | Optional. Marks the field invalid and renders under it. |
+
+## Driving it from a machine
+
+`UserProfileDeleteSection` wires the same block to a state machine rather than to `useState`. The machine's state maps onto the same props:
+
+```tsx
+ send({ type: open ? 'OPEN' : 'CANCEL' })}
+ onDelete={() => send({ type: 'CONFIRM' })}
+ isDeleting={snapshot.value === 'deleting'}
+ errorMessage={snapshot.context.errorMessage}
+ {...copy}
+/>
+```
diff --git a/packages/swingset/src/stories/destructive.stories.tsx b/packages/swingset/src/stories/destructive.stories.tsx
new file mode 100644
index 00000000000..27f9811e8cb
--- /dev/null
+++ b/packages/swingset/src/stories/destructive.stories.tsx
@@ -0,0 +1,103 @@
+import { Destructive } from '@clerk/ui/mosaic/blocks/destructive';
+import { Button } from '@clerk/ui/mosaic/components/button';
+import React from 'react';
+
+import type { StoryMeta } from '@/lib/types';
+
+// Exposes this file's own source (via the `?raw` webpack rule) so each `` example
+// renders a code footer with its function's source. See `StoryModule.__source`.
+export { default as __source } from './destructive.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'Blocks',
+ title: 'Destructive',
+ source: 'packages/ui/src/mosaic/blocks/destructive/destructive.tsx',
+};
+
+// A real delete is a network round trip. Without one the action never renders its pending
+// state, so both stories wait before they settle.
+const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+const trigger = (
+
+);
+
+/**
+ * The block holds the typed phrase and compares it to `confirmationValue`. Everything that
+ * decides what the dialog does next stays with the caller: `open` closes it, `isDeleting`
+ * marks it busy, `errorMessage` explains a failure.
+ */
+export function Default() {
+ const [open, setOpen] = React.useState(false);
+ const [isDeleting, setIsDeleting] = React.useState(false);
+
+ const handleDelete = async () => {
+ setIsDeleting(true);
+ await settleAfter(2000);
+ setIsDeleting(false);
+ setOpen(false);
+ };
+
+ return (
+ void handleDelete()}
+ isDeleting={isDeleting}
+ />
+ );
+}
+
+/**
+ * A failed attempt leaves the dialog up. Pass the sentence the user should read as
+ * `errorMessage`, and clear it when the next attempt starts.
+ */
+export function WithError() {
+ const [open, setOpen] = React.useState(false);
+ const [isDeleting, setIsDeleting] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState(undefined);
+
+ const handleDelete = async () => {
+ setErrorMessage(undefined);
+ setIsDeleting(true);
+ await settleAfter(2000);
+ setIsDeleting(false);
+ setErrorMessage('Your subscription is still active. Cancel it before you delete your account.');
+ };
+
+ // The error belongs to the caller, so the caller drops it. Without this a reopened dialog
+ // still shows why the last attempt failed.
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next);
+ if (!next) {
+ setErrorMessage(undefined);
+ }
+ };
+
+ return (
+ void handleDelete()}
+ isDeleting={isDeleting}
+ errorMessage={errorMessage}
+ />
+ );
+}
diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx
index bcb7f35e382..42da0e3aa7e 100644
--- a/packages/swingset/src/stories/user-page.stories.tsx
+++ b/packages/swingset/src/stories/user-page.stories.tsx
@@ -121,7 +121,7 @@ export function Default() {
isVerified: true,
},
]),
- onDeleteAccount: () => undefined,
+ onDeleteAccount: () => Promise.resolve(),
onEditProfilePicture: () => undefined,
onManageEmail: () => undefined,
onManagePhone: () => undefined,
@@ -162,7 +162,7 @@ export function Default() {
{ id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' },
]),
onChangePassword: () => undefined,
- onDeleteAccount: () => undefined,
+ onDeleteAccount: () => Promise.resolve(),
onManageDevice: () => undefined,
onManagePasskey: () => undefined,
onRegenerateBackupCodes: () =>
diff --git a/packages/swingset/src/stories/user-profile-delete-section.mdx b/packages/swingset/src/stories/user-profile-delete-section.mdx
index e6ca07a140b..6e29e05699d 100644
--- a/packages/swingset/src/stories/user-profile-delete-section.mdx
+++ b/packages/swingset/src/stories/user-profile-delete-section.mdx
@@ -12,3 +12,10 @@ The terminal destructive action for deleting the current user account.
{ name: 'Button', href: '/components/button', layer: 'Components' },
]}
/>
+
+A failed delete keeps the dialog up and renders the reason under the confirmation field.
+
+
diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx
index cc18ac403eb..864ce525c33 100644
--- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx
+++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx
@@ -1,4 +1,5 @@
-import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section.view';
+import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view';
+import { useState } from 'react';
import type { StoryMeta } from '@/lib/types';
@@ -9,9 +10,38 @@ export const meta: StoryMeta = {
title: 'UserProfileDeleteSection',
label: 'Danger zone',
navigation: { category: 'Sections' },
- source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx',
+ source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx',
};
+// A real delete is a network round trip. Without one the button never renders its pending
+// state, so both stories wait before they settle.
+const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
export function Default() {
- return undefined} />;
+ const [runId, setRunId] = useState(0);
+
+ // Deleting is terminal: the real flow signs the user out and the section goes away with the
+ // page. Nothing unmounts it here, so the story remounts it to make the demo repeatable.
+ const handleDelete = async () => {
+ await settleAfter(2000);
+ setRunId(current => current + 1);
+ };
+
+ return (
+
+ );
+}
+
+export function WithError() {
+ return (
+ {
+ await settleAfter(2000);
+ throw new Error('Your subscription is still active. Cancel it before you delete your account.');
+ }}
+ />
+ );
}
diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx
index 3a1b0164cc3..0371e85037d 100644
--- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx
+++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx
@@ -77,7 +77,7 @@ export function Default(_args: Record) {
])
}
onConnectAccount={() => undefined}
- onDeleteAccount={() => undefined}
+ onDeleteAccount={() => Promise.resolve()}
onEditProfilePicture={() => undefined}
onManageEmail={() => undefined}
onManagePhone={() => undefined}
diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx
index e02a43d8489..8c1614a3138 100644
--- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx
+++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx
@@ -82,7 +82,7 @@ export function Default() {
])
}
onChangePassword={() => undefined}
- onDeleteAccount={() => undefined}
+ onDeleteAccount={() => Promise.resolve()}
onManageDevice={() => undefined}
onManagePasskey={() => undefined}
onRegenerateBackupCodes={() =>
diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.test.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.test.tsx
new file mode 100644
index 00000000000..057b39fa78d
--- /dev/null
+++ b/packages/ui/src/mosaic/blocks/destructive/destructive.test.tsx
@@ -0,0 +1,144 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { Button } from '../../components/button';
+import { MosaicProvider } from '../../MosaicProvider';
+import type { DestructiveProps } from './destructive';
+import { Destructive } from './destructive';
+
+function renderBlock(overrides: Partial = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
+const confirmButton = () => screen.getByRole('button', { name: 'Delete account' });
+
+describe('Destructive', () => {
+ it('renders nothing until the caller opens it', () => {
+ renderBlock({ open: false });
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('asks to open from the trigger', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ renderBlock({ open: false, onOpenChange, trigger: });
+
+ await user.click(confirmButton());
+
+ expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything());
+ });
+
+ it('holds the action until the typed phrase matches', async () => {
+ const onDelete = vi.fn();
+ const user = userEvent.setup();
+ renderBlock({ onDelete });
+
+ expect(confirmButton()).toHaveAttribute('aria-disabled', 'true');
+
+ await user.type(screen.getByRole('textbox'), 'Delete accoun');
+ expect(confirmButton()).toHaveAttribute('aria-disabled', 'true');
+
+ await user.type(screen.getByRole('textbox'), 't');
+ expect(confirmButton()).not.toHaveAttribute('aria-disabled');
+
+ await user.click(confirmButton());
+ expect(onDelete).toHaveBeenCalledOnce();
+ });
+
+ it('submits on enter in the confirmation field, once the typed phrase matches', async () => {
+ const onDelete = vi.fn();
+ const user = userEvent.setup();
+ renderBlock({ onDelete });
+
+ await user.type(screen.getByRole('textbox'), 'Delete accoun{Enter}');
+ expect(onDelete).not.toHaveBeenCalled();
+
+ await user.type(screen.getByRole('textbox'), 't{Enter}');
+ expect(onDelete).toHaveBeenCalledOnce();
+ });
+
+ it('asks to close from cancel', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ renderBlock({ onOpenChange });
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything());
+ });
+
+ it('clears the typed phrase once the caller closes it', async () => {
+ const user = userEvent.setup();
+ const view = renderBlock();
+
+ await user.type(screen.getByRole('textbox'), 'Delete account');
+ view.rerender(
+
+
+ ,
+ );
+ view.rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('textbox')).toHaveValue('');
+ });
+
+ it('marks the field invalid and explains a failed attempt', () => {
+ renderBlock({ errorMessage: 'Your subscription is still active.' });
+
+ expect(screen.getByText('Your subscription is still active.')).toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
+ });
+
+ it('stays inert while the caller is deleting', async () => {
+ const onDelete = vi.fn();
+ const user = userEvent.setup();
+ renderBlock({ isDeleting: true, onDelete });
+
+ await user.type(screen.getByRole('textbox'), 'Delete account');
+
+ expect(screen.getByRole('textbox')).toBeDisabled();
+ // Busy, not unavailable: the block leaves the pending affordance to `isPending` rather than
+ // disabling the action a second time.
+ expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
+ await user.click(confirmButton());
+ expect(onDelete).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx
new file mode 100644
index 00000000000..b2902669272
--- /dev/null
+++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx
@@ -0,0 +1,171 @@
+import type { FormEvent } from 'react';
+import { useEffect, useId, useState } from 'react';
+
+import { Button, SubmitButton } from '../../components/button';
+import { Card } from '../../components/card';
+import type { DialogProps } from '../../components/dialog';
+import { Dialog } from '../../components/dialog';
+import { Field } from '../../components/field';
+import { Heading } from '../../components/heading';
+import { Input } from '../../components/input';
+import { Text } from '../../components/text';
+
+export interface DestructiveProps {
+ /** Whether the dialog is open */
+ open: boolean;
+ /** Callback when open state changes */
+ onOpenChange: (open: boolean) => void;
+ /** Element that opens the dialog */
+ trigger?: DialogProps['trigger'];
+ /** Dialog heading */
+ title: string;
+ /** What the action destroys */
+ description: string;
+ /** Label above the confirmation input */
+ fieldLabel: string;
+ /** Phrase the user must type to confirm. Also the input's placeholder */
+ confirmationValue: string;
+ /** Text of the delete button */
+ actionLabel: string;
+ /** Text of the cancel button (default: "Cancel") */
+ cancelLabel?: string;
+ /** Callback when delete is confirmed, by button or by Enter */
+ onDelete: () => void;
+ /** Whether the delete action is in progress */
+ isDeleting?: boolean;
+ /** Error message to display if the delete action fails */
+ errorMessage?: string;
+}
+
+/**
+ * Type-to-confirm dialog for an action that cannot be undone. The delete button stays inert
+ * until the typed phrase matches `confirmationValue`.
+ *
+ * Controlled: the caller owns `open`, `isDeleting`, and `errorMessage`. The block holds only
+ * the typed phrase.
+ *
+ * @example
+ * send({ type: open ? 'OPEN' : 'CANCEL' })}
+ * trigger={}
+ * title='Delete account?'
+ * description='All of your data will be permanently deleted.'
+ * fieldLabel='Type “Delete account” below to continue'
+ * confirmationValue='Delete account'
+ * actionLabel='Delete account'
+ * onDelete={() => send({ type: 'CONFIRM' })}
+ * isDeleting={snapshot.value === 'deleting'}
+ * errorMessage={snapshot.context.errorMessage}
+ * />
+ */
+export function Destructive({
+ open,
+ onOpenChange,
+ trigger,
+ title,
+ description,
+ fieldLabel,
+ confirmationValue,
+ actionLabel,
+ cancelLabel = 'Cancel',
+ onDelete,
+ isDeleting = false,
+ errorMessage,
+}: DestructiveProps) {
+ const formId = useId();
+ const [typedValue, setTypedValue] = useState('');
+
+ // The caller may close the dialog without going through the trigger or Cancel, so the
+ // field is cleared on close rather than in a handler.
+ useEffect(() => {
+ if (!open) {
+ setTypedValue('');
+ }
+ }, [open]);
+
+ const isConfirmed = typedValue === confirmationValue;
+
+ // The action sits in the footer, outside the form, so `form={formId}` associates the two.
+ // That is what makes Enter in the field submit. Both guards are re-checked here because
+ // neither spelling stops a native submit: `focusableWhenDisabled` only marks the button
+ // `aria-disabled`, and `isPending` only cancels the press.
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ if (isConfirmed && !isDeleting) {
+ onDelete();
+ }
+ };
+
+ return (
+
+ {trigger ? : null}
+
+
+
+
+ }
+ >
+
+
+ }>{title}
+ }>{description}
+
+
+
+
+
+
+ {cancelLabel}
+
+ }
+ />
+
+ {actionLabel}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/blocks/destructive/index.ts b/packages/ui/src/mosaic/blocks/destructive/index.ts
new file mode 100644
index 00000000000..39b75d99817
--- /dev/null
+++ b/packages/ui/src/mosaic/blocks/destructive/index.ts
@@ -0,0 +1,2 @@
+export { Destructive } from './destructive';
+export type { DestructiveProps } from './destructive';
diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts
index 08aaaae2625..8495c4ec902 100644
--- a/packages/ui/src/mosaic/components/button/button.styles.ts
+++ b/packages/ui/src/mosaic/components/button/button.styles.ts
@@ -172,7 +172,7 @@ export const styles = stylex.create({
},
// state / modifiers
- fullWidth: { width: '100%' },
+ fullWidth: { flex: '1', width: '100%' },
disabled: { cursor: 'not-allowed', opacity: 0.5 },
});
diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
index b8398f07523..a7fbca36ba8 100644
--- a/packages/ui/src/mosaic/components/button/submit-button.test.tsx
+++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
@@ -67,6 +67,21 @@ describe('Mosaic SubmitButton', () => {
expect(button).not.toHaveAttribute('data-pending');
});
+ it('keeps the focusable-disabled marking when the button is disabled but not pending', () => {
+ render(
+
+ Save
+ ,
+ );
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(button).not.toHaveAttribute('disabled');
+ expect(button).not.toHaveAttribute('aria-busy');
+ });
+
it('renders the spinner and reflects the pending state', () => {
render(Save);
const button = screen.getByRole('button');
diff --git a/packages/ui/src/mosaic/components/button/submit-button.tsx b/packages/ui/src/mosaic/components/button/submit-button.tsx
index 711af575e38..0a117779c73 100644
--- a/packages/ui/src/mosaic/components/button/submit-button.tsx
+++ b/packages/ui/src/mosaic/components/button/submit-button.tsx
@@ -88,9 +88,10 @@ export const SubmitButton = React.forwardRef(function MosaicFie
render,
ref,
props: {
- ...mergeStyleProps(themeProps('field-root'), stylex.props(reset.base), className, style),
+ ...mergeStyleProps(themeProps('field-root'), stylex.props(reset.base, styles.root), className, style),
...rest,
},
});
diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-delete-section.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-delete-section.view.test.tsx
new file mode 100644
index 00000000000..911d97432fc
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-delete-section.view.test.tsx
@@ -0,0 +1,74 @@
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { MosaicProvider } from '../../MosaicProvider';
+import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view';
+
+function renderView(onDelete: () => Promise = vi.fn(() => Promise.resolve())) {
+ return render(
+
+
+ ,
+ );
+}
+
+const openDialog = async (user: ReturnType) => {
+ await user.click(screen.getByRole('button', { name: 'Delete account' }));
+ return screen.getByRole('dialog');
+};
+
+describe('UserProfileDeleteSectionView', () => {
+ it('renders the danger zone with the dialog closed', () => {
+ renderView();
+
+ expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument();
+ expect(
+ screen.getByText('Permanently delete this account and all its data. This cannot be undone.'),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('deletes only after the phrase is typed back, then closes', async () => {
+ const onDelete = vi.fn(() => Promise.resolve());
+ const user = userEvent.setup();
+ renderView(onDelete);
+
+ const dialog = await openDialog(user);
+ const confirm = within(dialog).getByRole('button', { name: 'Delete account' });
+
+ expect(within(dialog).getByText('Type “Delete account” below to continue')).toBeInTheDocument();
+ expect(confirm).toHaveAttribute('aria-disabled', 'true');
+
+ await user.type(within(dialog).getByRole('textbox'), 'Delete account');
+ await user.click(confirm);
+
+ expect(onDelete).toHaveBeenCalledOnce();
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ });
+
+ it('keeps the dialog up and explains a failed delete', async () => {
+ const user = userEvent.setup();
+ renderView(() => Promise.reject(new Error('Your subscription is still active.')));
+
+ const dialog = await openDialog(user);
+ await user.type(within(dialog).getByRole('textbox'), 'Delete account');
+ await user.click(within(dialog).getByRole('button', { name: 'Delete account' }));
+
+ expect(await screen.findByText('Your subscription is still active.')).toBeInTheDocument();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('clears the phrase when the dialog is cancelled', async () => {
+ const user = userEvent.setup();
+ renderView();
+
+ const dialog = await openDialog(user);
+ await user.type(within(dialog).getByRole('textbox'), 'Delete account');
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ await openDialog(user);
+ expect(screen.getByRole('textbox')).toHaveValue('');
+ });
+});
diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx
index 5d3e26e4c52..e6227392e30 100644
--- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx
+++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx
@@ -139,7 +139,7 @@ describe('UserProfileProfilePanelView', () => {
it('renders connected accounts and the danger zone when provided', async () => {
const onConnectAccount = vi.fn();
const onManageConnectedAccount = vi.fn();
- const onDeleteAccount = vi.fn();
+ const onDeleteAccount = vi.fn(() => Promise.resolve());
const user = userEvent.setup();
renderView({
connectedAccounts: [
@@ -165,6 +165,9 @@ describe('UserProfileProfilePanelView', () => {
await user.click(screen.getByRole('menuitem', { name: 'Manage' }));
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(screen.getByRole('button', { name: 'Delete account' }));
+ const deleteDialog = screen.getByRole('dialog');
+ await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account');
+ await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' }));
expect(onManageConnectedAccount).toHaveBeenCalledWith('google');
expect(onConnectAccount).toHaveBeenCalledWith('apple');
diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx
index 5892b8c8cd8..0d5038bf3d9 100644
--- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx
+++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx
@@ -57,7 +57,7 @@ function renderView(overrides: Partial = {})
describe('UserProfileSecurityPanelView', () => {
it('composes authentication, active devices, and the danger zone', () => {
- renderView({ onDeleteAccount: vi.fn() });
+ renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) });
expect(screen.getByRole('heading', { level: 3, name: 'Security' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument();
@@ -83,7 +83,7 @@ describe('UserProfileSecurityPanelView', () => {
const onAddMfaMethod = vi.fn();
const onSignOutDevice = vi.fn();
const onSignOutAllOtherDevices = vi.fn();
- const onDeleteAccount = vi.fn();
+ const onDeleteAccount = vi.fn(() => Promise.resolve());
const user = userEvent.setup();
renderView({
@@ -107,7 +107,6 @@ describe('UserProfileSecurityPanelView', () => {
expect(screen.queryByRole('menuitem', { name: 'SMS verification' })).not.toBeInTheDocument();
await user.click(screen.getByRole('menuitem', { name: 'Authenticator app' }));
await user.click(screen.getByRole('button', { name: 'Sign out of all devices' }));
- await user.click(screen.getByRole('button', { name: 'Delete account' }));
await user.click(screen.getByRole('button', { name: 'Manage Passkey' }));
await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
@@ -118,6 +117,12 @@ describe('UserProfileSecurityPanelView', () => {
await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' }));
await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ // The danger zone confirms in a modal, so it goes last: nothing else is clickable while it is open.
+ await user.click(screen.getByRole('button', { name: 'Delete account' }));
+ const deleteDialog = screen.getByRole('dialog');
+ await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account');
+ await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' }));
+
expect(onChangePassword).toHaveBeenCalledOnce();
expect(onAddPasskey).toHaveBeenCalledOnce();
expect(onManagePasskey).toHaveBeenCalledWith('passkey_1');
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx
deleted file mode 100644
index 8ace3e6e9c6..00000000000
--- a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Button } from '../components/button';
-import { Section } from '../components/section';
-
-export interface UserProfileDeleteSectionViewProps {
- onDelete: () => void;
-}
-
-export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) {
- return (
-
- Danger zone
-
-
-
-
- Delete account
-
- Permanently delete this account and all its data. This cannot be undone.
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.test.ts
new file mode 100644
index 00000000000..3485de0a567
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.test.ts
@@ -0,0 +1,80 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { createActor } from '../../machine/createActor';
+import {
+ userProfileDeleteSectionMachine,
+ useUserProfileDeleteSectionController,
+} from './user-profile-delete-section.controller';
+
+function start(deleteAccount: () => Promise) {
+ const actor = createActor(userProfileDeleteSectionMachine, { context: { deleteAccount } }).start();
+ actor.send({ type: 'OPEN' });
+ return actor;
+}
+
+describe('userProfileDeleteSectionMachine', () => {
+ it('finishes in deleted when the account goes', async () => {
+ const actor = start(() => Promise.resolve());
+ actor.send({ type: 'CONFIRM' });
+ expect(actor.getSnapshot().value).toBe('deleting');
+
+ await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('deleted'));
+ expect(actor.getSnapshot().status).toBe('done');
+ });
+
+ it('returns to confirming with the reason when the delete fails', async () => {
+ const actor = start(() => Promise.reject(new Error('Your subscription is still active.')));
+ actor.send({ type: 'CONFIRM' });
+
+ await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('confirming'));
+ expect(actor.getSnapshot().context.errorMessage).toBe('Your subscription is still active.');
+ });
+
+ it('falls back to generic copy when the rejection is not an Error', async () => {
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test
+ const actor = start(() => Promise.reject('nope'));
+ actor.send({ type: 'CONFIRM' });
+
+ await vi.waitFor(() =>
+ expect(actor.getSnapshot().context.errorMessage).toBe('Something went wrong. Please try again.'),
+ );
+ });
+
+ it('drops the error when the dialog is cancelled', async () => {
+ const actor = start(() => Promise.reject(new Error('nope')));
+ actor.send({ type: 'CONFIRM' });
+ await vi.waitFor(() => expect(actor.getSnapshot().context.errorMessage).toBe('nope'));
+
+ actor.send({ type: 'CANCEL' });
+
+ expect(actor.getSnapshot().value).toBe('idle');
+ expect(actor.getSnapshot().context.errorMessage).toBeUndefined();
+ });
+});
+
+describe('useUserProfileDeleteSectionController', () => {
+ it('holds the dialog open across confirming and deleting', async () => {
+ const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() }));
+ expect(result.current.isOpen).toBe(false);
+
+ act(() => result.current.onOpenChange(true));
+ expect(result.current.isOpen).toBe(true);
+ expect(result.current.isDeleting).toBe(false);
+
+ act(() => result.current.onConfirm());
+ expect(result.current.isOpen).toBe(true);
+ expect(result.current.isDeleting).toBe(true);
+
+ await waitFor(() => expect(result.current.isOpen).toBe(false));
+ });
+
+ it('cancels on close', () => {
+ const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() }));
+ act(() => result.current.onOpenChange(true));
+
+ act(() => result.current.onOpenChange(false));
+
+ expect(result.current.isOpen).toBe(false);
+ });
+});
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts
new file mode 100644
index 00000000000..693f24fb8e4
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts
@@ -0,0 +1,92 @@
+import { setup } from '../../machine/setup';
+import { useMachine } from '../../machine/useMachine';
+
+export interface UserProfileDeleteSectionContext {
+ /** Deletes the account. Injected by the view from its `onDelete` prop. */
+ deleteAccount: () => Promise;
+ /** Why the last attempt failed. */
+ errorMessage: string | undefined;
+}
+
+export type UserProfileDeleteSectionEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' };
+
+const { createMachine, assign, fromPromise } = setup();
+
+/**
+ * The delete-account flow. `deleting` is the state that decides the dialog's fate: the
+ * account is gone on success, so the machine finishes in `deleted` and never reopens,
+ * while a failure drops back to `confirming` with the reason to render.
+ *
+ * The typed confirmation phrase is not here. It is a half-formed string that only the
+ * `Destructive` block can use, so the block keeps it.
+ */
+export const userProfileDeleteSectionMachine = createMachine({
+ id: 'deleteAccount',
+ initial: 'idle',
+ context: {
+ deleteAccount: async () => {},
+ errorMessage: undefined,
+ },
+ states: {
+ idle: { on: { OPEN: 'confirming' } },
+ confirming: {
+ on: {
+ CONFIRM: 'deleting',
+ CANCEL: { target: 'idle', actions: assign(() => ({ errorMessage: undefined })) },
+ },
+ },
+ deleting: {
+ invoke: fromPromise(context => context.deleteAccount(), {
+ onDone: 'deleted',
+ onError: {
+ target: 'confirming',
+ actions: assign((_, event) => ({
+ errorMessage:
+ event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.',
+ })),
+ },
+ }),
+ },
+ deleted: { type: 'final' },
+ },
+});
+
+export interface UserProfileDeleteSectionControllerOptions {
+ /**
+ * Deletes the account. Resolve and the flow finishes; reject with an `Error` and it returns to
+ * the confirmation step with that message.
+ */
+ onDelete: () => Promise;
+}
+
+export interface UserProfileDeleteSectionController {
+ /** Whether the confirmation dialog is open */
+ isOpen: boolean;
+ /** Opens or closes the confirmation dialog */
+ onOpenChange: (open: boolean) => void;
+ /** Starts the delete */
+ onConfirm: () => void;
+ /** Whether the delete is in progress */
+ isDeleting: boolean;
+ /** Why the last attempt failed */
+ errorMessage: string | undefined;
+}
+
+/**
+ * Drives the delete-account flow and hands the view plain props. A machine backs it because the
+ * flow has an async step, an error path back to a previous step, and a terminal state that must
+ * never reopen. A simpler section is free to hold its state in `useState`; the view cannot tell.
+ */
+export function useUserProfileDeleteSectionController({
+ onDelete,
+}: UserProfileDeleteSectionControllerOptions): UserProfileDeleteSectionController {
+ const [snapshot, send] = useMachine(userProfileDeleteSectionMachine, { context: { deleteAccount: onDelete } });
+
+ return {
+ isOpen: snapshot.value === 'confirming' || snapshot.value === 'deleting',
+ onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }),
+ onConfirm: () => send({ type: 'CONFIRM' }),
+ isDeleting: snapshot.value === 'deleting',
+ errorMessage: snapshot.context.errorMessage,
+ };
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts
new file mode 100644
index 00000000000..bbf8edc34eb
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts
@@ -0,0 +1,32 @@
+/**
+ * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so
+ * localizing this component is a matter of registering the namespace and swapping the reads for
+ * `useMessages('userButton', userButtonBase)`, not of hunting the literals down first.
+ *
+ * A plural message is its forms, the way `count()` takes them; a parameterized one is its template,
+ * the way `params()` takes it. `plural` and `fill` below resolve them until that layer lands.
+ */
+export const userProfileDeleteSectionBase = {
+ sectionTitle: 'Danger zone',
+ sectionLabel: 'Delete account',
+ sectionDescription: 'Permanently delete this account and all its data. This cannot be undone.',
+ dialogTitle: 'Delete account?',
+ dialogDescription: 'Are you sure you want to delete your account? All of your data will be permanently deleted.',
+ fieldLabel: 'Type “{phrase}” below to continue',
+ fieldPlaceholder: 'Delete account',
+ actionLabel: 'Delete account',
+ cancelLabel: 'Cancel',
+};
+
+/** Substitutes `{name}`-style placeholders. Replaced by the localization layer's own formatter. */
+export function fill(template: string, values: Record): string {
+ return template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match));
+}
+
+/**
+ * Picks a plural form and fills `{count}`. English has the two forms below; the localization layer
+ * selects across all six categories with `Intl.PluralRules`.
+ */
+export function plural(forms: { one: string; other: string }, count: number): string {
+ return fill(count === 1 ? forms.one : forms.other, { count });
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx
new file mode 100644
index 00000000000..fafba9c90e8
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx
@@ -0,0 +1,59 @@
+import { Destructive } from '../../blocks/destructive';
+import { Button } from '../../components/button';
+import { Section } from '../../components/section';
+import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller';
+import { fill, userProfileDeleteSectionBase as m } from './user-profile-delete-section.messages';
+
+export interface UserProfileDeleteSectionViewProps {
+ /**
+ * Deletes the account. Resolve and the confirmation dialog closes; reject with an `Error`
+ * and it stays open with that message under the confirmation field.
+ */
+ onDelete: () => Promise;
+}
+
+export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) {
+ const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({
+ onDelete,
+ });
+
+ return (
+
+ {m.sectionTitle}
+
+
+
+
+ {m.sectionLabel}
+ {m.sectionDescription}
+
+
+
+ {m.actionLabel}
+
+ }
+ title={m.dialogTitle}
+ description={m.dialogDescription}
+ fieldLabel={fill(m.fieldLabel, { phrase: m.fieldPlaceholder })}
+ confirmationValue={m.fieldPlaceholder}
+ actionLabel={m.actionLabel}
+ cancelLabel={m.cancelLabel}
+ onDelete={onConfirm}
+ isDeleting={isDeleting}
+ errorMessage={errorMessage}
+ />
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx
index 5e00b16a71b..9d2dca6376e 100644
--- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx
+++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx
@@ -11,12 +11,12 @@ import type {
import { UserProfileAccountSectionView } from './user-profile-account-section.view';
import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view';
import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view';
-import { UserProfileDeleteSectionView } from './user-profile-delete-section.view';
+import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view';
import { styles } from './user-profile-profile-panel.styles';
import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view';
import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view';
-export type { UserProfileEmail, UserProfilePhone, UserProfileConnectedAccount, UserProfileWeb3Wallet };
+export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet };
export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps {
connectedAccounts?: UserProfileConnectedAccount[];
@@ -28,7 +28,8 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect
onManageWeb3Wallet?: (id: string) => void;
onSetPrimaryWeb3Wallet?: (id: string) => void;
onRemoveWeb3Wallet?: (id: string) => void;
- onDeleteAccount?: () => void;
+ /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */
+ onDeleteAccount?: () => Promise;
}
export function UserProfileProfilePanelView({
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx
index 2ed2b1deadf..d4314011615 100644
--- a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx
+++ b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx
@@ -8,7 +8,7 @@ import type {
UserProfileDevice,
} from './user-profile-active-devices-section.view';
import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view';
-import { UserProfileDeleteSectionView } from './user-profile-delete-section.view';
+import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view';
import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view';
import { UserProfileMfaSectionView } from './user-profile-mfa-section.view';
import type { UserProfilePasskey } from './user-profile-passkeys-section.view';
@@ -30,7 +30,8 @@ export interface UserProfileSecurityPanelViewProps extends Omit void;
onRegenerateBackupCodes?: () => void;
onRemoveMfaMethod?: (id: string) => void;
- onDeleteAccount?: () => void;
+ /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */
+ onDeleteAccount?: () => Promise;
}
export function UserProfileSecurityPanelView({