Dashboard
The protected app shell: sidebar, org switcher, and the layout wrappers to build on.
This is a shell, not a product
The /dashboard page renders a single "Dashboard" heading with its "Intentionally empty." description and nothing else. It is an empty, auth-gated starting point. The sidebar, org switching, and layout wrappers are real and wired; the content is yours to build.
/dashboard is a route group at web/next/src/app/(protected)/. Its layout.tsx does two things: it guards the group behind a session, and wraps every page in the shared sidebar. The privileged /console area reuses the exact same chrome.
The guard
The layout checks the session server-side and bounces anyone without one:
const session = await auth.api.getSession()
if (!session?.user) redirect("/")auth.api.getSession() (in web/next/src/lib/auth/index.ts) calls the API with the request cookies. The redirect happens on the server, so an unauthenticated request never renders the protected page; there is no client-side flash.
The sidebar
The chrome is not inlined in the layout. It lives in SidebarShell (web/next/src/components/shell/sidebar-shell.tsx), a shadcn/ui Sidebar in collapsible="icon" mode, shared by both the dashboard and console layouts. The layout fills three slots:
- Header: the brand (
site.name, linking tohomeHref, which stays inside the app:/dashboardby default), the collapse trigger, and a caller node: for the dashboard, the org switcher. - Content: a caller
navnode. Empty for the dashboard; add your navigation items here. - Footer: a caller node: for the dashboard, the user actions.
The console layout is the same shell with two props flipped: a badge ("Console") and homeHref="/console"; both default to dashboard values. A SidebarRail handles drag-to-resize, and open/closed state persists in a sidebar_state cookie.
Org switcher and active-org persistence
The org switcher (web/next/src/components/dashboard/sidebar.tsx) lists the user's organizations, switches between them, and offers a Create Organization dialog (the slug is auto-generated from the name). The active org survives reloads:
- On switch, the component writes a
last-active-org_{userId}cookie. - On load, the dashboard reads that cookie and calls
POST /api/auth/organization/set-activeto restore it.
So a user returns to the same org context every session.
User actions
The footer (web/next/src/components/dashboard/sidebar.tsx) holds a docs link with a version badge, a cross-link to /console for anyone the ladder admits, which is member and above (canAccessConsole), and the shared SidebarUserMenu (web/next/src/components/shell/sidebar-user-menu.tsx): avatar, name, email, a Home link back to / (the sidebar brand stays inside the app, so this is the one way back to the landing page), an optional feedback link (only when NEXT_PUBLIC_USERJOT_URL is set), and sign-out (which returns to the home page).
Page content
Pages never hand-roll centering, width, or padding. They wrap content in two components:
PageShell(web/next/src/components/shell/page-shell.tsx): the container. Ownsmx-auto, width, andp-4 sm:p-6via asizevariant:sm,md(default),lg,full.PageHeader(web/next/src/components/shell/page-header.tsx): the title row. Takestitle, an optionaldescription, and optionalactions, and renders the page's single<h1>.
A new protected page is just a file under (protected)/ (it inherits the guard and sidebar automatically) with its content wrapped in the shell:
import { PageHeader } from "@/components/shell/page-header"
import { PageShell } from "@/components/shell/page-shell"
export default function Page() {
return (
<PageShell>
<PageHeader
title="Dashboard"
description="Intentionally empty. Auth, orgs, and the API are wired; this page is where your product begins."
/>
{/* your content here */}
</PageShell>
)
}Next
- Authentication: the sessions, orgs, and roles behind the guard.
- Theming: restyle the shell with design tokens.