Skip to main content

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:

  1. Server Layout calls getMe() which returns permissions: string[] for the active account
  2. PermissionProvider (React Context) makes permissions available to all client components
  3. 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:

  1. Add constant in crates/lo-auth/src/rbac.rs (in pub mod account {})
  2. Update default roles in rbac.rs (ROLE_OWNER, ROLE_ADMIN, ROLE_MODERATOR, ROLE_VIEWER)
  3. Register it once in account::get_all_account_permission_infos() (same file) — a p!(KEY, "Label", "Category") entry. Every picker and the validation registry derive from that list; there is no second list to edit.
  4. Write DB migration to add the permission to existing roles
  5. Add GraphQL guard -- #[graphql(guard = "lo_graphql::PermissionGuard::new(\"perm:name\")")]
  6. Add REST guard -- auth.require_permission("perm:name").map_err(...)?;
  7. Add #[utoipa::path] description noting the required permission
  8. Add sidebar mapping in apps/web/src/app/(main)/(app)/shell.tsx
  9. Wrap page with <PermissionErrorBoundary permission="perm:read">
  10. Gate write controls with <Gate permission="perm:create"> / perm:edit / perm:delete
  11. Update docs in apps/docs/docs/api-reference/permissions.md
  12. 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.tsx that wraps children in <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

  1. Server component fetches permissions:
    • Popout token: GET /v1/tokens/me?token=xxx via serverFetch()
    • Cookie auth: getMe(config).permissions
  2. Client wrapper wraps content in <PermissionProvider> + <ToastProvider>
  3. Components use <Gate> and useHasPerm() — same as dashboard
  4. Hooks pass popout token via withToken() to all API calls

Key Files

FilePurpose
apps/web/src/contexts/permission-context.tsxPermissionProvider + hooks
apps/web/src/contexts/toast-context.tsxToast system
apps/web/src/components/gate.tsxDeclarative permission gate
apps/web/src/components/permission-error-boundary.tsxPage-level access denial
apps/web/src/components/feature-route-gate.tsxFeature-flag gate used by segment layout.tsx files
apps/web/src/app/(main)/(app)/shell.tsxSidebar permission + feature filtering
crates/lo-auth/src/rbac.rsPermission constants, default roles, and get_all_account_permission_infos() registry
apps/api/src/graphql/roles.rsGraphQL availablePermissions picker (maps the registry)
apps/api/src/routes/roles.rsREST GET /v1/roles/permissions picker + role validation