@b1-road/react
v0.1.0-alpha.5
Published
Official React toolkit for integrating with Road — components, hooks, and helpers for embedding Road IAM into platform apps.
Readme
@b1-road/react
The official React toolkit for integrating with Road — components, hooks, and helpers for embedding Road IAM into platform apps.
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} // set true only for cross-origin BFFs
/>Defaults are tuned for the same-origin BFF deployment that the Laravel and NestJS plans ship.
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
apiBaseUrl="https://api.road.b1.app"
authMode="bearer"
jwt={() => getToken()}
>
...
</RoadProvider>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.
Coming from a bearer-first setup
If your app holds the JWT in JS and looks like:
<RoadProvider apiBaseUrl="https://api.road.b1.app" jwt={() => getToken()}>…opt into bearer mode explicitly by adding one prop:
<RoadProvider
apiBaseUrl="https://api.road.b1.app"
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.
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
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.
