@aiquants/authz-react-router
v0.14.0
Published
React Router (v7 / v8) adapter for @aiquants/authz-core: requirePermission/getMyPermissions guards, usePermission hook, and MyPermissionIndicator component.
Readme
@aiquants/authz-react-router
React Router (v7) adapter for @aiquants/authz-core. Binds appKey + DI deps and provides loader/action guards, a hook, and a self-permission indicator component.
Install
Monorepo (already a pnpm workspace package):
// consumer package.json
"dependencies": {
"@aiquants/authz-core": "workspace:*",
"@aiquants/authz-drizzle": "workspace:*",
"@aiquants/authz-react-router": "workspace:*"
}External projects:
pnpm add @aiquants/authz-react-router react react-router # peers: react >=18, react-router ^7react / react-router are peer dependencies; @aiquants/authz-core is transitive. Pair with @aiquants/authz-drizzle (or another adapter) for getEffectivePermissions. Build / test: pnpm run build / pnpm run test.
The admin tabs draw their pickers with @aiquants/select-box, a peer dependency as well: ^0.16.0, which brings its
own peers @aiquants/virtualscroll and @aiquants/fuzzy-search (the ranges its manifest names). Load its stylesheets as
the README of the select-box version you install ("CSS Setup") describes — a Tailwind v4 host imports the virtualscroll
and select-box component builds in layer(components).
Full integration (zero → guarded endpoint)
This walkthrough takes a React Router v7 app from nothing to a permission-guarded loader. It uses all three TS packages.
Step 0 — install & create tables. Install the three packages (above) plus your DB stack. Run the DDL and bootstrap seed from the @aiquants/authz-drizzle README once (this creates dbo_authz and an initial admin). You also need a user table with a numeric id and a way to resolve the current request to that id (your existing auth/session).
Step 1 — wire it once. Bind your appKey, how to resolve a user id, and how to load grants (delegated to the drizzle adapter).
// app/services/authz/config.ts
import { defineAuthzSchema, getEffectivePermissionsMany, listResourceKeys } from "@aiquants/authz-drizzle"
import { createAuthz } from "@aiquants/authz-react-router"
import { db } from "~/db.server" // your drizzle handle
import { Users } from "~/models/Users" // your user table (has numeric id)
import { getSessionUser } from "~/auth.server"
export const authzTables = defineAuthzSchema("dbo_authz", { userTable: Users })
const authz = createAuthz({
appKey: "myapp",
// request → your user id (or null → checked against "@anonymous" permissions)
resolveUserId: async (request) => (await getSessionUser(request))?.id ?? null,
// request → the tenant this request acts within (subdomain / header / session). REQUIRED.
// Fail fast: `?? "default"` would authorize a session-less request inside the default tenant.
resolveTenantId: async (request) => {
const user = await getSessionUser(request)
if (!user?.tenantId) throw new Response("tenant unresolved", { status: 400 })
return user.tenantId
},
// request + tenant → may this caller act AS that tenant? REQUIRED, and never `() => true`:
// resolveTenantId only says WHICH tenant was named, which on every transport the client can influence.
assertTenantMembership: async (request, tenantId) => (await getSessionUser(request))?.tenantId === tenantId,
// load + merge this user's grants for MANY resources in one round-trip
getEffectivePermissions: (args) => getEffectivePermissionsMany(db, authzTables, args),
// optional: lets `getMyPermissions(request)` report on every registered resource of that tenant
listResourceKeys: (_request, tenantId) => listResourceKeys(db, authzTables, "myapp", tenantId),
})
export const { requirePermission, getMyPermissions } = authzIf your DB pool can be cold at the first request (serverless / lazy connect), have
resolveUserIdandgetEffectivePermissionsawait a connection-ready guard first — otherwise a cold-start DB error can surface as a misleading 403 instead of a 500.
Tenancy is not optional. Every check happens inside exactly one tenant, so
resolveTenantIdis a required port and its result must be a non-empty string. Returning""/nullraisesAuthzTenantErrorbefore the permission port is consulted — it never degrades into "no filter", which would hand the caller every tenant's grants. The guard also verifies that the permission it received was issued for the requested tenant and denies (reason: "tenant-mismatch") when it was not, so a store that forgets its predicate cannot turn into cross-tenant access.Naming a tenant is not the same as belonging to it.
resolveTenantIdanswers which tenant the request named, and on every transport that name is ultimately client-influenced (a header, a sub-domain, a cookie).assertTenantMembershipis therefore also required, with no permissive default: without it a user of tenant A is authorized inside tenant B by simply naming tenant B — B's@authenticatedgrants apply, and every tenant check still passes because the permission genuinely belongs to B. It is consulted before any permission is loaded and denies withreason: "not-a-tenant-member".userIdisnullfor an anonymous caller; a tenant's@anonymousgrants are public by construction, so returningtruethere is the usual answer.Do not substitute a fallback tenant (
?? "default"). A request that cannot name its tenant is a request that must not be authorized — substituting one silently moves the missing-tenant-means-all-tenants inversion out of the library and into every consumer.
Step 2 — guard a loader/action, apply scope, and render the badge. On allow you get { userId, scope, permission } from a single evaluation, so you can authorize, narrow the data, and tell the user what they may do — without asking the database a second time.
// app/routes/report.tsx
import { buildRowScopeWhere, maskColumns } from "@aiquants/authz-drizzle"
import { requirePermission } from "~/services/authz/config"
import { db } from "~/db.server"
import { Report } from "~/models/Report"
export async function loader({ request }: { request: Request }) {
const { scope, permission } = await requirePermission(request, { resourceKey: "report", action: "read" })
// map the scope's logical field names → real columns (per endpoint; the core layer doesn't know your table)
const columnMap = { dept: Report.dept, amount: Report.amount }
const where = buildRowScopeWhere(scope.rowScope, columnMap) // undefined = all rows; 1=0 = no rows
const rows = await (where ? db.select().from(Report).where(where) : db.select().from(Report))
return { rows: maskColumns(rows, scope.columnScope), permission } // deny disallowed columns before returning
}
// denied → AuthzDeniedError (throw a 403 Response in your error boundary, or pass options.failureRedirect)Guarding a sub-resource endpoint? Use
requirePermission, not a hand-rolled check. Reading a boolean off a permission map and throwing your own 403 duplicates the decision — and a typo in the field name reads asundefined, i.e. a silent denial.requirePermissionis the only thing that decides access;getMyPermissionsexists to display permissions, not to enforce them.
Step 3 — register the resource + grant. Insert a TMResource('myapp','report') and a TDRolePermission for the role(s) that should see it. Either run SQL, or build a tiny admin screen with the CRUD helpers (see the host app's admin guide). Example grant: "role viewer may read report, rows where dept ∈ {A}, hiding the amount column":
-- row_scope / column_scope are the scope-contract JSON from @aiquants/authz-core
INSERT INTO dbo_authz.TDRolePermission (role_id, resource_id, action, row_scope, column_scope)
SELECT r.id, res.id, 'read',
N'{"filters":[{"field":"dept","op":"in","values":["A"]}]}',
N'{"mode":"deny","columns":["amount"]}'
FROM dbo_authz.TMRole r JOIN dbo_authz.TMResource res ON res.app_key='myapp' AND res.resource_key='report'
WHERE r.role_key='viewer';Step 4 — show the user their permissions. For the page's own resource, the badge data already came back from
requirePermission (Step 2). getMyPermissions is for the other resources a screen wants to describe:
// several named resources — one round-trip, and the returned keys are typed from the argument,
// so `myPermissions.reprot` is a compile error rather than an `undefined` at runtime
const myPermissions = await getMyPermissions(request, { resourceKeys: ["report", "report_comment"] })
// from a defineResources registry, keeping the keys literal-typed
const fromRegistry = await getMyPermissions(request, { resourceKeys: resourceKeysOf(REPORT_RESOURCES) })
// or every resource registered for the app (needs the `listResourceKeys` port)
const all = await getMyPermissions(request)import { MyPermissionIndicator, MyPermissionStatus } from "@aiquants/authz-react-router"
// 1. Single indicator badge group
<MyPermissionIndicator permission={myPermissions.report} resourceName="Monthly Report" />
// 2. Full "your permissions" status bar component for any page header/toolbar
<MyPermissionStatus permission={myPermissions.report} resourceName="Monthly Report" />
// → "あなたの権限: [閲覧 ○][編集 △][削除 ×]" with the tooltip "対象リソース: Monthly Report (report)"⚠️ Unlike the admin shell's
labels, these two components ship Japanese defaults ("あなたの権限","閲覧"/"編集"/"削除","部分制限", and the対象リソース: …tooltip). PassprefixLabelandlabelsto localize them.
Scope cheat-sheet (row / column scope contract)
The JSON you put in row_scope / column_scope (full contract in @aiquants/authz-core):
// row_scope — which rows. NULL/empty = all rows. filters are AND'd. op ∈ in|notIn|eq|ne. null in values → IS (NOT) NULL.
{ "filters": [ { "field": "dept", "op": "in", "values": ["A","B"] } ] }
// column_scope — which columns. NULL/empty = all columns.
{ "mode": "deny", "columns": ["amount"] } // deny = hide these; allow = show only theseMultiple roles union (wider wins): rows OR'd (any NULL ⇒ all rows); columns merged to the wider set. Anything unparseable fails closed (0 rows / 0 columns).
API
createAuthz({ appKey, resolveUserId, resolveTenantId, assertTenantMembership, resolveGroupIds?, getEffectivePermissions, listResourceKeys?, onDeny? })→{ requirePermission, getMyPermissions }.resolveTenantId(request)→ the tenant id this request acts within. Required, non-empty; forwarded to everygetEffectivePermissionscall and returned byrequirePermission. A blank result throwsAuthzTenantError.assertTenantMembership(request, tenantId, userId)→ may this caller act as that tenant? Required, no permissive default; consulted before any permission is loaded, andfalsedenies withreason: "not-a-tenant-member"(and reports no permissions at all fromgetMyPermissions).getEffectivePermissions({ userId, tenantId, groupIds?, appKey, resourceKeys })→Record<resourceKey, EffectivePermission | null>. One call, many resources — pair it withgetEffectivePermissionsManyfrom@aiquants/authz-drizzleand N resources cost one round-trip. Keys the port omits are treated as "no permission" (fail-close).listResourceKeys(request, tenantId)→ every resource key registered for the app within that tenant. Optional; configuring it is what makes the no-argumentgetMyPermissions(request)legal. Without it that call throwsAuthzUsageErrornaming both remedies instead of guessing. The tenant arrives already resolved and validated, so a host never derives it a second time.resolveGroupIds(request, tenantId)→ the acting user's group ids within that tenant. Forwarded automatically togetEffectivePermissionsby bothrequirePermissionandgetMyPermissions, so group-granted roles count toward effective permissions. Returningnull/undefined(or omitting the port) sends nogroupIdsat all. ⚠️ Without this port, roles assigned to groups in the admin UI have no runtime effect — the store writes succeed and the UI looks correct, so the failure is silent. Cover the wiring with a test.requirePermission(request, {resourceKey, action}, options?)→{ userId, tenantId, scope, permission }on allow: the scope for row WHERE / column mask, and thePermissionViewderived from the very same evaluation (no second lookup, so the guard and the badge can never disagree). Denies throwAuthzDeniedError(403), orthrow redirect(options.failureRedirect)when set. A malformed query (blankresourceKey, unknownaction, or the derived"write") throwsAuthzUsageErrorbefore anything is loaded — a mistake surfaces as a 500 you can read, never as a mysterious 403.getMyPermissions(request, {resourceKeys})→Record<K, PermissionView>whereKis the literal union of the keys you passed, so an unrequested key is a compile error rather than a runtimeundefined.getMyPermissions(request)reports on every registered resource (requireslistResourceKeys). Duplicate keys collapse; an empty list asks the database nothing. ⚠️ The no-argument form's result keys are your app's resource inventory. Every value is still evaluated fail-closed (an unauthenticated caller gets all-false views), but do not hand the whole map to an unauthenticated client if the resource names themselves are sensitive — name the resources you actually render instead.AuthzUsageError— thrown for API misuse (missing/ill-typed arguments, missing port). Deliberately distinct fromAuthzDeniedError: misuse must not masquerade as a denial.defineResources({ ... })→ Type-safe centralized resource registry helper for resource keys and display names. Keys stay literal-typed.resourceKeysOf(registry)→ the registry's resource keys as a literal union array. Use it instead ofObject.keys(registry), which both widens tostring[](losing the key check on the permission map) and returns property names — wrong for entries that set an explicitkey.toPermissionView(resourceKey, perm)→ serializable view (loader → client).usePermission(view)→ the samecan*booleans plus ○△× display states (readState/writeState/deleteState); pure, safe in render.<MyPermissionIndicator permission={view} resourceName="Documents" expectedResourceKey="documents" />→ read / write(create∪update) / delete as ○△×.readis △ when row/column-restricted;writeis ○ only when both create & update are held, △ when one. Hover shows resource name & key tooltip. Automatically flags mismatches ifpermission.resourceKey !== expectedResourceKey.<MyPermissionStatus permission={view} resourceName="Documents" expectedResourceKey="documents" prefixLabel="Your Permissions" />→ Reusable status component combining prefix label and permission indicator badges for any application page.<MyPermissionGroupStatus items={[{ resourceName: "Primary Resource", permission: primaryView }, { resourceName: "Sub Resource", permission: subView, level: 1 }]} variant="pills" | "popover" size="xs" labels={{ fullWrite: "Writable", readOnly: "Read-only" }} />→ Multi-resource permission widget for pages with multiple distinct resource scopes. Supports inline status tags (variant="pills"), single-button popover cards (variant="popover"), hierarchical tree indentation vialevel/children, and fully customizable status label mappings (labels).
One vocabulary, one decision
Two rules make the whole surface predictable, and both exist because their absence produced real production bugs:
- Every permission boolean is
can*, everywhere.PermissionViewandusePermission()expose the samecanRead/canCreate/canUpdate/canDelete/canWritenames. Previously the view saidreadwhile the hook saidcanRead, so readingview.canReadyieldedundefined— falsy, therefore a silent denial that looked like an authorization bug. A verb-shaped field is also undestructurable (const { delete } = viewis a syntax error). - The guard decides once.
requirePermissionreturns the UI view built from the permission it just evaluated, so there is no second query that could return a different answer. (The oldrequireAndGetPermissionclaimed to be atomic but issued two independent lookups; it is gone —requirePermissionalways returnspermission.)
Migrating from 0.6.x
| Before | Now |
| --- | --- |
| permissionView.read / .create / .update / .delete / .write | .canRead / .canCreate / .canUpdate / .canDelete / .canWrite |
| requireAndGetPermission(request, query) | requirePermission(request, query) — it already returns permission |
| getEffectivePermissions: (args) => …(args.resourceKey) (one resource) | getEffectivePermissions: (args) => …(args.resourceKeys) returning a record (many resources) |
| createAuthzAdminApp({ …, getMyPermissions }) | port removed — the admin shell reads its badge off the guard |
| createAuthzAdminApp({ requirePermission }) resolving to { userId } | must now resolve to { userId, tenantId, permission } — pass createAuthz's requirePermission directly, or add both fields to a hand-rolled guard |
| createAuthz({ … }) without resolveTenantId | the port is required; omitting it throws AuthzUsageError at factory time |
| createAuthz({ … }) without assertTenantMembership | also required; naming a tenant never proves membership in it, so omitting it throws AuthzUsageError at factory time |
| getEffectivePermissions({ userId, appKey, resourceKeys }) | tenantId is now always present and non-empty — scope your query by it (never treat its absence as "all tenants") |
| AuthzAdminStore methods called without a tenant (listRoles(), deleteRole(id), …) | every method takes a required tenantId first (listRoles(tenantId), deleteRole(tenantId, id)); RoleInput / RoleUpdate / ResourceInput / GrantInput also name their target tenant |
| usePermission(view) → { canRead, canWrite, canDelete, …States } | adds canCreate / canUpdate, so it is a superset of the view's booleans |
| expectedResourceKey?: string on the indicator components | now NoInfer<K> — bound to the permission's own key, so a mismatch is a compile error (0.6.x code that deliberately passed a different key stops compiling) |
| createAuthzAdminServer(...).loadMyPermissions | removed — layoutGuard returns { userId, myPermissions } from the guard it already runs |
| getMyPermissions(request, { resourceKeys: someStringArray }) → total map | values are now PermissionView \| undefined whenever the keys are not literals (a runtime-built list cannot promise which keys exist). Pass literals or resourceKeysOf(registry) to keep the total map, or add the presence check |
| getMyPermissions(request) → TypeError | configure listResourceKeys, or pass { resourceKeys } — otherwise a named AuthzUsageError |
Requirements. TypeScript ≥ 5.4 to consume the emitted .d.ts (the indicator props use the built-in NoInfer), and
an ES2022 runtime for Object.hasOwn (Node ≥ 16.9 — the package already declares engines.node >= 18 — and any
browser from 2022 on).
AuthzUsageErroris a programmer error, so it travels like any other thrown error: React Router hands the boundary a sanitized error outside development, and the actionable message lands in your server log. Read it there — the browser will only show the generic 500 body.
readPartial and actions keep their names (they are not booleans about an action).
Admin UI tabs
The splat-mounted admin app (createAuthzAdminApp) serves roles, resources, role_permissions, user_roles, plus:
group_roles— assign/revoke roles for a group. Group names are read from the group table; they are never synthesized from ids.graph— a read-only topology map of resource → role → group → user. Nodes with no connections are hidden, and selecting a node highlights only the paths that actually reach it. Node dimming and edge highlighting are driven by the same predicate, so an edge never glows while both of its endpoints are dimmed; edges carrydata-testidanddata-activeso this stays testable.
Reads require read on the admin resource; writes require the verb matching their effect (create / update / delete).
Tenancy in the admin UI
The whole screen is scoped to the tenant resolveTenantId returned for the request — the shell shows it
(data-testid="authz-tenant-badge"), every table has a tenant column, and the create-role / create-resource /
create-grant forms carry a tenantId field prefilled with it.
Two rules make that more than decoration:
- The form's tenant never widens the request's. A submitted
tenantIdstates which tenant the payload targets; it is compared with the guard's tenant and a mismatch is refused with 403. Neither side is preferred. - Every mutation re-checks ownership server-side. Ids arrive from a form, so before writing, the target row is loaded
within the tenant and its own
tenantIdcompared. A filtered list is not an authorization check: if the store ever dropped its predicate, every id in the database would look owned. A row that is absent or foreign is refused identically (403), so the screen cannot be used to probe which ids exist in another tenant.
Localization (labels.locale)
Every string the admin tabs draw lives in AuthzAdminLabels. The package default is English
(defaultAuthzAdminLabels) and the package ships no Japanese catalog of its own strings: a host translates them by
passing a partial override (PartialAuthzAdminLabels) to createAuthzAdminApp({ labels }) and to
makeAuthzErrorBoundary(labels). Sections it leaves out keep the English defaults.
One key carries language:
| Key | Type | defaultAuthzAdminLabels | Drives |
| --- | --- | --- | --- |
| locale | AuthzAdminLocale ("en" | "ja") | "en" | UI chrome language of every @aiquants/select-box picker on the admin tabs (SelectBox locale) |
localeis the UI language of the embedded pickers, not a BCP 47 tag. It is forwarded to all 8 pickers (the inline action picker of every grant row; role / resource / action on the new-grant form; user / role on the user assignment form; group / role on the group assignment form). It selects the select-box catalog, which supplies the pickers' built-in strings: the toggle and clear button names (Toggle options/候補を開閉,Clear selection/選択を解除), the empty message (No options found/一致する候補がありません), the live-region announcements, the searching message (Searching.../検索中...), the description of a guess row (suggestion/推測), the selection description (Selected: {label}/選択中: {label}), the default placeholder (Select.../選択...), the resize-handle tooltip and the scroll-arrow names of the option list (Scroll up/上へスクロール). Placeholders the pickers are given still come from the section labels (grants.selectPlaceholder,userRoles.selectPlaceholder,groupRoles.selectPlaceholder); they do not followlocale.- A host that translates
labelsto Japanese should also setlocale: "ja". Without it the translated screen keeps English picker chrome.AuthzAdminLocaleis the same type as select-box'sSelectBoxLocale. localeis validated once, byresolveLabels— which runs whencreateAuthzAdminApp/createAuthzAdminServeris built and whenmakeAuthzErrorBoundaryis called. An omitted value keeps"en";"en"/"ja"pass; anything else ("ja-JP","EN","",null, ...) throwsRangeError([authz-react-router] labels.locale is the UI language of the embedded pickers, not a BCP 47 tag: ...). The check is select-box's own:causeis theRangeErrorthatresolveSelectBoxLabelsthrows for the same value, and the message ends with its text ([select-box] resolveSelectBoxLabels: locale must be one of "en", "ja"; got "ja-JP"), so a mistake stops the app at startup instead of crashing a picker on first render. There is no language negotiation — mapnavigator.languageor a user setting to"en"/"ja"yourself.- Default output is English: a host that omits
localegets the select-box English catalog. - The package does not format dates or numbers and does not sort with
Intl, so there is no formatting-locale key. MyPermissionIndicator/MyPermissionGroupStatusare not part oflabels; see their ownprefixLabel/labelsprops above.
Migrating from 0.13.x (breaking change in 0.14.0)
@aiquants/select-box^0.16.0 is required (the published peer range is^0.16.0; a host on^0.15.xbumps it explicitly). Its own peers are@aiquants/fuzzy-search^3.0.0 and@aiquants/virtualscroll^3.11.1.labels,localeand every other API of this package are unchanged.- A host that also uses
@aiquants/select-boxdirectly follows that package's own 0.16.0 notes (itsCHANGELOG.md).
Migrating from 0.12.x (breaking change in 0.13.0)
@aiquants/select-box^0.15.0 is required (the published peer range is^0.15.0; a host on^0.14.xbumps it explicitly). Its own peers are@aiquants/fuzzy-search^2.7.0 and@aiquants/virtualscroll^3.9.2.labels,localeand every other API of this package are unchanged.- A host that also uses
@aiquants/select-boxdirectly follows that package's own 0.15.0 notes (itsCHANGELOG.md).
Migrating from 0.11.x (breaking change in 0.12.0)
@aiquants/select-box^0.14.0 is required (the published peer range is^0.14.0; a host on^0.13.xbumps it explicitly). Its own peers are@aiquants/fuzzy-search^2.6.0 and@aiquants/virtualscroll^3.9.2.labels,localeand every other API of this package are unchanged.- A host that also uses
@aiquants/select-boxdirectly follows that package's own 0.14.0 notes (itsCHANGELOG.md).
Migrating from 0.10.x (breaking change in 0.11.0)
@aiquants/select-box^0.13.0 is required (the published peer range is^0.13.0; a 0.x minor is outside the caret range, so a host on^0.11.xor^0.12.xbumps it explicitly). Its own peers are@aiquants/fuzzy-search^2.5.0 and@aiquants/virtualscroll^3.9.1.labels,localeand every other API of this package are unchanged.- A host that also uses
@aiquants/select-boxdirectly follows that package's own 0.12.0 and 0.13.0 notes (itsCHANGELOG.md).
Migrating from 0.9.x (breaking change in 0.10.0)
@aiquants/select-box^0.11.0 is required (the published peer range is^0.11.0; a 0.x minor is outside the caret range, so a host on^0.10.xbumps it explicitly). Its own peers are@aiquants/virtualscrolland@aiquants/fuzzy-search(the ranges its manifest names); load its stylesheets as the Install section above says.- Only code that reads the text of a rejected
labels.localechanges. TheRangeErrorand its[authz-react-router] …prefix are unchanged; thecauseand the end of the message are now[select-box] resolveSelectBoxLabels: locale must be one of "en", "ja"; got …(0.9.x:[virtualscroll] locale must be one of …).labels,localeand every other API of this package are unchanged. - A host that also uses
@aiquants/select-boxdirectly follows that package's own 0.11.0 migration (itsCHANGELOG.md).
Migrating from 0.8.x (breaking changes in 0.9.0)
basePathis now required oncreateAuthzAdminAppandcreateAuthzAdminServer: the absolute path the console is mounted at, without a trailing slash ("/authz"for anauthz/*splat route). There is no default any more — the tab links and the index redirect (<basePath>/roles, formerly the fixed/authz/roles) are built from it. Omitting it fails type-checking.AuthzLayoutData.basePathis required too, so a host that feedsAuthzLayoutfrom its own loader must returnbasePath.@aiquants/select-box^0.10.0 is required (which itself requires@aiquants/virtualscroll>= 3.7.0). Upgrade both in the host.labels.localeis new. Hosts passing a partial override (PartialAuthzAdminLabels) need no change; to get Japanese picker chrome addlocale: "ja"next to the translated strings. A host that builds a fullAuthzAdminLabelsliteral must addlocale.
Demo App
This package contains an interactive React Router 7 SPA demo in the demo/ subdirectory. The demo app runs completely in the browser, using localStorage to mock database storage.
Features
- Role-Based Access Control: Demonstrates role switching (Admin, Manager, Operator, Reader) and how it affects UI elements.
- Route Guards: Uses
authz.requirePermissionto protect routes and handle unauthorized access. - Scope Verification: Demonstrates
rowScope(row filters) andcolumnScope(masked values) dynamically applied based on the active role's permissions. - In-Browser Database Reset: Includes a "Reset Demo Data" button in the header of key views (Dashboard, Protected Documents, and Sales pages) to wipe customized roles, assignments, permissions, and CRUD data from
localStorage, instantly restoring default test states.
Running the Demo
From the project root:
pnpm --filter authz-react-router-demo devOpen http://localhost:5178/ in your browser.
Testing
Playwright tests are included in the demo/ package to verify the RBAC features, CRUD interactions, and the data reset functionality.
Running Integration Tests
Ensure the dev server is running, then run Playwright tests from the workspace root:
# Run all tests in the demo package
pnpm exec playwright test packages/authz-react-router/demo/
# Run specific tests
pnpm exec playwright test packages/authz-react-router/demo/integration-tests.spec.ts
pnpm exec playwright test packages/authz-react-router/demo/check.spec.tsintegration-tests.spec.ts: Runs a comprehensive workflow verifying permission guards, dynamic role granting/revocation in the Admin Panel, row/column scoping on sales data, CRUD validation, and the final state reset.check.spec.ts: Verifies basic CRUD operations (list, create, update, delete) on protected document files.
License
MIT
