Lumen Docs

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 full tier on every project, but rendering them as "admin, full" is ambiguous. Show admin role on the user profile; show full tier only in project context.

  • Don't gate UI from userRole / JWT claims. Role comes from DB on every request now. Use useAuth().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.