Admin panels: four failures that ship looking complete
Every one of these was found in a live admin panel, and none is visible in a code review or a green build. Plus why the screen list should be derived fromβ¦
A product whose users, content and settings can only be managed through a database console is unfinished. The console has no audit trail, no safe destructive action, and no way to answer a support question at ten at night. So every product I run has an admin panel β and building several has taught me that the ways they fail are remarkably consistent.
These four were all found in live, shipped panels. None of them is visible in a code review, and every one passed its build.
1. A guard that exists and is never passed
A requireAdmin helper, correctly written, thoroughly considered β and supported by the route component while being passed to zero of thirty-nine admin routes. The prop was optional. Optional means "off by default", and off by default on an authorization check means the whole panel was open to anyone who typed the URL.
The lesson is not "remember to pass the prop". It is that a security default must be the safe one: the guard belongs on the layout every admin route nests under, so a new screen inherits it by existing rather than by someone remembering.
How to find it: list your admin routes, list the ones that reach the guard, and compare the counts. If you cannot produce both lists mechanically, you do not know the answer.
2. A promote button that writes a field nothing reads
"Make Admin" wrote role. Every gate in the application read isAdmin. The button worked β it showed a success toast, the row updated, the table re-rendered with the new value β and the person was not an admin.
This one is nasty because every part is individually correct and the failure is in the join between them. It survives review because a reviewer reads the mutation, sees it write a sensible column, and moves on.
The only reliable detection is end to end: promote a real account, sign in as that account, and confirm it can reach a thing it could not reach before. Which leads directly to the next one.
3. Verifying while signed in as an admin
The branch you are testing is the branch that always passes.
A sibling project of mine reported PASS twice on a feature that was completely broken for every real user, because both verification runs were done from an admin account whose privileges short-circuited the rule being tested. Row-level security makes this worse rather than better: a policy that filters rather than refuses returns success with zero rows, so a forbidden update comes back as HTTP 200 and an empty array.
A refused write under row-level security is a 200, not a 403. Prove the refusal by reading the row back from the database, never by looking at a status code.
So the rule I now hold to: every admin capability is exercised twice. Once as an admin, to prove it works. Once as an ordinary user, to prove it is refused β and the refusal is proven by re-reading the data, not by the response.
4. Settings that persist nothing
Six toggles on a settings screen, including a maintenance-mode switch, all backed by local component state. They flipped. They looked saved. A refresh reset all six, and maintenance mode had never once been on.
Trivial to catch and almost never caught, because nobody refreshes a settings page after toggling something β you toggle it, you see it move, you leave. The check is one sentence long: change it, reload the page, and look.
Derive the screen list; do not copy it
Beyond the failures, the structural mistake is starting from a template. A list of admin screens written against no particular project demands pages you do not need and stays silent about the ones you do.
What works better is letting each fact about the project name the capability it obliges:
- A users table owes you suspend and delete.
- A plans table owes you five things, not one: create a plan, edit its limits, change a user's plan, override one user's limits, and show where a grant came from.
- A reports table owes a moderation queue.
- Any row with a
pending status owes a review queue with a reason field.
Then run the sweeps a list would never have produced. Which admin-readable tables does no screen query? One product I audited had two separate contact inboxes nobody could read. Which roles and permission keys does nothing in the codebase check? And the honest one: "where does the owner do this today?" β if the answer is "in the database console", the capability is owed.
Write the result down at a fixed path and re-derive it whenever a table, role or flag changes. Merging two capabilities onto one screen is fine. Merging them silently is not, because a gap with no sentence beside it reads exactly like the oversight it usually is.
The audit log, and the one rule about it
Every administrative action taken against somebody else's account is written to a log: who did it, to whom, what changed from what, and when. Not "important" actions β every one, because the ones you would not have thought to log are exactly the ones you need during an incident.
The rule that makes it worth having: the log row is written in the same transaction as the change. Not afterwards, not from application code that runs next, not in a queue. If the change succeeds and the log write fails, you have a mutation nobody can account for β and that is precisely the situation an audit log exists to make impossible.
In practice that means the write goes through a database function rather than through the client. The function checks the caller is an administrator, performs the change and inserts the audit row, and the columns it touches are revoked from the client role entirely. There is then no path that mutates without logging, because there is no other path at all.
Two smaller rules I would defend. One log table, not several β a project with two diverged audit collections effectively has none, because answering any question means reconciling them first. And nothing deletes from it, including the tidy-up that removes test data: deleting from an audit log is the single thing it exists to prevent, and a log with a gap is worth less than no log, because it looks complete.
Impersonation, if you build it, is view-as: a permanent banner, sensitive actions blocked, and both ends of the session logged. Never an actual credential switch β the moment an administrator is holding somebody else's session, the log stops being able to tell you who did what.
It is a product surface, not an internal tool
The panel obeys every standard the rest of the app does: responsive from 320 pixels up, keyboard operable, every action acknowledged at the control within a tenth of a second, the same theme, the same components.
"It is only admin" is how a panel ends up unusable on the phone you actually have with you when something breaks. It is also the surface you personally spend the most time in β which makes it the strangest possible place to accept worse ergonomics than you gave to a marketing page.
https://aoneahsan.com/blog/designing-perfect-admin-dashboards