Security settings
Password change, two-factor management and a session list with a guarded bulk action.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import { Laptop, Smartphone, ShieldCheck } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/dialog'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Panel } from '@/components/ui/card'
import { Switch } from '@/components/ui/choice'
import { useDemoForm } from '@/hooks/use-demo-form'
import { matches, required, strongPassword } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Security settings
*
* Three jobs on one page — change password, manage two-factor, review sessions
* — with a destructive action guarded by a confirmation. The session list is
* the part users actually come for; putting it below the fold would be a
* mistake.
*/
const sessions = [
{ id: 's1', device: 'MacBook Pro — Chrome', location: 'London, UK', current: true, icon: Laptop },
{
id: 's2',
device: 'iPhone 15 — Safari',
location: 'London, UK',
current: false,
icon: Smartphone,
},
{ id: 's3', device: 'Windows — Edge', location: 'Berlin, DE', current: false, icon: Laptop },
]
export default function SecuritySettingsForm() {
const [confirmOpen, setConfirmOpen] = useState(false)
const [twoFactor, setTwoFactor] = useState(true)
const form = useDemoForm({
schema: {
current: { validators: [required('Current password')] },
next: { validators: [required('New password'), strongPassword()] },
confirm: {
validators: [required('Confirmation'), matches('next', 'Both passwords must match.')],
},
},
simulateServerError: (values) =>
values.current === 'foundry'
? null
: 'Your current password is not correct. Demo password: foundry.',
})
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<FormShell
title="Change password"
description="Demo current password: foundry."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Update password"
submittingLabel="Updating"
onReset={form.reset}
successTitle="Password updated"
width="lg"
>
<Field name="current" label="Current password" required error={form.error('current')}>
{(field) => (
<Input
{...field}
type="password"
autoComplete="current-password"
{...form.field('current')}
/>
)}
</Field>
<Field
name="next"
label="New password"
required
error={form.error('next')}
hint="At least 10 characters, including a number and a letter."
>
{(field) => (
<Input {...field} type="password" autoComplete="new-password" {...form.field('next')} />
)}
</Field>
<Field name="confirm" label="Confirm new password" required error={form.error('confirm')}>
{(field) => (
<Input
{...field}
type="password"
autoComplete="new-password"
{...form.field('confirm')}
/>
)}
</Field>
</FormShell>
<Panel
title="Two-factor authentication"
description="Strongly recommended."
headingLevel="h4"
>
<Switch
id="security-2fa"
name="twoFactor"
align="trailing"
checked={twoFactor}
onChange={(event) => setTwoFactor(event.target.checked)}
label="Require a code at sign-in"
description="Codes are generated by an authenticator app."
/>
{twoFactor ? (
<Alert tone="success" title="Two-factor is on" className="mt-3">
<span className="inline-flex items-center gap-1.5">
<ShieldCheck className="size-3.5" aria-hidden="true" />
Recovery codes were last generated 12 January 2026.
</span>
</Alert>
) : (
<Alert tone="warning" title="Your account is less protected" className="mt-3">
Anyone with your password can sign in.
</Alert>
)}
</Panel>
<Panel
title="Active sessions"
description="Signing out ends access on that device immediately."
headingLevel="h4"
flush
>
<ul className="divide-y divide-[var(--color-border-subtle)]">
{sessions.map((session) => {
const Icon = session.icon
return (
<li key={session.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<Icon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-ink">{session.device}</p>
<p className="text-xs text-ink-muted">
{session.location}
{session.current ? ' — this device' : ''}
</p>
</div>
{!session.current ? (
<Button variant="outline" size="sm">
Sign out
</Button>
) : null}
</li>
)
})}
</ul>
<div className="border-t border-line-subtle p-4">
<Button variant="destructive" size="sm" onClick={() => setConfirmOpen(true)}>
Sign out everywhere else
</Button>
</div>
</Panel>
<ConfirmDialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)}
title="Sign out of all other devices?"
description="Two other sessions will end immediately. You will stay signed in here."
confirmLabel="Sign out everywhere else"
/>
</div>
)
}
components/blocks/forms/security-settings.tsx
'use client'
import { useState } from 'react'
import { Laptop, Smartphone, ShieldCheck } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/dialog'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Panel } from '@/components/ui/card'
import { Switch } from '@/components/ui/choice'
import { useDemoForm } from '@/hooks/use-demo-form'
import { matches, required, strongPassword } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Security settings
*
* Three jobs on one page — change password, manage two-factor, review sessions
* — with a destructive action guarded by a confirmation. The session list is
* the part users actually come for; putting it below the fold would be a
* mistake.
*/
const sessions = [
{ id: 's1', device: 'MacBook Pro — Chrome', location: 'London, UK', current: true, icon: Laptop },
{
id: 's2',
device: 'iPhone 15 — Safari',
location: 'London, UK',
current: false,
icon: Smartphone,
},
{ id: 's3', device: 'Windows — Edge', location: 'Berlin, DE', current: false, icon: Laptop },
]
export default function SecuritySettingsForm() {
const [confirmOpen, setConfirmOpen] = useState(false)
const [twoFactor, setTwoFactor] = useState(true)
const form = useDemoForm({
schema: {
current: { validators: [required('Current password')] },
next: { validators: [required('New password'), strongPassword()] },
confirm: {
validators: [required('Confirmation'), matches('next', 'Both passwords must match.')],
},
},
simulateServerError: (values) =>
values.current === 'foundry'
? null
: 'Your current password is not correct. Demo password: foundry.',
})
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<FormShell
title="Change password"
description="Demo current password: foundry."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Update password"
submittingLabel="Updating"
onReset={form.reset}
successTitle="Password updated"
width="lg"
>
<Field name="current" label="Current password" required error={form.error('current')}>
{(field) => (
<Input
{...field}
type="password"
autoComplete="current-password"
{...form.field('current')}
/>
)}
</Field>
<Field
name="next"
label="New password"
required
error={form.error('next')}
hint="At least 10 characters, including a number and a letter."
>
{(field) => (
<Input {...field} type="password" autoComplete="new-password" {...form.field('next')} />
)}
</Field>
<Field name="confirm" label="Confirm new password" required error={form.error('confirm')}>
{(field) => (
<Input
{...field}
type="password"
autoComplete="new-password"
{...form.field('confirm')}
/>
)}
</Field>
</FormShell>
<Panel
title="Two-factor authentication"
description="Strongly recommended."
headingLevel="h4"
>
<Switch
id="security-2fa"
name="twoFactor"
align="trailing"
checked={twoFactor}
onChange={(event) => setTwoFactor(event.target.checked)}
label="Require a code at sign-in"
description="Codes are generated by an authenticator app."
/>
{twoFactor ? (
<Alert tone="success" title="Two-factor is on" className="mt-3">
<span className="inline-flex items-center gap-1.5">
<ShieldCheck className="size-3.5" aria-hidden="true" />
Recovery codes were last generated 12 January 2026.
</span>
</Alert>
) : (
<Alert tone="warning" title="Your account is less protected" className="mt-3">
Anyone with your password can sign in.
</Alert>
)}
</Panel>
<Panel
title="Active sessions"
description="Signing out ends access on that device immediately."
headingLevel="h4"
flush
>
<ul className="divide-y divide-[var(--color-border-subtle)]">
{sessions.map((session) => {
const Icon = session.icon
return (
<li key={session.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<Icon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-ink">{session.device}</p>
<p className="text-xs text-ink-muted">
{session.location}
{session.current ? ' — this device' : ''}
</p>
</div>
{!session.current ? (
<Button variant="outline" size="sm">
Sign out
</Button>
) : null}
</li>
)
})}
</ul>
<div className="border-t border-line-subtle p-4">
<Button variant="destructive" size="sm" onClick={() => setConfirmOpen(true)}>
Sign out everywhere else
</Button>
</div>
</Panel>
<ConfirmDialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)}
title="Sign out of all other devices?"
description="Two other sessions will end immediately. You will stay signed in here."
confirmLabel="Sign out everywhere else"
/>
</div>
)
}
components/ui/dialog.tsx
'use client'
import { useId, useRef, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useFocusTrap } from '@/hooks/use-focus-trap'
import { useDismiss, useScrollLock } from '@/hooks/use-dismiss'
import { Portal } from './portal'
import { Button } from './button'
/**
* Dialog
*
* A modal built on the ARIA dialog pattern rather than `<dialog>`, because the
* native element still varies too much across engines in how it handles
* scroll locking, backdrop styling and Escape inside nested layers.
*
* The contract implemented here: focus moves in on open, is trapped while
* open, Escape and backdrop press close, body scroll is locked, and focus
* returns to the trigger on close.
*/
export interface DialogProps {
open: boolean
onClose: () => void
title: string
description?: string
children?: ReactNode
footer?: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
/** Hides the corner close button when the dialog demands an explicit choice. */
hideClose?: boolean
/** Blocks backdrop dismissal for destructive confirmations. */
staticBackdrop?: boolean
}
const sizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
} as const
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
size = 'md',
hideClose = false,
staticBackdrop = false,
}: DialogProps) {
const panelRef = useRef<HTMLDivElement>(null)
const uid = useId()
const titleId = `${uid}-title`
const descriptionId = description ? `${uid}-description` : undefined
useFocusTrap(panelRef, open)
useScrollLock(open)
useDismiss([panelRef], open, onClose, { outside: !staticBackdrop })
if (!open) return null
return (
<Portal>
<div className="fixed inset-0 z-[100] flex items-end justify-center p-4 sm:items-center">
<div className="animate-fade-in absolute inset-0 bg-scrim" aria-hidden="true" />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
className={cn(
'animate-scale-in relative flex max-h-[85vh] w-full flex-col overflow-hidden rounded-xl border border-line bg-surface-raised shadow-overlay',
sizes[size],
)}
>
<div className="flex items-start justify-between gap-4 border-b border-line-subtle px-5 py-4">
<div className="min-w-0">
<h2 id={titleId} className="text-md font-semibold text-ink-strong">
{title}
</h2>
{description ? (
<p id={descriptionId} className="mt-1 text-sm text-ink-muted">
{description}
</p>
) : null}
</div>
{!hideClose ? (
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close dialog">
<X className="size-4" aria-hidden="true" />
</Button>
) : null}
</div>
{children ? (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm">
{children}
</div>
) : null}
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line-subtle bg-surface-sunken px-5 py-3">
{footer}
</div>
) : null}
</div>
</div>
</Portal>
)
}
export interface ConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
tone?: 'danger' | 'accent'
loading?: boolean
/** Requires typing this exact string before confirming — for irreversible acts. */
confirmPhrase?: string
}
/**
* ConfirmDialog
*
* A destructive-action guard. The backdrop is static and the confirm button is
* never the initially focused element, so a stray Enter cannot delete anything.
*/
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
tone = 'danger',
loading = false,
}: ConfirmDialogProps) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
description={description}
size="sm"
staticBackdrop
footer={
<>
<Button variant="outline" onClick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={tone === 'danger' ? 'destructive' : 'primary'}
onClick={onConfirm}
loading={loading}
>
{confirmLabel}
</Button>
</>
}
/>
)
}
components/ui/choice.tsx
import type { InputHTMLAttributes, ReactNode } from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Checkbox / Radio / Switch
*
* All three keep a real, focusable native input in the DOM and paint the
* visible control with a sibling element. That keeps `aria-checked`, form
* submission, `:checked`, `:disabled` and keyboard behaviour native, while
* still allowing a token-driven appearance.
*
* The native input is positioned over the visual control rather than hidden
* with `display:none`, so the tap target is the full 44px row on touch.
*/
const controlBox = cn(
'pointer-events-none flex shrink-0 items-center justify-center border transition-colors duration-150 ease-standard',
'border-line-strong bg-surface text-accent-ink',
'peer-hover:border-accent',
'peer-checked:border-accent peer-checked:bg-accent',
'peer-disabled:opacity-50',
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
'peer-aria-[invalid=true]:border-danger',
// The tick/dot is a *descendant* of this box, not a sibling of the input, so
// the peer variant has to reach through with a child selector.
'[&>*]:opacity-0 peer-checked:[&>*]:opacity-100',
)
const nativeInput = cn(
'peer absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
)
export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
label: ReactNode
description?: ReactNode
/** `card` turns the whole row into a bordered, selectable surface. */
appearance?: 'inline' | 'card'
}
export function Checkbox({
label,
description,
appearance = 'inline',
className,
id,
indeterminate,
...props
}: ChoiceProps & { indeterminate?: boolean }) {
return (
<label
className={cn(
'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
appearance === 'card' &&
'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
appearance === 'inline' && 'py-1.5',
className,
)}
htmlFor={id}
>
<span className="relative flex size-4.5 shrink-0 items-center justify-center">
<input
type="checkbox"
id={id}
className={nativeInput}
aria-checked={indeterminate ? 'mixed' : undefined}
{...props}
/>
<span className={cn(controlBox, 'size-4.5 rounded-sm')} aria-hidden="true">
{indeterminate ? (
<Minus className="size-3" strokeWidth={3} />
) : (
<Check className="size-3" strokeWidth={3} />
)}
</span>
</span>
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
</label>
)
}
export function Radio({
label,
description,
appearance = 'inline',
className,
id,
...props
}: ChoiceProps) {
return (
<label
className={cn(
'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
appearance === 'card' &&
'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
appearance === 'inline' && 'py-1.5',
className,
)}
htmlFor={id}
>
<span className="relative flex size-4.5 shrink-0 items-center justify-center">
<input type="radio" id={id} className={nativeInput} {...props} />
<span className={cn(controlBox, 'size-4.5 rounded-full')} aria-hidden="true">
<span className="size-1.5 rounded-full bg-current" />
</span>
</span>
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
</label>
)
}
export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
label: ReactNode
description?: ReactNode
/** Places the switch on the trailing edge — the settings-row convention. */
align?: 'leading' | 'trailing'
}
export function Switch({
label,
description,
align = 'leading',
className,
id,
...props
}: SwitchProps) {
const control = (
<span className="relative inline-flex h-5 w-9 shrink-0 items-center">
<input type="checkbox" role="switch" id={id} className={nativeInput} {...props} />
<span
className={cn(
'pointer-events-none h-5 w-9 rounded-full border border-line-strong bg-surface-sunken transition-colors duration-150 ease-standard',
'peer-checked:border-accent peer-checked:bg-accent',
'peer-disabled:opacity-50',
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
)}
aria-hidden="true"
/>
<span
className={cn(
'pointer-events-none absolute left-0.5 size-4 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-standard',
'border border-line peer-checked:translate-x-4 peer-checked:border-transparent',
)}
aria-hidden="true"
/>
</span>
)
return (
<label
className={cn(
'flex min-h-9 cursor-pointer items-start gap-3 py-1.5 text-sm has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
align === 'trailing' && 'justify-between',
className,
)}
htmlFor={id}
>
{align === 'leading' ? control : null}
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
{align === 'trailing' ? control : null}
</label>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Three jobs on one page, with the destructive action guarded by a confirmation. The session list is what users actually come for; putting it below the fold would be a mistake.
- Turning two-factor off shows a warning rather than silently accepting it.
- The current session is marked and cannot be signed out — removing your own access from a settings page is never the intent.
- “Sign out everywhere else” is destructive and therefore confirmed.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Password change with server-style error
- Two-factor toggle with consequence alert
- Session list
- Confirmation dialog
Accessibility
- Confirmation guard
- The bulk sign-out uses ConfirmDialog with a static backdrop.
- Consequence messaging
- Both two-factor states have an explanatory Alert.
- Session labelling
- Each sign-out button names its device.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsReset password
Two fields with a cross-field match rule and a strength meter.
starter4 variantsConfirmation
A destructive-action guard with a static backdrop, a pending state and a confirm button that is never focused first.
starter4 variantsNotification settings
An event-by-channel matrix behind a master switch that disables rather than hides.
intermediate4 variants