{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "session-list",
  "type": "registry:ui",
  "title": "Session List",
  "description": "Signed-in devices that fold away on sign-out, one or all at once.",
  "categories": [
    "settings"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/session-list.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Loader, Monitor } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type SessionDevice = \"desktop\" | \"laptop\" | \"phone\" | \"tablet\";\n\nexport type Session = {\n  id: string;\n  device: SessionDevice;\n  /** “Chrome”, “Safari”, “Stealth for iOS”. */\n  browser?: string;\n  /** “macOS”, “Windows 11”, “iOS 19”. */\n  os?: string;\n  /** Overrides “Browser on OS” as the row title. */\n  name?: string;\n  /** “Berlin, Germany”. */\n  location?: string;\n  ip?: string;\n  lastActiveAt: Date | string | number;\n  /** The session you're using right now. Listed first, can't be signed out here. */\n  current?: boolean;\n  /** A short warning tag, like “New location”. */\n  flag?: string;\n};\n\nconst title = (s: Session) => s.name ?? ([s.browser, s.os].filter(Boolean).join(\" on \") || \"Unknown device\");\n\n/* ------------------------------------------------------------------ */\n/* Time                                                                */\n/* ------------------------------------------------------------------ */\n\n// One clock for every list on the page, ticking every 30s while the tab is visible.\n// The server has no \"now\", so relative times render on the client only.\nlet clockNow = 0;\nconst clockListeners = new Set<() => void>();\nlet clockTimer: number | undefined;\nfunction subscribeClock(cb: () => void) {\n  clockListeners.add(cb);\n  if (clockListeners.size === 1) {\n    clockNow = Date.now();\n    clockTimer = window.setInterval(() => {\n      if (document.hidden) return;\n      clockNow = Date.now();\n      clockListeners.forEach((l) => l());\n    }, 30_000);\n  }\n  return () => {\n    clockListeners.delete(cb);\n    if (!clockListeners.size) window.clearInterval(clockTimer);\n  };\n}\nfunction useNow() {\n  return useSyncExternalStore(\n    subscribeClock,\n    () => clockNow || (clockNow = Date.now()),\n    () => null,\n  );\n}\n\nfunction relative(t: number, now: number) {\n  const s = Math.round((t - now) / 1000);\n  const abs = Math.abs(s);\n  const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n  if (abs < 3600) return rtf.format(Math.round(s / 60), \"minute\");\n  if (abs < 86400) return rtf.format(Math.round(s / 3600), \"hour\");\n  if (abs < 86400 * 7) return rtf.format(Math.round(s / 86400), \"day\");\n  return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\", year: t < now - 86400_000 * 300 ? \"numeric\" : undefined }).format(t);\n}\n\n/* ------------------------------------------------------------------ */\n/* Device glyphs, on the library's 16px grid                           */\n/* ------------------------------------------------------------------ */\n\nfunction DeviceIcon({ device }: { device: SessionDevice }) {\n  if (device === \"desktop\") return <Monitor />;\n  const common = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true, focusable: false };\n  if (device === \"laptop\")\n    return (\n      <svg {...common}>\n        <rect x=\"3\" y=\"3.25\" width=\"10\" height=\"7.25\" rx=\"1.25\" />\n        <path d=\"M1.75 12.75h12.5\" />\n      </svg>\n    );\n  if (device === \"tablet\")\n    return (\n      <svg {...common}>\n        <rect x=\"2.75\" y=\"2\" width=\"10.5\" height=\"12\" rx=\"1.75\" />\n        <path d=\"M7.25 11.75h1.5\" />\n      </svg>\n    );\n  return (\n    <svg {...common}>\n      <rect x=\"4.25\" y=\"1.75\" width=\"7.5\" height=\"12.5\" rx=\"1.75\" />\n      <path d=\"M7 11.75h2\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* SessionList                                                         */\n/* ------------------------------------------------------------------ */\n\nexport type SessionListProps = Omit<React.ComponentProps<\"section\">, \"title\" | \"defaultValue\" | \"onChange\"> & {\n  sessions?: Session[];\n  defaultSessions?: Session[];\n  onSessionsChange?: (sessions: Session[]) => void;\n  /** Sign one session out. The row folds away when it resolves and shows the error if it rejects. */\n  onRevoke?: (session: Session) => void | Promise<unknown>;\n  /** Sign out every session but the current one. Falls back to onRevoke for each. */\n  onRevokeOthers?: (sessions: Session[]) => void | Promise<unknown>;\n  /** Show skeleton rows while the first load is in flight. */\n  loading?: boolean;\n  heading?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Minutes within which a session counts as “Active now”. */\n  activeWithin?: number;\n};\n\ntype RowError = Record<string, string>;\n\nexport function SessionList({\n  sessions: sessionsProp,\n  defaultSessions = [],\n  onSessionsChange,\n  onRevoke,\n  onRevokeOthers,\n  loading = false,\n  heading = \"Active sessions\",\n  description = \"Devices signed in to your account. Sign out any you don’t recognize.\",\n  activeWithin = 5,\n  className,\n  ...rest\n}: SessionListProps) {\n  const uid = useId();\n  const reduce = !!useReducedMotion();\n  const now = useNow();\n  const [sessions, setSessions] = useControllableState({ value: sessionsProp, defaultValue: defaultSessions, onChange: onSessionsChange });\n  const [busy, setBusy] = useState<ReadonlySet<string>>(() => new Set());\n  const [errors, setErrors] = useState<RowError>({});\n  const [confirming, setConfirming] = useState(false);\n  const [signingOutAll, setSigningOutAll] = useState(false);\n  const [allError, setAllError] = useState<string | null>(null);\n  // Exit order for a sign-out-everywhere cascade; single sign-outs have no entry and leave at once.\n  const [cascade, setCascade] = useState<ReadonlyMap<string, number>>(() => new Map());\n  const [announcement, setAnnouncement] = useState(\"\");\n  const headingRef = useRef<HTMLHeadingElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n  const focusAfter = useRef<string | null>(null);\n  const refocusAction = useRef(false);\n  const latest = useRef(sessions);\n  useEffect(() => {\n    latest.current = sessions;\n  });\n\n  // Current session first, then most recently active.\n  const sorted = [...sessions].sort(\n    (a, b) => Number(!!b.current) - Number(!!a.current) || new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime(),\n  );\n  const others = sorted.filter((s) => !s.current);\n\n  const setRowError = (id: string, message: string | null) =>\n    setErrors((prev) => {\n      const next = { ...prev };\n      if (message) next[id] = message;\n      else delete next[id];\n      return next;\n    });\n\n  const revoke = async (s: Session) => {\n    setRowError(s.id, null);\n    setBusy((b) => new Set(b).add(s.id));\n    try {\n      await onRevoke?.(s);\n      const i = others.findIndex((o) => o.id === s.id);\n      focusAfter.current = others[i + 1]?.id ?? others[i - 1]?.id ?? \"heading\";\n      setCascade(new Map());\n      setSessions(latest.current.filter((x) => x.id !== s.id));\n      setAnnouncement(`Signed out ${title(s)}`);\n    } catch {\n      setRowError(s.id, \"Couldn’t sign out this session. Try again.\");\n    } finally {\n      setBusy((b) => {\n        const next = new Set(b);\n        next.delete(s.id);\n        return next;\n      });\n    }\n  };\n\n  const revokeOthers = async () => {\n    setAllError(null);\n    setSigningOutAll(true);\n    try {\n      if (onRevokeOthers) await onRevokeOthers(others);\n      else await Promise.all(others.map((s) => onRevoke?.(s)));\n      setCascade(new Map(others.map((s, i) => [s.id, i])));\n      focusAfter.current = \"heading\";\n      setSessions(latest.current.filter((x) => x.current));\n      setConfirming(false);\n      setAnnouncement(`Signed out ${others.length} other ${others.length === 1 ? \"session\" : \"sessions\"}`);\n    } catch {\n      setAllError(\"Couldn’t sign out the other sessions. Try again.\");\n    } finally {\n      setSigningOutAll(false);\n    }\n  };\n\n  const onExitComplete = () => {\n    const target = focusAfter.current;\n    focusAfter.current = null;\n    if (!target) return;\n    const el = target === \"heading\" ? headingRef.current : listRef.current?.querySelector<HTMLElement>(`[data-session=\"${CSS.escape(target)}\"] button`);\n    (el ?? headingRef.current)?.focus({ preventScroll: true });\n  };\n\n  return (\n    <section aria-labelledby={`${uid}-h`} aria-busy={loading || undefined} className={cn(\"@container flex w-full min-w-0 flex-col gap-3\", className)} {...rest}>\n      {/* Title and the sign-out-everywhere action share a row; in a narrow column the action drops beneath. */}\n      <header className=\"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2.5 px-0.5 @max-[30rem]:grid-cols-1\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <h3 ref={headingRef} id={`${uid}-h`} tabIndex={-1} className=\"rounded-sm text-[14px] font-medium leading-5 tracking-[-0.015em] text-fg outline-none\">\n            {heading}\n          </h3>\n          {description && <p className=\"text-[12.5px] leading-[18px] text-pretty text-fg-3\">{description}</p>}\n        </div>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {/* Present but disabled while loading, so the header doesn't grow when the list arrives. */}\n          {(loading || others.length > 0) && (\n            <motion.div\n              key={confirming ? \"confirm\" : \"action\"}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className=\"flex items-center gap-2\"\n            >\n              {confirming ? (\n                <ConfirmAll\n                  count={others.length}\n                  busy={signingOutAll}\n                  onCancel={() => {\n                    refocusAction.current = true;\n                    setConfirming(false);\n                  }}\n                  onConfirm={() => void revokeOthers()}\n                />\n              ) : (\n                <Button\n                  variant=\"secondary\"\n                  disabled={loading}\n                  onClick={() => setConfirming(true)}\n                  // Backing out of the confirm puts focus back where it started.\n                  ref={(el) => {\n                    if (el && refocusAction.current) {\n                      refocusAction.current = false;\n                      el.focus({ preventScroll: true });\n                    }\n                  }}\n                >\n                  Sign out other sessions\n                </Button>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </header>\n\n      {allError && (\n        <p role=\"alert\" className=\"rounded-lg bg-danger-soft px-3 py-2 text-[12.5px] leading-[18px] text-danger\">\n          {allError}\n        </p>\n      )}\n\n      <div className=\"overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\">\n        {loading ? (\n          <ul aria-label=\"Loading sessions\" className=\"flex flex-col\">\n            {[0, 1, 2].map((i) => (\n              <li key={i} className=\"flex gap-3 border-t border-line px-4 py-3 first:border-t-0\">\n                {/* Shaped like a real row, so nothing moves when the sessions arrive. */}\n                <span className=\"mt-0.5 size-8 shrink-0 animate-pulse-soft self-start rounded-lg bg-fg/[0.06]\" />\n                <span className=\"flex flex-1 flex-col gap-0.5\">\n                  <span className=\"flex h-5 items-center\">\n                    <span className=\"h-3 w-36 animate-pulse-soft rounded-sm bg-fg/[0.06]\" />\n                  </span>\n                  <span className=\"flex h-4 items-center\">\n                    <span className=\"h-2.5 w-52 max-w-full animate-pulse-soft rounded-sm bg-fg/[0.05]\" />\n                  </span>\n                </span>\n              </li>\n            ))}\n          </ul>\n        ) : (\n          <ul ref={listRef} aria-labelledby={`${uid}-h`} className=\"flex flex-col\">\n            <AnimatePresence initial={false} custom={cascade} onExitComplete={onExitComplete}>\n              {sorted.map((s) => (\n                <motion.li\n                  key={s.id}\n                  data-session={s.id}\n                  custom={cascade}\n                  initial={false}\n                  exit=\"gone\"\n                  variants={{\n                    // Signing out everywhere folds the rows shut top to bottom, 45ms apart, so you watch them go.\n                    gone: (order: ReadonlyMap<string, number>) => {\n                      const i = order.get(s.id) ?? 0;\n                      const delay = Math.min(i, 8) * 0.045;\n                      return reduce\n                        ? { opacity: 0, transition: { duration: 0.12, delay: delay / 2 } }\n                        : {\n                            opacity: 0,\n                            height: 0,\n                            x: 8,\n                            transition: {\n                              opacity: { duration: 0.14, delay },\n                              x: { duration: 0.18, ease: ease.in, delay },\n                              height: { duration: 0.26, ease: ease.inOut, delay: delay + 0.06 },\n                            },\n                          };\n                    },\n                  }}\n                  className=\"overflow-hidden border-t border-line first:border-t-0\"\n                >\n                  <Row\n                    session={s}\n                    now={now}\n                    activeWithin={activeWithin}\n                    busy={busy.has(s.id) || (signingOutAll && !s.current)}\n                    error={errors[s.id]}\n                    canRevoke={!!onRevoke && !s.current}\n                    onRevoke={() => void revoke(s)}\n                  />\n                </motion.li>\n              ))}\n            </AnimatePresence>\n          </ul>\n        )}\n        <AnimatePresence initial={false}>\n          {!loading && others.length === 0 && (\n            <motion.p\n              key=\"alone\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              transition={{ height: { duration: 0.24, ease: ease.inOut, delay: 0.2 }, opacity: { duration: 0.2, delay: 0.3 } }}\n              className=\"overflow-hidden text-[12.5px] text-fg-3\"\n            >\n              <span className=\"block border-t border-line px-4 py-3\">No other sessions. You’re only signed in here.</span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </section>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Row                                                                 */\n/* ------------------------------------------------------------------ */\n\nfunction Row({\n  session: s,\n  now,\n  activeWithin,\n  busy,\n  error,\n  canRevoke,\n  onRevoke,\n}: {\n  session: Session;\n  now: number | null;\n  activeWithin: number;\n  busy: boolean;\n  error?: string;\n  canRevoke: boolean;\n  onRevoke: () => void;\n}) {\n  const errorId = useId();\n  const t = new Date(s.lastActiveAt).getTime();\n  const active = s.current || (now != null && now - t < activeWithin * 60_000);\n  const name = title(s);\n\n  return (\n    <div className=\"flex min-w-0 items-start gap-3 px-4 py-3\">\n      <span aria-hidden className=\"relative mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-hover text-fg-2 shadow-[inset_0_0_0_1px_var(--line-2)]\">\n        <DeviceIcon device={s.device} />\n      </span>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <p className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[13px] font-medium leading-5 text-fg\">\n          <span className=\"min-w-0\">{name}</span>\n          {s.current && <span className=\"rounded-full bg-fg/[0.07] px-1.5 py-px text-[11px] font-medium leading-4 text-fg-2\">This device</span>}\n          {s.flag && <span className=\"rounded-full bg-warning-soft px-1.5 py-px text-[11px] font-medium leading-4 text-warning\">{s.flag}</span>}\n        </p>\n        <p className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 text-[12px] leading-4 text-fg-3\">\n          {(s.location || s.ip) && (\n            // Where it is stays on one line; if space runs out the address trims, never a dangling dot.\n            <span className=\"flex min-w-0 max-w-full items-center gap-x-1.5 overflow-hidden whitespace-nowrap\" title={[s.location, s.ip].filter(Boolean).join(\" · \")}>\n              {s.location && <span className=\"shrink-0\">{s.location}</span>}\n              {s.location && s.ip && <Sep />}\n              {s.ip && <span className=\"min-w-0 truncate font-mono text-[11px] tracking-[0.01em]\">{s.ip}</span>}\n            </span>\n          )}\n          {/* In a narrow column the status takes its own line instead of leaving a dot at a line end. */}\n          {(s.location || s.ip) && <Sep className=\"@max-[30rem]:hidden\" />}\n          {active ? (\n            <span className=\"inline-flex items-center gap-1.5 text-success @max-[30rem]:basis-full\">\n              <span aria-hidden className=\"relative grid size-1.5 place-items-center\">\n                <span className=\"absolute inset-0 animate-ping-soft rounded-full bg-success/70 motion-reduce:hidden\" />\n                <span className=\"size-1.5 rounded-full bg-success\" />\n              </span>\n              Active now\n            </span>\n          ) : (\n            <time dateTime={now != null ? new Date(t).toISOString() : undefined} title={now != null ? new Date(t).toLocaleString() : undefined} className=\"tabular @max-[30rem]:basis-full\">\n              {now != null ? `Last active ${relative(t, now)}` : \" \"}\n            </time>\n          )}\n        </p>\n        {error && (\n          <p id={errorId} role=\"alert\" className=\"pt-1 text-[12px] leading-4 text-danger\">\n            {error}\n          </p>\n        )}\n      </div>\n\n      {canRevoke && (\n        <Button\n          variant=\"ghost\"\n          busy={busy}\n          onClick={onRevoke}\n          aria-label={`Sign out ${name}${s.location ? `, ${s.location}` : \"\"}`}\n          aria-describedby={error ? errorId : undefined}\n          className=\"-mr-1.5 mt-0.5\"\n        >\n          Sign out\n        </Button>\n      )}\n    </div>\n  );\n}\n\nconst Sep = ({ className }: { className?: string }) => (\n  <span aria-hidden className={cn(\"text-fg-4\", className)}>\n    ·\n  </span>\n);\n\n/* ------------------------------------------------------------------ */\n/* Buttons                                                             */\n/* ------------------------------------------------------------------ */\n\nfunction Button({\n  variant,\n  busy = false,\n  className,\n  children,\n  onClick,\n  ...props\n}: Omit<React.ComponentProps<\"button\">, \"type\"> & { variant: \"secondary\" | \"ghost\" | \"danger\"; busy?: boolean }) {\n  // The spinner only shows if the wait passes 150ms, so quick sign-outs never flash it.\n  const [slow, setSlow] = useState(false);\n  useEffect(() => {\n    if (!busy) return;\n    const t = window.setTimeout(() => setSlow(true), 150);\n    return () => {\n      window.clearTimeout(t);\n      setSlow(false);\n    };\n  }, [busy]);\n  const spinning = busy && slow;\n\n  return (\n    <button\n      type=\"button\"\n      aria-busy={busy || undefined}\n      data-variant={variant}\n      onClick={busy ? undefined : onClick}\n      className={cn(\n        \"relative inline-grid h-7 shrink-0 place-items-center rounded-md px-2.5 text-[12.5px] font-medium outline-none select-none\",\n        \"transition-[background-color,border-color,color,scale] duration-150 active:scale-[0.97] active:duration-75 aria-busy:active:scale-100\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n        variant === \"secondary\" && \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] enabled:hover:border-fg-4 enabled:hover:bg-hover disabled:opacity-50\",\n        variant === \"ghost\" && (busy ? \"text-fg-3\" : \"text-fg-2 hover:bg-danger-soft hover:text-danger\"),\n        variant === \"danger\" && \"bg-danger text-frame shadow-[var(--shadow)] hover:bg-danger/90\",\n        className,\n      )}\n      {...props}\n    >\n      {/* The label keeps its place under the spinner, so the button never changes width. */}\n      <span className={cn(\"col-start-1 row-start-1 transition-opacity duration-150\", spinning && \"opacity-0\")}>{children}</span>\n      {spinning && (\n        <span className=\"col-start-1 row-start-1 grid place-items-center\">\n          <Loader size={14} className=\"animate-spin\" />\n        </span>\n      )}\n    </button>\n  );\n}\n\nfunction ConfirmAll({ count, busy, onCancel, onConfirm }: { count: number; busy: boolean; onCancel: () => void; onConfirm: () => void }) {\n  const confirmRef = useRef<HTMLButtonElement>(null);\n  // Keyboard users land on the verb; Escape backs out.\n  useEffect(() => {\n    confirmRef.current?.focus({ preventScroll: true });\n  }, []);\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Confirm signing out other sessions\"\n      className=\"flex items-center gap-2\"\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && !busy) {\n          e.preventDefault();\n          onCancel();\n        }\n      }}\n    >\n      <span className=\"text-[12.5px] text-fg-2\">\n        Sign out {count} other {count === 1 ? \"session\" : \"sessions\"}?\n      </span>\n      <Button variant=\"secondary\" onClick={onCancel} disabled={busy}>\n        Cancel\n      </Button>\n      <Button ref={confirmRef} variant=\"danger\" busy={busy} onClick={onConfirm}>\n        Sign out\n      </Button>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/session-list"
}
