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.
draftoverlayspublished;effective = {...published, ...draft};dirty = Object.keys(draft).length > 0.- 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.
- An edited row is
data-state="selected"so the reader sees what they touched. - 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). - An empty field means "leave it alone", not zero. Clamp to the legal range on the way in.
- 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).
- Change the row in state immediately, then write.
- 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.
- A per-row spinner beside the control while it saves; the control itself is disabled, not replaced.
- 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.