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)
  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