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.
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 propscomponentOptionalThe wrapper component's own props (beyond AuthGuardConfig).
configAuthGuardConfigOptionalThe shared guard config (see below).
pathnamestringOptionalRequired. The current path (e.g. from Next's usePathname()). Used to decide whether the route needs auth.
onNavigate(path: string) => voidOptionalRequired. Called to redirect. When the user is unauthenticated on a protected route, LoginChecker calls onNavigate(config.loginPath) — this, not onLogout, drives the login redirect.
childrenReactNodeOptionalRendered once the user is authenticated (or immediately on unauth/public paths).
RoleChecker propscomponentOptionalThe route/role gate you mount yourself (typically inside LoginChecker).
configAuthGuardConfigOptionalThe same shared guard config LoginChecker reads.
pathnamestringOptionalRequired. The current path, matched against globalRoleGate and routeRequirements.
childrenReactNodeOptionalRendered 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.
AuthGuardConfigobjectOptionalThe single config both wrappers read.
unauthPathsstring[]OptionalPaths 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[]OptionalPaths 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.
loginPathstringOptionalWhere unauthenticated users are sent when they hit a protected route.
forbiddenPathstringOptionalPresent 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>OptionalPer-path role/claim rules. Matched by exact path or path-prefix, so a rule on /admin also guards /admin/anything.
globalRoleGatestring[]OptionalA 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 }) => voidOptionalHow the guard loads identity. Wrap your ME action here; its data hydrates the store. Called by LoginChecker on mount.
loadingComponent / forbiddenComponentReactNodeOptionalRendered while the check is pending, and when access is denied, respectively.
onLogout() => voidOptionalPresent 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[]OptionalRole names accepted for the route. By default any one of them suffices.
claimsstring[]OptionalClaim actions accepted for the route, e.g. 'get.reports'. Matched hierarchically — a held 'get.reports' satisfies a required 'get.reports.summary'.
requireAllbooleanOptionalWhen true, the user must hold ALL listed roles (and ALL listed claims) rather than any one.
scopeRecord<string,string>OptionalRow/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 / claimsstateOptionalHydrated 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) => booleanOptionalRole 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?) => booleanOptionalClaim 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 }?) => booleanOptionalThe 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 / getEffectiveUserIdhelpersOptionalisGodmin() reflects user.isGod or the 'godmin' role; getEffectiveUserId() returns the acting user id (the hook point for impersonation-aware UI).
verifyIntegrity() => booleanOptionalRecomputes 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 actionsOptionalMutations 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.
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?)booleanOptionalThe 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 / isGodminpredicatesOptionalDirect 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 stateOptionalThe current identity, re-rendering consumers when it changes — isAuthenticated is true once login is checked and a user is present.
Related sections