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

@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/react

Alpha pre-release while the Road API contract is in alpha. The latest dist-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', no Authorization header. The BFF attaches the bearer server-side.
  • Echoes the XSRF-TOKEN cookie into X-XSRF-TOKEN on every mutation (Laravel's convention; the NestJS BFF adopts the same names).
  • Calls onUnauthenticated on 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 HttpOnly session 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. |

trigger and the open/onOpenChange pair are mutually exclusive — passing open without onOpenChange, or mixing in trigger, 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.colorScheme defaults to 'light'. The widgets follow the OS prefers-color-scheme only when you opt in with colorScheme: '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-ui shield, 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 without revert-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 typecheck

The playground (playground/) mounts the widget against mocked data — no Road API required.