/settings/profileProfile settings
The signed-in person's own details: avatar, name, email and timezone in one saved form, with the account facts the form cannot change beside it.
The page is a server component inside AppShell: it reads the workspace owner's row through db.members and hands it to the form, which is the only client island. Saving goes through a server action that validates the name, the address and the timezone, refuses an address another member already uses, and writes the row back with db.members.update — every refusal naming the field it belongs to so the message renders under that input. Role, join date, teammate count and two-factor state are read off the same row rather than restated. The photo upload is a reachable placeholder: aria-disabled with the reason beside it, not a dead disabled button. 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, Input, Select, Avatar, DescriptionList and StatusBadge.
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 { AccountSummary } from "./components/account-summary"
import { ProfileForm } from "./components/profile-form"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import {
currentUser,
DEFAULT_TIMEZONE,
joinedOn,
membershipYears,
profile,
shellNotifications,
teammateCount,
TIMEZONES,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Your profile. The page is a server component inside the shell: it reads the
* member row through `db` and hands it to the form, which is the only client
* island. SettingsLayout draws the settings area's own section list beside it;
* every section there is a real route, so its links are plain anchors.
*/
export default function SettingsProfilePage() {
const me = profile()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Profile"
description="Your name, your address, and how dates are drawn for you."
/>
{/* The shell's sidebar already lists the six settings sections, so the
layout's own 220px column would be the same links a second time; the
page takes the width back and holds the form to a readable measure. */}
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE} sectionsInSidebar>
<div className="flex max-w-2xl flex-col gap-6">
<ProfileForm
name={me.name}
email={me.email}
initials={me.initials}
avatarUrl={me.avatarUrl}
timezone={DEFAULT_TIMEZONE}
timezones={TIMEZONES}
/>
<AccountSummary
memberId={me.id}
role={me.role}
joinedOn={joinedOn()}
years={membershipYears()}
teammates={teammateCount()}
twoFactor={me.twoFactor}
/>
</div>
</SettingsLayout>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/settings-profileNeeds 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 { AccountSummary } from "./components/account-summary"
import { ProfileForm } from "./components/profile-form"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import {
currentUser,
DEFAULT_TIMEZONE,
joinedOn,
membershipYears,
profile,
shellNotifications,
teammateCount,
TIMEZONES,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Your profile. The page is a server component inside the shell: it reads the
* member row through `db` and hands it to the form, which is the only client
* island. SettingsLayout draws the settings area's own section list beside it;
* every section there is a real route, so its links are plain anchors.
*/
export default function SettingsProfilePage() {
const me = profile()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Profile"
description="Your name, your address, and how dates are drawn for you."
/>
{/* The shell's sidebar already lists the six settings sections, so the
layout's own 220px column would be the same links a second time; the
page takes the width back and holds the form to a readable measure. */}
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE} sectionsInSidebar>
<div className="flex max-w-2xl flex-col gap-6">
<ProfileForm
name={me.name}
email={me.email}
initials={me.initials}
avatarUrl={me.avatarUrl}
timezone={DEFAULT_TIMEZONE}
timezones={TIMEZONES}
/>
<AccountSummary
memberId={me.id}
role={me.role}
joinedOn={joinedOn()}
years={membershipYears()}
teammates={teammateCount()}
twoFactor={me.twoFactor}
/>
</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/profile"
/**
* 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 editing it is the workspace owner in
* `db.members`, so their name, address, role, id and join date all come from
* that row — and saving the form writes back through the same repository. The
* one thing no repository records is the display timezone, which is stated
* here beside the vocabulary the save action validates it against. "Now" is
* `REFERENCE_DATE`.
*/
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member } from "@/lib/sample-data"
export type Profile = {
id: string
name: string
email: string
role: Member["role"]
initials: string
avatarUrl?: string
joinedAt: Date
lastActiveAt: Date
twoFactor: boolean
}
/** The row the whole page is about: whoever owns this workspace. */
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The signed-in person, read fresh so a save is visible on the next render. */
export function profile(): Profile {
const owner = ownerRow()
return {
id: owner.id,
name: owner.name,
email: owner.email,
role: owner.role,
initials: getInitials(owner.name),
avatarUrl: owner.avatarUrl,
joinedAt: owner.joinedAt,
lastActiveAt: owner.lastActiveAt,
twoFactor: owner.twoFactor,
}
}
/**
* The zones the select offers. A timezone is a display preference rather than
* a row in any repository, so it is stated here — and `saveProfile` rejects
* anything outside this list, which is why the vocabulary lives beside the
* action instead of in the component that draws it.
*/
export const TIMEZONES = [
"Europe/London",
"Europe/Berlin",
"Europe/Lisbon",
"America/New_York",
"America/Los_Angeles",
"Asia/Singapore",
"Australia/Sydney",
] as const
export type Timezone = (typeof TIMEZONES)[number]
/** Where the owner reads their dashboards from; the form's starting value. */
export const DEFAULT_TIMEZONE: Timezone = "Europe/Berlin"
/** "13 June 2023" — the date the owner's row says they joined. */
export function joinedOn(): string {
return formatDate(ownerRow().joinedAt, "long", { timeZone: "UTC" })
}
/** How long this account has been open, in whole years, against REFERENCE_DATE. */
export function membershipYears(): number {
const ms = REFERENCE_DATE.getTime() - ownerRow().joinedAt.getTime()
return Math.floor(ms / (365.25 * 24 * 60 * 60 * 1000))
}
/** How many people share the workspace with them. */
export function teammateCount(): number {
return db.members.all().length - 1
}
/** The person looking at the page — the same row the form edits. */
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, type Result } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"
import { profile, TIMEZONES, type Timezone } from "./data"
export type SavedProfile = { name: string; email: string; timezone: Timezone }
/** What the form holds between submits: nothing yet, or the last outcome. */
export type ProfileState = Result<SavedProfile> | null
function field(formData: FormData, key: string): string {
const value = formData.get(key)
return typeof value === "string" ? value.trim() : ""
}
/** A name as a person would write theirs: long enough for anyone's, short enough for a row. */
const MAX_NAME = 80
/**
* Writes the form back to the signed-in member's row. Whose row is decided
* here, from the session — `profile()` — and never read off the form: a hidden
* id was a suggestion anyone could edit, and a crafted one renamed and
* re-addressed another member (CONVENTIONS block rule 8). Every refusal names
* the field it belongs to, so the form can render it under that input rather
* than as a message the reader has to match up by hand.
*/
export async function saveProfile(
_previous: ProfileState,
formData: FormData
): Promise<Result<SavedProfile>> {
if (!isForm(formData)) return invalidInput("Send your details as the form sends them.")
const { id } = profile()
const name = field(formData, "name")
const email = field(formData, "email")
const timezone = field(formData, "timezone")
if (!name) {
return { ok: false, error: { code: "invalid_input", field: "name", message: "Enter your name." } }
}
if (name.length > MAX_NAME) {
return { ok: false, error: { code: "invalid_input", field: "name", message: `Keep your name under ${MAX_NAME} characters.` } }
}
if (!isEmail(email)) {
return {
ok: false,
error: { code: "invalid_input", field: "email", message: "Enter a valid email address." },
}
}
if (!TIMEZONES.includes(timezone as Timezone)) {
return {
ok: false,
error: { code: "invalid_input", field: "timezone", message: "Choose a timezone from the list." },
}
}
// Someone else may already use the address; members share one workspace, so
// this is a real constraint rather than a formatting rule.
const taken = db.members.all().some((member) => member.id !== id && member.email === email)
if (taken) {
return {
ok: false,
error: { code: "email_taken", field: "email", message: "Another member already uses that address." },
}
}
const updated = await db.members.update(id, { name, email })
if (!updated.ok) return updated
return { ok: true, data: { name: updated.data.name, email: updated.data.email, timezone: timezone as Timezone } }
}
/**
* 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 } }
}import { DescriptionList } from "@/components/ui/description-list"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
export type AccountSummaryProps = {
memberId: string
role: string
joinedOn: string
years: number
teammates: number
twoFactor: boolean
}
/**
* The facts about the account that the form does not edit — every one of them
* read off the member row rather than restated here.
*/
export function AccountSummary({
memberId,
role,
joinedOn,
years,
teammates,
twoFactor,
}: AccountSummaryProps) {
return (
<Card>
<CardHeader>
<SectionHeader
as="h2"
title="Account"
description="What the workspace records about this account. Only another owner can change the role."
/>
</CardHeader>
<CardContent>
<DescriptionList
items={[
{
term: "Role",
description: <StatusBadge status={role} variant="info" dot={false} size="sm" />,
},
{
term: "Member since",
description: `${joinedOn} · ${years} years`,
},
{
term: "Teammates",
description: `${teammates} other people in this workspace`,
},
{
term: "Two-factor",
description: (
<StatusBadge
status={twoFactor ? "on" : "off"}
variant={twoFactor ? "success" : "warning"}
size="sm"
label={twoFactor ? "On" : "Off"}
/>
),
},
{
// font-mono earns its place here: this is an identifier a reader
// copies into a support ticket, not prose.
term: "Member ID",
description: <span className="font-mono text-xs">{memberId}</span>,
},
]}
/>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { FormRow } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import { SectionHeader } from "@/components/ui/section-header"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { saveProfile, type ProfileState } from "../actions"
export type ProfileFormProps = {
name: string
email: string
initials: string
/** The face on file, beside the initials it falls back to. */
avatarUrl?: string
timezone: string
timezones: readonly string[]
}
/**
* The one form on this page. Both text inputs are controlled: React resets an
* uncontrolled form once its action settles, which would wipe what was typed
* on every rejected submit. The timezone rides along in a hidden input so the
* action reads it out of FormData like every other field. Whose row the save
* writes is the action's to know, not the form's: no id travels with it.
*/
export function ProfileForm({ name, email, initials, avatarUrl, timezone, timezones }: ProfileFormProps) {
const [state, formAction, pending] = React.useActionState<ProfileState, FormData>(
saveProfile,
null
)
const [nameValue, setNameValue] = React.useState(name)
const [emailValue, setEmailValue] = React.useState(email)
const [zone, setZone] = React.useState(timezone)
const error = state && !state.ok ? state.error : undefined
const errorFor = (field: string) => (error?.field === field ? error.message : undefined)
// A refusal with no field is nobody's input in particular, so it goes above
// the buttons as a form-level alert rather than under an arbitrary control.
const formError = error && !error.field ? error.message : undefined
return (
<Card>
<CardHeader>
<SectionHeader
as="h2"
title="Your details"
description="How you appear to everyone else in this workspace."
/>
</CardHeader>
<form action={formAction}>
{/* A measure, not the card's full width: a name and an address are
short, and a 900px input invites neither. */}
<CardContent className="flex max-w-md flex-col gap-5">
<input type="hidden" name="timezone" value={zone} />
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<Avatar className="size-14">
{avatarUrl ? <AvatarImage src={avatarUrl} alt="" /> : null}
<AvatarFallback className="text-base">{initials}</AvatarFallback>
</Avatar>
<Button
type="button"
variant="outline"
size="sm"
// aria-disabled rather than disabled: the control stays
// reachable, and the line under it says why nothing happens.
aria-disabled="true"
aria-describedby="avatar-upload-reason"
>
Upload a photo
</Button>
</div>
{/* Under the row rather than beside the button, so the mark and the
control stay on one line at every width this page is read at. */}
<p id="avatar-upload-reason" className="text-xs text-muted-foreground">
Uploads are off in this demo — the mark is drawn from your initials.
</p>
</div>
<FormRow label="Full name" htmlFor="profile-name" required error={errorFor("name")}>
<Input
id="profile-name"
name="name"
autoComplete="name"
value={nameValue}
onChange={(event) => setNameValue(event.target.value)}
/>
</FormRow>
<FormRow
label="Email"
htmlFor="profile-email"
required
description="Sign-in address, and where every notification is sent."
error={errorFor("email")}
>
<Input
id="profile-email"
name="email"
type="email"
autoComplete="email"
value={emailValue}
onChange={(event) => setEmailValue(event.target.value)}
/>
</FormRow>
<FormRow
label="Timezone"
htmlFor="profile-timezone"
description="Dates and charts are drawn in this zone."
error={errorFor("timezone")}
>
{/* The wiring goes on the trigger, not on Select's root: the root
is a context provider and renders no element of its own, so
aria-describedby handed to it would go nowhere. */}
{(field) => (
<Select value={zone} onValueChange={(next) => setZone(String(next))}>
<SelectTrigger
id={field.id}
aria-describedby={field["aria-describedby"]}
aria-invalid={field["aria-invalid"]}
className="w-full sm:w-72"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{timezones.map((option) => (
<SelectItem key={option} value={option}>
{option.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</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 ? `Profile saved. You are ${state.data.name}.` : null}
</p>
<Button type="submit" disabled={pending} aria-busy={pending || undefined}>
{pending ? "Saving…" : "Save changes"}
</Button>
</CardFooter>
</form>
</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()