admin
A table admin for the app’s own resources: every record type as a plain
table to search, sort and page, one record to read, fields to fix, rows to
delete. It reads the columns off the app’s Drizzle tables, and every call is
checked with @recranet/flare/access:
- A member works in their active organization with their role: a viewer reads, a member edits, an owner deletes (with the default roles).
- A platform admin (Better Auth’s
user.role, see access’s “Platform admins”) asking with?context=adminworks across every organization, with each row’s organization shown by name.
Rows come back display-ready: a reference shows the record’s name (a
company, a user), never an id, and id and organizationId aren’t columns.
The page is the app’s own; examples/crm has one to copy.
It needs @recranet/flare/access, and uses void/db.
Set up
Section titled “Set up”Three seams, all the app’s own.
lib/admin.ts, with the tables from db/schema.ts. Each resource’s name is
its @recranet/flare/access resource, so the role’s actions on it are what the
admin may do:
import { defineAdmin } from "@recranet/flare/admin"import { audit } from "@recranet/flare/audit"import { companies, contacts } from "../db/schema.ts"import { access } from "./access.ts"
export const admin = defineAdmin({ access, audit, // optional: edits and deletes log in the same batch resources: { company: { table: companies, label: "Companies" }, contact: { table: contacts, label: "Contacts", title: (c) => `${c.firstName} ${c.lastName}`.trim() }, },})
export const route = admin.routeroutes/api/admin/[...path].ts and routes/api/admin/index.ts (Void’s
catch-all doesn’t match the bare /api/admin):
export { route as GET, route as PATCH, route as DELETE } from "../../../lib/admin.ts"
// routes/api/admin/index.tsexport { route as GET } from "../../../lib/admin.ts"A page under pages/admin/ whose loader calls admin.resources(c) and
admin.list(c, resource, query), and edits and deletes through the API.
The API
Section titled “The API”| Request | What |
|---|---|
GET /api/admin |
{ allOrganizations, resources }: each listable resource’s name, label, columns and can (read, update, delete) |
GET /api/admin/:resource |
{ items, nextCursor }. q searches, sort and dir order by a sortable column, cursor and limit (1 to 100, default 25) page |
GET /api/admin/:resource/:id |
One record, with values for the editable fields |
PATCH /api/admin/:resource/:id |
The fields to change. Any other field is a 400 INVALID_INPUT with issues |
DELETE /api/admin/:resource/:id |
204 |
Add ?context=admin to any of them to work across organizations as a
platform admin; anyone else gets their own organization with it, as without.
The same calls are functions for loaders and actions: admin.resources(c),
admin.list(c, resource, query), admin.get(c, resource, id),
admin.update(c, resource, id, patch), admin.remove(c, resource, id).
A row is { id, title, organization, cells }. id is the handle for get,
update and delete: never show it. organization is the organization’s name
when reading across organizations, else null. cells holds, per column,
text, a number, a boolean, an ISO date, a reference’s name, or null.
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
signed out |
| 403 | NO_ACTIVE_ORGANIZATION |
a member without an active organization |
| 403 | FORBIDDEN |
the role lacks the action, or the resource isn’t editable or deletable here |
| 404 | NOT_FOUND |
no such resource, or no such record in the organization |
| 400 | INVALID_INPUT |
a field that isn’t editable, or a value its schema refuses |
Columns
Section titled “Columns”From the table, in its order, with the title’s column first. Left out: id,
organizationId, and names like secrets (password, token, secret,
apiKey, …).
| Column | Type | Sortable | Editable |
|---|---|---|---|
| text | text |
when not null | yes |
text({ enum }) |
enum, with options |
when not null | yes |
| integer, real | number |
when not null | yes |
| boolean | boolean |
when not null | yes |
| timestamp | date (ISO) |
when not null | yes, but not createdAt and updatedAt |
| JSON | json (as text) |
no | no |
| a reference | ref, shown by name |
no | no |
A reference is <resource>Id for another resource in the admin, and
userId for Better Auth’s user (shown by name, else email). Name others in
refs. Sorting is keyset on (column, id), so only non-null columns sort;
without sort, newest first by createdAt.
Resource options
Section titled “Resource options”| Option | What |
|---|---|
table |
The Drizzle table, with id and organizationId |
label |
Plural, for the page. Default the name, capitalized |
title |
A row’s name: a column or a function. Default the first of name, title, subject, label, else the first text column |
columns |
The columns shown, in order |
search |
Columns q searches (LIKE, case-insensitive for ASCII). Default the shown text columns |
refs |
{ assigneeId: "user", parentId: "company" } |
edit |
The editable columns, or false |
input |
The update’s Zod schema. Pass the app’s own (CompanyUpdate) so the admin checks what the API checks |
remove |
false to refuse deletes |
onUpdate |
Statements for an update’s batch, given the row as it will be: rewrite its search document |
onDelete |
Statements for the delete’s batch: clear references to the row, drop its search document |
defineAdmin({ base }) moves the API from /api/admin; db replaces
void/db’s database.
Writes
Section titled “Writes”An update writes only the editable fields that came, filtered on the record’s
own organization, in one db.batch() with onUpdate’s statements and the
audit row when audit is given: the acting user, in the record’s
organization, as the actor. A delete runs onDelete’s statements and the
audit row in the same batch.
Writes go straight to the table: the app’s own rules in its record functions
(defaults, derived fields, reference checks, search documents) don’t run,
but for what onUpdate and onDelete add. Keep those columns out
of edit, or pass an input schema that checks them. References aren’t
editable, so the admin never points a record at another organization’s.
In examples/crm
Section titled “In examples/crm”lib/admin.ts: contacts, companies, deals, activities and tasks, each with its update schema fromlib/schemas.ts, its search document rewritten on edit and dropped on delete (search), and its references cleared on delete (detachinlib/crm.ts). Tasks show and edit fewer fields: a changed reminder must clearreminderSentAt, which only the API does.routes/api/admin/: the two route files.pages/admin/: the page, with a tab per record type, search, sortable headers, Next / First page, an edit dialog built from the editable columns, and delete. Platform admins get “This team / All teams”.auth.ts: Better Auth’s admin plugin, for the platform tier;pnpm db:seedmakesowner@example.coma platform admin. The sidebar shows Admin to owners, admins and platform admins (“Beheer” in Dutch); the page itself is English, like the CRM’s other untranslated pages.
- Names, not ids. References are read in one query per referenced resource per page (and one for users, one for organization names). A reference to another organization’s record shows nothing, as it would for a deleted one.
- Not here: creating records (the app’s own forms do that, with their rules), filters beyond search, Better Auth’s own tables (users, members, organizations: Better Auth’s admin and organization endpoints manage them), and bulk edits.