# Security Settings

> Password, two-factor, passkeys and sessions in grouped rows: a tiny ring counts recommended steps, passkeys are added inline while your device confirms, and signed-out sessions slide off the table one after another.

A shadcn/ui block from the [@devl](https://devl.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/devl/security-settings).

- Page, with a live preview: https://shadcn.exchange/r/devl/security-settings
- Screenshot: https://shadcn.exchange/shot/devl/security-settings/light.png
- Registry item: https://devl.dev/r/security-settings.json
- Categories: screen, Settings
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/devl/security-settings.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://coss.com/ui/r/badge.json`, `https://coss.com/ui/r/button.json`, `https://coss.com/ui/r/input.json`, `https://coss.com/ui/r/label.json`, `https://coss.com/ui/r/menu.json`

## Files

### `registry/devl/security-settings.tsx`

```tsx
"use client";

import {
  ArrowLeftIcon,
  BellIcon,
  CheckIcon,
  CopyIcon,
  CreditCardIcon,
  DownloadIcon,
  FingerprintIcon,
  KeyRoundIcon,
  LaptopIcon,
  LogOutIcon,
  MonitorIcon,
  MoreHorizontalIcon,
  PaletteIcon,
  PencilIcon,
  PlusIcon,
  SettingsIcon,
  ShieldIcon,
  SmartphoneIcon,
  TabletIcon,
  Trash2Icon,
  UsbIcon,
  UserIcon,
  UsersIcon,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input, type InputProps } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "@/components/ui/menu";

export type SettingsSectionId =
  | "profile"
  | "appearance"
  | "notifications"
  | "security"
  | "general"
  | "members"
  | "billing"
  | "developer";

export type SecurityDeviceKind = "laptop" | "desktop" | "phone" | "tablet" | "key";

export interface SecurityPasskey {
  id: string;
  name: string;
  kind: SecurityDeviceKind;
  added: string;
  lastUsed: string;
}

export interface SecuritySession {
  id: string;
  browser: string;
  os: string;
  kind: SecurityDeviceKind;
  location: string;
  lastActive: string;
  current?: boolean;
}

export interface SecuritySettingsProps {
  className?: string;
  passwordChanged?: string;
  passkeys?: SecurityPasskey[];
  sessions?: SecuritySession[];
  authenticatorSince?: string;
  recoveryCodesLeft?: number;
  recoveryCodesSaved?: boolean;
  newPasskey?: { name: string; kind: SecurityDeviceKind; authenticator: string };
  onNavigate?: (section: SettingsSectionId) => void;
}

const easeOut = [0.23, 1, 0.32, 1] as const;

const navGroups: { label: string; items: { id: SettingsSectionId; label: string; icon: ReactNode }[] }[] = [
  {
    label: "Account",
    items: [
      { id: "profile", label: "Profile", icon: <UserIcon /> },
      { id: "appearance", label: "Appearance", icon: <PaletteIcon /> },
      { id: "notifications", label: "Notifications", icon: <BellIcon /> },
      { id: "security", label: "Security", icon: <ShieldIcon /> },
    ],
  },
  {
    label: "Workspace",
    items: [
      { id: "general", label: "General", icon: <SettingsIcon /> },
      { id: "members", label: "Members & roles", icon: <UsersIcon /> },
      { id: "billing", label: "Billing", icon: <CreditCardIcon /> },
    ],
  },
  {
    label: "Developer",
    items: [{ id: "developer", label: "API keys & webhooks", icon: <KeyRoundIcon /> }],
  },
];

const deviceIcons: Record<SecurityDeviceKind, ReactNode> = {
  laptop: <LaptopIcon />,
  desktop: <MonitorIcon />,
  phone: <SmartphoneIcon />,
  tablet: <TabletIcon />,
  key: <UsbIcon />,
};

const defaultPasskeys: SecurityPasskey[] = [
  { id: "pk1", name: "MacBook Air", kind: "laptop", added: "Mar 4, 2026", lastUsed: "2 hours ago" },
  { id: "pk2", name: "Pixel 9", kind: "phone", added: "Jun 18, 2026", lastUsed: "Yesterday" },
  { id: "pk3", name: "YubiKey 5C", kind: "key", added: "Aug 30, 2026", lastUsed: "3 weeks ago" },
];

const defaultSessions: SecuritySession[] = [
  { id: "s1", browser: "Chrome", os: "macOS", kind: "laptop", location: "Lisbon, PT", lastActive: "Now", current: true },
  { id: "s2", browser: "Ridgeline", os: "Android", kind: "phone", location: "Lisbon, PT", lastActive: "18 min ago" },
  { id: "s3", browser: "Safari", os: "iPadOS", kind: "tablet", location: "Porto, PT", lastActive: "2 days ago" },
  { id: "s4", browser: "Firefox", os: "Windows", kind: "desktop", location: "Rotterdam, NL", lastActive: "9 days ago" },
];

const defaultNewPasskey = { name: "MacBook Pro", kind: "laptop" as const, authenticator: "Touch ID" };

const codeAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";

function makeCodes(seed: number, count = 10) {
  let state = seed >>> 0;
  const next = () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  return Array.from({ length: count }, () => {
    let code = "";
    for (let i = 0; i < 8; i++) {
      code += codeAlphabet[Math.floor(next() * codeAlphabet.length)];
      if (i === 3) code += "-";
    }
    return code;
  });
}

function useFocusOnMount<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  useEffect(() => {
    ref.current?.focus({ preventScroll: true });
  }, []);
  return ref;
}

function AutoFocusInput(props: InputProps) {
  const ref = useFocusOnMount<HTMLInputElement>();
  return <Input ref={ref} {...props} />;
}

function useCalmMotion() {
  const prefers = useReducedMotion();
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted && Boolean(prefers);
}

function RidgelineShell({
  active,
  onNavigate,
  children,
  className,
}: {
  active: SettingsSectionId;
  onNavigate?: (section: SettingsSectionId) => void;
  children: ReactNode;
  className?: string;
}) {
  const pillsRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const scroller = pillsRef.current;
    const el = scroller?.querySelector<HTMLElement>(`[data-section="${active}"]`);
    if (!scroller || !el || scroller.scrollWidth <= scroller.clientWidth) return;
    scroller.scrollLeft = el.offsetLeft - 16;
  }, [active]);

  return (
    <div className={cn("flex h-[760px] w-full bg-background text-foreground", className)}>
      <aside className="hidden w-60 shrink-0 flex-col border-r bg-muted/30 px-3 py-4 md:flex">
        <button
          className="flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-muted-foreground text-sm outline-none transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
          type="button"
        >
          <ArrowLeftIcon className="size-4" />
          Back to Ridgeline
        </button>
        <nav aria-label="Settings" className="flex flex-col">
          {navGroups.map((group) => (
            <div className="flex flex-col" key={group.label}>
              <span className="px-2 pt-5 pb-1.5 font-medium text-muted-foreground text-xs">{group.label}</span>
              <div className="flex flex-col gap-0.5">
                {group.items.map((item) => {
                  const current = item.id === active;
                  return (
                    <button
                      aria-current={current ? "page" : undefined}
                      className={cn(
                        "flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
                        current ? "bg-accent font-medium text-foreground" : "text-foreground/80 hover:bg-accent/60",
                      )}
                      key={item.id}
                      onClick={() => onNavigate?.(item.id)}
                      type="button"
                    >
                      {item.icon}
                      <span className="truncate">{item.label}</span>
                    </button>
                  );
                })}
              </div>
            </div>
          ))}
        </nav>
        <div className="mt-auto flex items-center gap-2.5 px-2 pt-4">
          <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-foreground font-medium text-[11px] text-background">
            AL
          </span>
          <span className="flex min-w-0 flex-col">
            <span className="truncate font-medium text-sm">Ada Lindqvist</span>
            <span className="truncate text-muted-foreground text-xs">ada@ridgeline.app</span>
          </span>
        </div>
      </aside>
      <div className="flex min-w-0 flex-1 flex-col">
        <div className="md:hidden">
          <div className="flex h-12 items-center gap-2 border-b px-4">
            <button
              aria-label="Back to Ridgeline"
              className="-ms-1.5 flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring"
              type="button"
            >
              <ArrowLeftIcon className="size-4" />
            </button>
            <span className="font-medium text-sm">Settings</span>
          </div>
          <nav
            aria-label="Settings"
            className="overflow-x-auto border-b px-4 py-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
            ref={pillsRef}
          >
            <div className="flex w-max gap-1.5">
              {navGroups.flatMap((g) => g.items).map((item) => {
                const current = item.id === active;
                return (
                  <button
                    aria-current={current ? "page" : undefined}
                    className={cn(
                      "flex h-7 cursor-pointer items-center rounded-full px-3 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                      current
                        ? "bg-foreground font-medium text-background"
                        : "bg-muted text-muted-foreground hover:text-foreground",
                    )}
                    data-section={item.id}
                    key={item.id}
                    onClick={() => onNavigate?.(item.id)}
                    type="button"
                  >
                    {item.label}
                  </button>
                );
              })}
            </div>
          </nav>
        </div>
        <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
      </div>
    </div>
  );
}

function SectionHeader({ title, description, action }: { title: string; description: ReactNode; action?: ReactNode }) {
  return (
    <div className="flex items-center gap-4">
      <div className="flex min-w-0 flex-1 flex-col gap-0.5">
        <h2 className="font-medium text-sm">{title}</h2>
        <p className="text-pretty text-muted-foreground text-sm">{description}</p>
      </div>
      {action && <div className="shrink-0">{action}</div>}
    </div>
  );
}

function Collapse({ open, children, reduce }: { open: boolean; children: ReactNode; reduce: boolean }) {
  return (
    <AnimatePresence initial={false}>
      {open && (
        <motion.div
          animate={{ height: "auto", opacity: 1 }}
          className="overflow-hidden"
          exit={{ height: 0, opacity: 0 }}
          initial={{ height: 0, opacity: 0 }}
          transition={reduce ? { duration: 0 } : { duration: 0.22, ease: easeOut }}
        >
          {children}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

function ConfirmStrip({
  tone = "destructive",
  title,
  detail,
  confirmLabel,
  onCancel,
  onConfirm,
}: {
  tone?: "destructive" | "default";
  title: string;
  detail: string;
  confirmLabel: string;
  onCancel: () => void;
  onConfirm: () => void;
}) {
  const cancelRef = useFocusOnMount<HTMLButtonElement>();
  return (
    <div
      className={cn(
        "flex flex-wrap items-center gap-x-4 gap-y-2.5 border-t px-4 py-3",
        tone === "destructive" ? "bg-destructive/4" : "bg-muted/40",
      )}
      role="alert"
    >
      <p className="min-w-0 flex-1 basis-56 text-pretty text-sm">
        <span className="font-medium">{title}</span> <span className="text-muted-foreground">{detail}</span>
      </p>
      <div className="flex gap-2">
        <Button onClick={onCancel} ref={cancelRef} size="sm" variant="ghost">
          Cancel
        </Button>
        <Button onClick={onConfirm} size="sm" variant={tone === "destructive" ? "destructive" : "default"}>
          {confirmLabel}
        </Button>
      </div>
    </div>
  );
}

function DeviceIcon({ kind, className }: { kind: SecurityDeviceKind; className?: string }) {
  return (
    <span
      className={cn(
        "flex size-8 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground [&_svg]:size-4",
        className,
      )}
    >
      {deviceIcons[kind]}
    </span>
  );
}

function ProgressRing({ steps, done, reduce }: { steps: number; done: number; reduce: boolean }) {
  const r = 8;
  const c = 2 * Math.PI * r;
  const gap = 3;
  const seg = c / steps - gap;
  const complete = done === steps;
  return (
    <svg aria-hidden className="size-5 shrink-0 -rotate-90" fill="none" viewBox="0 0 20 20">
      {Array.from({ length: steps }, (_, i) => (
        <motion.circle
          animate={{ opacity: i < done ? 1 : 0.18 }}
          className={cn(
            "transition-[stroke] duration-300",
            i < done ? (complete ? "stroke-success" : "stroke-foreground") : "stroke-foreground",
          )}
          cx="10"
          cy="10"
          initial={false}
          key={i}
          r={r}
          strokeDasharray={`${seg.toFixed(2)} ${(c - seg).toFixed(2)}`}
          strokeDashoffset={(-(i * (c / steps)) - gap / 2).toFixed(2)}
          strokeLinecap="round"
          strokeWidth="2.5"
          transition={reduce ? { duration: 0 } : { duration: 0.3, ease: easeOut }}
        />
      ))}
    </svg>
  );
}

function DrawnCheck({ className }: { className?: string }) {
  return (
    <svg aria-hidden className={cn("size-3.5", className)} fill="none" viewBox="0 0 16 16">
      <motion.path
        animate={{ pathLength: 1 }}
        d="M3.5 8.5 6.5 11.5 12.5 4.5"
        initial={{ pathLength: 0 }}
        stroke="currentColor"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth="2"
        transition={{ duration: 0.3, ease: easeOut, delay: 0.05 }}
      />
    </svg>
  );
}

function PasswordRow({ changed, reduce }: { changed: string; reduce: boolean }) {
  const [open, setOpen] = useState(false);
  const [value, setValue] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [updated, setUpdated] = useState(false);
  const inputId = useId();
  const errorId = useId();

  const submit = () => {
    if (value.length < 12) return setError("Use at least 12 characters.");
    setOpen(false);
    setValue("");
    setError(null);
    setUpdated(true);
  };

  return (
    <div className="mt-3 overflow-hidden rounded-xl border bg-card">
      <div className="flex items-center gap-4 px-4 py-3.5">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <span className="font-medium text-sm">Password</span>
          <span className="flex items-center gap-1 text-muted-foreground text-xs">
            {updated && <DrawnCheck className="text-success-foreground" />}
            {updated ? "Changed just now" : `Last changed ${changed}`}
          </span>
        </div>
        <Button onClick={() => setOpen((v) => !v)} size="sm" variant="outline">
          {open ? "Cancel" : "Change"}
        </Button>
      </div>
      <Collapse open={open} reduce={reduce}>
        <form
          className="flex flex-col gap-2 border-t bg-muted/30 px-4 py-3.5"
          noValidate
          onSubmit={(e) => {
            e.preventDefault();
            submit();
          }}
        >
          <Label htmlFor={inputId}>New password</Label>
          <div className="flex gap-2">
            <AutoFocusInput
              aria-describedby={error ? errorId : undefined}
              aria-invalid={!!error || undefined}
              autoComplete="new-password"
              id={inputId}
              onChange={(e) => {
                setValue(e.target.value);
                if (error) setError(null);
              }}
              type="password"
              value={value}
            />
            <Button type="submit">Update</Button>
          </div>
          <p className={cn("text-xs", error ? "text-destructive-foreground" : "text-muted-foreground")} id={errorId}>
            {error ?? "Other sessions stay signed in. Sign them out below if you need to."}
          </p>
        </form>
      </Collapse>
    </div>
  );
}

function PasskeyRow({
  passkey,
  isLast,
  isNew,
  editing,
  confirming,
  onRename,
  onStartRename,
  onCancelRename,
  onAskRemove,
  onCancelRemove,
  onRemove,
  reduce,
}: {
  passkey: SecurityPasskey;
  isLast: boolean;
  isNew: boolean;
  editing: boolean;
  confirming: boolean;
  onRename: (name: string) => void;
  onStartRename: () => void;
  onCancelRename: () => void;
  onAskRemove: () => void;
  onCancelRemove: () => void;
  onRemove: () => void;
  reduce: boolean;
}) {
  const [draft, setDraft] = useState(passkey.name);
  const inputId = useId();

  useEffect(() => {
    if (editing) setDraft(passkey.name);
  }, [editing, passkey.name]);

  const commit = () => {
    const name = draft.trim();
    if (name && name !== passkey.name) onRename(name);
    else onCancelRename();
  };

  return (
    <motion.li
      animate={{ opacity: 1, height: "auto" }}
      className="relative overflow-hidden border-t first:border-t-0"
      exit={{ opacity: 0, height: 0 }}
      initial={reduce ? false : { opacity: 0, height: 0 }}
      transition={reduce ? { duration: 0 } : { duration: 0.24, ease: easeOut }}
    >
      {isNew && !reduce && (
        <motion.span
          animate={{ opacity: 0 }}
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-success/8"
          initial={{ opacity: 1 }}
          transition={{ duration: 1.4, ease: "easeOut", delay: 0.3 }}
        />
      )}
      <div className="relative flex items-center gap-3 px-4 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_7.5rem_7.5rem_2rem] sm:gap-4">
        <div className="flex min-w-0 flex-1 items-center gap-3">
          <DeviceIcon kind={passkey.kind} />
          <div className="flex min-w-0 flex-1 flex-col">
            {editing ? (
              <form
                onSubmit={(e) => {
                  e.preventDefault();
                  commit();
                }}
              >
                <Label className="sr-only" htmlFor={inputId}>
                  Passkey name
                </Label>
                <AutoFocusInput

                  id={inputId}
                  maxLength={40}
                  onBlur={commit}
                  onChange={(e) => setDraft(e.target.value)}
                  onFocus={(e) => e.currentTarget.select()}
                  onKeyDown={(e) => {
                    if (e.key === "Escape") {
                      e.preventDefault();
                      onCancelRename();
                    }
                  }}
                  size="sm"
                  value={draft}
                />
              </form>
            ) : (
              <span className="truncate font-medium text-sm">{passkey.name}</span>
            )}
            <span className="truncate text-muted-foreground text-xs sm:hidden">
              Added {passkey.added} · {passkey.lastUsed === "Never" ? "Not used yet" : `Used ${passkey.lastUsed}`}
            </span>
          </div>
        </div>
        <span className="hidden text-muted-foreground text-sm sm:block">{passkey.added}</span>
        <span className="hidden text-muted-foreground text-sm sm:block">
          {passkey.lastUsed === "Never" ? "Not yet" : passkey.lastUsed}
        </span>
        <Menu>
          <MenuTrigger
            render={<Button aria-label={`Options for ${passkey.name}`} className="shrink-0" size="icon-sm" variant="ghost" />}
          >
            <MoreHorizontalIcon />
          </MenuTrigger>
          <MenuPopup align="end">
            <MenuItem onClick={onStartRename}>
              <PencilIcon /> Rename
            </MenuItem>
            <MenuSeparator />
            <MenuItem onClick={onAskRemove} variant="destructive">
              <Trash2Icon /> Remove
            </MenuItem>
          </MenuPopup>
        </Menu>
      </div>
      <Collapse open={confirming} reduce={reduce}>
        <ConfirmStrip
          confirmLabel="Remove"
          detail={
            isLast
              ? "It's your only passkey, so you'll sign in with your password and a code."
              : "You can still sign in with your other passkeys."
          }
          onCancel={onCancelRemove}
          onConfirm={onRemove}
          title={`Remove ${passkey.name}?`}
        />
      </Collapse>
    </motion.li>
  );
}

function PendingPasskeyRow({
  authenticator,
  onCancel,
  reduce,
}: {
  authenticator: string;
  onCancel: () => void;
  reduce: boolean;
}) {
  return (
    <motion.li
      animate={{ opacity: 1, height: "auto" }}
      className="overflow-hidden border-t first:border-t-0"
      exit={{ opacity: 0, height: 0 }}
      initial={reduce ? false : { opacity: 0, height: 0 }}
      transition={reduce ? { duration: 0 } : { duration: 0.24, ease: easeOut }}
    >
      <div className="flex items-center gap-3 px-4 py-3" role="status">
        <span className="relative flex size-8 shrink-0 items-center justify-center rounded-lg border bg-background text-foreground">
          {!reduce && (
            <motion.span
              animate={{ opacity: [0.6, 0], scale: [1, 1.45] }}
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-lg border border-foreground/30"
              transition={{ duration: 1.4, ease: "easeOut", repeat: Number.POSITIVE_INFINITY }}
            />
          )}
          <FingerprintIcon className="size-4" />
        </span>
        <div className="flex min-w-0 flex-1 flex-col">
          <span className="font-medium text-sm">Waiting for your device…</span>
          <span className="truncate text-muted-foreground text-xs">Confirm with {authenticator} to create the passkey</span>
        </div>
        <Button onClick={onCancel} size="sm" variant="ghost">
          Cancel
        </Button>
      </div>
    </motion.li>
  );
}

function RecoveryCodesPanel({
  codes,
  used,
  copied,
  onCopy,
  onDownload,
}: {
  codes: string[];
  used: number;
  copied: boolean;
  onCopy: () => void;
  onDownload: () => void;
}) {
  return (
    <div className="border-t bg-muted/30 px-4 py-4">
      <ol className="grid max-w-md grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm tabular-nums">
        {codes.map((code, i) => (
          <li className="flex items-baseline gap-3" key={code + i}>
            <span className="w-4 text-right text-muted-foreground/70 text-xs">{i + 1}</span>
            <span className={cn(i < used && "text-muted-foreground/60 line-through")}>{code}</span>
            {i < used && <span className="sr-only">(used)</span>}
          </li>
        ))}
      </ol>
      <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2.5">
        <p className="min-w-0 flex-1 basis-48 text-pretty text-muted-foreground text-xs">
          Each code works once. Keep them somewhere other than this device.
        </p>
        <div className="flex gap-2">
          <Button onClick={onDownload} size="sm" variant="ghost">
            <DownloadIcon /> Download
          </Button>
          <Button className="min-w-22" onClick={onCopy} size="sm" variant="outline">
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span
                animate={{ opacity: 1, scale: 1 }}
                className="flex items-center gap-1.5"
                exit={{ opacity: 0, scale: 0.96 }}
                initial={{ opacity: 0, scale: 0.96 }}
                key={copied ? "copied" : "copy"}
                transition={{ duration: 0.15, ease: easeOut }}
              >
                {copied ? <DrawnCheck className="size-4 text-success-foreground" /> : <CopyIcon />}
                {copied ? "Copied" : "Copy"}
              </motion.span>
            </AnimatePresence>
          </Button>
        </div>
      </div>
    </div>
  );
}

function SessionRow({
  session,
  index,
  onSignOut,
  reduce,
}: {
  session: SecuritySession;
  index: number;
  onSignOut: () => void;
  reduce: boolean;
}) {
  return (
    <motion.li
      animate={{ opacity: 1, x: 0, height: "auto" }}
      className="overflow-hidden border-t first:border-t-0"
      exit="leave"
      initial={false}
      transition={reduce ? { duration: 0 } : { duration: 0.24, ease: easeOut }}
      variants={{
        leave: (stagger: boolean) => {
          const delay = stagger ? index * 0.05 : 0;
          return {
            opacity: 0,
            x: reduce ? 0 : 24,
            height: 0,
            transition: reduce
              ? { duration: 0.12 }
              : {
                  opacity: { duration: 0.16, delay },
                  x: { duration: 0.22, ease: easeOut, delay },
                  height: { duration: 0.22, ease: easeOut, delay: 0.1 + delay },
                },
          };
        },
      }}
    >
      <div className="flex items-center gap-3 px-4 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_6.5rem_5.5rem_4.5rem] sm:gap-3">
        <div className="flex min-w-0 flex-1 items-center gap-3">
          <DeviceIcon kind={session.kind} />
          <div className="flex min-w-0 flex-1 flex-col">
            <span className="flex min-w-0 items-center gap-2">
              <span className="truncate font-medium text-sm">
                {session.browser} on {session.os}
              </span>
              {session.current && (
                <Badge className="shrink-0" variant="success">
                  This device
                </Badge>
              )}
            </span>
            <span className="truncate text-muted-foreground text-xs sm:hidden">
              {session.location} · {session.current ? "Active now" : session.lastActive}
            </span>
          </div>
        </div>
        <span className="hidden truncate text-muted-foreground text-sm sm:block">{session.location}</span>
        <span className={cn("hidden text-sm sm:block", session.current ? "text-success-foreground" : "text-muted-foreground")}>
          {session.current ? "Active now" : session.lastActive}
        </span>
        <div className="flex shrink-0 justify-end">
          {!session.current && (
            <Button
              aria-label={`Sign out ${session.browser} on ${session.os}`}
              onClick={onSignOut}
              size="sm"
              variant="ghost"
            >
              <LogOutIcon className="sm:hidden" />
              <span className="hidden sm:inline">Sign out</span>
            </Button>
          )}
        </div>
      </div>
    </motion.li>
  );
}

export function SecuritySettings({
  className,
  passwordChanged = "4 months ago",
  passkeys: initialPasskeys = defaultPasskeys,
  sessions: initialSessions = defaultSessions,
  authenticatorSince = "Jan 12, 2026",
  recoveryCodesLeft = 8,
  recoveryCodesSaved = false,
  newPasskey = defaultNewPasskey,
  onNavigate,
}: SecuritySettingsProps) {
  const reduce = useCalmMotion();

  const [passkeys, setPasskeys] = useState(initialPasskeys);
  const [editingId, setEditingId] = useState<string | null>(null);
  const [confirmingId, setConfirmingId] = useState<string | null>(null);
  const [newId, setNewId] = useState<string | null>(null);
  const [pending, setPending] = useState(false);
  const [created, setCreated] = useState(0);

  const [authOn, setAuthOn] = useState(true);
  const [confirmDisable, setConfirmDisable] = useState(false);
  const [codesOpen, setCodesOpen] = useState(false);
  const [confirmRegen, setConfirmRegen] = useState(false);
  const [codeSeed, setCodeSeed] = useState(4471);
  const [used, setUsed] = useState(Math.max(0, 10 - recoveryCodesLeft));
  const [saved, setSaved] = useState(recoveryCodesSaved);
  const [copied, setCopied] = useState(false);

  const [sessions, setSessions] = useState(initialSessions);
  const [confirmAll, setConfirmAll] = useState(false);
  const [bulk, setBulk] = useState(false);

  const codes = makeCodes(codeSeed);
  const others = sessions.filter((s) => !s.current);
  const steps = [
    { done: passkeys.length > 0, hint: "Add a passkey" },
    { done: authOn, hint: "Turn on two-factor" },
    { done: authOn && saved, hint: "Save your recovery codes" },
  ];
  const doneCount = steps.filter((s) => s.done).length;
  const nextStep = steps.find((s) => !s.done);

  useEffect(() => {
    if (!copied) return;
    const t = setTimeout(() => setCopied(false), 1800);
    return () => clearTimeout(t);
  }, [copied]);

  useEffect(() => {
    if (!pending) return;
    const t = setTimeout(
      () => {
        const id = `pk-new-${created + 1}`;
        setCreated((c) => c + 1);
        setPasskeys((all) => [
          ...all,
          { id, name: newPasskey.name, kind: newPasskey.kind, added: "Today", lastUsed: "Never" },
        ]);
        setNewId(id);
        setEditingId(id);
        setPending(false);
      },
      reduce ? 400 : 1600,
    );
    return () => clearTimeout(t);
  }, [pending, created, newPasskey.name, newPasskey.kind, reduce]);

  const copyCodes = () => {
    const text = codes.filter((_, i) => i >= used).join("\n");
    navigator.clipboard?.writeText(text).catch(() => {});
    setCopied(true);
    setSaved(true);
  };

  const downloadCodes = () => {
    const text = `Ridgeline recovery codes for ada@ridgeline.app\n\n${codes.filter((_, i) => i >= used).join("\n")}\n`;
    const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
    const a = Object.assign(document.createElement("a"), { href: url, download: "ridgeline-recovery-codes.txt" });
    a.click();
    URL.revokeObjectURL(url);
    setSaved(true);
  };

  const fixNext = () => {
    if (!passkeys.length) setPending(true);
    else if (!authOn) setAuthOn(true);
    else setCodesOpen(true);
  };

  return (
    <RidgelineShell active="security" className={className} onNavigate={onNavigate}>
      <div className="mx-auto w-full max-w-2xl px-6 py-10 md:px-10">
        <h1 className="font-semibold text-2xl tracking-tight">Security</h1>
        <p className="mt-1 text-muted-foreground text-sm">How you sign in, and where you're signed in right now.</p>
        <div aria-live="polite" className="mt-4 flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
          <ProgressRing done={doneCount} reduce={reduce} steps={steps.length} />
          <span className="font-medium tabular-nums">
            {doneCount === steps.length
              ? "All recommended steps done"
              : `${doneCount} of ${steps.length} recommended steps done`}
          </span>
          {nextStep && (
            <>
              <span aria-hidden className="text-muted-foreground max-sm:hidden">
                ·
              </span>
              <button
                className="cursor-pointer rounded-sm max-sm:basis-full max-sm:ps-7 max-sm:text-left text-muted-foreground underline-offset-4 outline-none transition-colors hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
                onClick={fixNext}
                type="button"
              >
                {nextStep.hint}
              </button>
            </>
          )}
        </div>

        <section className="mt-10">
          <SectionHeader description="How you prove it's you when a passkey isn't handy." title="Sign-in" />
          <PasswordRow changed={passwordChanged} reduce={reduce} />
        </section>

        <section className="mt-10">
          <SectionHeader
            description="A second check after your password, so a leaked password isn't enough."
            title="Two-factor authentication"
          />
          <div className="mt-3 overflow-hidden rounded-xl border bg-card">
            <div className="flex items-center gap-4 px-4 py-3.5">
              <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                <span className="flex items-center gap-2">
                  <span className="font-medium text-sm">Authenticator app</span>
                  <Badge variant={authOn ? "success" : "outline"}>{authOn ? "Enabled" : "Off"}</Badge>
                </span>
                <span className="text-pretty text-muted-foreground text-xs">
                  {authOn ? `6-digit codes from an app on your phone. Added ${authenticatorSince}.` : "Use an app on your phone for 6-digit codes."}
                </span>
              </div>
              {authOn ? (
                <Button onClick={() => setConfirmDisable((v) => !v)} size="sm" variant="outline">
                  Disable
                </Button>
              ) : (
                <Button onClick={() => setAuthOn(true)} size="sm" variant="outline">
                  Set up
                </Button>
              )}
            </div>
            <Collapse open={confirmDisable && authOn} reduce={reduce}>
              <ConfirmStrip
                confirmLabel="Disable"
                detail="Anyone with your password could sign in as you."
                onCancel={() => setConfirmDisable(false)}
                onConfirm={() => {
                  setConfirmDisable(false);
                  setCodesOpen(false);
                  setConfirmRegen(false);
                  setAuthOn(false);
                }}
                title="Turn off two-factor?"
              />
            </Collapse>
            <div className={cn("flex items-center gap-4 border-t px-4 py-3.5 transition-opacity", !authOn && "opacity-60")}>
              <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                <span className="flex items-center gap-2">
                  <span className="font-medium text-sm">Recovery codes</span>
                  {authOn && !saved && <Badge variant="warning">Not saved</Badge>}
                </span>
                <span className="text-muted-foreground text-xs tabular-nums">
                  {authOn ? `${10 - used} of 10 left. Use one if you lose your phone.` : "Available once two-factor is on."}
                </span>
              </div>
              <div className="flex shrink-0 gap-2">
                <Button
                  className="max-sm:hidden"
                  disabled={!authOn}
                  onClick={() => setConfirmRegen((v) => !v)}
                  size="sm"
                  variant="ghost"
                >
                  Regenerate
                </Button>
                <Button
                  aria-expanded={codesOpen}
                  disabled={!authOn}
                  onClick={() => setCodesOpen((v) => !v)}
                  size="sm"
                  variant="outline"
                >
                  {codesOpen ? "Hide" : "View"}
                </Button>
              </div>
            </div>
            <Collapse open={confirmRegen && authOn} reduce={reduce}>
              <ConfirmStrip
                confirmLabel="Regenerate"
                detail="Your current codes stop working straight away."
                onCancel={() => setConfirmRegen(false)}
                onConfirm={() => {
                  setConfirmRegen(false);
                  setCodeSeed((s) => s * 7 + 13);
                  setUsed(0);
                  setSaved(false);
                  setCopied(false);
                  setCodesOpen(true);
                }}
                title="Make a new set?"
                tone="default"
              />
            </Collapse>
            <Collapse open={codesOpen && authOn} reduce={reduce}>
              <RecoveryCodesPanel codes={codes} copied={copied} onCopy={copyCodes} onDownload={downloadCodes} used={used} />
              <div className="flex justify-end border-t px-4 py-2.5 sm:hidden">
                <Button onClick={() => setConfirmRegen(true)} size="sm" variant="ghost">
                  Regenerate codes
                </Button>
              </div>
            </Collapse>
          </div>
        </section>

        <section className="mt-10">
          <SectionHeader
            action={
              <Button disabled={pending} onClick={() => setPending(true)} size="sm" variant="outline">
                <PlusIcon /> Add passkey
              </Button>
            }
            description="Sign in with your fingerprint, face or a security key."
            title="Passkeys"
          />
          <div className="mt-3 overflow-hidden rounded-xl border bg-card">
            <div className="hidden grid-cols-[minmax(0,1fr)_7.5rem_7.5rem_2rem] gap-4 border-b px-4 py-2.5 font-medium text-muted-foreground text-xs sm:grid">
              <span>Name</span>
              <span>Added</span>
              <span>Last used</span>
              <span className="sr-only">Actions</span>
            </div>
            <ul aria-label="Passkeys">
              <AnimatePresence initial={false}>
                {passkeys.map((p) => (
                  <PasskeyRow
                    confirming={confirmingId === p.id}
                    editing={editingId === p.id}
                    isLast={passkeys.length === 1}
                    isNew={newId === p.id}
                    key={p.id}
                    onAskRemove={() => {
                      setEditingId(null);
                      setConfirmingId(p.id);
                    }}
                    onCancelRemove={() => setConfirmingId(null)}
                    onCancelRename={() => setEditingId(null)}
                    onRemove={() => {
                      setConfirmingId(null);
                      setPasskeys((all) => all.filter((x) => x.id !== p.id));
                    }}
                    onRename={(name) => {
                      setPasskeys((all) => all.map((x) => (x.id === p.id ? { ...x, name } : x)));
                      setEditingId(null);
                    }}
                    onStartRename={() => {
                      setConfirmingId(null);
                      setEditingId(p.id);
                    }}
                    passkey={p}
                    reduce={reduce}
                  />
                ))}
                {pending && (
                  <PendingPasskeyRow
                    authenticator={newPasskey.authenticator}
                    key="pending"
                    onCancel={() => setPending(false)}
                    reduce={reduce}
                  />
                )}
              </AnimatePresence>
            </ul>
            <Collapse open={passkeys.length === 0 && !pending} reduce={reduce}>
              <p className="px-4 py-6 text-center text-muted-foreground text-sm">
                No passkeys yet. Add one and skip the password next time.
              </p>
            </Collapse>
          </div>
        </section>

        <section className="mt-10">
          <SectionHeader
            action={
              <Button
                className="max-sm:hidden"
                disabled={others.length === 0}
                onClick={() => setConfirmAll((v) => !v)}
                size="sm"
                variant="outline"
              >
                Sign out of all other sessions
              </Button>
            }
            description="Where you're signed in. Locations are approximate."
            title="Sessions"
          />
          <div className="mt-3 overflow-hidden rounded-xl border bg-card">
            <div className="hidden grid-cols-[minmax(0,1fr)_6.5rem_5.5rem_4.5rem] gap-3 border-b px-4 py-2.5 font-medium text-muted-foreground text-xs sm:grid">
              <span>Device</span>
              <span>Location</span>
              <span>Last active</span>
              <span className="sr-only">Actions</span>
            </div>
            <ul aria-label="Sessions">
              <AnimatePresence custom={bulk} initial={false}>
                {sessions.map((s) => (
                  <SessionRow
                    index={Math.max(0, others.findIndex((o) => o.id === s.id))}
                    key={s.id}
                    onSignOut={() => {
                      setBulk(false);
                      setSessions((all) => all.filter((x) => x.id !== s.id));
                    }}
                    reduce={reduce}
                    session={s}
                  />
                ))}
              </AnimatePresence>
            </ul>
            <Collapse open={confirmAll && others.length > 0} reduce={reduce}>
              <ConfirmStrip
                confirmLabel={`Sign out ${others.length}`}
                detail="They'll need a passkey or password to get back in."
                onCancel={() => setConfirmAll(false)}
                onConfirm={() => {
                  setConfirmAll(false);
                  setBulk(true);
                  setSessions((all) => all.filter((s) => s.current));
                }}
                title={`Sign out of ${others.length} other ${others.length === 1 ? "session" : "sessions"}?`}
              />
            </Collapse>
            {others.length > 0 && !confirmAll && (
              <div className="border-t px-4 py-2.5 sm:hidden">
                <Button className="w-full" onClick={() => setConfirmAll(true)} size="sm" variant="ghost">
                  Sign out of all other sessions
                </Button>
              </div>
            )}
            <Collapse open={others.length === 0} reduce={reduce}>
              <p className="flex items-center gap-2 border-t px-4 py-3 text-muted-foreground text-xs">
                <CheckIcon className="size-3.5 text-success-foreground" />
                You're only signed in on this device.
              </p>
            </Collapse>
          </div>
        </section>
      </div>
    </RidgelineShell>
  );
}
```
