Admin UI

battery/admin is the back office: a dashboard, the entity screens, and the operations pages (jobs, audit log, roles, user roles, process modules), drawn in one shell through your app's UI host. Every page is a host screen, so it renders with runtime.js, navigates client-side, and shows toasts from the host's toast stack. The battery ships no CSS and no JavaScript: the shell is ui.Sidebar beside a toolbar with breadcrumbs, the command palette, the theme toggle and the account menu.

Every page and route sits behind one default-deny gate: see Authorization.

Quick start

11 lines
site := appui.NewApp("My App")
app := framework.NewUIHostApp(uihost.New(site), framework.WithDB(db))

app.Entity("products", productsConfig)
app.Entity("customers", customersConfig)

app.RegisterBattery(admin.New(admin.Config{
    Title:    "Back office",
    UI:       app.EntityUI(entityui.Extensions{}),
    Entities: []string{"products", "customers"},
}))

The admin needs a UI host: Init fails without one. It also needs Config.UI (the app's App.EntityUI) whenever it exposes an entity, because the entity screens are framework/entityui's list and record.

Exposure is opt-in. An empty Entities exposes none, so a zero-value config never turns every table into an editable back office. Name the entities, or set AllEntities: true for every registered entity whose CRUD is on. CRUD-off entities (battery/auth's users and sessions) stay hidden, so AllEntities never exposes credential tables. Naming an entity in Entities exposes it even with CRUD off. A name the app does not register fails boot.

Pages and routes

RoutePage
GET /adminDashboard: the figures strip (with a Queue, Failed jobs last), a count card per entity with when its newest record was written, recent activity, Needs attention (watched views and failed jobs), the app's cards
GET /admin/search?q=Search results: the palette's scriptless twin
GET /admin/shortcutsThe keyboard shortcuts: the help sheet's scriptless twin
GET /admin/accountAccount settings: the signed-in user's profile, theme and password
GET /admin/entities/<name>Entity list
GET /admin/entities/<name>/createCreate form (opens as a drawer from the list)
GET /admin/entities/<name>/:idRecord (opens as a drawer from the list, whose bar steps to the previous and next row, and as a drawer stacked over a related record; as a full page it offers Open in panel, back to the list with the record in a drawer)
GET /admin/queueJobs, with ?status= links and counts (needs Queue)
GET /admin/auditAudit log, newest first
GET /admin/rbac/rolesRole permissions (needs Policy + GrantStore)
GET /admin/rbac/usersUser roles (needs Auth)
GET /admin/modulesProcess modules (needs ProcessModules)
GET /admin/emails?name=Email previews, one tab per mail (needs EmailPreviews)

A page whose backing is not wired is not mounted and has no sidebar entry. Config.PathPrefix moves everything off /admin.

Entity screens

Each entity's sidebar row ends in how many records the admin can read, counted the way the list's "All" tab counts. The count is a route area of the shell layout, so it re-renders on every client navigation while the sidebar stays put: a record created in a drawer shows in it on the next click. A table too large to count per click sets EntityNav{HideCount: true} (hide_count in a declaration). The phone nav drawer, which no navigation re-renders, draws no counts.

The list, record and create screens are entityui screens, so they carry everything the entity declares: its Display names, fields and nav group, its States transitions, relations (a Related tab on the record), bulk actions, CSV export, and the record's activity tab. The admin also turns on every optional entityui tool: the query box, the columns menu, the trash view of a soft-deleting entity and Undo on its delete toast, saved views (with Config.SavedViews), a row count on each view tab, relation cells that link to the related record when the admin exposes that entity, the record's API tab and the status override. The admin reads each entity under the caller's own context plus the elevation described below, and points the screens' writes at its own routes:

RoutePurpose
POST /admin/api/<name>Create
PUT/PATCH /admin/api/<name>/{id}Update
DELETE /admin/api/<name>/{id}Delete
POST /admin/api/<name>/{id}/transitions/{key}A States transition
POST /admin/api/<name>/{id}/_overrideStatus override (States only)
POST /admin/api/<name>/{id}/_restoreRestore from the trash (SoftDelete only)
POST /admin/api/<name>/{id}/_purgeDelete permanently (SoftDelete only)
POST /admin/api/<name>/_bulkBulk action
GET /admin/api/<name>/_export.csvCSV export
POST /admin/api/<name>/_pick?field=<field>A relation picker's search
POST /admin/api/<name>/_viewsSave a view (Config.SavedViews)
POST /admin/api/<name>/_views/_delete?id=<id>Delete a saved view
GET /admin/_count/<name>A dashboard count card (polled every 60 s; a count past its 2-second deadline falls back to a 1-second read of at most 10,000 ids, "10k+" past it, and "—" only when that fails too). The newest-record age has a separate 1-second budget, so a late count does not hide it.

For an entity with a composite PrimaryKey, {id} above is one path segment per key column (/admin/api/enrollments/{student_id}/{course_id}), and the record screen is /admin/entities/<name>/<v1>/<v2>.

Every write goes through the app's own CRUD handler, so validation, hooks, events, WithAuditLog rows, tenant and OwnerField scope, and the field write checks apply exactly as on the JSON API.

Elevation. The admin routes and the admin's own screens run with crud.WithElevation, naming the entities the admin exposes, which lifts one check on those entities only: their Exposure.Access permissions. An entity locked to posts:write on the app API is still editable from the admin by a caller the gate admits; an entity the admin does not expose keeps its check, so a relation label, a picker or a stat that reaches it reads as the caller. Elevation never lifts tenant scope, owner scope, soft delete, the field read and write checks, a transition's Permission or the <name>:override_state capability the status override needs, and it never reaches app code: a Page or Card builds, and a page's Access answers, with the caller's own context; an entityui action, tab, view func or field kind, and the component it returns, gets the caller's context with the elevation removed (crud.WithoutElevation); and every lifecycle hook an admin write or read fires runs without it.

Platform pages. Queue jobs, auth accounts, role grants and process modules carry no tenant, so the Jobs page, both RBAC pages, the Modules page and every post under them (replay, grant, assign, enable, disable, bump, revoke) answer 403 to a caller whose request carries a tenant (tenant.GetTenantID), before they read or change anything. The sidebar, the command palette, the dashboard's jobs figure and the attention panel's failed jobs leave them out for that caller. Only a caller with no tenant, the platform operator, reaches them; the audit page, which does scope by tenant, stays open to both.

19 lines
// Declared once, at package level.
var (
    reportsTitle = i18n.Define("myapp.admin.reports", "Reports")
    revenueTitle = i18n.Define("myapp.admin.revenue", "Revenue")
    docsLabel    = i18n.Define("myapp.admin.docs", "Docs")
)

admin.New(admin.Config{
    Pages: []admin.Page{{
        Path:   "/reports",
        Title:  reportsTitle,
        Nav:    &entity.EntityNav{Group: "insights", Icon: "chart"},
        Access: isFinance, // optional, narrows beyond the gate
        Build:  reports,
    }},
    Cards: []admin.Card{{Key: "revenue", Title: revenueTitle, Build: revenue, Poll: 30 * time.Second}},
    Links:    []admin.Link{{Group: "help", Label: docsLabel, Href: "/docs"}},
    Commands: []ui.PaletteCommand{{Label: "Open docs", Href: "/docs"}},
})

A page's and a card's Title, a link's Label and a metric's Label are i18n.Phrase values, so the admin draws them in the request's language and a catalog entry for the key translates them (see gofastr docs i18n). A command's Label is the text itself: build the command list per request with the phrase's Text(ctx) when it needs translating.

  • A Page draws in the shell under <PathPrefix><Path> with a crumb and a palette entry. Its path may not take one of the admin's own (/, /search, /shortcuts, /account, /queue, /audit, /rbac, /modules, /emails, /entities, /api, or anything under /_); boot fails if it does. Access refuses with 403, hides the nav entry and the palette entry, and Build never runs for a refused caller. An Access that panics refuses too, for that page alone; the rest of the admin draws. (A panicking EffectiveRoles likewise leaves the User roles page showing direct roles.)
  • A Card draws on the dashboard. A positive Poll redraws it from GET <PathPrefix>/_card/<key> on that interval (data-cui-poll).
  • Metrics are the strip at the top of the dashboard, above the entity cards: a count or a sum over an exposed entity, read in the admin's scope under the same 2-second deadline as the entity cards, each polled from GET <PathPrefix>/_metric/<index>, all in one ui.StatStrip. View links the figure to one of the entity's list views; Detail is a second figure under the value, its label after the number, and the Detail's Tone colours it (ui.TrendDown red, ui.TrendUp green). With a Queue, the strip ends with a Failed jobs figure (polled from GET <PathPrefix>/_metric/jobs) that links to the failed filter and says "Needs a replay" while any wait. The strip holds six figures, so Metrics holds at most six, five with a Queue. Boot fails on a metric that could only ever draw "—": an unknown entity, agg, field, filter, format, view or icon, and on a Tone anywhere but a Detail.
7 lines
  Metrics: []admin.Metric{
      {Label: mrrLabel, Entity: "customers", Agg: "sum", Field: "mrr",
          Where: `status = "active"`, Format: "money", View: "active"},
      {Label: pastDueLabel, Entity: "invoices", Where: `status = "past_due"`, View: "past_due",
          Detail: &admin.Metric{Label: outstandingLabel, Agg: "sum", Field: "amount",
              Where: `status = "past_due"`, Format: "money", Tone: ui.TrendDown}},
  },
  • DashboardNew names an exposed entity whose New button heads the dashboard as its primary action (DashboardNew: "customers" draws "New Customer"). It and each entity card's New action appear only when the caller passes the entity's scoped create gate, including explicit policy denials and the required owner identity. Init refuses an entity the admin does not expose.
  • Attention is the Needs attention panel beside the recent activity. Each Watch names an entity and one of its declared list views (View); the panel previews the first Rows rows (5 by default, at most 20) of every watched view that has any, with Columns replacing the list's columns, a link to the full view, and no pager or sorting. A view with no rows draws nothing. With a Queue the panel also lists the newest failed jobs, each with Replay, under a link to the failed filter. With nothing left it says "Nothing needs attention." The rows sit in the card with no table frame (ListBuilder.Flush). Boot fails on an unknown entity, view or column.
3 lines
  Attention: []admin.Watch{
      {Entity: "invoices", View: "past_due", Columns: []string{"number", "customer_id", "amount"}},
  },
  • Themes are page themes offered as the Look, a ui.ThemePicker (Default plus each choice) under the light and dark choice, in the avatar's account panel and on the account page's Appearance card. The toolbar row keeps the light and dark toggle alone, so it fits a phone in any language. Each is a theme registered with style.RegisterThemeOverride; theme.Brutal() is one. The choice is per browser.
1 lines
  Themes: []ui.ThemeChoice{{Label: brutalName, Theme: style.RegisterThemeOverride(theme.Brutal())}},
  • Build failures are contained: an error, a panic or a nil component draws a generic notice in the shell and logs app slot failed with the slot name, never what the page read.
  • Links must be same-origin paths; boot refuses anything else.

Account settings

The avatar opens the account panel, as the prototype's does: the signed-in user's name over their email and roles, the Theme switch and (with Themes) the Look picker, then Account settings and Sign out. It is a ui.Dropdown with an Avatar trigger, not a menu, because it holds the theme switches; its rows are a ui.ActionList.

"Account settings" opens <PathPrefix>/account, the signed-in user's own page. It reads only the caller's record, so it is never elevated.

  • Profile: name and email, with a Verified or Unverified badge when the auth store implements auth.EmailVerifiedChecker, and the roles the caller holds. When the store implements auth.NameStore the name is a form posting to POST <PathPrefix>/account/_name, which names the caller only (a user_id in the body is not read) and refuses what auth.CleanName refuses. A saved name is audited as account / set-name with the cleaned name. The account menu and its avatar's initials use that name, falling back to the email.
  • Appearance: the theme choice, ui.ThemeToggle's pill, stored in the browser like the toolbar's toggle.
  • Password (only with Config.Auth): current, new and confirm fields posting to the auth battery's POST <BasePath>/password. The route checks every field and answers a refusal per field, and the form draws it beside the input. A change signs the user out of their other sessions. An account with no password (a store implementing auth.PasswordChecker reports none, as after an OAuth or magic-link sign-up) is told to use "Forgot password" instead of seeing a form it cannot pass.

Command palette

The toolbar's search field opens the palette. It lists the pages, each entity's list and permitted create screen, and your Commands. Create entries require both scoped CRUD read and create permission in the same elevated context as the destination screen, including a resource Decider's refusal of either permission. Typing searches the exposed entities' SearchFields through entityui.SearchRecords, in the caller's scope, and lists the matching records. The palette posts to POST <PathPrefix>/_palette; the body is capped at 4 KiB and the query at 200 runes. Without JavaScript the field submits to <PathPrefix>/search.

Keyboard shortcuts

⌘K opens the palette, / focuses the list's search, ⌘S saves the record, Escape closes a drawer or dialog, and ? opens the keyboard help sheet (ui.ShortcutSheet) that lists them. Without JavaScript the sheet's trigger links to <PathPrefix>/shortcuts, which lists the same keys.

Operations pages

The operations pages draw through the UI host like every other admin page: an app that mounts the admin only for its queue or audit log still builds with framework.NewUIHostApp, or Init fails. Jobs status-filter counts and the dashboard's Failed jobs figure use the request locale's number formatting, including localized digits and grouping, just like the page's other counts.

5 lines
q, _ := queue.NewDBQueue(db)
app.RegisterBattery(admin.New(admin.Config{
    Queue: q,  // the Jobs page, the Failed jobs figure and Needs attention's failed jobs
    DB:    db, // the audit log; defaults to the app's DB
}))
RoutePurpose
POST /admin/queue/_replay/{id}Re-queue one failed job
POST /admin/queue/_replay_allRe-queue every failed job, up to 10,000 a click

The Jobs page filters by All, Pending, Running, Failed and Done, each a link with its count (a ui.SegmentedLinks strip, nothing to apply), and lists each job's id, type, status, attempts, when it last changed and its last error (one line, cut with an ellipsis). Done jobs show when the queue keeps them (queue.WithDoneRetention). Replay is offered on each failed row, and "Replay N failed" in the header whenever a job has failed, when the queue supports it (DBQueue does). Each replay writes an audit row (entity queue, op replay) naming the actor. A failed job whose UniqueKey a newer live job holds is not replayed: one replay answers 409 with a notice, and "Replay N failed" leaves it failed, replays the rest, and says some were skipped. A failed list or stats read shows a generic notice and logs the driver error; the page never prints it. The Jobs page shows QueueListLimit jobs a page (default 50), newest first; the pager under the table turns pages through ?p= and keeps the status. The audit page reads AuditTable (default audit_log) and, when the request carries a tenant, only that tenant's rows, newest first, AuditListLimit to a page (default 50). The pager under the table turns pages through ?p= and keeps the filter; a page past the end shows the last one.

Naming the actor and the record. With Auth set, the audit page and the dashboard's recent activity name each actor by its account's email, the account the User roles page lists; an id no account matches, or every id without Auth, shows as written, and a row with no actor reads "System". The dashboard reads each row as a sentence, "ada updated Invoice INV-1010 · 5m ago": the actor in bold, an account by its email's local part with the full email on hover; then a record of an exposed entity after its entity's singular name, muted, so a payment titled by its invoice never reads as the invoice. A live record is named by its title, read the way its record screen's breadcrumb is, as a link to that screen; a deleted or purged one, unlinked, by the title in the row's stored copy, the way the audit page names it, else by the singular name alone. A delete row names what was deleted even after a restore brings the record back. An edit's line lists what it changed under it, as the audit page's Changes column does. On an entity with an OwnerField, both pages name a record and list its values only when the caller could read it: its stored copy names the caller as owner, the live record reads under the caller's owner scope, or the request allows cross-owner reads (owner.AllowCrossOwner). Another owner's record shows by its id alone, with no title and no changes. A bulk run's summary row reads "ada deleted 2 payments in bulk": a delete or a restore by its own verb, a set or a move as an update, counting the records it went through on; an app's own bulk action, and a run that went through on none, read "ran a bulk action on Payment".

The audit page's columns are Time ("2h ago", the exact UTC time on hover), Actor, Operation, Record and Changes. Record reads "Invoice · INV-1010" for an exposed entity: a live record by the title its screen shows, linked there, and a deleted or purged one by the title in the row's stored copy, unlinked. An entity the admin does not expose shows its table name and the id. Changes lists the fields an update changed, through entityui.UI.Changes, so masked and hidden fields never show, and neither does a field the entity's AfterGet hooks mask: the admin's own reads of the record show the mask, so the audit page and the dashboard's recent activity do not print the stored values. The hooks are asked about the record each row names, with its real id and stored row, as the signed-in admin, once per record per page however many rows name it; a hook that masks a field unless the caller owns the row hides that field and leaves the rest of the change drawn. A deleted record falls back to a probe row with no id, and a hook that refuses it hides the row's changes. Masking runs the app's AfterGet hooks with the admin's context on every page that draws changes, so they must be free of side effects (see Changes in entityui). The raw diff stays in the audit table for an operator who reads it there. A Roles change reads "Role · billing" and "Granted plans:write" (or Revoked, or Refused to grant or revoke); a User roles change names the account by its email and lists the roles it was given, or the one refused. A bulk run's row reads "2 payments" and what the run did, Deleted, Restored or Updated, with how many it skipped or failed on; an app's own bulk action keeps the run id. On a phone each row is a card. Both read elevated: the page is behind the admin gate, and the trail names records whatever the entity's own read permission says.

Hidden records stay hidden. The Audit log page and the dashboard's activity feed show only the id and operation of a record that is still stored (live or in the trash) but hidden from the admin by its BeforeGet/BeforeList hooks (entityui.UI.RecordHidden): its title and changed values stay out of the page, the answer the owner-scope check already gave.

Audit filters. The audit page carries a GET filter form, the list toolbar's Filters dropdown (ui.FilterToolbar with Dropdown), so a filter lives in the page's own query string and works without script: ?actor=<user id>, ?entity=<exposed entity name>, ?op=<operation>, ?from=YYYY-MM-DD, ?to=YYYY-MM-DD (to inclusive; days run midnight to midnight UTC, whatever the server's zone). The operation select offers the fixed set the audit log writes — create, update, delete, restore, purge, state_override and entityui's bulk summary, then the operations pages' replay, grant, revoke, assign-roles and module_enable/disable/bump/revoke — plus "any". A state transition (transition:<key>) is found by entity, and a refused operation in the unfiltered log. The entity select offers the exposed entities. Every value is checked server-side before it reaches SQL, and values travel as placeholders. An invalid value is ignored with a warning naming the parameter, never a 500; a link clears the filter.

Saved views

1 lines
admin.New(admin.Config{SavedViews: true, DB: db})

Config.SavedViews turns on the admin's saved-view store over the admin's database (Config.DB, default the app's): one named filter/columns set per user per entity, in admin_saved_views (Config.SavedViewsTable; a lowercase identifier). The admin's lists offer it; (*Battery).SavedViews() returns the store for an app's own screens (UI.WithSavedViews). The store reads the owner and the tenant from the caller's context only, keeps every owner's and tenant's views apart, caps a user at entityui.SavedViewCap views per entity, and refuses blank, duplicate and over-long names, filters and column lists with the errors the SavedViewStore contract names. A generated app's admin config sets SavedViews: true.

Bulk jobs in the background

A bulk action over more than entityui.InRequestCap (100) records runs outside the request, on Config.Queue's queue, through admin.NewBulkJobs:

8 lines
q, _ := queue.NewDBQueue(db)
jobs, _ := admin.NewBulkJobs(q, admin.AuthPrincipal(authManager, memberships.Has))

app.RegisterBattery(admin.New(admin.Config{
    UI:       app.EntityUI(entityui.Extensions{Jobs: jobs}),
    Entities: []string{"posts"},
    BulkJobs: jobs,
}))

The wiring order is fixed by the two directions: the UI needs the runner at app.EntityUI time (Extensions.Jobs), and the runner needs the UI to run jobs, so Config.BulkJobs closes the cycle — the admin's Init binds the runner to Config.UI and then calls UI.ResumeBulkJobs, handing back any job a crash left between its snapshot and its Enqueue. The bound UI counts an entity the admin exposes as written through the admin's routes and any other entity through the app's own (UI.WritePath), so a queued run over a named entity with CRUD off finishes, and so does one the app's own bulk route queued. The queue job's payload carries only the job id; the confirmed selection lives in the host's snapshot store.

A busy or lost bulk lease schedules a fresh queue delivery at least entityui.BulkJobLease (five minutes) later instead of burning the queue's five execution attempts immediately. The replacement preserves the job's creator identity for erasure. If that enqueue fails, the queue's normal failure policy still applies. UI.ResumeBulkJobs also adopts an enqueued snapshot whose bulk lease expired before the grace cutoff, so a dead queue entry cannot strand a partially settled run. Live leases and enqueued jobs that have never started are not adopted. Long-running hosts should call resume periodically; initialization alone recovers only at startup.

Erasure must run with bulk producers and active deliveries quiesced. The queue's erasure sweep and a lease-contention successor enqueue do not share an atomic transaction across queue backends: a delivery that observed a busy lease before erasure can enqueue a successor carrying the creator's user id after that sweep. Deleting the bulk snapshot stops its later execution but does not prevent that new queue metadata. Stop/drain deliveries before erasure when the export must contain no newly recreated user metadata.

PrincipalFunc (the second argument) rebuilds the confirming user's request context before every chunk: the user and their current roles, read fresh, and the run's tenant. The tenant is the one the selection was confirmed in and stays fixed for the run, because the snapshot's records belong to it. An error stops the run, so a creator who is gone runs nothing. admin.AuthPrincipal(am, member) builds one from battery/auth, loading the user and their current roles through the manager's user store. battery/auth keeps no tenant membership, so before every chunk of a tenant run it asks member (admin.TenantMember, a func of the user and tenant ids) whether the creator still belongs to the tenant, and stops the run on false or an error. With a nil member a tenant run is refused; a run with no tenant never asks. admin.AnyTenant admits every run, for an app whose users never leave a tenant. The admin then puts Config.Policy on a context that has no policy and runs its own gate again: a creator it admits runs elevated, as the admin's bulk route does; one whose admin role was revoked runs as a plain caller, so the writes pass only that user's own permissions.

Roles and user roles

10 lines
policy := framework.NewRolePolicy()
store := framework.NewGrantStore(db, policy)
_ = store.EnsureSchema(ctx)
_ = store.LoadInto(ctx, policy)

app.RegisterBattery(admin.New(admin.Config{
    Policy:     policy,
    GrantStore: store,
    Auth:       authManager, // the User roles page
}))
RoutePurpose
POST /admin/rbac/_permissionsSave the Roles grid
POST /admin/rbac/_grantGrant a permission to a role (the "Add a role" form)
POST /admin/rbac/_assignReplace a user's roles

The Roles page is a grid: a row per permission, a column per role, and a checkbox where they meet. Its rows are the policy's declared capabilities and every permission a role holds; one a role holds that the app does not declare is marked "Undeclared". A role holding the wildcard reads as holding everything, its boxes checked and locked. "Save permissions" posts the whole grid to _permissions (each role and permission it showed, and a grant value <role>:<permission> per checked box, as indexes into those lists); the admin grants and revokes only what changed, and a save that changed nothing writes nothing. With no GrantStore the grid is read-only. On a phone each permission is a card listing every role. The "Add a role" form under the grid gives a new role its first permission, offering the declared capabilities as a select. Under StrictCapabilities a grant of an undeclared permission is refused. The User roles page lists each user's roles once, as tags; the row's Edit roles dropdown holds the form that replaces them. It shows 50 accounts a page (?limit=, up to 500), and the pager under the table turns pages through ?p=.

Role and permission names must be valid UTF-8 without NUL bytes. A grant, grid save or role assignment containing such text is refused before any change: form posts return bad-input, and RPCs answer 400 with a fields entry naming the submitted control.

Role assignment also checks user_id before looking up the account: NUL or malformed UTF-8 produces bad-input for a form and a 400 RPC refusal keyed by user_id, without a lookup or role mutation. A valid ID is used exactly as submitted; whitespace is not trimmed from account keys.

Every change writes an audit row (entity access, op grant, revoke or assign-roles), one per permission a save grants or revokes. A caller may grant or revoke only a permission its own roles hold (or the wildcard), and assign only roles it holds or whose permissions its own roles imply. The assign check covers every role that changes: the roles added and the roles the new set takes away, so a tier cannot strip a role it could not grant; a role the account keeps is no change. A role the policy grants nothing, or does not know, is checked by name (auth.RequireRole), so only a caller holding it or the wildcard assigns or removes it. An unknown account is a 400. A refusal is a 403 with a grant-refused, revoke-refused or assign-roles-refused row, so a narrower admin tier cannot mint a role above its own. An access.Decider on the request is asked about each permission first: its DecisionDeny refuses the change even when the policy grants it, and its DecisionAllow admits nothing the policy does not. A grid save is checked whole before it writes: one refused change refuses the save, and nothing in it is applied. A grant inside a wildcard revocation (the role's teams:* was revoked, and the save grants teams:read) is refused that way with a 409 (GrantStore.CheckGrant, see access control).

Process modules

With Config.ProcessModules set to app.ProcessModules() (see process-modules), /admin/modules lists each module's state, restart count, and the circuit-open and lease-failing flags. Its levers (enable, disable, bump generation, revoke a grant, which takes that one grant away and keeps the rest) post to /admin/modules/_enable, _disable, _bump and _revoke, and need the modules:manage permission on top of the gate, held through the caller's roles in Config.Policy and not denied by an access.Decider; without it, or without a policy, the page is read-only. Each lever writes an audit row (module_enable, ...), and a refused one a *_refused row. A failed lever answers a generic notice and logs the error.

Email previews

Config.EmailPreviews lists the app's mails, each rendered from sample data so a template can be read before anything sends it. Render builds the mail the way the app does before sending, usually email.Execute over a template (see email):

8 lines
welcome := tmpls["welcome"] // from email.LoadFromDir
admin.New(admin.Config{
    EmailPreviews: []admin.EmailPreview{
        {Name: "welcome", Render: func(ctx context.Context) (email.Email, error) {
            return email.Execute(welcome, map[string]any{"Name": "Ada"})
        }},
    },
})

/admin/emails draws a tab per preview (?name= picks one; the first is the default), the subject, the text body, and the HTML body in a frame. The frame loads /admin/_email/<name>, which is gated like every admin route and answers with its own policy: Content-Security-Policy: sandbox; default-src 'none' plus inline styles and https:/data: images and fonts, frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN. The <iframe> carries an empty sandbox attribute too, so the mail gets an opaque origin with no scripts, forms or navigation of the admin page. Nothing is sent from the page.

Name is lowercase letters, digits, - and _, unique; a bad or repeated name, or a preview with no Render, fails Init. Render runs with the admin's context, never elevated. An error or a panic draws a generic notice on the page, answers the frame with a 500, and logs the preview's name and the error's type, not its text.

How ops posts answer

Each ops form posts two ways. With the runtime it is a form RPC: success is 204 with a toast, a refusal is JSON {"error": ...} with its status. A plain post redirects (303) back to the page with ?result=<name>, which the page draws as a notice. Bodies are capped at 1 MiB (413 past it).

Authorization

The gate admits an authenticated user whose GetRoles() holds Config.AdminRole (default "admin"); battery/auth's User does. Config.Authorize replaces the role check with your own predicate; one that panics refuses, as false does, and the log names the callback and the panic's type. Three refusals run before either:

  • an embed grant on the request (an embedded surface never reaches the back office);
  • an API token without admin.AdminScope ("admin:access"; "admin:*" and "*:*" cover it). A token carries its owner's roles, so one an admin minted with customers:read for a reporting job would otherwise open the whole back office. Sessions and JWTs carry no token scopes and are unaffected;
  • an access.Decider on the request returning DecisionDeny.

A signed-out caller gets 401, a signed-in caller without the role 403, on every page, route and shell widget (the nav drawer and the palette). With Config.LoginPath set, a signed-out GET redirects to LoginPath?next=<path> instead; a signed-out post is still a 401.

9 lines
admin.New(admin.Config{AdminRole: "superuser", LoginPath: "/login"})

// ...or a custom predicate:
admin.New(admin.Config{
    Authorize: func(ctx context.Context) bool {
        u := auth.GetCurrentUser(ctx)
        return u != nil && slices.Contains(u.GetRoles(), "staff")
    },
})

Config.SignOutPath is where the account menu's Sign out posts; it defaults to Auth's logout route.

Response headers

Every response under the prefix, refusals included, carries Cache-Control: no-store and the security headers (middleware.SecurityHeaders), whether or not the app installs its default middleware. Admin pages hold per-caller, tenant-scoped data, and HTTP's storage rules cover Authorization headers, not cookie sessions.

CSRF

The battery refuses every forgeable cross-site request to a mutating route (a browser post whose Sec-Fetch-Site is cross-site, or whose Origin host differs from the request host) with 403, before the gate runs. Non-browser clients send neither header and pass. This holds without the optional CSRF middleware. Mount middleware.CSRF app-wide for a token on top: the kit's forms stamp _csrf from the context and the runtime sends X-CSRF-Token from <meta name="csrf-token">.

Common mistakes

  • Don't expose /admin to the public. It shows entity data, actor ids and job counts.
  • Per-user data needs OwnerField. Elevation keeps owner scope, so an admin sees only the rows the entity's scope allows; declare it. See Entity Declarations → per-user scoping.
  • Set UI. Exposing an entity without Config.UI fails boot.

See also

examples/backoffice is a runnable back office: SQLite, entities, a demo login, and the admin.