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.

SectionPages (permission)
OverviewOverview (admin.access)
PeopleUsers (users.read), Waitlist (waitlist.read), Feedback (feedback.read)
RevenueSubscriptions and Orders (billing.read), Credits (credits.read), Affiliate (affiliate.read)
DevelopersAPI keys (api_keys.manage), Email log (email.read), Audit log (audit.read)
SettingsGeneral (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.

  1. Declare the permission in your feature's permissions.ts (reports.read) and add its import line to src/core/authz/handlers.ts. Details on Roles and permissions.

  2. 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()
    })
    
  3. Create the route at src/routes/_app/admin/reports.tsx with a guard, a loader and a component. Use PageHeader from src/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.

  4. Add the row to adminNav in src/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>] start and end comments so the deletion recipe can remove it.

  5. Add the copy to messages/en.json and messages/zh.json. Keys that start with admin_ 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.