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, hydrates identity from the ME action, shows a loader until the user is authenticated, then renders its children directly. RoleChecker is a separate component you mount yourself (typically wrapping the children inside LoginChecker); it evaluates the current path against your rules and renders the children, nothing (while still checking — it returns null, NOT the loadingComponent), or a forbidden screen. LoginChecker does NOT render RoleChecker for you — mount it if you need route/role gating.

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 broadly agree — but mind one asymmetry: checkPermission's mode 'all' tightens only the claims side (roles stay any-of), whereas a RouteRequirement's requireAll tightens BOTH roles and claims. To mirror a requireAll route that lists several roles you must check roles with hasAllRoles, not checkPermission.

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 break the hash and the CLAIM predicates (plus checkPermission) fail closed — the role predicates (hasRole/hasAnyRole/hasAllRoles) are NOT integrity-gated, so a role edit still answers true there.

LoginChecker & RoleChecker#

Mount LoginChecker once at the root. It calls your fetchUser (the ME action), hydrates the store, and shows the loader until the user is authenticated — then it renders its children directly. If the ME fetch errors it retries up to 2 times, 1s apart, before redirecting to loginPath, so a transient auth-check failure doesn't eject the user immediately. It does NOT render RoleChecker. To gate routes by role/claim you mount RoleChecker yourself, typically wrapping the children inside LoginChecker. Both are React components (not just config consumers): LoginChecker requires config, pathname and onNavigate props; RoleChecker requires config and pathname. Both read a single AuthGuardConfig.

app/providers.tsx
1import { LoginChecker, RoleChecker } from "nucleus-core-ts/fe";2import { useApiActions } from "@/lib/api";3import { usePathname, useRouter } from "next/navigation";4 5const actions = useApiActions();6const pathname = usePathname();7const router = useRouter();8 9const config = {10  loginPath: "/login",11  forbiddenPath: "/403",12  unauthPaths: ["/login", "/register", "/forgot-password"],13  publicPaths: ["/", "/pricing", "/docs"],14  globalRoleGate: ["user-free", "godmin"],15  routeRequirements: {16    "/admin": { roles: ["admin"], requireAll: false },17    "/reports": { claims: ["get.reports"], scope: { orgId: currentOrg } },18  },19  fetchUser: ({ onSuccess, onError }) =>20    actions.ME.start({ onAfterHandle: (r) => onSuccess(r.data), onErrorHandle: onError }),21  loadingComponent: <Splash />,22  forbiddenComponent: <Forbidden />,23};24 25// LoginChecker + RoleChecker are mounted together. LoginChecker requires26// pathname + onNavigate; the unauthenticated redirect is driven by27// onNavigate(loginPath) — not by onLogout. RoleChecker gates by role/claim.28<LoginChecker29  config={config}30  pathname={pathname}31  onNavigate={(p) => router.push(p)}32>33  <RoleChecker config={config} pathname={pathname}>34    {children}35  </RoleChecker>36</LoginChecker>
LoginChecker propscomponentOptional

The wrapper component's own props (beyond AuthGuardConfig).

configAuthGuardConfigOptional

The shared guard config (see below).

pathnamestringOptional

