Auth Guard

Client route protection & permission gating

Auth Guard is the browser-side companion to the Authorization config. It answers two questions continuously: is this user signed in, and are they allowed on this route? LoginChecker wraps your app and hydrates identity from the ME action; RoleChecker, nested inside it, evaluates the current path against your rules and renders the children, a loader, or a forbidden screen.

Identity lives in useAuthGuardStore — user, profile, roles and claims — with a rich set of predicates (hasRole, hasClaim, checkPermission…) that you also call directly through usePermission to show, hide or disable individual pieces of UI. The same hierarchical claim and scope semantics as the backend are mirrored here, so a button and the route it leads to agree.

This is defense-in-depth, not the security boundary. The server middleware is the real guard; the client guard exists to avoid flashing forbidden screens and to drive UX. The store even carries a tamper-evident integrity hash so casual DevTools edits to roles/claims fail closed.

LoginChecker & RoleChecker#

Mount LoginChecker once at the root. It calls your fetchUser (the ME action), hydrates the store, and renders RoleChecker which gates the current path. Both read a single AuthGuardConfig.

app/providers.tsx
1import { LoginChecker } from "nucleus-core/fe";2import { useApiActions } from "@/lib/api";3 4const actions = useApiActions();5 6<LoginChecker7  config={{8    loginPath: "/login",9    forbiddenPath: "/403",10    unauthPaths: ["/login", "/register", "/forgot-password"],11    publicPaths: ["/", "/pricing", "/docs"],12    globalRoleGate: ["user-free", "godmin"],13    routeRequirements: {14      "/admin": { roles: ["admin"], requireAll: false },15      "/reports": { claims: ["get.reports"], scope: { orgId: currentOrg } },16    },17    fetchUser: ({ onSuccess, onError }) =>18      actions.ME.start({ onAfterHandle: (r) => onSuccess(r.data), onErrorHandle: onError }),19    onLogout: () => router.push("/login"),20    loadingComponent: <Splash />,21    forbiddenComponent: <Forbidden />,22  }}23>24  {children}25</LoginChecker>
AuthGuardConfigobjectOptional

The single config both wrappers read.

unauthPathsstring[]Optional

Paths reachable while signed out (login, register, forgot-password). Never gated.

publicPathsstring[]Optional

Paths anyone may see regardless of auth (marketing, docs). Never gated.

loginPathstringOptional

Where unauthenticated users are sent when they hit a protected route.

forbiddenPathstringOptional

Optional route for authenticated-but-not-allowed users (used alongside forbiddenComponent).

routeRequirementsRecord<string, RouteRequirement>Optional

Per-path role/claim rules. Matched by exact path or path-prefix, so a rule on /admin also guards /admin/anything.

globalRoleGatestring[]Optional

A baseline role gate applied to every protected route before per-route rules — e.g. require any of ['user-free','godmin'] to enter the app at all.

fetchUser({ onSuccess, onError }) => voidOptional

How the guard loads identity. Wrap your ME action here; its data hydrates the store. Called by LoginChecker on mount.

loadingComponent / forbiddenComponentReactNodeOptional

Rendered while the check is pending, and when access is denied, respectively.

Route requirements#

A RouteRequirement expresses what a path needs. Keys are matched by prefix, so you guard a whole subtree with one entry.

rolesstring[]Optional

Role names accepted for the route. By default any one of them suffices.

claimsstring[]Optional

Claim actions accepted for the route, e.g. 'get.reports'. Matched hierarchically — a held 'get.reports' satisfies a required 'get.reports.summary'.

requireAllbooleanOptional

When true, the user must hold ALL listed roles (and ALL listed claims) rather than any one.

scopeRecord<string,string>Optional

Row/tenant scoping applied to the claim check, e.g. { orgId: currentOrg }. A claim scoped to a different value fails; a 'self:'-prefixed scope is treated as ownership and allowed.

useAuthGuardStore#

The hydrated identity and every predicate the guard uses. Read it anywhere; the predicates are what you compose your own UI rules from.

user / profile / roles / claimsstateOptional

Hydrated from the ME response. user.isGod marks the super-admin. isLoginChecked flips true once the first fetch resolves.

hasRole / hasAnyRole / hasAllRoles(name | names) => booleanOptional

Role predicates. Godmin short-circuits all of them to true.

hasClaim / hasAnyClaim / hasAllClaims(action, scope?) => booleanOptional

Claim predicates with hierarchical matching and scope filtering. Godmin → true; a failed integrity check → false (fails closed).

checkPermission(roles, claims, { mode, scope }?) => booleanOptional

The combined check RoleChecker uses. mode 'any' (default) or 'all' applies to the claims side; roles+claims combine as OR.

isGodmin / getEffectiveUserIdhelpersOptional

isGodmin() reflects user.isGod or the 'godmin' role; getEffectiveUserId() returns the acting user id (the hook point for impersonation-aware UI).

verifyIntegrity() => booleanOptional

Recomputes the hash of roles+claims against a closure-scoped random key captured at hydrate. If they no longer match (store was mutated out-of-band), claim checks return false.

usePermission — gating UI#

A reactive hook bound to the current auth state for granular show/hide/disable decisions. Same semantics as the store predicates, ergonomic for components.

components/Toolbar.tsx
1import { usePermission } from "nucleus-core/fe";2 3function Toolbar({ orgId }: { orgId: string }) {4  const { checkPermission, hasClaim, isGodmin } = usePermission();5 6  return (7    <>8      <Button disabled={!checkPermission(["supervisor"], ["create.orders"])}>9        New order10      </Button>11 12      {/* row-level: only show analytics for the current org */}13      <section hidden={!hasClaim("get.analytics", { orgId })}>14        <Analytics />15      </section>16 17      {isGodmin() && <AdminPanel />}18    </>19  );20}
checkPermission(roles, claims, options?)booleanOptional

The same OR-combined role/claim check used for routes, for use on a button, menu item or section.

hasRole / hasClaim / hasAnyClaim / hasAllClaimspredicatesOptional

Direct predicates when you only need one axis. hasClaim takes an optional scope for row/tenant-level gating.

user / roles / claims / isAuthenticatedreactive stateOptional

The current identity, re-rendering consumers when it changes — isAuthenticated is true once login is checked and a user is present.

Related sections