@b1-road/react
v0.1.0-alpha.20
Published
Official React toolkit for Eduzz Plat — components, hooks, and helpers for embedding Plat's login, roles and business units into your app.
Downloads
746
Readme
@b1-road/react
The official React toolkit for integrating with Road — components, hooks, and helpers for embedding Road IAM into platform apps.
New to Eduzz Plat?
Plat is Eduzz's platform layer — login, roles and permissions, and the business units your app builds on. This package is one piece of it; start here.
The fastest way in is to let your AI coding agent drive the integration:
claude mcp add road --scope user -- npx -y -p @b1-road/mcp road-mcpThat gives the agent the full integration guide plus tools to register a platform and issue its credentials.
Prefer to click through it? Create a platform in the
Dev Portal. Prefer a scaffold in your
repo? Run npx @b1-road/integrate.
Status
First public alpha. Cookie mode is the default. Bearer mode remains
available behind authMode="bearer" for headless / mobile / partner
integrations.
If you're coming from a bearer-first setup, opt in explicitly by adding
one prop (authMode="bearer"). See Coming from a bearer-first setup
below.
Install
npm install @b1-road/reactAlpha pre-release while the Road API contract is in alpha. The
latestdist-tag tracks the newest release, so the install above is all you need.
Quick start (cookie mode — recommended)
You are probably here because you're behind a Laravel or NestJS BFF
(b1-road/laravel or @b1-road/nestjs) that proxies your app to the
Road API. The BFF holds the Auth Server JWT server-side; the browser
ships a session cookie. The React SDK never sees a token.
import {
RoadProvider,
BusinessUnitSwitcher,
BusinessUnitsMgmt,
} from "@b1-road/react";
import "@b1-road/react/style.css";
export function App() {
return (
<RoadProvider
apiBaseUrl="/road-api"
onUnauthenticated={() => window.location.assign("/auth/road/login")}
>
<BusinessUnitSwitcher />
<BusinessUnitsMgmt />
</RoadProvider>
);
}That's the whole integration. The SDK:
- Sends requests with
credentials: 'include', noAuthorizationheader. The BFF attaches the bearer server-side. - Echoes the
XSRF-TOKENcookie intoX-XSRF-TOKENon every mutation (Laravel's convention; the NestJS BFF adopts the same names). - Calls
onUnauthenticatedon terminal 401 with a 1s debounce so parallel React Query refetches trigger one redirect, not a stampede.
Required: onUnauthenticated
Cookie mode requires onUnauthenticated. Without it the SDK logs a
one-time console.warn and 401s become silent. The handler typically
redirects to the BFF's login URL.
Optional cookie-mode knobs
<RoadProvider
apiBaseUrl="/road-api"
onUnauthenticated={...}
csrfCookieName="XSRF-TOKEN" // default — matches Laravel + NestJS BFFs
csrfHeaderName="X-XSRF-TOKEN" // default
withCredentials={false} // cookie mode is same-origin — see "Deploying cookie mode"
/>Defaults are tuned for the same-origin BFF deployment that the Laravel and NestJS plans ship.
Deploying cookie mode (same-origin is required)
Cookie mode needs the SPA and the BFF to share one origin. The session cookie and the CSRF token are bound to the BFF's origin — the browser won't send them to, nor let the SPA read them from, a different host.
Locally this is invisible: the Vite dev proxy makes :5173 and the BFF look
like one origin, so apiBaseUrl="/road-api" "just works." The cliff is at
deploy time — a static SPA hosted separately from the BFF silently breaks:
clean success on localhost, 401s in production. (The SDK console.warns once if
it sees a cross-origin apiBaseUrl in cookie mode.)
Deploy them on one origin. The simplest way is to have the BFF serve the
built SPA bundle (Laravel, or NestJS via @nestjs/serve-static), or put both
behind a single reverse proxy / domain. Then the same relative
apiBaseUrl="/road-api" you used in dev works in production unchanged — no CORS,
no cookie tuning. If the SPA and BFF are on genuinely separate hosts, cookie
mode can't apply (cookies don't cross origins); bring them onto one origin.
Bearer mode (advanced / opt-out)
For headless apps, React Native, partner integrations, or any deployment where the host actually holds the JWT in JS:
<RoadProvider authMode="bearer" jwt={() => getToken()}>
...
</RoadProvider>Bearer mode calls Eduzz Plat directly, so it needs an absolute base —
and it already has one. env picks the hosted instance and defaults to
"sandbox", so going live is one word:
<RoadProvider authMode="bearer" jwt={() => getToken()} env="production">Set apiBaseUrl only to override both — a local stack, or your own
gateway in front of Plat.
jwt accepts either a static string or a getter (() => string |
Promise<string>). Prefer the getter — Auth Server access tokens
are short-lived (15–60 minutes); the getter re-resolves per request so
host-side rotation works without re-mounting the provider.
The trade-offs vs cookie mode, honestly stated:
- XSS surface. A token in JS reachable from JS-evaluated XSS is
fundamentally weaker than a
HttpOnlysession cookie. Cookie mode removes this surface entirely. - Refresh complexity. Bearer-mode integrators own token rotation; cookie-mode BFFs do it server-side.
- IETF BCP. The IETF OAuth 2.0 for Browser-Based Applications BCP (draft -26) ranks BFF first; PKCE-in-browser is the fallback.
Signing out
Signing out has two halves: ending the local session AND revoking the token at the Road API. Dropping a token client-side does not revoke it — it keeps working until natural expiry.
Cookie mode: point your sign-out control at the BFF's logout route (
GET /auth/road/logoutfor the RP-initiated flow). The BFF SDKs (@b1-road/nestjs,b1-road/laravel) revoke the access token at the Road API server-side as part of that route — nothing to do in React.Bearer mode: call
client.logout()before discarding the JWT:const { client } = useRoad(); async function signOut() { try { await client.logout(); // POST /iam/identity/me/logout — revokes } finally { discardToken(); // your host's token cleanup — always runs } }
Revocation semantics: logout() terminates all of the user's Auth
Server sessions (there is no per-session variant — the access token carries
no session id), and every already-issued access token for the user stops
working at the Road API immediately.
Coming from a bearer-first setup
If your app holds the JWT in JS and looks like:
<RoadProvider jwt={() => getToken()}>…opt into bearer mode explicitly by adding one prop:
<RoadProvider authMode="bearer" jwt={() => getToken()}>The provider throws a loud, actionable error at boot if authMode
is omitted but jwt is provided — so the failure mode is "won't
mount in dev" rather than "401 in production." The error message
contains the diff above.
Why two modes
Cookie mode and bearer mode exist because no single deployment shape fits every Road consumer. Cookie mode covers same-origin BFFs (Laravel
- Inertia + React, NestJS BFF + React SPA); bearer mode covers headless / mobile / partner integrations where the host genuinely owns the JWT.
This is the same pattern Auth0 ships (@auth0/auth0-react bearer-only
plus @auth0/nextjs-auth0 cookie BFF), Clerk ships (cookie-based
session with a bearer fallback), and WorkOS AuthKit ships (cookie-only
authkit-react). Plan
10-react-cookie-mode-spec.md
has the detailed rationale.
Widgets
Two drop-in UI widgets ship with the SDK. Both must render inside
<RoadProvider> and inherit your theming from the provider's
appearance prop — no extra wiring. They gate themselves on the user's
permissions, so you don't guard them with useCan.
<BusinessUnitSwitcher />
A sidebar control that shows the active business unit, switches between the user's memberships, surfaces pending invitations (accept / reject inline), and links into management and "create unit". Built to sit at the top or bottom of an app sidebar.
import { BusinessUnitSwitcher } from "@b1-road/react";
<aside className="sidebar">
<BusinessUnitSwitcher />
</aside>;| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| collapsed | boolean | auto | Force the compact icon-only trigger (true) or the expanded avatar + name + role trigger (false). Left unset, it auto-detects via ResizeObserver — under 180px wide it collapses. Pass it when your sidebar's collapse state lives in app state. |
| className | string | — | Extra classes on the trigger button (for sidebar layout). |
The active BU is read from / written to the provider's business-unit context
(useCurrentBusinessUnit / useSetCurrentBusinessUnit), so switching here
re-scopes every useCan(buId) and BU-scoped query across your app.
<BusinessUnitsMgmt />
A dialog for managing business units and their roles: list / create / edit
BUs, manage members and invitations, and define roles + permissions (the role
editor with the permission picker lives here). Two tabs: "business-units"
and "roles".
It runs in either uncontrolled mode (renders its own trigger) or controlled mode (you own the open state). The prop types enforce the pairing at compile time.
import { useState } from "react";
import { BusinessUnitsMgmt } from "@b1-road/react";
// Uncontrolled — renders a default trigger (or pass your own):
<BusinessUnitsMgmt trigger={<button>Manage units</button>} />;
// Controlled — you own the open state:
function Example() {
const [open, setOpen] = useState(false);
return <BusinessUnitsMgmt open={open} onOpenChange={setOpen} />;
}| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| trigger | ReactNode | default button | (uncontrolled only) element that opens the dialog. |
| open | boolean | — | (controlled only) open state; must be paired with onOpenChange. |
| onOpenChange | (open: boolean) => void | — | (controlled only) open-state callback. |
| initialTab | "business-units" \| "roles" | "business-units" | Tab shown each time the dialog opens. |
| initialBusinessUnitId | string | — | Deep-link: open straight into this BU's detail view. |
triggerand theopen/onOpenChangepair are mutually exclusive — passingopenwithoutonOpenChange, or mixing intrigger, is a compile-time type error.
BusinessUnitSwitcher already embeds this dialog for its manage/settings
entry, so adding the switcher often covers both.
<ExtensionsMgmt />
The catalogue of extensions a business unit can install, plus what it already has installed. Install and uninstall are handled inside the widget, and like the other widgets it gates itself on the caller's permissions.
import { ExtensionsMgmt } from "@b1-road/react";
<ExtensionsMgmt businessUnitId={buId} />| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| businessUnitId | string | current BU from context | Whose catalogue and installs to show. |
| trigger | ReactNode | a default button | Uncontrolled: what opens the dialog. |
| open / onOpenChange | boolean / (open) => void | — | Controlled: you own the open state. |
Same controlled/uncontrolled shape as
<BusinessUnitsMgmt />:triggerand theopen/onOpenChangepair are mutually exclusive, and mixing them is a compile-time type error.
The hooks behind it are exported too, if you would rather build the UI
yourself: useExtensionCatalogue, useExtensionInstalls,
useInstallExtension, useUninstallExtension.
Permission-gated UI
Widgets gate themselves. For your own UI, useCan(buId) returns a
callable that checks the current user's effective permissions for that
business unit (read from the server, cached in React Query):
import { useCan } from "@b1-road/react";
function InviteButton({ buId }: { buId: string }) {
const can = useCan(buId); // omit buId to read the BU from <BusinessUnitSwitcher />
return can("manage:Member") ? <button>Invite member</button> : null;
}"manage:Member" is compile-time-checked against Road's permission
algebra (RoadPermission — ${action}:${Subject} plus "*");
platform-specific permission strings pass through too. For several
checks in one render, useCanMany([...]) resolves them in a single
batch. This is a UI hint, not a security boundary — see
Security model.
Errors
Every failed call rejects with a typed subclass of RoadApiError
carrying the ids you need to find it in Road's logs:
import { RoadForbiddenError, RoadValidationError } from "@b1-road/react";
try {
await client.createRole(buId, { name, permissions });
} catch (err) {
if (err instanceof RoadValidationError) {
err.fieldErrors; // { name: ["already taken"] }
} else if (err instanceof RoadForbiddenError) {
err.requestId; // correlates to the Road API log line
err.traceId; // W3C trace id, when the API emits one
}
}React errors are deliberately thinner than the server SDKs' — the full
authorization DecisionTrace is surfaced server-side, and appended
to the 403 response body in non-prod via the debug header (see
Security model). It is not rehydrated onto the
browser error object; the browser gets the correlation ids and asks the
server for the rest.
Testing
The SDK ships its own in-memory client — no Auth Server, no mock-fetch
boilerplate, no fake JWTs. Assemble state with the fluent builder and
hand it to the provider's client override:
import { render, screen } from "@testing-library/react";
import {
RoadProvider,
BusinessUnitsMgmt,
mockClientBuilder,
} from "@b1-road/react";
const client = mockClientBuilder()
.withBusinessUnit({ name: "Acme", role: "Owner" }) // current user is Owner (wildcard)
.withMember("Acme", { name: "Alex", email: "[email protected]", role: "Admin" })
.withInvitation("Acme", { email: "[email protected]", roleName: "Member" })
.build();
render(
<RoadProvider client={client}>
<BusinessUnitsMgmt />
</RoadProvider>,
);
expect(await screen.findByText("Acme")).toBeInTheDocument();createMockClient({ empty, latency, failWith }) is the one-liner for
happy-path / empty / slow-network cases; mockClientBuilder() is for
curated state. Drive error and unauthenticated paths with a scenario:
const client = mockClientBuilder()
.withScenario("auth-error") // also: "network-error" | "rate-limit" | "server-error"
.withCookieMode({ onUnauthenticated: redirectSpy })
.build();Scope. The in-memory client exercises the SDK's hooks, widgets, and
React Query wiring — not its HTTP transport. It has no fetch, so it
can't assert cookie-mode wire behavior (credentials: 'include', the
XSRF-TOKEN echo, the omitted Authorization header). For those, stub
globalThis.fetch against HttpRoadClient directly.
Security model
Authorization is server-side authoritative. Every data call goes
through the Road API, which validates the JWT signature against the
configured Auth Server's JWKS and rejects forged, expired, or revoked
tokens. The toolkit's useCan/useCanMany hooks are a UI hint — they
read the server's effective-permissions response cached in React Query.
A user who tampers with their session client-side gets a 401 on the
first data call, not a privilege escalation.
Identity in cookie mode. getCurrentUser() calls GET /me/profile
on the Road API. The API resolves the user from the bearer the BFF
attaches; the SDK never decodes claims locally.
Identity in bearer mode. getCurrentUser() decodes sub,
email, name, picture from the JWT payload without signature
verification — a cosmetic-only trust model (a tampered token still
gets rejected on the next data call). Bearer-mode integrators who
need server-authoritative identity can call the API's /me/profile
endpoint directly.
Theming
Out of the box the widgets ship the Eduzz 2026 palette — Azul Criação
#00264D, Amarelo Impacto #F9B224, Branco Calmaria #F5F5F5 — set in
DM Sans, in both light and dark. Every pairing clears WCAG AA.
Load DM Sans yourself if you want it. A widget cannot inject a font into your document, so the stylesheet only declares the family. If your page doesn't serve DM Sans the widgets fall back to your system UI stack and still look deliberate. To match the brand exactly, self-host it (or add a
<link>) for weights 300–900 — a single variable file covers the range:https://fonts.googleapis.com/css2?family=DM+Sans:opsz,[email protected],300..900&display=swapOverride
fontFamilyviaappearance.variablesto use your own face instead.
All widget styles live under a .road-ui scope — tokens never leak into the host page's :root. Override via appearance.variables; the supported keys map to internal CSS variables:
Color scheme.
appearance.colorSchemedefaults to'light'. The widgets follow the OSprefers-color-schemeonly when you opt in withcolorScheme: 'auto'('light'/'dark'force it) — so a light-only host never gets surprise dark widgets.Host CSS resets are safe. The stylesheet ships an unlayered
.road-uishield, so a global reset like* { margin: 0; padding: 0 }no longer collapses widget spacing — no consumer action required. If you must support a pre-2022 engine withoutrevert-layer, scope your reset away from the widget:*:not(.road-ui, .road-ui *) { margin: 0; padding: 0; box-sizing: border-box }.
| Variable | CSS var |
| ------------------------ | ------------------------ |
| colorPrimary | --primary |
| colorPrimaryForeground | --primary-foreground |
| colorBackground | --background |
| colorForeground | --foreground |
| colorMuted | --muted |
| colorAccent | --accent |
| colorDanger | --destructive |
| colorBorder | --border |
| borderRadius | --radius |
| fontFamily | --font-family |
Accepts any valid CSS color (hex, rgb, hsl, oklch) and CSS lengths.
Local development
npm install
npm run dev # opens the playground at http://localhost:5174
npm run build # library build → dist/
npm run typecheckThe playground (playground/) mounts the widget against mocked data — no Road API required.
Bundle size budget
npm run size checks dist/ against the size-limit block in package.json:
110 kB brotlied for the ESM and CJS bundles, 8 kB for the stylesheet.
react, react-dom, react/jsx-runtime and @b1-road/types are excluded — a
consumer already has them.
The number is deliberate rather than aspirational. The package bundles eight
Radix components, which share transitive primitives (@radix-ui/primitive,
react-compose-refs, react-context, react-presence, …). A Radix release
moves several of those together, so what arrives as a single "patch" bump
routinely shifts 2–3 kB. The previous 95 kB limit left under 2 kB of headroom
and was breached by exactly that (B1-396) — it was where the package happened
to sit when the check was added, not a budget anyone chose.
110 kB gives roughly 15 kB of room on top of today's ~94.7 kB. If a change
approaches it, the fix is to find what grew — not to raise the number again.
npx size-limit --why opens a treemap of the bundle. Two things worth knowing
before reaching for the limit:
lucide-reactis large (~4 MB unminified) but tree-shakes correctly; every import site uses named imports. Keep it that way — oneimport * aswould dominate the bundle on its own.- Externalising Radix the way
reactis externalised would cut the most, at the cost of making consumers install eight packages. That is an API decision, not a build tweak.
