Dashboard and Settings
Build on the authenticated dashboard surface, the one flag-gated settings hub behind its rail, and the role-gated admin panel.
The dashboard is the authenticated surface at config.routes.dashboard (/dashboard), a Server Component that fetches GET /dashboard/status once on the server. Everything under /settings sits in ONE hub behind a vertical rail (settingsNavGroups, packages/config/src/settings-nav.ts); the admin panel beside it keeps its horizontal tab strip (sectionTabsConfig, packages/config/src/section-tabs.ts). Both carry requires (and orgRoles) gates that hide anything the viewer or the active config cannot reach.
Dashboard surface
The page renders from that single server-side fetch - no client loading spinner - and drives the plan cards, security score, team stats, usage chart, and activity grid from one payload.
// GET /dashboard/status - packages/api/src/routes/internal/dashboard.ts (authGuard)
{
fundedByOther, // true when the workspace owner pays, not you
planName, planId, planType, planExpiresAt, // ALL null when fundedByOther
isActive, // always present
organizationCreatedAt, // null when single-tenant / no active org
security: { twoFactorEnabled, emailVerified, activeSessionCount, passkeyCount },
team: config.tenancy.multiTenant
? { memberCount, pendingInviteCount, userRole }
: null, // null when single-tenant
}planType is "free" | "lifetime" | "subscription" | null, derived from the resolved billing plan.
When fundedByOther is true the caller is a member of a workspace someone else funds, and every plan field comes back null - only isActive survives. The owner's balance is personal and funds every organization they own, so showing it inside one workspace would leak their spending in another. Render from isActive and point the member at the owner. See Organizations.
Page chrome
Stack an authenticated page's chrome in this order: top bar (the section title, see Navigation), then either the settings rail down the left (anywhere under /settings) or the admin tab row above the content, then PageHeader, then content. The rail is drawn by the section LAYOUT, so it stays on screen while a route resolves; a tab row is drawn by the page. Either way the page renders inside one PageContent column.
PageContent comes from @repo/ui/components/ui/page-content. It caps the readable width, centres the column in the content area, and carries the page padding; the vertical rhythm stays with the page (space-y-* or flex flex-col gap-* via className).
width | Cap | Use for |
|---|---|---|
default | max-w-3xl | forms, settings, prose |
wide | max-w-6xl | data tables, admin, billing, overview grids |
full | none | pages that fill the area but still want the padding |
Deliberately full-bleed surfaces (chat) render OUTSIDE PageContent and take the whole content area.
PageHeader comes from @repo/ui/components/ui/page-header and takes optional title, description, and actions. On a hub page or a tabbed page pass only description plus any page action - the lit rail entry or tab is the title. Elsewhere add a title only when it says more than the top bar does (a greeting, a record's name).
The settings hub
ONE hub behind ONE sidebar row (/settings), with a 224px vertical rail down its left. Both shells render the same rail from the same settingsNavGroups, so a destination cannot exist on web and not on desktop by accident.
| Run | Destination | Route | Gate |
|---|---|---|---|
| (unnamed) | General | /settings | desktop only - the web serves it as a redirect |
| Account | Profile | /settings/profile | always on |
| Account | Billing | /settings/billing | always listed; sections gated by config.payment.enabled |
| Account | Transactions | /settings/billing/history | always on - the billing and credit ledger |
| Account | Security | /settings/security | always on |
| Advanced | Models | /settings/models | requires: ["aiModels"] |
| Advanced | Tasks | /settings/tasks | desktop only; requires: ["desktopTasks"] - agents on AND a builder task targeting the desktop |
| Advanced | Integrations | /settings/integrations | requires: ["ai"] |
| Advanced | MCP server | /settings/mcp | requires: ["mcpServer"] |
| Advanced | Developers | /settings/developers | requires: ["apiKeys"] |
| Organization | Overview | /settings/organization | requires: ["multiTenant"], match: "exact" |
| Organization | Activity | /settings/organization-activity | requires: ["multiTenant"], orgRoles: ["owner", "admin"] |
| Project | Project | /settings/project | requires: ["projects"] - see projects |
The admin panel is the one section outside the hub. It lives outside /settings and keeps its tab strip. Organization and Project are runs of the rail with no sidebar rows of their own, so the footer Settings row lights on their pages too.
The rail is three pieces: the data (packages/config/src/settings-nav.ts), the chrome (packages/ui/src/screens/_shared/settings-nav-chrome.tsx - rail frame, phone menu, group boxes, item classes, two-column layout), and a short binder per shell that maps the visible items to its own link primitive. The chrome carries no links, because the two shells route with different ones.
- A group heading is an
h2ABOVE its list, never a word among the items, and a run whose every destination is gated off does not render at all. - Below 768px the rail becomes a menu - a dropdown whose trigger names the current destination, with the same runs as labels and each entry a real link. A column expanded in place pushed the page off the screen; a menu floats over it. Both shapes render from the same visible-groups data, so they cannot disagree.
sidebarConfig's Settings row derives itsactiveMatchfrom these runs, so the row lights on exactly the pages the rail lists.- The desktop passes its own feature resolution.
ai,aiByokandaiModelsfold in the web-onlyai.webUiswitch, so a desktop-first build resolves them from its own facts instead.
Billing always appears, but its sections only appear when config.payment.enabled is true (the default). With payments off, the page shows nothing actionable - and the desktop, which registers no billing route in that build, drops the destination from the rail entirely.
Admin panel
Access is enforced server-side in admin/layout.tsx: any session whose user.role !== "admin" is redirected to config.routes.loginRedirect, and the sidebar link is separately hidden by roles: ["admin"] in config/sidebar.ts. Because the layout gates the whole panel, individual tabs carry no roles gate; only feature flags hide one from an admin.
The admin pages carry their own horizontal tab row, and the sidebar's Admin row opens the section's first enabled tab.
| Tab | Route | Tab gate |
|---|---|---|
| Finance | /admin | requires: ["credits"] |
| Users | /admin/users | none |
| Organizations | /admin/organizations | requires: ["multiTenant"] |
| Audit logs | /admin/audit-logs | none |
| Billing logs | /admin/billing-logs | none |
| API keys | /admin/api-keys | requires: ["apiKeys"] |
| Announcements | /admin/announcements | none |
| Emails | /admin/emails | none |
/admin is the finance dashboard - revenue per currency, stamped vendor cost, top spenders, and a ledger-integrity check. config.routes.admin stays /admin/users: it is the impersonation-return target.
Every tab above is a shared screen: one body each, rendered by whichever shells your build ships, so the panel cannot drift between them. The role gate is enforced per shell - server-side in admin/layout.tsx on the web, and at the route on any other shell - and the tab gates in the table are the same config flags either way. The admin panel has no feature flag of its own: it ships in every project, and the role is what decides who opens it.
Navigation
Configure the navbar, sidebar, user menu, and the settings rail from typed config files, with role, org-role, and feature-flag gating plus i18n labels.
Banners
Ship announcement bars on the marketing and dashboard surfaces, each toggled by config.banner, with role and plan targeting plus dismissal on the dashboard.