Skip to content

Security settings

Password change, two-factor management and a session list with a guarded bulk action.

ProfileadvancedFeaturedsecuritypassword2fasessionsconfirm

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.

All forms