All documents

Admin and permissions

The pattern for every screen that WRITES. Two working examples ship with the kit: examples/admin-settings-screen.tsx (draft and publish) and examples/admin-users-screen.tsx (per-row optimistic writes).

Two roles, and a third is a decision

admin and member. member reads every screen; admin additionally reaches the screens that write. No per-screen permission matrix — a matrix is a system nobody can hold in their head, guarding one table. If you genuinely need a third role, that is a conversation, not a refactor.

The role lives on the session (lib/session.tsx). Wire it to your auth in one provider; nothing else in the kit knows where it came from.

Root admins, and why they exist

Somebody must be an administrator whatever the database says. A root list lives in the repository, not in the database and not in an environment variable:

  • It cannot be demoted from any screen, so there is no sequence of clicks — or bad write, or lost document — that locks everyone out of the screen where access is granted.
  • It still works when the database is unreachable or unconfigured.
  • Adding a name to it is a commit with a reviewer on it.

Granted admins live in the database and carry granted_by and granted_at. That stamp is the audit trail that the commit used to provide; a role with no history is a fact nobody can question.

On screen, a root admin is a badge with the reason on hover, not a disabled control. A control that cannot be used is a question the reader has to answer ("is it broken?"); a badge is a statement.

The gate, and it is a sentence

Three states, in this order:

if (status === "loading") return null                       // never flash the admin UI
if (status === "signed-in" && !isAdmin) return <Refusal />   // a sentence, not a locked door
return <TheScreen />

The refusal is an EmptyState: ShieldAlert, the title "Admins only", a description that names the reader's own address, says what the screen decides, tells them who to ask, and offers a real destination (quiet button) — usually the page that explains what the screen governs.

Never render a disabled version of the admin UI "so they can see it". Never a blank page. Never a redirect that loses where they were.

The browser check is a courtesy, not the gate

The client check stops a member wandering in. The real enforcement is where the write lands — database security rules, a server action, an API route. Keep the two lists in step: an admin list in code and a rule in the database that disagree means one of them is wrong in the direction that lets someone in.

Pattern A — draft and publish (a SET of changes)

Use when the changes belong together and the world must not see half of them.

  1. draft overlays published; effective = {...published, ...draft}; dirty = Object.keys(draft).length > 0.
  2. The effect sits beside the input, recomputed as you type — "18/24 jurisdictions, 92% of homes". A settings screen without this is a form with consequences nobody can see.
  3. An edited row is data-state="selected" so the reader sees what they touched.
  4. Two actions on the page header's title line: Discard (quiet, only while dirty) and Publish (default, disabled unless there is something to publish, spinner inside while saving).
  5. An empty field means "leave it alone", not zero. Clamp to the legal range on the way in.
  6. Failure is an Alert variant="warning" on the screen that names what did not save. Never a toast that disappears.

Pattern B — optimistic per-row writes (independent changes)

Use when each change is small and independent (a role, a flag, an assignment).

  1. Change the row in state immediately, then write.
  2. On failure, put the old value back and print the reason. Keep the whole previous array, not a patch — reverting from a patch reverts the wrong thing when two rows are in flight.
  3. A per-row spinner beside the control while it saves; the control itself is disabled, not replaced.
  4. No Save button anywhere on the screen. If the screen needs one, it is Pattern A.

Derived values are shown, not edited

A number computed upstream gets its own column, a derived marker in tertiary ink, and a title explaining where it comes from. A reader must know which value was applied even when nobody can move it. Making a derived number editable is how one flat threshold gets applied to ten different distributions.

Say what the list is

A directory built from sign-ins is a record of sign-ins, not a roster of the company. Somebody with access who has never opened the app is simply not there, and the screen must say so rather than let a reader assume otherwise.

The unconfigured deploy

With no auth configured, the app is open on every layer. Say it out loud — the account menu carries the sentence, and the admin tools stay visible. Hiding the tools would hide the evidence, and an auth layer that bricks a dev checkout gets deleted.

Screens that explain must not sit behind the gate

"How it works", the FAQ, the definitions — put them on a documentation route that every member reads. An explanation locked behind an admin gate is an explanation nobody reads.