Frontend
Permissions in the UI
Show, hide and guard UI by the current user's permissions, mirrored from the API.
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.csbut not topermissions.ts. The fix is mechanical: add the matchingArea.Actionentry. 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/meon 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.csand the endpoint requires it. - A matching
Area.Actionentry exists infrontend/src/shared/auth/permissions.ts, same casing. - Components gate actions with
usePermissions().has(...), returningnullfor unavailable actions. - Navigation entries carry a
permissionand are filtered withhas. - The route is guarded in
beforeLoad(viaensureAuthenticatedplus anisAdminorpermissions.includescheck), not just hidden in the menu. - A
403from the API is handled gracefully (toast plus invalidate['me']), since the UI gate is advisory and the server is authoritative. -
pnpm typecheckpasses.
Adding a whole feature behind a permission? Pair this with Adding a frontend feature and the Frontend overview.