Access control UI
Patterns for showing and managing role + grant state across Lumen.
Role badges
Three badge primitives live in apps/web/components/org/:
PlatformRoleBadge
<PlatformRoleBadge role="admin" />
| Role | Tone | Color |
|---|---|---|
| superadmin | Magenta | #c026d3 |
| admin | Blue | #2a6fdb |
| engineer | Purple | #6b46c1 |
| none | Gray | subtle |
Used in Users table, audit log, user detail modal.
PositionBadge
<PositionBadge position="ceo" />
| Position | Style |
|---|---|
| ceo | Light purple outline |
| manager | Green outline |
| member | Gray outline |
AccessTierBadge
<AccessTierBadge tier="edit" />
| Tier | Icon | Color |
|---|---|---|
| full | Shield-full | Accent blue |
| edit | Pencil | Green |
| use | Eye | Gray |
Used in project member list, grant target modal, project cards.
Grant access modal
Single primitive for granting access to user, group, OR department. Located at components/org/grant-access-modal.tsx.
Structure:
┌─────────────────────────────────────────┐
│ Grant access to <project name> │
│ Pick a target — individual, group, or │
│ department — then choose an access tier.│
│ │
│ 1. Who │
│ ┌────────────────────────────────────┐ │
│ │ [Users] [Groups] [Departments] │ │
│ │ ────── │ │
│ │ │ │
│ │ ○ Gani Sigit │ │
│ │ gani@company.com │ │
│ │ │ │
│ │ ○ Ari Rachman │ │
│ │ ari@company.com │ │
│ └────────────────────────────────────┘ │
│ │
│ 2. Tier │
│ [ Use ] [ Edit ] [✓ Full ] │
│ │
│ [Cancel] [Grant Full access]│
└─────────────────────────────────────────┘
API contract:
<GrantAccessModal
open
onClose={() => setShowInvite(false)}
projectName={project.name}
onGrant={async (target, tier) => {
// target = { kind: "user" | "group" | "department", id: string }
await api.fetch(`/projects/${projectId}/grants`, {
method: "POST",
body: JSON.stringify({
targetType: target.kind, // careful: backend field is "targetType"
targetId: target.id,
tier,
}),
});
}}
/>
Gotcha from issue #9: the target object uses kind not type — backend field is targetType. Map carefully.
Department picker
<DepartmentPicker
value={departmentId}
onChange={setDepartmentId}
disabled={platformRole === "admin" || platformRole === "engineer" || orgPosition === "ceo"}
/>
When disabled (admin/engineer/CEO), a subtitle explains why:
- CEO → "CEO is not assigned"
- Admin/Engineer → "Platform staff — not tied to a department"
This is how the user creation modal handles the fact that platform roles don't belong to departments.
Project access tier resolution in UI
When a project list response includes accessTier + accessSource:
// from api
{ id: "...", name: "...", accessTier: "use", accessSource: "public", ... }
Use it to gate UI affordances:
// Hide "Delete" for users without full tier
{project.accessTier === "full" && (
<Button variant="primary" className="bg-error">Delete</Button>
)}
// Show the source as a badge
<Badge>
{project.accessSource === "public" && <GlobeIcon />}
{project.accessSource === "owner" && "Owner"}
{project.accessSource === "direct" && "Direct grant"}
{project.accessSource}
</Badge>
Page-level gating
The sidebar hides Admin / Engineer nav items when user.platformRole doesn't permit them:
const NAV_ITEMS = [
{ id: "admin", label: "Admin", href: "/admin", roles: ["admin", "superadmin"] },
{ id: "engineer", label: "Engineer", href: "/engineer", roles: ["engineer", "superadmin"] },
// ...
];
// In sidebar render:
{NAV_ITEMS.map(item => {
if (item.roles && !item.roles.includes(user.platformRole)) return null;
return <NavLink key={item.id} {...item} />;
})}
Never rely on hiding alone for security — the backend requirePlatformRole middleware is the actual gate. UI hiding is just to avoid confusion.
Don't
-
Don't mix role and tier in a single badge. Platform role is about who you are on the platform. Tier is about your access on a specific project. Different axes. Users in admin usually have
fulltier on every project, but rendering them as "admin, full" is ambiguous. Showadminrole on the user profile; showfulltier only in project context. -
Don't gate UI from
userRole/ JWT claims. Role comes from DB on every request now. UseuseAuth().user.platformRole, which is hydrated from/auth/me. -
Don't build custom role dropdowns. Use
<Select>with the fixed list (Regular user / Admin / Engineer) — superadmin is disabled because it's never reassigned.