npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 ^7

react / 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 } = authz

If your DB pool can be cold at the first request (serverless / lazy connect), have resolveUserId and getEffectivePermissions await 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 resolveTenantId is a required port and its result must be a non-empty string. Returning "" / null raises AuthzTenantError before 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. resolveTenantId answers which tenant the request named, and on every transport that name is ultimately client-influenced (a header, a sub-domain, a cookie). assertTenantMembership is 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 @authenticated grants apply, and every tenant check still passes because the permission genuinely belongs to B. It is consulted before any permission is loaded and denies with reason: "not-a-tenant-member". userId is null for an anonymous caller; a tenant's @anonymous grants are public by construction, so returning true there 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 as undefined, i.e. a silent denial. requirePermission is the only thing that decides access; getMyPermissions exists 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). Pass prefixLabel and labels to 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 these

Multiple 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 every getEffectivePermissions call and returned by requirePermission. A blank result throws AuthzTenantError.
  • assertTenantMembership(request, tenantId, userId) → may this caller act as that tenant? Required, no permissive default; consulted before any permission is loaded, and false denies with reason: "not-a-tenant-member" (and reports no permissions at all from getMyPermissions).
  • getEffectivePermissions({ userId, tenantId, groupIds?, appKey, resourceKeys }) → Record<resourceKey, EffectivePermission | null>. One call, many resources — pair it with getEffectivePermissionsMany from @aiquants/authz-drizzle and 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-argument getMyPermissions(request) legal. Without it that call throws AuthzUsageError naming 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 to getEffectivePermissions by both requirePermission and getMyPermissions, so group-granted roles count toward effective permissions. Returning null/undefined (or omitting the port) sends no groupIds at 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 the PermissionView derived from the very same evaluation (no second lookup, so the guard and the badge can never disagree). Denies throw AuthzDeniedError (403), or throw redirect(options.failureRedirect) when set. A malformed query (blank resourceKey, unknown action, or the derived "write") throws AuthzUsageError before anything is loaded — a mistake surfaces as a 500 you can read, never as a mysterious 403.
  • getMyPermissions(request, {resourceKeys}) → Record<K, PermissionView> where K is the literal union of the keys you passed, so an unrequested key is a compile error rather than a runtime undefined. getMyPermissions(request) reports on every registered resource (requires listResourceKeys). 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 from AuthzDeniedError: 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 of Object.keys(registry), which both widens to string[] (losing the key check on the permission map) and returns property names — wrong for entries that set an explicit key.
  • toPermissionView(resourceKey, perm) → serializable view (loader → client).
  • usePermission(view) → the same can* booleans plus ○△× display states (readState/writeState/deleteState); pure, safe in render.
  • <MyPermissionIndicator permission={view} resourceName="Documents" expectedResourceKey="documents" /> → read / write(create∪update) / delete as ○△×. read is △ when row/column-restricted; write is ○ only when both create & update are held, △ when one. Hover shows resource name & key tooltip. Automatically flags mismatches if permission.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 via level / 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:

  1. Every permission boolean is can*, everywhere. PermissionView and usePermission() expose the same canRead / canCreate / canUpdate / canDelete / canWrite names. Previously the view said read while the hook said canRead, so reading view.canRead yielded undefined — falsy, therefore a silent denial that looked like an authorization bug. A verb-shaped field is also undestructurable (const { delete } = view is a syntax error).
  2. The guard decides once. requirePermission returns the UI view built from the permission it just evaluated, so there is no second query that could return a different answer. (The old requireAndGetPermission claimed to be atomic but issued two independent lookups; it is gone — requirePermission always returns permission.)

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).

