/settingsWorkspace settings
A six-section settings page: workspace and brand, members, billing and usage, API keys, notification preferences, and a danger zone — with a save bar that appears the moment anything changes.
The page is a server component inside AppShell, with nav.ts describing the navigation as plain data; SettingsLayout switches the panel below it, and the page reads h1 (the page) to h2 (the section on show) to h3 (the panels inside it), each level saying something the one above it did not. The people, the live API keys and the kinds of email the workspace can send come from db, and the plan and its seats are `seatUsage()` from the sample data — members who are in or yet to join plus the open invitations, against the seats Team includes, the one count every page and section shows — so the seat count is a fact rather than a figure, read by the nav, the members list and the billing meter, and no two panels can drift apart; the danger callout counts the people on the list. What is left is this workspace's own configuration rather than a row in a repository: the name, the brand and the region are stated in data.ts, which is what the form on the first panel edits. Unsaved state lives at the page level, so the ActionBar appears for a changed field or a flipped toggle and discarding puts both back. Composes AppShell, PageHeader, SettingsLayout, FormSection, FormRow, ColorPicker, FileDropzone, AvatarGroup, SimpleTable, UserCell, StatusBadge, DateCell, DataTableRowActions, UsageMeter, PercentageBar, ApiKeyField, Callout, ToggleRow, ConfirmDialog and ActionBar.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { signOut } from "./actions"
import { SettingsView } from "./components/settings-view"
import { currentUser, settingsData, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Workspace settings. The page is a server component inside the shell; the
* section switch, the form state, and the save bar that appears once something
* changes are one client island, handed every row it draws — the island never
* imports the module that reads the store.
*/
export default function SettingsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<SettingsView data={settingsData()} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/settings-generalNeeds 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 { signOut } from "./actions"
import { SettingsView } from "./components/settings-view"
import { currentUser, settingsData, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Workspace settings. The page is a server component inside the shell; the
* section switch, the form state, and the save bar that appears once something
* changes are one client island, handed every row it draws — the island never
* imports the module that reads the store.
*/
export default function SettingsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<SettingsView data={settingsData()} />
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at; the page hands it to AppShell as `activeHref`. */
export const ROUTE = "/settings"
/**
* This product's navigation, as plain data. AppShell resolves the icon names,
* so nothing here is a component and nothing here says "I am the page you are
* on" — `ROUTE` above settles that.
*/
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: "Analytics", href: "/analytics", icon: "chart-line" },
{ title: "Revenue", href: "/saas/revenue", icon: "credit-card" },
{ title: "Customers", href: "/ecommerce/customers", icon: "users" },
{ title: "Monitoring", href: "/engineering/monitoring", icon: "activity" },
{ title: "Reports", href: "/reports", icon: "file-text", badge: 4 },
],
},
],
// Pinned under the groups, the way the secondary links were.
footer: [
{ title: "Settings", href: "/settings", icon: "settings" },
{ title: "Support", href: "/support", icon: "life-buoy" },
],
}/**
* What this page reads. The people, the keys and the kinds of email the
* workspace can send all come from `db`; the plan and its seats are
* `seatUsage()`, the one count every page shows. What is left — the workspace's own name,
* brand and region — is this workspace's configuration rather than a row in a
* repository, so it is stated here. "Now" is `REFERENCE_DATE`.
*/
import { getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
seatUsage,
seeded,
type ApiKey,
type Member as MemberRow,
type Notification,
} from "@/lib/sample-data"
/** The workspace's own settings — the things the form on this page edits. */
export const WORKSPACE = {
name: "Northwind Analytics",
slug: "northwind",
region: "eu-central-1",
brandColor: "#3D63DD",
supportEmail: "support@northwind.example",
}
export type Member = {
id: string
name: string
email: string
avatarUrl?: string
role: string
status: MemberRow["status"]
lastActive: Date
}
const ROLE_ORDER: MemberRow["role"][] = ["owner", "admin", "member", "viewer"]
/** Everyone in the workspace, most powerful role first, then most recently active. */
export function members(): Member[] {
return db.members
.all()
.sort((a, b) => {
const byRole = ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role)
return byRole !== 0 ? byRole : b.lastActiveAt.getTime() - a.lastActiveAt.getTime()
})
.map((member) => ({
id: member.id,
name: member.name,
email: member.email,
avatarUrl: member.avatarUrl,
role: member.role.charAt(0).toUpperCase() + member.role.slice(1),
status: member.status,
lastActive: member.lastActiveAt,
}))
}
const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
const NEXT_INVOICE = new Date(
Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 1)
)
// The metered figures no row records, drawn once in this order from the
// page's own seed: the events used, then storage, then support.
const rand = seeded("settings-01")
const EVENTS_LIMIT = 3_000_000
const EVENTS_USED = Math.round(EVENTS_LIMIT * (0.58 + rand() * 0.12))
const STORAGE = Math.round(20 + rand() * 30)
const PREMIUM_SUPPORT = Math.round(80 + rand() * 60)
export type Billing = {
plan: string
cycle: string
nextInvoice: Date
seats: { used: number; limit: number }
events: { used: number; limit: number }
charges: { label: string; value: number }[]
/** What this month has cost so far — the charges bar and the heading above it. */
total: number
/** The month the charges are running up, e.g. "September". */
month: string
/** When the metered allowance starts again, e.g. "1 October". */
resetsAt: string
}
/**
* The plan, its seats and what the month has cost, read per request: the
* seats are counted the one way every page counts them — members who are in
* or yet to join, and open invitations — so an invitation sent or a member
* removed since the server started is in the next count.
*/
export function billing(): Billing {
const seats = seatUsage()
const charges = [
{ label: "Platform", value: seats.plan.priceMonthlyCents / 100 },
// Events are metered; storage and support are what the plan adds on top.
{ label: "Events", value: Math.round((EVENTS_USED / 1_000_000) * 45) },
{ label: "Storage", value: STORAGE },
{ label: "Premium support", value: PREMIUM_SUPPORT },
]
return {
plan: seats.plan.name,
cycle: "Billed monthly",
nextInvoice: NEXT_INVOICE,
// An open invitation holds a seat as a member does; a deactivated member
// holds none. Nothing on this page states that count a second time.
seats: { used: seats.used, limit: seats.limit ?? seats.used },
events: { used: EVENTS_USED, limit: EVENTS_LIMIT },
charges,
total: charges.reduce((sum, charge) => sum + charge.value, 0),
month: billingMonth(),
resetsAt: usageResetsAt(),
}
}
/** "25 of 30 seats", written once so no panel can drift from another. */
function seatsSummary({ seats }: Pick<Billing, "seats">): string {
return `${seats.used} of ${seats.limit} seats`
}
/** The month the charges below are running up, e.g. "September". */
export function billingMonth(): string {
return MONTH_NAME.format(REFERENCE_DATE)
}
/** When the metered allowance starts again, e.g. "1 October". */
export function usageResetsAt(): string {
return `${NEXT_INVOICE.getUTCDate()} ${MONTH_NAME.format(NEXT_INVOICE)}`
}
const KEY_COUNT = 3
/** The live keys, most recently used first. A revoked key is not shown at all. */
export function apiKeys(): ApiKey[] {
return db.apiKeys
.all()
.filter((key) => key.status === "active")
.sort((a, b) => (b.lastUsedAt?.getTime() ?? 0) - (a.lastUsedAt?.getTime() ?? 0))
.slice(0, KEY_COUNT)
}
export type NotificationPref = {
id: string
label: string
description: string
enabled: boolean
}
// One toggle per kind of notification the product sends, in the order a reader
// cares about them.
const KIND_ORDER: Notification["kind"][] = ["mention", "assignment", "billing", "security", "system"]
const KIND_COPY: Record<Notification["kind"], { label: string; description: string }> = {
mention: {
label: "Mentions",
description: "When someone names you in a comment or a thread.",
},
assignment: {
label: "Assignments",
description: "When a task or an incident lands in your queue.",
},
billing: { label: "Billing", description: "Invoices, failed payments, and plan changes." },
security: {
label: "Security",
description: "New sign-ins, key changes, and changes to what someone may see.",
},
system: {
label: "System",
description: "Maintenance windows and status changes, about once a month.",
},
}
/** A toggle for every kind of notification the workspace actually sends. */
export function notificationPrefs(): NotificationPref[] {
const sent = new Set(db.notifications.all().map((notification) => notification.kind))
return KIND_ORDER.filter((kind) => sent.has(kind)).map((kind) => ({
id: kind,
...KIND_COPY[kind],
// Everything that is about you is on; the ones about the platform are not.
enabled: kind !== "system",
}))
}
/** How many people lose access if the workspace goes — the danger callout's number. */
export function memberTotal(): number {
return db.members.all().length
}
function ownerRow(): MemberRow {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** 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 }
}
/** Everything the settings island draws, read on the server and handed over as props. */
export type SettingsData = {
workspace: typeof WORKSPACE
billing: Billing
/** "25 of 30 seats", written once so no panel can drift from another. */
seatsSummary: string
members: Member[]
apiKeys: ApiKey[]
notificationPrefs: NotificationPref[]
memberTotal: number
}
/** The island's props, read in one place per request. */
export function settingsData(): SettingsData {
const plan = billing()
return {
workspace: WORKSPACE,
billing: plan,
seatsSummary: seatsSummary(plan),
members: members(),
apiKeys: apiKeys(),
notificationPrefs: notificationPrefs(),
memberTotal: memberTotal(),
}
}
/** 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 { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes. 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 { Callout } from "@/components/ui/callout"
import { ApiKeyField } from "@/components/ui/api-key-field"
import { FormSection } from "@/components/ui/form-section"
import { type SettingsData } from "../data"
/** The live keys, read on the server and handed in. */
export function ApiKeysSection({ keys }: { keys: SettingsData["apiKeys"] }) {
return (
<FormSection as="h3"
title="Live keys"
description="Rotating a key takes effect immediately; anything still using the old one starts failing."
>
<Callout variant="info" title="Keys are shown once">
Copy a key when you create it. After that only the last four characters are readable.
</Callout>
{keys.map((key) => (
<ApiKeyField
key={key.id}
label={key.name}
value={key.prefix}
createdAt={key.createdAt}
lastUsedAt={key.lastUsedAt}
onRegenerate={() => {}}
onRevoke={() => {}}
/>
))}
</FormSection>
)
}import { formatCompact, formatCurrency, formatDate } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { FormSection } from "@/components/ui/form-section"
import { PercentageBar } from "@/components/ui/percentage-bar"
import { UsageMeter } from "@/components/ui/usage-meter"
import { type Billing } from "../data"
/** Nothing on this panel holds state, so it stays a plain function component. */
export function BillingSection({ billing }: { billing: Billing }) {
return (
<FormSection as="h3"
title="Plan and usage"
description={`The next invoice is due ${formatDate(billing.nextInvoice, "medium", { timeZone: "UTC" })}.`}
actions={
<Button size="sm" variant="outline">
Change plan
</Button>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<UsageMeter
label="Seats"
used={billing.seats.used}
limit={billing.seats.limit}
unit="seats"
tone="auto"
resetsAt="A seat is billed from the day it is filled."
/>
<UsageMeter
label="Events"
used={billing.events.used}
limit={billing.events.limit}
unit="events"
tone="auto"
format={(value) => formatCompact(value)}
resetsAt={`Resets ${billing.resetsAt}.`}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<h3 className="text-sm font-medium">{billing.month} so far</h3>
<span className="text-sm tabular-nums">
{formatCurrency(billing.total, "USD", { maximumFractionDigits: 0 })}
</span>
</div>
<PercentageBar
segments={billing.charges}
showLegend
showValues
format={(value) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
/>
</div>
</FormSection>
)
}"use client"
import { TrashIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { FormSection } from "@/components/ui/form-section"
import { type SettingsData } from "../data"
export function DangerSection({
workspace,
memberTotal,
}: Pick<SettingsData, "workspace" | "memberTotal">) {
return (
<FormSection as="h3"
title="Delete this workspace"
description="The workspace, everything in it, and every key it holds."
>
<Callout variant="danger" title="Deleting the workspace deletes its data">
Every report, saved view, and API key goes with it, the workspace name{" "}
<span className="font-mono">{workspace.slug}</span> is released for anyone to claim, and{" "}
{memberTotal} people lose access the moment it runs.
</Callout>
<div className="flex flex-wrap items-center justify-between gap-3 panel ring-danger/40 p-4">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-sm font-medium">Delete {workspace.name}</p>
<p className="text-sm text-muted-foreground">
Exports stay available for 30 days, then are removed.
</p>
</div>
<ConfirmDialog
variant="destructive"
confirmationWord="delete"
title={`Delete ${workspace.name}?`}
description="This removes the workspace and everything in it. There is no undo."
confirmText="Delete workspace"
onConfirm={() => {}}
trigger={
<Button variant="destructive" size="sm">
<TrashIcon data-icon="inline-start" />
Delete workspace
</Button>
}
/>
</div>
</FormSection>
)
}"use client"
import * as React from "react"
import { ColorPicker } from "@/components/ui/color-picker"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { FormRow, FormSection } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import { type SettingsData } from "../data"
/** The swatches the picker offers. UI vocabulary, so it lives with the control. */
const BRAND_PRESETS = [
"#3D63DD",
"#0F766E",
"#B45309",
"#B91C1C",
"#7C3AED",
"#0E7490",
"#15803D",
"#BE185D",
]
export function GeneralSection({
workspace,
onDirty,
}: {
workspace: SettingsData["workspace"]
onDirty: () => void
}) {
const [name, setName] = React.useState(workspace.name)
const [supportEmail, setSupportEmail] = React.useState(workspace.supportEmail)
const [brandColor, setBrandColor] = React.useState(workspace.brandColor)
const [logo, setLogo] = React.useState<File[]>([])
return (
<div className="flex flex-col gap-10">
<FormSection as="h3"
aside
title="Workspace"
description="How Northwind refers to you in reports, invites, and alerts."
>
<FormRow label="Name" htmlFor="workspace-name" required>
<Input
id="workspace-name"
value={name}
onChange={(event) => {
setName(event.target.value)
onDirty()
}}
/>
</FormRow>
<FormRow
label="Workspace URL"
htmlFor="workspace-slug"
description="Changing this breaks links people have already saved."
>
<Input id="workspace-slug" defaultValue={workspace.slug} onChange={onDirty} />
</FormRow>
<FormRow
label="Support email"
htmlFor="workspace-support"
description="Where replies to alerts and digests go."
>
<Input
id="workspace-support"
type="email"
value={supportEmail}
onChange={(event) => {
setSupportEmail(event.target.value)
onDirty()
}}
/>
</FormRow>
<FormRow label="Data region" htmlFor="workspace-region" description="Set when the workspace was created.">
<Input id="workspace-region" defaultValue={workspace.region} readOnly className="font-mono" />
</FormRow>
</FormSection>
<FormSection as="h3"
aside
title="Brand"
description="Used on shared dashboards, exported reports, and the status page."
>
<FormRow label="Accent colour" description="Charts keep their own palette.">
<ColorPicker
aria-label="Accent colour"
value={brandColor}
presets={BRAND_PRESETS}
onValueChange={(hex) => {
setBrandColor(hex)
onDirty()
}}
/>
</FormRow>
<FormRow label="Logo" description="Shown at 32px beside the workspace name.">
<FileDropzone
accept={[".svg", ".png"]}
maxSize={1024 * 1024}
maxFiles={1}
description="SVG or PNG, up to 1 MB. A new file replaces the current logo."
value={logo}
onFilesAccepted={(files) => {
setLogo(files.slice(0, 1))
onDirty()
}}
onRemove={() => setLogo([])}
/>
</FormRow>
</FormSection>
</div>
)
}"use client"
import { MailIcon, ShieldIcon, UserRoundMinusIcon, UserRoundPlusIcon } from "lucide-react"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Button } from "@/components/ui/button"
import { DataTableRowActions } from "@/components/ui/data-table"
import { FormSection } from "@/components/ui/form-section"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { DateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type Member } from "../data"
/** Two of the three member states are not in StatusBadge's own vocabulary. */
const STATUS_MAP = { invited: "warning", deactivated: "neutral" } as const
const COLUMNS: SimpleTableColumn<Member>[] = [
{
key: "name",
header: "Member",
cell: (member) => <UserCell size="sm" name={member.name} email={member.email} src={member.avatarUrl} />,
},
{
key: "role",
header: "Role",
cell: (member) => <span className="text-muted-foreground">{member.role}</span>,
},
{
key: "status",
header: "Status",
cell: (member) => <StatusBadge status={member.status} map={STATUS_MAP} />,
},
{
key: "lastActive",
header: "Last active",
align: "right",
cell: (member) => (
<DateCell date={member.lastActive} relative className="text-muted-foreground" />
),
},
{
key: "actions",
header: <span className="sr-only">Actions</span>,
width: "44px",
cell: (member) => (
<DataTableRowActions
label={`Open menu for ${member.name}`}
actions={[
{ label: "Change role", icon: <ShieldIcon />, onSelect: () => {} },
{ label: "Resend invite", icon: <MailIcon />, onSelect: () => {} },
{
label: "Remove from workspace",
icon: <UserRoundMinusIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => {},
},
]}
/>
),
},
]
/** Everyone in the workspace, read on the server and handed in with the seat count and the plan. */
export function MembersSection({ members: rows, seats, plan }: { members: Member[]; seats: string; plan: string }) {
return (
<FormSection as="h3"
title="Who has access"
description="Owners and admins can invite people and change what everyone else may see."
actions={
<Button size="sm">
<UserRoundPlusIcon data-icon="inline-start" />
Invite people
</Button>
}
>
<div className="flex items-center gap-3">
<AvatarGroup users={rows.map((member) => ({ name: member.name, src: member.avatarUrl }))} max={5} ring />
<p className="text-sm text-muted-foreground">
{seats} used on the {plan} plan.
</p>
</div>
<SimpleTable size="sm" columns={COLUMNS} rows={rows} rowKey="id" hoverable />
</FormSection>
)
}"use client"
import { FormSection } from "@/components/ui/form-section"
import { ToggleRow } from "@/components/ui/toggle-row"
import { type NotificationPref } from "../data"
export function NotificationsSection({
prefs,
values,
onChange,
}: {
prefs: NotificationPref[]
values: Record<string, boolean>
onChange: (id: string, enabled: boolean) => void
}) {
return (
<FormSection as="h3"
title="Email"
description="Everyone in the workspace sets their own; changing these affects nobody else."
>
<div className="divide-y panel">
{prefs.map((pref) => (
<ToggleRow
key={pref.id}
className="px-4"
label={pref.label}
description={pref.description}
checked={values[pref.id]}
onCheckedChange={(enabled) => onChange(pref.id, enabled)}
/>
))}
</div>
</FormSection>
)
}"use client"
import * as React from "react"
import {
BellIcon,
CreditCardIcon,
KeyRoundIcon,
SettingsIcon,
TriangleAlertIcon,
UsersIcon,
} from "lucide-react"
import { ActionBar } from "@/components/ui/action-bar"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout, type SettingsNavItem } from "@/components/ui/settings-layout"
import { type SettingsData } from "../data"
import { ApiKeysSection } from "./api-keys-section"
import { BillingSection } from "./billing-section"
import { DangerSection } from "./danger-section"
import { GeneralSection } from "./general-section"
import { MembersSection } from "./members-section"
import { NotificationsSection } from "./notifications-section"
/**
* The settings island. Everything it draws is read on the server and handed in
* as `data` — the page's rows never follow the island into the browser, and
* neither does the store they come from.
*/
export function SettingsView({ data }: { data: SettingsData }) {
const { workspace, billing, seatsSummary: seats, members, apiKeys, notificationPrefs, memberTotal } = data
const nav: SettingsNavItem[] = [
{ title: "General", href: "/settings", icon: <SettingsIcon /> },
{ title: "Members", href: "/settings/members", icon: <UsersIcon />, description: seats },
{ title: "Billing", href: "/settings/billing", icon: <CreditCardIcon /> },
{ title: "API keys", href: "/settings/api-keys", icon: <KeyRoundIcon /> },
{ title: "Notifications", href: "/settings/notifications", icon: <BellIcon /> },
{ title: "Danger zone", href: "/settings/danger", icon: <TriangleAlertIcon /> },
]
/** The line under each section's heading. Keyed the same way the nav is. */
const summary: Record<string, string> = {
"/settings": "The name, the brand, and the region the data lives in.",
"/settings/members": `${memberTotal} people, on ${seats}.`,
"/settings/billing": `${billing.plan}, ${billing.cycle.toLowerCase()}.`,
"/settings/api-keys": "Keys authenticate every request to the events API.",
"/settings/notifications": "What Northwind emails you, and how often.",
"/settings/danger": "Only an owner can run these.",
}
const [activeHref, setActiveHref] = React.useState("/settings")
const [saved, setSaved] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(notificationPrefs.map((pref) => [pref.id, pref.enabled]))
)
const [prefs, setPrefs] = React.useState<Record<string, boolean>>(saved)
const [edited, setEdited] = React.useState(false)
// Bumped on discard so the General panel's own fields go back to what is
// stored, the same way the toggles do.
const [formKey, setFormKey] = React.useState(0)
const dirty = edited || notificationPrefs.some((pref) => prefs[pref.id] !== saved[pref.id])
const section = nav.find((item) => item.href === activeHref)
function discard() {
setPrefs(saved)
setEdited(false)
setFormKey((key) => key + 1)
}
function save() {
setSaved(prefs)
setEdited(false)
}
return (
<>
{/* Three levels, each saying something the one above it did not: the page
(h1), the section on show (SettingsLayout's title, h2), and the panels
inside it (FormSection, h3). */}
<PageHeader
title="Settings"
description={`Everyone in ${workspace.name} works from these.`}
/>
<SettingsLayout
nav={nav}
activeHref={activeHref}
onNavigate={setActiveHref}
title={section?.title}
description={summary[activeHref]}
>
{activeHref === "/settings" ? (
<GeneralSection key={formKey} workspace={workspace} onDirty={() => setEdited(true)} />
) : null}
{activeHref === "/settings/members" ? (
<MembersSection members={members} seats={seats} plan={billing.plan} />
) : null}
{activeHref === "/settings/billing" ? <BillingSection billing={billing} /> : null}
{activeHref === "/settings/api-keys" ? <ApiKeysSection keys={apiKeys} /> : null}
{activeHref === "/settings/notifications" ? (
<NotificationsSection
prefs={notificationPrefs}
values={prefs}
onChange={(id, enabled) => setPrefs((current) => ({ ...current, [id]: enabled }))}
/>
) : null}
{activeHref === "/settings/danger" ? <DangerSection workspace={workspace} memberTotal={memberTotal} /> : null}
</SettingsLayout>
<ActionBar
open={dirty}
message="You have unsaved changes."
onSave={save}
onDiscard={discard}
/>
</>
)
}