# Permissions in the UI

> Show, hide and guard UI by the current user's permissions, mirrored from the API.

## Where permissions come from

Permissions are defined and enforced on the API, not invented in the SPA. The catalog lives in
`api/src/Slicekit.Core/Permissions/Allow.cs` as `Area.Action` strings, and every endpoint declares
the permission it requires (see [Adding a permission](/docs/adding-a-permission)). The frontend only
**mirrors** that catalog so it can hide UI the user could never use anyway. The server is still the
source of truth: a hidden button is a courtesy, a `403` from the API is the real gate.

The current user's permission set arrives with the rest of their profile. The `GET /me` request
loads `{ id, isAdmin, permissions, ... }` once, and TanStack Query caches it under the `['me']` key.
Every component that reads permissions hits that one cache, so gating N components costs zero extra
requests.

## The mirrored enum

`frontend/src/shared/auth/permissions.ts` holds a plain const map plus a `hasPermission` helper:

```ts

  UserUpdateProfile: 'User.UpdateProfile',
  UserListSessions: 'User.ListSessions',
  UserListApiKeys: 'User.ListApiKeys',
  UserCreateApiKey: 'User.CreateApiKey',
  AdminListUsers: 'Admin.ListUsers',
  AdminSetPermissions: 'Admin.SetPermissions',
  // ...
} as const;

  permissions: readonly string[] | undefined,
  name: PermissionName,
): boolean {
  return !!permissions?.includes(name);
}
```

Each constant maps one-to-one to `Allow.` in `Allow.cs`: same `Area.Action` shape, same
casing. The string value is what the server returns in the `permissions` array. You only need to
list the permissions the SPA actually renders against, not the whole catalog.

## Reading permissions in a component

`frontend/src/shared/hooks/use-permissions.ts` wraps the `['me']` query and exposes a `has`
predicate:

```ts

  const { data } = useCurrentUser();
  const permissions = data?.permissions ?? [];
  return {
    permissions,
    has: (name: PermissionName) => hasPermission(permissions, name),
  };
}
```

Use it to render an action only when the user holds the matching permission:

```tsx

  const { has } = usePermissions();
  if (!has(Permission.UserCreateApiKey)) return null;
  return {t('api_keys.create')};
}
```

Prefer returning `null` over rendering a disabled control: a control the user can never use is just
noise. Reserve the disabled state for things they could do but cannot right now.

## Gating navigation

The settings shell at `frontend/src/routes/_app.settings.tsx` builds its tab list from a typed array
where each section carries an optional `permission`, then filters the list with `has`:

```tsx
const sections: Section[] = [
  { to: '/settings/profile', labelKey: 'profile', permission: Permission.UserUpdateProfile },
  { to: '/settings/sessions', labelKey: 'sessions', permission: Permission.UserListSessions },
  { to: '/settings/api-keys', labelKey: 'api_keys', permission: Permission.UserListApiKeys },
];

function SettingsLayout() {
  const { has } = usePermissions();
  const visible = sections.filter((s) => !s.permission || has(s.permission));
  // render `visible` as  tabs
}
```

A section with no `permission` is always shown. Adding a new gated tab is one array entry.

## Route guards

Hiding a link does not protect the route it points to. The route itself runs a `beforeLoad` guard
that resolves the current user and redirects when the check fails. The admin layout at
`frontend/src/routes/_app.admin.tsx` does this with the shared `ensureAuthenticated` helper:

```tsx

  beforeLoad: async ({ context, location }) => {
    const me = await ensureAuthenticated(context.queryClient, location.pathname);
    if (!me.isAdmin) throw redirect({ to: '/' });
  },
  component: AdminLayout,
});
```

`ensureAuthenticated` (in `frontend/src/shared/auth/guard.ts`) fetches the `['me']` query, redirects
to `/auth/login` when there is no session, and otherwise returns the `CurrentUser`. Because it reads
the same cache as `usePermissions`, the guard runs without a second network round trip. To gate a
route on a specific permission rather than the admin flag, check `me.permissions.includes(...)` in
the same `beforeLoad`:

```tsx
beforeLoad: async ({ context, location }) => {
  const me = await ensureAuthenticated(context.queryClient, location.pathname);
  if (!me.permissions.includes(Permission.AdminListUsers)) throw redirect({ to: '/' });
},
```

For more on how sessions and the `['me']` query are wired, see [Authentication](/docs/authentication).

## Keeping the mirror in sync with the API

Permissions live on the API; the SPA copy can drift. Two failure modes to keep in mind:

- **The enum is stale.** A permission was added to `Allow.cs` but not to `permissions.ts`. The fix
  is mechanical: add the matching `Area.Action` entry. Do it in the same change that touches the API,
  the way [Adding a permission](/docs/adding-a-permission) describes.
- **The cache is stale.** The `['me']` query is cached for 60 seconds (`staleTime`), and the API
  caches the user's permission set briefly too. After a server-side change (an admin grant, a
  permission added to the catalog), the UI can show stale permissions until the cache refreshes or
  the user signs out and back in. This is deliberate: the alternative is refetching `/me` on every
  navigation.

When a privileged action returns `403` even though the button was visible, treat it as a sync gap,
not a bug. Surface the error as a toast and invalidate the `['me']` query so the next render reflects
reality:

```ts

const queryClient = useQueryClient();
await queryClient.invalidateQueries({ queryKey: meQueryKey });
```

## Checklist

- [ ] The permission exists in `api/src/Slicekit.Core/Permissions/Allow.cs` and the endpoint requires it.
- [ ] A matching `Area.Action` entry exists in `frontend/src/shared/auth/permissions.ts`, same casing.
- [ ] Components gate actions with `usePermissions().has(...)`, returning `null` for unavailable actions.
- [ ] Navigation entries carry a `permission` and are filtered with `has`.
- [ ] The route is guarded in `beforeLoad` (via `ensureAuthenticated` plus an `isAdmin` or
      `permissions.includes` check), not just hidden in the menu.
- [ ] A `403` from the API is handled gracefully (toast plus invalidate `['me']`), since the UI gate
      is advisory and the server is authoritative.
- [ ] `pnpm typecheck` passes.

Adding a whole feature behind a permission? Pair this with
[Adding a frontend feature](/docs/adding-a-frontend-feature) and the
[Frontend overview](/docs/frontend-overview).
