Skip to contentVibraUI
Shared pagesinstalls at /settings/profile

Profile 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.

Open the live page

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

Install

npx shadcn@latest add @vibra/settings-profile

Needs the @vibra registry in your components.json — set it up once.

Source

app/settings/profile/page.tsx
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>
  )
}
app/settings/profile/nav.ts
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" }],
}
app/settings/profile/data.ts
/**
 * 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 }))
}
app/settings/profile/actions.ts
"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 } }
}
app/settings/profile/components/account-summary.tsx
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>
  )
}
app/settings/profile/components/profile-form.tsx
"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>
  )
}
app/settings/profile/components/settings-sections.tsx
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()