AuthzUsageError is 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 carry data-testid and data-active so 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 tenantId states 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 tenantId compared. 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) |

  • locale is 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 follow locale.
  • A host that translates labels to Japanese should also set locale: "ja". Without it the translated screen keeps English picker chrome. AuthzAdminLocale is the same type as select-box's SelectBoxLocale.
  • locale is validated once, by resolveLabels — which runs when createAuthzAdminApp / createAuthzAdminServer is built and when makeAuthzErrorBoundary is called. An omitted value keeps "en"; "en" / "ja" pass; anything else ("ja-JP", "EN", "", null, ...) throws RangeError ([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: cause is the RangeError that resolveSelectBoxLabels throws 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 — map navigator.language or a user setting to "en" / "ja" yourself.
  • Default output is English: a host that omits locale gets 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 / MyPermissionGroupStatus are not part of labels; see their own prefixLabel / labels props above.

Migrating from 0.13.x (breaking change in 0.14.0)

  1. @aiquants/select-box ^0.16.0 is required (the published peer range is ^0.16.0; a host on ^0.15.x bumps it explicitly). Its own peers are @aiquants/fuzzy-search ^3.0.0 and @aiquants/virtualscroll ^3.11.1. labels, locale and every other API of this package are unchanged.
  2. A host that also uses @aiquants/select-box directly follows that package's own 0.16.0 notes (its CHANGELOG.md).

Migrating from 0.12.x (breaking change in 0.13.0)

  1. @aiquants/select-box ^0.15.0 is required (the published peer range is ^0.15.0; a host on ^0.14.x bumps it explicitly). Its own peers are @aiquants/fuzzy-search ^2.7.0 and @aiquants/virtualscroll ^3.9.2. labels, locale and every other API of this package are unchanged.
  2. A host that also uses @aiquants/select-box directly follows that package's own 0.15.0 notes (its CHANGELOG.md).

Migrating from 0.11.x (breaking change in 0.12.0)

  1. @aiquants/select-box ^0.14.0 is required (the published peer range is ^0.14.0; a host on ^0.13.x bumps it explicitly). Its own peers are @aiquants/fuzzy-search ^2.6.0 and @aiquants/virtualscroll ^3.9.2. labels, locale and every other API of this package are unchanged.
  2. A host that also uses @aiquants/select-box directly follows that package's own 0.14.0 notes (its CHANGELOG.md).

Migrating from 0.10.x (breaking change in 0.11.0)

  1. @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.x or ^0.12.x bumps it explicitly). Its own peers are @aiquants/fuzzy-search ^2.5.0 and @aiquants/virtualscroll ^3.9.1. labels, locale and every other API of this package are unchanged.
  2. A host that also uses @aiquants/select-box directly follows that package's own 0.12.0 and 0.13.0 notes (its CHANGELOG.md).

Migrating from 0.9.x (breaking change in 0.10.0)

  1. @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.x bumps it explicitly). Its own peers are @aiquants/virtualscroll and @aiquants/fuzzy-search (the ranges its manifest names); load its stylesheets as the Install section above says.
  2. Only code that reads the text of a rejected labels.locale changes. The RangeError and its [authz-react-router] … prefix are unchanged; the cause and 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, locale and every other API of this package are unchanged.
  3. A host that also uses @aiquants/select-box directly follows that package's own 0.11.0 migration (its CHANGELOG.md).

Migrating from 0.8.x (breaking changes in 0.9.0)

  1. basePath is now required on createAuthzAdminApp and createAuthzAdminServer: the absolute path the console is mounted at, without a trailing slash ("/authz" for an authz/* 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.basePath is required too, so a host that feeds AuthzLayout from its own loader must return basePath.
  2. @aiquants/select-box ^0.10.0 is required (which itself requires @aiquants/virtualscroll >= 3.7.0). Upgrade both in the host.
  3. labels.locale is new. Hosts passing a partial override (PartialAuthzAdminLabels) need no change; to get Japanese picker chrome add locale: "ja" next to the translated strings. A host that builds a full AuthzAdminLabels literal must add locale.

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.requirePermission to protect routes and handle unauthorized access.
  • Scope Verification: Demonstrates rowScope (row filters) and columnScope (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 dev

Open 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.ts
  • integration-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