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.
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>AuthGuardConfigobjectOptionalThe single config both wrappers read.
unauthPathsstring[]OptionalPaths reachable while signed out (login, register, forgot-password). Never gated.
publicPathsstring[]OptionalPaths anyone may see regardless of auth (marketing, docs). Never gated.
loginPathstringOptionalWhere unauthenticated users are sent when they hit a protected route.
forbiddenPathstringOptionalOptional route for authenticated-but-not-allowed users (used alongside forbiddenComponent).
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.
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 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 / claimsstateOptionalHydrated from the ME response. user.isGod marks the super-admin. isLoginChecked flips true once the first fetch resolves.
hasRole / hasAnyRole / hasAllRoles(name | names) => booleanOptionalRole predicates. Godmin short-circuits all of them to true.
hasClaim / hasAnyClaim / hasAllClaims(action, scope?) => booleanOptionalClaim predicates with hierarchical matching and scope filtering. Godmin → true; a failed integrity check → false (fails closed).
checkPermission(roles, claims, { mode, scope }?) => booleanOptionalThe combined check RoleChecker uses. mode 'any' (default) or 'all' applies to the claims side; roles+claims combine as OR.
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 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.
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?)booleanOptionalThe same OR-combined role/claim check used for routes, for use on a button, menu item or section.
hasRole / hasClaim / hasAnyClaim / hasAllClaimspredicatesOptionalDirect predicates when you only need one axis. hasClaim takes an optional scope for row/tenant-level gating.
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