/settings/securitySecurity settings
Account security in three panels: change the password, pair an authenticator app with a six-digit code, and sign out of the places this account is signed in from.
The page is a server component inside AppShell. The password form applies the mock adapter's own rule locally rather than calling signIn, which would mint a session as a side effect of changing a password; it refuses a new password shorter than eight characters or the same as the old one, ties a mismatch to both boxes, and appends the change to db.auditEvents against the address the account last acted from. Two-factor goes through mockAuthAdapter.verifyCode (000000 is the code it always refuses) and only a verified code writes twoFactor back to the member row; the confirmation that replaces the form takes focus. The session list is a rule over rows db already holds: the owner's own audit entries grouped by IP address are the places the account has acted from, newest first, and only the device, browser and city — which no row records — are generated, from a seed keyed by the address itself rather than by the row's position, so signing one session out leaves every other row saying what it said before. Revoking has no row to delete, so it is written to that same trail as a revoked/session entry and read back when the list is derived — which is what makes a revocation survive a reload rather than living in the page's own state; whatever the action refuses is said out loud under the table. The section list beside the page is the nav's own: the leaves of the `/settings` entry in this block's `nav.ts`, in nav order, resolved through the same NAV_ICONS table the sidebar reads. Nothing lists the sections twice, so editing that one file — or letting a template replace it — moves the sidebar and the sub-nav together, and the sub-nav can never offer a section the product has no page for. Composes AppShell, PageHeader, SettingsLayout, SectionHeader, Card, FormRow, PasswordInput, InputOTP, CopyButton, StatusBadge, SimpleTable, Badge and ConfirmDialog.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"
import { signOut } from "./actions"
import { ChangePasswordForm } from "./components/change-password-form"
import { SessionsTable } from "./components/sessions-table"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import { TwoFactorPanel } from "./components/two-factor-panel"
import {
activeSessions,
currentUser,
lastActiveLabel,
securityProfile,
shellNotifications,
TWO_FACTOR_SECRET,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Account security. The page is a server component inside the shell: it reads
* the member row and the audit trail through `db`, turns the trail into the
* places this account is signed in from, and hands both to the three client
* islands that can change them.
*/
export default function SettingsSecurityPage() {
const me = securityProfile()
const sessions = activeSessions().map((session) => ({
id: session.id,
device: session.device,
browser: session.browser,
location: session.location,
ip: session.ip,
lastActive: lastActiveLabel(session.lastActiveAt),
current: session.current,
}))
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Security"
description="The password on this account, the second factor behind it, and where it is signed in."
/>
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE}>
<div className="flex flex-col gap-6">
<ChangePasswordForm email={me.email} />
<TwoFactorPanel enabled={me.twoFactor} secret={TWO_FACTOR_SECRET} />
<SessionsTable sessions={sessions} />
</div>
</SettingsLayout>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/settings-securityNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"
import { signOut } from "./actions"
import { ChangePasswordForm } from "./components/change-password-form"
import { SessionsTable } from "./components/sessions-table"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import { TwoFactorPanel } from "./components/two-factor-panel"
import {
activeSessions,
currentUser,
lastActiveLabel,
securityProfile,
shellNotifications,
TWO_FACTOR_SECRET,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Account security. The page is a server component inside the shell: it reads
* the member row and the audit trail through `db`, turns the trail into the
* places this account is signed in from, and hands both to the three client
* islands that can change them.
*/
export default function SettingsSecurityPage() {
const me = securityProfile()
const sessions = activeSessions().map((session) => ({
id: session.id,
device: session.device,
browser: session.browser,
location: session.location,
ip: session.ip,
lastActive: lastActiveLabel(session.lastActiveAt),
current: session.current,
}))
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Security"
description="The password on this account, the second factor behind it, and where it is signed in."
/>
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE}>
<div className="flex flex-col gap-6">
<ChangePasswordForm email={me.email} />
<TwoFactorPanel enabled={me.twoFactor} secret={TWO_FACTOR_SECRET} />
<SessionsTable sessions={sessions} />
</div>
</SettingsLayout>
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/settings/security"
/**
* This product's navigation, as plain data. AppShell resolves the icon names
* and works out which item is current from the route, so nothing here is a
* component and nothing here says "I am the page you are on".
*
* Settings is a disclosure with one leaf per settings page, so this page's own
* route is a leaf: a parent with `items` renders as a button and has no anchor
* for `aria-current="page"` to land on.
*/
export const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
groups: [
{
label: "Workspace",
items: [
{ title: "Overview", href: "/saas", icon: "layout-dashboard" },
{ title: "Customers", href: "/ecommerce/customers", icon: "users" },
{ title: "Revenue", href: "/saas/revenue", icon: "credit-card" },
{ title: "Monitoring", href: "/engineering/monitoring", icon: "activity" },
],
},
{
label: "Account",
items: [
{
title: "Settings",
href: "/settings",
icon: "settings",
items: [
// The parent route is a real page (the workspace settings block),
// so it gets a leaf of its own — a parent with `items` renders as
// a disclosure button and is never a link.
{ title: "General", href: "/settings", icon: "settings" },
{ title: "Profile", href: "/settings/profile", icon: "user-round" },
{ title: "Security", href: "/settings/security", icon: "shield" },
{ title: "Notifications", href: "/settings/notifications", icon: "bell" },
{ title: "API keys", href: "/settings/api-keys", icon: "key-round" },
{ title: "Integrations", href: "/settings/integrations", icon: "plug" },
],
},
],
},
],
// Pinned under the groups, where the old secondary links sat.
footer: [{ title: "Support", href: "/support", icon: "life-buoy" }],
}/**
* What this page reads. The person is the workspace owner in `db.members`, and
* `twoFactor` on that row is the state the enrolment flow writes back. The
* sessions are a rule over rows db already holds: the workspace audit trail
* records who did what, when, and from which address, so the owner's own
* entries grouped by address are exactly the places they are signed in from —
* newest first, the newest of them being this device. Only the hardware behind
* an address has no row anywhere, so the device, browser and city come from
* `seeded("settings-security")`. "Now" is `REFERENCE_DATE`.
*/
import { formatRelative, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type Member } from "@/lib/sample-data"
export type SecurityProfile = {
id: string
name: string
email: string
twoFactor: boolean
}
export type SessionRow = {
/** The audit event this session was read off — a real row id. */
id: string
device: string
browser: string
location: string
ip: string
lastActiveAt: Date
/** The newest address the owner acted from: the one they are reading this on. */
current: boolean
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The signed-in person, read fresh so an enrolment is visible on the next render. */
export function securityProfile(): SecurityProfile {
const owner = ownerRow()
return { id: owner.id, name: owner.name, email: owner.email, twoFactor: owner.twoFactor }
}
const DEVICES = [
{ device: "MacBook Pro", browser: "Chrome 141" },
{ device: "iPhone 17", browser: "Safari 26" },
{ device: "ThinkPad X1", browser: "Firefox 148" },
{ device: "iPad Air", browser: "Safari 26" },
{ device: "Pixel 10", browser: "Chrome 141" },
]
const CITIES = [
"Berlin, Germany",
"Lisbon, Portugal",
"London, United Kingdom",
"Amsterdam, Netherlands",
"Dublin, Ireland",
]
/** How many places the list shows before it stops being a list and starts being a log. */
const SESSION_LIMIT = 4
/**
* The machine behind an address. Keyed by the address itself rather than by
* where the row happens to sit in the list, so signing one session out leaves
* every other row saying exactly what it said before — a generator walked in
* array order would rename them all.
*/
function machineFor(ip: string): { device: string; browser: string; location: string } {
const rand = seeded(`settings-security:${ip}`)
return {
...DEVICES[Math.floor(rand() * DEVICES.length)],
location: CITIES[Math.floor(rand() * CITIES.length)],
}
}
/** The trail's own word for a session that was signed out from this page. */
export const SESSION_RESOURCE = "session"
/**
* The sessions the trail says were revoked. Revoking has no row to delete —
* the list is derived — so it is recorded as a trail entry instead, and read
* back here. That is what makes a revocation survive a reload.
*/
function revokedSessionIds(actorId: string): Set<string> {
return new Set(
db.auditEvents
.all()
.filter(
(event) =>
event.actor === actorId &&
event.action === "revoked" &&
event.resource === SESSION_RESOURCE
)
.map((event) => event.resourceId)
)
}
/**
* Where this account is signed in. One row per address the owner has acted
* from, newest activity first; the newest is this device.
*/
export function activeSessions(): SessionRow[] {
const owner = ownerRow()
const seen = new Map<string, { id: string; at: Date }>()
const revoked = revokedSessionIds(owner.id)
for (const event of db.auditEvents
.all()
.filter((event) => event.actor === owner.id && event.resource !== SESSION_RESOURCE)
.sort((a, b) => b.at.getTime() - a.at.getTime())) {
if (!seen.has(event.ip)) seen.set(event.ip, { id: event.id, at: event.at })
}
return [...seen.entries()]
.filter(([, event]) => !revoked.has(event.id))
.slice(0, SESSION_LIMIT)
.map(([ip, event], index) => ({
id: event.id,
...machineFor(ip),
ip,
lastActiveAt: event.at,
current: index === 0,
}))
}
/** "2d ago" — measured against REFERENCE_DATE, never the clock. */
export function lastActiveLabel(at: Date): string {
return formatRelative(at, REFERENCE_DATE)
}
/**
* The shared secret the authenticator app is given. Generated once from the
* block's own seed, so the page shows the same code on every render and in
* every screenshot — a real product would mint one per enrolment.
*/
export const TWO_FACTOR_SECRET = (() => {
const rand = seeded("settings-security-secret")
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
const raw = Array.from({ length: 16 }, () => alphabet[Math.floor(rand() * alphabet.length)]).join("")
return raw.replace(/(.{4})(?=.)/g, "$1 ")
})()
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, invalidInput, isForm, REFERENCE_DATE, type Result } from "@/lib/sample-data"
import { activeSessions, securityProfile, SESSION_RESOURCE } from "./data"
export type PasswordState = Result<{ changed: true }> | null
export type TwoFactorState = Result<{ enabled: true }> | null
const MIN_PASSWORD_LENGTH = 8
/**
* The one password `mockAuthAdapter` refuses. The adapter has no
* verify-password call and `signIn` would mint a session as a side effect —
* signing you in as a consequence of changing your password — so the mock's
* own rule is applied here instead of calling it. Swap this for the real
* adapter's verify when there is one.
*/
const REJECTED_PASSWORD = "wrong"
function field(formData: FormData, key: string): string {
const value = formData.get(key)
return typeof value === "string" ? value : ""
}
/** The address this account last acted from, for the entry a change leaves. */
function currentAddress(): string | undefined {
return activeSessions()[0]?.ip
}
/**
* Sets a new password. The current one is checked against the auth adapter
* rather than against anything stored here — the adapter owns credentials, and
* `db` owns rows — and the change is appended to the workspace audit trail,
* which is the same trail the session list below is read from.
*/
export async function changePassword(
_previous: PasswordState,
formData: FormData
): Promise<Result<{ changed: true }>> {
if (!isForm(formData)) return invalidInput("Send the form as the page sends it.")
const current = field(formData, "currentPassword")
const next = field(formData, "newPassword")
const confirm = field(formData, "confirmPassword")
if (!current) {
return {
ok: false,
error: { code: "invalid_input", field: "currentPassword", message: "Enter your current password." },
}
}
if (next.length < MIN_PASSWORD_LENGTH) {
return {
ok: false,
error: {
code: "weak_password",
field: "newPassword",
message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`,
},
}
}
if (next === current) {
return {
ok: false,
error: {
code: "weak_password",
field: "newPassword",
message: "Choose a password you have not used here before.",
},
}
}
// A mismatch is never one field's fault, so the message is tied to the
// confirmation and the form marks both boxes.
if (next !== confirm) {
return {
ok: false,
error: { code: "invalid_input", field: "confirmPassword", message: "Those passwords do not match." },
}
}
if (current === REJECTED_PASSWORD) {
return {
ok: false,
error: { code: "invalid_credentials", field: "currentPassword", message: "That password is not right." },
}
}
const me = securityProfile()
const ip = currentAddress()
if (ip) {
await db.auditEvents.create({
at: REFERENCE_DATE,
actor: me.id,
action: "updated",
resource: "member",
resourceId: me.id,
ip,
})
}
return { ok: true, data: { changed: true } }
}
/**
* Confirms an authenticator app and turns two-factor on for the member row.
* The code goes to the auth adapter — `000000` is the one it always rejects —
* and only a verified code writes `twoFactor` back through `db.members`.
*/
export async function enableTwoFactor(
_previous: TwoFactorState,
formData: FormData
): Promise<Result<{ enabled: true }>> {
if (!isForm(formData)) return invalidInput("Send the form as the page sends it.")
const code = field(formData, "code").trim()
if (!/^\d{6}$/.test(code)) {
return { ok: false, error: { code: "invalid_code", field: "code", message: "Enter the 6-digit code." } }
}
const verified = await mockAuthAdapter.verifyCode({ code })
if (!verified.ok) {
return { ok: false, error: { code: "invalid_code", field: "code", message: verified.error.message } }
}
const me = securityProfile()
const updated = await db.members.update(me.id, { twoFactor: true })
if (!updated.ok) return updated
return { ok: true, data: { enabled: true } }
}
/**
* Ends one of the other sessions. There is no session table to delete from —
* the list is read out of the audit trail — so the revocation is written to
* that same trail, and `activeSessions` reads it back. A reload therefore
* still shows the session gone, which a client-side removal would not.
*/
export async function revokeSession(id: string): Promise<Result<{ id: string }>> {
const sessions = activeSessions()
const session = sessions.find((row) => row.id === id)
if (!session) {
return { ok: false, error: { code: "not_found", message: "That session has already ended." } }
}
if (session.current) {
return {
ok: false,
error: { code: "invalid_input", message: "Use Log out to end the session you are reading this on." },
}
}
const me = securityProfile()
const recorded = await db.auditEvents.create({
at: REFERENCE_DATE,
actor: me.id,
action: "revoked",
resource: SESSION_RESOURCE,
// The session's own id, which is the trail entry it was read off — so the
// entry names a real row rather than a plausible-looking one.
resourceId: id,
ip: sessions[0].ip,
})
if (!recorded.ok) return recorded
return { ok: true, data: { id } }
}
/**
* The one thing the shell calls. A server action so the page can stay a server
* component and still hand the shell something to call, and a `Result` so the
* caller reads the same success-or-error shape every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { FormRow } from "@/components/ui/form-section"
import { PasswordInput } from "@/components/ui/password-input"
import { SectionHeader } from "@/components/ui/section-header"
import { changePassword, type PasswordState } from "../actions"
/**
* The password form. Every box is controlled: React resets an uncontrolled
* form once its action settles, so a rejected submit would empty all three and
* make a typo cost the whole password twice.
*/
export function ChangePasswordForm({ email }: { email: string }) {
const [state, formAction, pending] = React.useActionState<PasswordState, FormData>(
changePassword,
null
)
const [current, setCurrent] = React.useState("")
const [next, setNext] = React.useState("")
const [confirm, setConfirm] = React.useState("")
// The render-phase reset app-shell uses for its seeded props: a settled
// success empties the three boxes without an effect, and a rejected submit
// leaves what was typed exactly where it was.
const [settled, setSettled] = React.useState(state)
if (state !== settled) {
setSettled(state)
if (state?.ok) {
setCurrent("")
setNext("")
setConfirm("")
}
}
const error = state && !state.ok ? state.error : undefined
const errorFor = (field: string) => (error?.field === field ? error.message : undefined)
const formError = error && !error.field ? error.message : undefined
// A mismatch belongs to both boxes; the message sits under the second one,
// and the first is marked so a reader lands on it too.
const mismatch = error?.field === "confirmPassword"
return (
<Card>
<CardHeader>
<SectionHeader
as="h2"
title="Password"
description={`The password behind ${email}. Changing it signs nothing else out.`}
/>
</CardHeader>
<form action={formAction}>
<CardContent className="flex max-w-md flex-col gap-5">
<FormRow
label="Current password"
htmlFor="current-password"
required
error={errorFor("currentPassword")}
>
<PasswordInput
id="current-password"
name="currentPassword"
autoComplete="current-password"
value={current}
onChange={(event) => setCurrent(event.target.value)}
/>
</FormRow>
<FormRow
label="New password"
htmlFor="new-password"
required
description="At least 8 characters."
error={errorFor("newPassword")}
>
<PasswordInput
id="new-password"
name="newPassword"
autoComplete="new-password"
showStrength
aria-invalid={mismatch || undefined}
value={next}
onChange={(event) => setNext(event.target.value)}
/>
</FormRow>
<FormRow
label="Confirm new password"
htmlFor="confirm-password"
required
error={errorFor("confirmPassword")}
>
<PasswordInput
id="confirm-password"
name="confirmPassword"
autoComplete="new-password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
/>
</FormRow>
{formError ? (
<p role="alert" className="text-sm text-danger">
{formError}
</p>
) : null}
</CardContent>
<CardFooter className="mt-4 justify-between gap-3">
<p role="status" className="text-sm text-muted-foreground">
{state?.ok ? "Password updated. It is in the audit trail." : null}
</p>
<Button type="submit" disabled={pending} aria-busy={pending || undefined}>
{pending ? "Updating…" : "Update password"}
</Button>
</CardFooter>
</form>
</Card>
)
}"use client"
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { SectionHeader } from "@/components/ui/section-header"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { revokeSession } from "../actions"
export type SessionView = {
id: string
device: string
browser: string
location: string
ip: string
lastActive: string
current: boolean
}
/**
* Where this account is signed in. Revoking asks first, and whatever the
* action refuses is said out loud under the table rather than swallowed.
*/
export function SessionsTable({ sessions }: { sessions: SessionView[] }) {
const [revoked, setRevoked] = React.useState<string[]>([])
const [error, setError] = React.useState<string>()
const [ended, setEnded] = React.useState<string>()
const rows = sessions.filter((session) => !revoked.includes(session.id))
async function revoke(session: SessionView) {
const result = await revokeSession(session.id)
if (!result.ok) {
setEnded(undefined)
setError(result.error.message)
return
}
setError(undefined)
setEnded(`${session.device} in ${session.location} was signed out.`)
setRevoked((current) => [...current, session.id])
}
const columns: SimpleTableColumn<SessionView>[] = [
{
key: "device",
header: "Device",
cell: (session) => (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 font-medium">
{session.device}
{session.current ? (
<Badge variant="secondary" className="text-avatar">
This device
</Badge>
) : null}
</span>
<span className="text-xs text-muted-foreground">{session.browser}</span>
</div>
),
},
{ key: "location", header: "Location" },
{
key: "ip",
header: "IP address",
// font-mono earns its place: an address is a code, not prose.
cell: (session) => <span className="font-mono text-xs">{session.ip}</span>,
},
{ key: "lastActive", header: "Last active", align: "right" },
{
key: "actions",
header: <span className="sr-only">Actions</span>,
align: "right",
width: "6rem",
cell: (session) =>
session.current ? (
<span className="text-xs text-muted-foreground">Current</span>
) : (
<ConfirmDialog
variant="destructive"
title={`Sign out of ${session.device}?`}
description={`Whoever is using ${session.browser} in ${session.location} will have to sign in again.`}
confirmText="Sign it out"
onConfirm={() => revoke(session)}
trigger={
<Button variant="ghost" size="sm" aria-label={`Revoke ${session.device}`}>
Revoke
</Button>
}
/>
),
},
]
return (
<Card>
<CardHeader>
<SectionHeader
as="h2"
title="Where you are signed in"
description="Read from the audit trail: one row per address this account has acted from."
/>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<SimpleTable
columns={columns}
rows={rows}
rowKey="id"
emptyMessage="No other sessions."
className="overflow-x-auto"
/>
{error ? (
<p role="alert" className="text-sm text-danger">
{error}
</p>
) : null}
<p role="status" className="text-sm text-muted-foreground">
{ended ?? null}
</p>
</CardContent>
</Card>
)
}import { flattenNav, type NavConfig } from "@/lib/nav-config"
import { navIcon } from "@/components/ui/app-shell/icons"
import { type SettingsNavItem } from "@/components/ui/settings-layout"
import { NAV } from "../nav"
// Not `SETTINGS_HREF`: this is the entry to look up, not a link. The template
// generator reads any *href constant holding an absolute path as a route a
// page links to, and would stub "/settings" for a product that has no page
// there — the same reason a page nav's own ROUTE is not called a href.
/** The route the settings area is rooted at. */
const SETTINGS_ROOT = "/settings"
/**
* The settings area's own sections, as SettingsLayout wants them: the leaves of
* the nav's `/settings` entry, in nav order. Read from the nav rather than
* written out a second time, because a template replaces `nav.ts` with its own
* — a hand-listed set would then offer sections that product has no page for,
* and the sub-nav beside the page would disagree with the sidebar above it.
*
* `findNavItem` is not the lookup: these navs give `/settings` a leaf of its
* own (General, the area's front page), and that leaf ties with its parent on
* href length, so the entry that owns the list has to be asked for directly.
* An entry with no leaves is its own only section.
*
* Each section is a real route, so these stay plain anchors — no `onNavigate`,
* no client state — and SettingsLayout marks the one matching `activeHref` as
* the current page.
*/
export function settingsSections(nav: NavConfig = NAV): SettingsNavItem[] {
const entries = flattenNav(nav).filter((item) => item.href === SETTINGS_ROOT)
// The parent wins over its own General leaf, which shares its href: the one
// that carries the list is the one being asked for. `findNavItem` would hand
// back the leaf instead, since equal href lengths go to the later item.
const settings = entries.find((item) => item.items?.length) ?? entries[0]
if (!settings) return []
const leaves = settings.items?.length ? settings.items : [settings]
return leaves.map((leaf) => {
// A NavConfig names its icons rather than holding them; this is the table
// the sidebar resolves them through, so the two cannot drift.
const Icon = navIcon(leaf.icon)
return { title: leaf.title, href: leaf.href, icon: Icon ? <Icon /> : undefined }
})
}
/** This block's own sections: the nav it ships with, resolved once. */
export const SETTINGS_SECTIONS: SettingsNavItem[] = settingsSections()"use client"
import * as React from "react"
import { ShieldCheckIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { CopyButton } from "@/components/ui/copy-button"
import { FormRow } from "@/components/ui/form-section"
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { enableTwoFactor, type TwoFactorState } from "../actions"
/** The panel that replaces the form once a code is accepted. */
function Enrolled({ secret }: { secret: string }) {
const headingRef = React.useRef<HTMLHeadingElement>(null)
// The form the reader was in is gone, so the heading that replaced it takes
// focus — otherwise focus falls back to the document and a screen reader is
// left at the top of the page with no idea the submit worked.
React.useEffect(() => {
headingRef.current?.focus()
}, [])
return (
<div data-slot="two-factor-enrolled" className="flex flex-col items-start gap-2">
<span className="flex size-9 items-center justify-center rounded-full bg-success-muted text-success">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
</span>
<h3 ref={headingRef} tabIndex={-1} className="text-sm font-medium outline-none">
Two-factor authentication is on
</h3>
<p role="status" className="text-sm text-muted-foreground">
Codes now come from the app you just paired. Keep{" "}
<span className="font-mono">{secret}</span> somewhere safe — it is the only way back in if
you lose the device.
</p>
</div>
)
}
export type TwoFactorPanelProps = {
enabled: boolean
secret: string
}
/**
* Two-factor state, and the enrolment behind it. The button says what it does
* from the state db reports: turning it on the first time, or pairing a new
* app over the top of the old one.
*/
export function TwoFactorPanel({ enabled, secret }: TwoFactorPanelProps) {
const [state, formAction, pending] = React.useActionState<TwoFactorState, FormData>(
enableTwoFactor,
null
)
const [enrolling, setEnrolling] = React.useState(false)
const [code, setCode] = React.useState("")
const error = state && !state.ok ? state.error : undefined
const done = state?.ok === true
return (
<Card>
<CardHeader>
<SectionHeader
as="h2"
title="Two-factor authentication"
description="A six-digit code from an authenticator app, on top of the password."
actions={
<StatusBadge
status={enabled || done ? "on" : "off"}
variant={enabled || done ? "success" : "warning"}
size="sm"
label={enabled || done ? "On" : "Off"}
/>
}
/>
</CardHeader>
{done ? (
<CardContent>
<Enrolled secret={secret} />
</CardContent>
) : enrolling ? (
<form action={formAction}>
<CardContent className="flex max-w-md flex-col gap-5">
<input type="hidden" name="code" value={code} />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Add this key to your authenticator app, then type the code it shows.
</p>
<div className="flex items-center gap-1 rounded-md border bg-surface px-2 py-1.5">
<span data-slot="two-factor-secret" className="font-mono text-xs tracking-wider">
{secret}
</span>
<CopyButton value={secret} size="icon-xs" label="Copy setup key" successLabel="Copied" />
</div>
</div>
<FormRow
label="Authentication code"
htmlFor="two-factor-code"
required
description="Six digits. 000000 is the code the mock adapter always refuses."
error={error?.message}
>
{/* The slots are decorative divs beside the real input, not
descendants of it, so the invalid state is painted onto them
from this wrapper; aria-invalid goes on the input itself,
which is what FormRow's field wiring hands down. */}
{(field) => (
<div
className={
error ? "[&_[data-slot=input-otp-slot]]:border-danger" : undefined
}
>
<InputOTP
{...field}
id="two-factor-code"
maxLength={6}
value={code}
onChange={setCode}
containerClassName="gap-2"
>
<InputOTPGroup>
<InputOTPSlot index={0} className="size-10" />
<InputOTPSlot index={1} className="size-10" />
<InputOTPSlot index={2} className="size-10" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} className="size-10" />
<InputOTPSlot index={4} className="size-10" />
<InputOTPSlot index={5} className="size-10" />
</InputOTPGroup>
</InputOTP>
</div>
)}
</FormRow>
</CardContent>
<CardFooter className="mt-4 gap-2">
<Button type="submit" disabled={pending} aria-busy={pending || undefined}>
{pending ? "Verifying…" : "Verify and turn on"}
</Button>
<Button type="button" variant="ghost" onClick={() => setEnrolling(false)}>
Cancel
</Button>
</CardFooter>
</form>
) : (
<CardContent>
<Button type="button" variant="outline" onClick={() => setEnrolling(true)}>
{enabled ? "Replace authenticator app" : "Turn on two-factor authentication"}
</Button>
</CardContent>
)}
</Card>
)
}