Frontend Permission System
This guide explains how the frontend permission system works and how to use it when developing new features.
Architecture
Permissions flow from the backend to every component:
- Server Layout calls
getMe()which returnspermissions: string[]for the active account - PermissionProvider (React Context) makes permissions available to all client components
- Hooks and components use
usePermissions(),useHasPerm(),<Gate>, and<PermissionErrorBoundary>to gate UI elements
API Reference
usePermissions(): string[]
Returns the full array of permissions for the current user's active account.
const permissions = usePermissions();
useHasPerm(permission: string): boolean
Returns whether the current user has a specific permission. The check is perms.includes("*") || perms.includes(permission) — the "*" wildcard is only ever present on a System-key identity, never on an account role.
const canEdit = useHasPerm("overlays:edit");
Permissions are granular: there is no resource:write. Write actions map to create / edit / delete (plus domain actions such as overlays:access-grant).
<Gate permission="...">
Conditionally renders children only if the user has the specified permission. Renders nothing otherwise.
<Gate permission="overlays:create">
<Button onClick={createOverlay}>Create Overlay</Button>
</Gate>
<PermissionErrorBoundary permission="...">
Renders a "No Access" fallback UI when the user lacks the required permission. Use this to wrap entire page content for direct URL access protection.
<PermissionErrorBoundary permission="spotify:read">
<MusicPlayer />
</PermissionErrorBoundary>
useToast()
Exposes showToast(message, type) for displaying toast notifications. Used for 403 error feedback.
const { showToast } = useToast();
if (res.status === 403) {
showToast(t("permissions.forbidden"), "error");
}
Adding New Permissions
When adding a new permission to the system, follow this checklist:
- Add constant in
crates/lo-auth/src/rbac.rs(inpub mod account {}) - Update default roles in
rbac.rs(ROLE_OWNER,ROLE_ADMIN,ROLE_MODERATOR,ROLE_VIEWER) - Register it once in
account::get_all_account_permission_infos()(same file) — ap!(KEY, "Label", "Category")entry. Every picker and the validation registry derive from that list; there is no second list to edit. - Write DB migration to add the permission to existing roles
- Add GraphQL guard --
#[graphql(guard = "lo_graphql::PermissionGuard::new(\"perm:name\")")] - Add REST guard --
auth.require_permission("perm:name").map_err(...)?; - Add
#[utoipa::path]description noting the required permission - Add sidebar mapping in
apps/web/src/app/(main)/(app)/shell.tsx - Wrap page with
<PermissionErrorBoundary permission="perm:read"> - Gate write controls with
<Gate permission="perm:create">/perm:edit/perm:delete - Update docs in
apps/docs/docs/api-reference/permissions.md - Update docs in
apps/docs/docs/user-guide/roles-and-permissions.md
Route-segment gates
Hiding a nav item in shell.tsx does not close the route — a deep link still renders the page. Two layers cover that:
- Feature flags — each gated dashboard segment has a
layout.tsxthat wrapschildrenin<FeatureRouteGate feature="feature:...">(apps/web/src/components/feature-route-gate.tsx), so every page and nested route under the segment is covered by one gate. - Permissions — page content is wrapped in
<PermissionErrorBoundary permission="...">. Wrap the top-level client component of the segment, not individual widgets, so direct URL access is caught too.
Sidebar entries in shell.tsx carry both a permission and (where applicable) a feature key; the shell filters items on both and hides sections that end up empty.
Popout Pages
Popout pages (music, events, chat) operate outside the dashboard layout and need their own permission handling.
Pattern
- Server component fetches permissions:
- Popout token:
GET /v1/tokens/me?token=xxxviaserverFetch() - Cookie auth:
getMe(config).permissions
- Popout token:
- Client wrapper wraps content in
<PermissionProvider>+<ToastProvider> - Components use
<Gate>anduseHasPerm()— same as dashboard - Hooks pass popout token via
withToken()to all API calls
Key Files
| File | Purpose |
|---|---|
apps/web/src/contexts/permission-context.tsx | PermissionProvider + hooks |
apps/web/src/contexts/toast-context.tsx | Toast system |
apps/web/src/components/gate.tsx | Declarative permission gate |
apps/web/src/components/permission-error-boundary.tsx | Page-level access denial |
apps/web/src/components/feature-route-gate.tsx | Feature-flag gate used by segment layout.tsx files |
apps/web/src/app/(main)/(app)/shell.tsx | Sidebar permission + feature filtering |
crates/lo-auth/src/rbac.rs | Permission constants, default roles, and get_all_account_permission_infos() registry |
apps/api/src/graphql/roles.rs | GraphQL availablePermissions picker (maps the registry) |
apps/api/src/routes/roles.rs | REST GET /v1/roles/permissions picker + role validation |