Skip to content
Slicekit

Frontend

Permissions in the UI

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

View .md
On this page

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

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

export type PermissionName = (typeof Permission)[keyof typeof Permission];

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

Each constant maps one-to-one to Allow.<Name> 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:

import { useCurrentUser } from '@/features/account/hooks';
import { hasPermission, type PermissionName } from '@/shared/auth/permissions';

export function usePermissions() {
  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:

import { usePermissions } from '@/shared/hooks/use-permissions';
import { Permission } from '@/shared/auth/permissions';

export function CreateApiKeyButton() {
  const { has } = usePermissions();
  if (!has(Permission.UserCreateApiKey)) return null;
  return <Button onClick={openCreateDialog}>{t('api_keys.create')}</Button>;
}

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:

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 <Link> 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:

export const Route = createFileRoute('/_app/admin')({
  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:

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.

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

import { useQueryClient } from '@tanstack/react-query';
import { meQueryKey } from '@/features/account/hooks';

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 and the Frontend overview.