Required. The current path (e.g. from Next's usePathname()). Used to decide whether the route needs auth.

onNavigate(path: string) => voidOptional

Required. Called to redirect. When the user is unauthenticated on a protected route, LoginChecker calls onNavigate(config.loginPath) — this, not onLogout, drives the login redirect.

childrenReactNodeOptional

Rendered once the user is authenticated (or immediately on unauth/public paths).

RoleChecker propscomponentOptional

The route/role gate you mount yourself (typically inside LoginChecker).

configAuthGuardConfigOptional

The same shared guard config LoginChecker reads.

pathnamestringOptional

Required. The current path, matched against globalRoleGate and routeRequirements.

childrenReactNodeOptional

Rendered when access is authorized; while the check is pending RoleChecker renders nothing (returns null — loadingComponent is a LoginChecker concern, not RoleChecker's), and forbiddenComponent is shown on denial.

AuthGuardConfigobjectOptional

The single config both wrappers read.

unauthPathsstring[]Optional

Paths reachable while signed out (login, register, forgot-password). Never gated. Matched by exact path OR path-prefix (same matchesAnyPath rule as routeRequirements), so an entry '/reset' also ungates '/reset/anything' — list the narrowest prefixes intentionally.

publicPathsstring[]Optional

Paths anyone may see regardless of auth (marketing, docs). Never gated. Also matched by exact path OR path-prefix, so a publicPath '/docs' whitelists its whole '/docs/*' subtree — a security-relevant reach, so keep the prefixes tight.

loginPathstringOptional

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

forbiddenPathstringOptional

Present on the config type but currently unused by both LoginChecker and RoleChecker — denial is handled by rendering forbiddenComponent inline, not by navigating to forbiddenPath.

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.

onLogout() => voidOptional

Present on the config type but currently unused by LoginChecker and RoleChecker — the unauthenticated redirect is driven solely by onNavigate(loginPath), not by onLogout.

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 concrete, differing value fails; a 'self:'-prefixed scope is treated as ownership and allowed. A claim with NO scope (null) is treated as global and satisfies any required scope — so a global/unscoped claim passes scope gating; only a claim scoped to a different value is rejected.

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 / files / roles / claimsstateOptional

Hydrated from the ME response. user.isGod marks the super-admin. files holds AuthFile[] identity media (avatar etc.) from the response. isLoginChecked flips true once the first fetch resolves; isLoading is true while a fetch is in flight.

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

Role predicates. The isGod super-admin flag (user.isGod) short-circuits all of them to true. Note the 'godmin' ROLE does not — only isGodmin() and RoleChecker's godmin bypass honor the role; a user holding the 'godmin' role but lacking isGod is evaluated normally here.

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

Claim predicates with hierarchical matching and scope filtering. The isGod super-admin flag → true (the bare 'godmin' role does not short-circuit these); a failed integrity check → false (fails closed).

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

The combined predicate you gate UI with. RoleChecker does NOT call this — it implements the equivalent OR-combination inline via hasAnyRole/hasAllRoles/hasAnyClaim/hasAllClaims. mode 'any' (default) or 'all' switches only the CLAIMS side between any-of and all-of; roles are always any-of. When both roles and claims are passed they combine as OR. Because RouteRequirement.requireAll applies to both roles and claims, use hasAllRoles (not checkPermission) to mirror a requireAll route with multiple roles. checkPermission fails closed on an integrity-check failure: it calls verifyIntegrity() up front and returns false for its ENTIRE result when the hash is tampered — even a roles-only call like checkPermission(['admin'], []). This is asymmetric with hasRole/hasAnyRole/hasAllRoles, which are NOT integrity-gated and still answer from the (possibly mutated) roles under the same tamper.

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 generated once at module load (stable for the module lifetime). The hash itself is (re)computed and stored at hydrate. If a later recompute no longer matches (store was mutated out-of-band), claim checks return false.

hydrate / setLoading / setLoginChecked / resetlifecycle actionsOptional

Mutations beyond the predicates: hydrate(meResponse) loads identity + recomputes the integrity hash (LoginChecker calls it after the ME fetch), setLoading(bool) toggles the in-flight flag, setLoginChecked(bool) flips isLoginChecked once the ME check resolves — including the failure/retry-exhausted path, where LoginChecker calls it before redirecting to loginPath — and reset() clears user/profile/roles/claims. reset() is not automatic — call it yourself on logout to drop the cached identity.

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-ts/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 OR-combined role/claim predicate, for use on a button, menu item or section. Its options.mode 'all' tightens only the claims side; roles are always any-of. Note this is NOT the exact same code path routes use — RoleChecker evaluates requirements inline and its requireAll tightens both roles and claims. Like the store predicate it wraps, checkPermission fails closed on an integrity-check failure — it returns false for its ENTIRE result (including a roles-only call) when the roles/claims hash is tampered, unlike hasRole/hasAnyRole/hasAllRoles, which are not integrity-gated.

hasRole / hasAnyRole / hasClaim / hasAnyClaim / hasAllClaims / isGodminpredicatesOptional

Direct predicates when you only need one axis. hasClaim takes an optional scope for row/tenant-level gating; isGodmin() returns true for a super-admin (user.isGod or the 'godmin' role). Note hasAllRoles is NOT on usePermission — it lives only on useAuthGuardStore. So the overview's advice to mirror a requireAll multi-role route with hasAllRoles must be done by reading hasAllRoles from useAuthGuardStore() directly, not from this hook.

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