Admin console
The /admin console — its five sections, the user drawer, overview KPIs, operator settings, and how to add a page of your own.
The console lives at /admin, inside the same signed-in shell as the user's
own pages. Anyone whose role holds admin.access sees an Admin entry at
the bottom of the sidebar. Under /admin the sidebar switches to the console
sections plus Back to app. What a person sees inside depends on their
role — see Roles and permissions. To give yourself the
built-in admin role locally, follow Getting started.
The five sections
The sidebar has one row per section. Each section's pages appear as tabs under the page title, and a section with only one page the viewer may open shows no tabs. Pages the viewer's role does not allow are hidden, and a section with none left drops out of the sidebar.
| Section | Pages (permission) |
|---|---|
| Overview | Overview (admin.access) |
| People | Users (users.read), Waitlist (waitlist.read), Feedback (feedback.read) |
| Revenue | Subscriptions and Orders (billing.read), Credits (credits.read), Affiliate (affiliate.read) |
| Developers | API keys (api_keys.manage), Email log (email.read), Audit log (audit.read) |
| Settings | General (settings.read), Roles (roles.write), Emails (email.read) |
Pages from optional modules disappear when you delete that module; see Deleting features.
All of these rows live in one array, adminNav in
src/components/app-sidebar.tsx. A row's section decides where it sits and
its position decides the tab order.
Overview
/admin shows the product's headline numbers for the last 7, 30 or 90 days,
each with the change against the previous window of the same length. A chart
plots any series you click. Counts that are someone's job — open feedback,
pending waitlist entries, failed emails, queued affiliate payouts — are shown
as to-dos that link to the page where they get handled. A custom UTC range
also works through the URL: /admin?from=2026-01-01&to=2026-01-31.
Every figure comes from a stats registry. Each feature registers one provider
in its server/stats.ts, with a line in src/core/stats/handlers.ts:
registerStats('feedback', async (range) => ({
metrics: [
{ key: 'feedback.open', value: open, format: 'count' },
{ key: 'feedback.received', value: current, format: 'count', previous },
],
series: [
{ key: 'feedback.received', format: 'count', points: fillDays(range, perDay) },
],
}))
A metric with previous and a matching series is a period figure (something
that happened in the window); one without is a state figure (true right now).
Give each new key a label in the labels map in
src/routes/_app/admin/index.tsx. The same data is served to agents at
/api/v1/admin/stats — see API keys.
The overview is gated on admin.access only, so every console role sees every
figure on it, revenue included. If that matters for your team, check a finer
permission in the providers you want to hide.
People and the user drawer
Wherever a console table names a person, the name is a <UserLink userId>
(src/components/user-link.tsx). Clicking it adds ?user=<id> to the current
URL and opens the user drawer over the page, so the list underneath keeps its
filters and scroll, and the link still works when reloaded or shared. The
drawer has a button to open the full page at /admin/users/<id>. Viewers
without users.read see plain text instead of a link.
Both views render the same component and show:
- the account facts: role (editable in place), created, last seen, id;
- a strip of headline figures, such as the current plan and credit balance;
- one tab per feature: billing, credits, audit, emails, feedback, files, waitlist;
- a menu with ban/unban and, for a closed account still inside its retention window, restore.
The tabs come from a registry. A feature adds one by calling
registerAdminUserSection({ key, label, query, component }) in its
components/admin-user-section.tsx, with an import line in
src/core/user-detail/handlers.ts. The line order is the tab order.
Tables
Console lists use DataTable (src/components/data-table.tsx): search,
dropdown filters, sorting, paging and a CSV export of the current view. Search
and filters live in the URL, so a filtered view is a link you can share.
Settings
Settings → General (/admin/settings) holds the values an operator can
change without a deploy: how long closed accounts, audit rows, email log rows
and webhook records are kept, the grace period before an unrenewed
subscription expires, the low-credit threshold, and feedback image limits.
Affiliate terms are settings too, but they sit on the Affiliate page beside
the data they govern. Each feature registers its
settings in a client-safe settings.ts with a line in
src/core/settings/handlers.ts:
registerSetting({
key: 'auth.deleted_account_retention_days',
group: 'accounts',
groupLabel: () => m.admin_settings_group_accounts(),
kind: 'number',
fallback: ACCOUNT_RETENTION_DAYS,
min: 7,
max: 3650,
label: () => m.setting_account_retention(),
unit: () => m.setting_unit_days(),
})
The database stores only overrides. An untouched setting falls back to the
default in code, so changing a default reaches every deployment that never
overrode it. Kinds are number, boolean, string and secret; a secret is
encrypted and never sent back to the browser once stored. Server code reads a
value with getSetting or getNumberSetting from
src/core/settings/store.ts, which caches for 60 seconds per isolate.
Changing a setting requires settings.write; settings.read shows the page
read-only. The full list is on the Configuration page.
The other two Settings tabs: Roles, the role editor, and Emails, which previews every email template in every language and can send a test to your own address (see Email and notifications).
Adding a page
Say you are adding a Reports page to the Revenue section.
-
Declare the permission in your feature's
permissions.ts(reports.read) and add its import line tosrc/core/authz/handlers.ts. Details on Roles and permissions. -
Write the server function from
adminFn, and assert the permission first thing in the handler:export const listReportsAdminFn = adminFn.handler(async ({ context }) => { await assertPermission(context.session, 'reports.read') return getReports() }) -
Create the route at
src/routes/_app/admin/reports.tsxwith a guard, a loader and a component. UsePageHeaderfromsrc/components/page-header.tsx, which renders the section title and tabs:export const Route = createFileRoute('/_app/admin/reports')({ beforeLoad: async ({ context }) => { await requirePermission(context.queryClient, 'reports.read') }, loader: ({ context }) => context.queryClient.ensureQueryData(adminReportsQuery), component: ReportsPage, })Then run
bun run generate-routes. -
Add the row to
adminNavinsrc/components/app-sidebar.tsx:{ to: '/admin/reports', section: 'revenue', permission: 'reports.read', label: () => m.admin_reports_title(), },If the page belongs to an optional feature, wrap the row in
// [feature: <name>] startandendcomments so the deletion recipe can remove it. -
Add the copy to
messages/en.jsonandmessages/zh.json. Keys that start withadmin_load with the console, not with the marketing site.
bun run test will fail if the route has no requirePermission or the
server function has no assertPermission. For the whole feature workflow,
including the data layer, see Adding features; the
/add-feature skill walks an agent through it.