Skip to content

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=admin works 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.

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.route

routes/api/admin/[...path].ts and routes/api/admin/index.ts (Void’s catch-all doesn’t match the bare /api/admin):

routes/api/admin/[...path].ts
export { route as GET, route as PATCH, route as DELETE } from "../../../lib/admin.ts"
// routes/api/admin/index.ts
export { 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.

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

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.

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.

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.

  • lib/admin.ts: contacts, companies, deals, activities and tasks, each with its update schema from lib/schemas.ts, its search document rewritten on edit and dropped on delete (search), and its references cleared on delete (detach in lib/crm.ts). Tasks show and edit fewer fields: a changed reminder must clear reminderSentAt, 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:seed makes owner@example.com a 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.