Back to Blog

Locking Down an Admin Dashboard: Role Guards and a Shared Resizable-Panel System

By · 7 min read
RBAC Enterprise Platform React Access Control

Two unrelated-looking pieces of work landed on the enterprise workflow platform this week: a role-based access split for the admin console, and a shared resizable/collapsible panel system for the reviewer UI. They shipped as separate tickets, but they share a lesson worth pulling out together — both are about a single source of truth doing the work that three or four ad hoc implementations were doing badly before.

Two roles with the same name doing different things is worse than one role with a smaller scope. Split the role before the permission check gets any more special cases bolted onto it.

One admin role, two very different jobs

The platform's ADMIN role had accumulated two distinct sets of responsibilities under one name: day-to-day operational powers over auto-seed and review workflows, and full access to the platform configuration console — user management, taxonomy, global settings. Those aren't the same job, and treating them as the same permission level meant every operational admin, by default, could also reach platform-wide configuration whether or not that was ever intended for their role.

AGT-1428 (PR #730) splits them. A new PLATFORM_ADMIN tier gates the /admin route and the admin link in the dashboard header — reachable only by that tier. The existing ADMIN role keeps every operational power it had (auto-seed controls, via the same isAdminRole check and Firebase admin claim it always used) but is now explicitly redirected away from /admin rather than allowed in ambiently. The redirect matters as much as the permission check itself: a role that used to have access and now doesn't needs to fail predictably, at a known point, not silently render a broken or partial page. The PR also adds a client-side analytics event on a blocked console-entry attempt, so an accidental or exploratory access attempt against the newly-restricted route is visible rather than just silently bounced.

The dropdown has to agree with the guard

Splitting a role into a new tier is only half the change — the tier also has to be assignable, or it exists in the permission-check code with no path for anyone to actually be granted it. AGT-1429 (PR #731) makes PLATFORM_ADMIN a first-class option on the admin users page and the underlying user-role update endpoint, and centralizes the set of assignable roles, their display labels, and the parsing logic that validates a role string in one shared module read by both the UI dropdown and the API route.

// src/lib/users/roles.ts — one definition, read by the dropdown and the API
export const ASSIGNABLE_USER_ROLES = ['ADMIN', 'PLATFORM_ADMIN', 'REVIEWER', /* ... */] as const;
export const USER_ROLE_LABELS: Record = {
  ADMIN: 'Admin',
  PLATFORM_ADMIN: 'Platform Admin',
  // ...
};
export function parseAssignableUserRole(value: string): AssignableUserRole | null {
  return (ASSIGNABLE_USER_ROLES as readonly string[]).includes(value)
    ? (value as AssignableUserRole)
    : null;
}

That single source of truth is what stops the dropdown and the guard from silently drifting apart — a real risk any time "what roles exist" is spelled out once in a UI component and again, separately, in an API validator. The PR also threads the new tier through the existing Firebase claim sync: granting PLATFORM_ADMIN sets the admin claim through the same isAdminRole check the operational role already used, and demoting away from it clears the claim the same way — no parallel claim-management path invented for the new tier, and the existing audit trail and role-change notification events keep working unmodified because the role-change plumbing underneath them didn't need to change at all.

Three ad hoc panel layouts, one shared shell

AGT-1409 (PR #727) is the other half of the week's work, and it's a consolidation rather than a new capability: the Trainer and Final Review gates were using a fixed 280px explorer panel bolted to a separate resizable-split implementation — two different sizing mechanisms stitched together in one screen. Model Output Review, Golden Data, and Calibration each already had their own two-to-three-pane resizable shell, built separately and not quite consistently.

The fix unifies all of them onto one shared ResizableColumns and CollapsedRail component pair, with a side: start | end option covering panels that collapse toward either edge, and a shared set of layout constants replacing per-screen magic numbers. No new resize library was introduced — this is entirely about collapsing duplicated, slightly-inconsistent implementations into one, so a layout fix applied to one review gate is structurally guaranteed to apply to all of them rather than needing to be manually ported across three or four separate component trees. The early-step browsers — the step artifact browser, Model Output, and Packaging views — gained drag-resize they hadn't had before, and Source Data and Prompt panels gained the same dual-rail collapse behavior. A smaller but real UX fix rode along with the consolidation: filter chips and stat cards in the Preview pane now wrap instead of clipping when that pane is narrowed, which the previous ad hoc layouts hadn't accounted for at all.

The pattern: consolidate the definition, not just the behavior

Both pieces of work this week solve the same underlying problem from different angles. The RBAC split works because the set of assignable roles lives in exactly one file that both the UI and the API read — there's no second copy to forget to update. The panel consolidation works because the resizing and collapsing behavior lives in exactly one component pair that every review gate imports — there's no second implementation to patch separately when a bug in it is found. Neither change added a new capability the platform didn't have some version of already. Both changes removed the second (and third, and fourth) copy of a definition that had been drifting slightly out of sync with the others.

Related Articles