@b1-road/react 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/dist/__tests__/a11y.test.d.ts +1 -0
- package/dist/__tests__/contract-replay.test.d.ts +1 -0
- package/dist/__tests__/create-role-dialog.test.d.ts +1 -0
- package/dist/__tests__/form-errors.test.d.ts +1 -0
- package/dist/__tests__/pending-invitations.test.d.ts +1 -0
- package/dist/__tests__/setup.d.ts +0 -0
- package/dist/api/client.d.ts +253 -0
- package/dist/api/client.test.d.ts +1 -0
- package/dist/api/cookie-mode.test.d.ts +1 -0
- package/dist/api/errors.d.ts +107 -0
- package/dist/api/errors.test.d.ts +1 -0
- package/dist/api/hooks.d.ts +126 -0
- package/dist/api/hooks.test.d.ts +1 -0
- package/dist/api/mock-client.d.ts +121 -0
- package/dist/api/mock-client.test.d.ts +1 -0
- package/dist/api/types.d.ts +7 -0
- package/dist/appearance/appearance.d.ts +19 -0
- package/dist/components/BusinessUnitSwitcher.d.ts +15 -0
- package/dist/components/BusinessUnitsMgmt.d.ts +35 -0
- package/dist/components/business-units/BusinessUnitDetail.d.ts +6 -0
- package/dist/components/business-units/BusinessUnitList.d.ts +5 -0
- package/dist/components/business-units/BusinessUnitRow.d.ts +7 -0
- package/dist/components/business-units/BusinessUnitSettings.d.ts +5 -0
- package/dist/components/business-units/BusinessUnitsTab.d.ts +5 -0
- package/dist/components/business-units/CreateBusinessUnitForm.d.ts +6 -0
- package/dist/components/business-units/MembersList.d.ts +5 -0
- package/dist/components/business-units/PendingInvitations.d.ts +15 -0
- package/dist/components/invitations/InvitationsList.d.ts +7 -0
- package/dist/components/invitations/InvitationsTab.d.ts +5 -0
- package/dist/components/invitations/InviteForm.d.ts +7 -0
- package/dist/components/roles/BUSelector.d.ts +8 -0
- package/dist/components/roles/CreateRoleDialog.d.ts +8 -0
- package/dist/components/roles/PermissionPicker.d.ts +10 -0
- package/dist/components/roles/RoleEditor.d.ts +7 -0
- package/dist/components/roles/RoleRow.d.ts +7 -0
- package/dist/components/roles/RolesList.d.ts +7 -0
- package/dist/components/roles/RolesTab.d.ts +1 -0
- package/dist/components/shared/Avatar.d.ts +7 -0
- package/dist/components/shared/EmptyState.d.ts +10 -0
- package/dist/components/shared/LoadMoreFooter.d.ts +18 -0
- package/dist/i18n/context.d.ts +21 -0
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/pt-BR.d.ts +2 -0
- package/dist/i18n/types.d.ts +227 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +16566 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cn.d.ts +2 -0
- package/dist/lib/use-form-errors.d.ts +34 -0
- package/dist/provider/RoadProvider.d.ts +93 -0
- package/dist/provider/context.d.ts +12 -0
- package/dist/provider/cookie-mode-integration.test.d.ts +1 -0
- package/dist/provider/current-business-unit.d.ts +37 -0
- package/dist/provider/current-business-unit.test.d.ts +1 -0
- package/dist/provider/strict-mode-checks.test.d.ts +1 -0
- package/dist/style.css +1 -0
- package/dist/ui/alert-dialog.d.ts +8 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/button.d.ts +11 -0
- package/dist/ui/checkbox.d.ts +2 -0
- package/dist/ui/dialog.d.ts +17 -0
- package/dist/ui/dropdown-menu.d.ts +10 -0
- package/dist/ui/field.d.ts +26 -0
- package/dist/ui/input.d.ts +4 -0
- package/dist/ui/label.d.ts +2 -0
- package/dist/ui/select.d.ts +6 -0
- package/dist/ui/skeleton.d.ts +2 -0
- package/dist/ui/tabs.d.ts +5 -0
- package/dist/ui/textarea.d.ts +2 -0
- package/dist/ui/tooltip.d.ts +5 -0
- package/package.json +126 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 B1 Produtos Digitais Ltda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# @b1-road/react
|
|
2
|
+
|
|
3
|
+
The official React toolkit for integrating with Road — components, hooks,
|
|
4
|
+
and helpers for embedding Road IAM into platform apps.
|
|
5
|
+
|
|
6
|
+
See [`docs/plans/done/03-road-react-package-plan.md`](../../../docs/plans/done/03-road-react-package-plan.md)
|
|
7
|
+
for the predecessor plan (public API surface, hooks taxonomy, packaging)
|
|
8
|
+
and [`docs/plans/10-react-cookie-mode-spec.md`](../../../docs/plans/10-react-cookie-mode-spec.md)
|
|
9
|
+
for the cookie-mode default.
|
|
10
|
+
|
|
11
|
+
## Status
|
|
12
|
+
|
|
13
|
+
First public alpha. Cookie mode is the default. Bearer mode remains
|
|
14
|
+
available behind `authMode="bearer"` for headless / mobile / partner
|
|
15
|
+
integrations.
|
|
16
|
+
|
|
17
|
+
If you're coming from a bearer-first setup, opt in explicitly by adding
|
|
18
|
+
one prop (`authMode="bearer"`). See [Coming from a bearer-first setup](#coming-from-a-bearer-first-setup)
|
|
19
|
+
below.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @b1-road/react@alpha
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> Published under the `alpha` dist-tag while the Road API contract is in alpha —
|
|
28
|
+
> install with the explicit `@alpha` tag (there is no `latest` release yet).
|
|
29
|
+
|
|
30
|
+
## Quick start (cookie mode — recommended)
|
|
31
|
+
|
|
32
|
+
You are probably here because you're behind a Laravel or NestJS BFF
|
|
33
|
+
(`b1-road/laravel` or `@b1-road/nestjs`) that proxies your app to the
|
|
34
|
+
Road API. The BFF holds the Auth Server JWT server-side; the browser
|
|
35
|
+
ships a session cookie. The React SDK never sees a token.
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import {
|
|
39
|
+
RoadProvider,
|
|
40
|
+
BusinessUnitSwitcher,
|
|
41
|
+
BusinessUnitsMgmt,
|
|
42
|
+
} from "@b1-road/react";
|
|
43
|
+
import "@b1-road/react/style.css";
|
|
44
|
+
|
|
45
|
+
export function App() {
|
|
46
|
+
return (
|
|
47
|
+
<RoadProvider
|
|
48
|
+
apiBaseUrl="/road-api"
|
|
49
|
+
onUnauthenticated={() => window.location.assign("/auth/road/login")}
|
|
50
|
+
>
|
|
51
|
+
<BusinessUnitSwitcher />
|
|
52
|
+
<BusinessUnitsMgmt />
|
|
53
|
+
</RoadProvider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That's the whole integration. The SDK:
|
|
59
|
+
|
|
60
|
+
- Sends requests with `credentials: 'include'`, no `Authorization`
|
|
61
|
+
header. The BFF attaches the bearer server-side.
|
|
62
|
+
- Echoes the `XSRF-TOKEN` cookie into `X-XSRF-TOKEN` on every mutation
|
|
63
|
+
(Laravel's convention; the NestJS BFF adopts the same names).
|
|
64
|
+
- Calls `onUnauthenticated` on terminal 401 with a 1s debounce so
|
|
65
|
+
parallel React Query refetches trigger one redirect, not a stampede.
|
|
66
|
+
|
|
67
|
+
### Required: `onUnauthenticated`
|
|
68
|
+
|
|
69
|
+
Cookie mode requires `onUnauthenticated`. Without it the SDK logs a
|
|
70
|
+
one-time `console.warn` and 401s become silent. The handler typically
|
|
71
|
+
redirects to the BFF's login URL.
|
|
72
|
+
|
|
73
|
+
### Optional cookie-mode knobs
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
<RoadProvider
|
|
77
|
+
apiBaseUrl="/road-api"
|
|
78
|
+
onUnauthenticated={...}
|
|
79
|
+
csrfCookieName="XSRF-TOKEN" // default — matches Laravel + NestJS BFFs
|
|
80
|
+
csrfHeaderName="X-XSRF-TOKEN" // default
|
|
81
|
+
withCredentials={false} // set true only for cross-origin BFFs
|
|
82
|
+
/>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Defaults are tuned for the same-origin BFF deployment that the Laravel
|
|
86
|
+
and NestJS plans ship.
|
|
87
|
+
|
|
88
|
+
## Bearer mode (advanced / opt-out)
|
|
89
|
+
|
|
90
|
+
For headless apps, React Native, partner integrations, or any
|
|
91
|
+
deployment where the host actually holds the JWT in JS:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
<RoadProvider
|
|
95
|
+
apiBaseUrl="https://api.road.b1.app"
|
|
96
|
+
authMode="bearer"
|
|
97
|
+
jwt={() => getToken()}
|
|
98
|
+
>
|
|
99
|
+
...
|
|
100
|
+
</RoadProvider>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`jwt` accepts either a static string or a getter (`() => string |
|
|
104
|
+
Promise<string>`). **Prefer the getter** — Auth Server access tokens
|
|
105
|
+
are short-lived (15–60 minutes); the getter re-resolves per request so
|
|
106
|
+
host-side rotation works without re-mounting the provider.
|
|
107
|
+
|
|
108
|
+
The trade-offs vs cookie mode, honestly stated:
|
|
109
|
+
|
|
110
|
+
- **XSS surface.** A token in JS reachable from JS-evaluated XSS is
|
|
111
|
+
fundamentally weaker than a `HttpOnly` session cookie. Cookie mode
|
|
112
|
+
removes this surface entirely.
|
|
113
|
+
- **Refresh complexity.** Bearer-mode integrators own token rotation;
|
|
114
|
+
cookie-mode BFFs do it server-side.
|
|
115
|
+
- **IETF BCP.** The IETF *OAuth 2.0 for Browser-Based Applications*
|
|
116
|
+
BCP (draft -26) ranks BFF first; PKCE-in-browser is the fallback.
|
|
117
|
+
|
|
118
|
+
## Coming from a bearer-first setup
|
|
119
|
+
|
|
120
|
+
If your app holds the JWT in JS and looks like:
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
<RoadProvider apiBaseUrl="https://api.road.b1.app" jwt={() => getToken()}>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
…opt into bearer mode explicitly by adding one prop:
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
<RoadProvider
|
|
130
|
+
apiBaseUrl="https://api.road.b1.app"
|
|
131
|
+
authMode="bearer"
|
|
132
|
+
jwt={() => getToken()}
|
|
133
|
+
>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The provider throws a loud, actionable error at boot if `authMode`
|
|
137
|
+
is omitted but `jwt` is provided — so the failure mode is "won't
|
|
138
|
+
mount in dev" rather than "401 in production." The error message
|
|
139
|
+
contains the diff above.
|
|
140
|
+
|
|
141
|
+
## Why two modes
|
|
142
|
+
|
|
143
|
+
Cookie mode and bearer mode exist because no single deployment shape
|
|
144
|
+
fits every Road consumer. Cookie mode covers same-origin BFFs (Laravel
|
|
145
|
+
+ Inertia + React, NestJS BFF + React SPA); bearer mode covers
|
|
146
|
+
headless / mobile / partner integrations where the host genuinely owns
|
|
147
|
+
the JWT.
|
|
148
|
+
|
|
149
|
+
This is the same pattern Auth0 ships (`@auth0/auth0-react` bearer-only
|
|
150
|
+
plus `@auth0/nextjs-auth0` cookie BFF), Clerk ships (cookie-based
|
|
151
|
+
session with a bearer fallback), and WorkOS AuthKit ships (cookie-only
|
|
152
|
+
authkit-react). Plan
|
|
153
|
+
[10-react-cookie-mode-spec.md](../../../docs/plans/10-react-cookie-mode-spec.md)
|
|
154
|
+
has the detailed rationale.
|
|
155
|
+
|
|
156
|
+
## Permission-gated UI
|
|
157
|
+
|
|
158
|
+
Widgets gate themselves. For your own UI, `useCan(buId)` returns a
|
|
159
|
+
callable that checks the current user's effective permissions for that
|
|
160
|
+
business unit (read from the server, cached in React Query):
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
import { useCan } from "@b1-road/react";
|
|
164
|
+
|
|
165
|
+
function InviteButton({ buId }: { buId: string }) {
|
|
166
|
+
const can = useCan(buId); // omit buId to read the BU from <BusinessUnitSwitcher />
|
|
167
|
+
return can("manage:Member") ? <button>Invite member</button> : null;
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`"manage:Member"` is compile-time-checked against Road's permission
|
|
172
|
+
algebra (`RoadPermission` — `${action}:${Subject}` plus `"*"`);
|
|
173
|
+
platform-specific permission strings pass through too. For several
|
|
174
|
+
checks in one render, `useCanMany([...])` resolves them in a single
|
|
175
|
+
batch. This is a UI hint, not a security boundary — see
|
|
176
|
+
[Security model](#security-model).
|
|
177
|
+
|
|
178
|
+
## Errors
|
|
179
|
+
|
|
180
|
+
Every failed call rejects with a typed subclass of `RoadApiError`
|
|
181
|
+
carrying the ids you need to find it in Road's logs:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
import { RoadForbiddenError, RoadValidationError } from "@b1-road/react";
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await client.createRole(buId, { name, permissions });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (err instanceof RoadValidationError) {
|
|
190
|
+
err.fieldErrors; // { name: ["already taken"] }
|
|
191
|
+
} else if (err instanceof RoadForbiddenError) {
|
|
192
|
+
err.requestId; // correlates to the Road API log line
|
|
193
|
+
err.traceId; // W3C trace id, when the API emits one
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
React errors are deliberately thinner than the server SDKs' — the full
|
|
199
|
+
authorization `DecisionTrace` is surfaced **server-side**, and appended
|
|
200
|
+
to the 403 response body in non-prod via the debug header (see
|
|
201
|
+
[Security model](#security-model)). It is not rehydrated onto the
|
|
202
|
+
browser error object; the browser gets the correlation ids and asks the
|
|
203
|
+
server for the rest.
|
|
204
|
+
|
|
205
|
+
## Testing
|
|
206
|
+
|
|
207
|
+
The SDK ships its own in-memory client — no Auth Server, no mock-fetch
|
|
208
|
+
boilerplate, no fake JWTs. Assemble state with the fluent builder and
|
|
209
|
+
hand it to the provider's `client` override:
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
import { render, screen } from "@testing-library/react";
|
|
213
|
+
import {
|
|
214
|
+
RoadProvider,
|
|
215
|
+
BusinessUnitsMgmt,
|
|
216
|
+
mockClientBuilder,
|
|
217
|
+
} from "@b1-road/react";
|
|
218
|
+
|
|
219
|
+
const client = mockClientBuilder()
|
|
220
|
+
.withBusinessUnit({ name: "Acme", role: "Owner" }) // current user is Owner (wildcard)
|
|
221
|
+
.withMember("Acme", { name: "Alex", email: "alex@x.com", role: "Admin" })
|
|
222
|
+
.withInvitation("Acme", { email: "pending@x.com", roleName: "Member" })
|
|
223
|
+
.build();
|
|
224
|
+
|
|
225
|
+
render(
|
|
226
|
+
<RoadProvider client={client}>
|
|
227
|
+
<BusinessUnitsMgmt />
|
|
228
|
+
</RoadProvider>,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(await screen.findByText("Acme")).toBeInTheDocument();
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`createMockClient({ empty, latency, failWith })` is the one-liner for
|
|
235
|
+
happy-path / empty / slow-network cases; `mockClientBuilder()` is for
|
|
236
|
+
curated state. Drive error and unauthenticated paths with a scenario:
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
const client = mockClientBuilder()
|
|
240
|
+
.withScenario("auth-error") // also: "network-error" | "rate-limit" | "server-error"
|
|
241
|
+
.withCookieMode({ onUnauthenticated: redirectSpy })
|
|
242
|
+
.build();
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Scope.** The in-memory client exercises the SDK's hooks, widgets, and
|
|
246
|
+
React Query wiring — not its HTTP transport. It has no `fetch`, so it
|
|
247
|
+
can't assert cookie-mode wire behavior (`credentials: 'include'`, the
|
|
248
|
+
`XSRF-TOKEN` echo, the omitted `Authorization` header). For those, stub
|
|
249
|
+
`globalThis.fetch` against `HttpRoadClient` directly.
|
|
250
|
+
|
|
251
|
+
## Security model
|
|
252
|
+
|
|
253
|
+
**Authorization is server-side authoritative.** Every data call goes
|
|
254
|
+
through the Road API, which validates the JWT signature against the
|
|
255
|
+
configured Auth Server's JWKS and rejects forged, expired, or revoked
|
|
256
|
+
tokens. The toolkit's `useCan`/`useCanMany` hooks are a UI hint — they
|
|
257
|
+
read the server's effective-permissions response cached in React Query.
|
|
258
|
+
A user who tampers with their session client-side gets a 401 on the
|
|
259
|
+
first data call, not a privilege escalation.
|
|
260
|
+
|
|
261
|
+
**Identity in cookie mode.** `getCurrentUser()` calls `GET /me/profile`
|
|
262
|
+
on the Road API. The API resolves the user from the bearer the BFF
|
|
263
|
+
attaches; the SDK never decodes claims locally.
|
|
264
|
+
|
|
265
|
+
**Identity in bearer mode.** `getCurrentUser()` decodes `sub`,
|
|
266
|
+
`email`, `name`, `picture` from the JWT payload without signature
|
|
267
|
+
verification — a cosmetic-only trust model (a tampered token still
|
|
268
|
+
gets rejected on the next data call). Bearer-mode integrators who
|
|
269
|
+
need server-authoritative identity can call the API's `/me/profile`
|
|
270
|
+
endpoint directly.
|
|
271
|
+
|
|
272
|
+
## Theming
|
|
273
|
+
|
|
274
|
+
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:
|
|
275
|
+
|
|
276
|
+
| Variable | CSS var |
|
|
277
|
+
| ------------------------ | ------------------------ |
|
|
278
|
+
| `colorPrimary` | `--primary` |
|
|
279
|
+
| `colorPrimaryForeground` | `--primary-foreground` |
|
|
280
|
+
| `colorBackground` | `--background` |
|
|
281
|
+
| `colorForeground` | `--foreground` |
|
|
282
|
+
| `colorMuted` | `--muted` |
|
|
283
|
+
| `colorAccent` | `--accent` |
|
|
284
|
+
| `colorDanger` | `--destructive` |
|
|
285
|
+
| `colorBorder` | `--border` |
|
|
286
|
+
| `borderRadius` | `--radius` |
|
|
287
|
+
| `fontFamily` | `--font-family` |
|
|
288
|
+
|
|
289
|
+
Accepts any valid CSS color (hex, rgb, hsl, oklch) and CSS lengths.
|
|
290
|
+
|
|
291
|
+
## Local development
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
npm install
|
|
295
|
+
npm run dev # opens the playground at http://localhost:5174
|
|
296
|
+
npm run build # library build → dist/
|
|
297
|
+
npm run typecheck
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The playground (`playground/`) mounts the widget against mocked data — no Road API required.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
File without changes
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { BusinessUnitDetail, CreateBusinessUnitInput, CreateInvitationInput, CreateRoleInput, CurrentUser, Invitation, Member, MyBusinessUnits, MyPermissions, PaginatedList, PaginationInput, Permission, Role, UpdateBusinessUnitInput, UpdateRoleInput } from './types';
|
|
2
|
+
import { RoadApiError } from './errors';
|
|
3
|
+
export interface RoadClient {
|
|
4
|
+
getCurrentUser(): Promise<CurrentUser>;
|
|
5
|
+
getMyBusinessUnits(): Promise<MyBusinessUnits>;
|
|
6
|
+
getMyPermissions(): Promise<MyPermissions>;
|
|
7
|
+
getBusinessUnit(buId: string): Promise<BusinessUnitDetail>;
|
|
8
|
+
createBusinessUnit(input: CreateBusinessUnitInput): Promise<BusinessUnitDetail>;
|
|
9
|
+
updateBusinessUnit(buId: string, input: UpdateBusinessUnitInput): Promise<BusinessUnitDetail>;
|
|
10
|
+
listMembers(buId: string, pagination?: PaginationInput): Promise<PaginatedList<Member>>;
|
|
11
|
+
suspendMember(buId: string, memberId: string): Promise<void>;
|
|
12
|
+
reinstateMember(buId: string, memberId: string): Promise<void>;
|
|
13
|
+
removeMember(buId: string, memberId: string): Promise<void>;
|
|
14
|
+
listInvitations(buId: string, pagination?: PaginationInput): Promise<PaginatedList<Invitation>>;
|
|
15
|
+
createInvitation(buId: string, input: CreateInvitationInput): Promise<Invitation>;
|
|
16
|
+
cancelInvitation(buId: string, invitationId: string): Promise<void>;
|
|
17
|
+
resendInvitation(buId: string, invitationId: string): Promise<void>;
|
|
18
|
+
acceptInvitation(invitationId: string): Promise<void>;
|
|
19
|
+
rejectInvitation(invitationId: string): Promise<void>;
|
|
20
|
+
listRoles(buId: string, pagination?: PaginationInput): Promise<PaginatedList<Role>>;
|
|
21
|
+
getRole(buId: string, roleId: string): Promise<Role>;
|
|
22
|
+
createRole(buId: string, input: CreateRoleInput): Promise<Role>;
|
|
23
|
+
updateRole(buId: string, roleId: string, input: UpdateRoleInput): Promise<Role>;
|
|
24
|
+
deleteRole(buId: string, roleId: string): Promise<void>;
|
|
25
|
+
listPermissions(buId: string): Promise<Permission[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Authoritative batch permission check — calls the IAM engine and
|
|
28
|
+
* returns a record from permission code → allowed boolean. Honors
|
|
29
|
+
* the engine's full evaluation (manage → CRUD expansion, wildcards,
|
|
30
|
+
* scope inheritance) — strictly more accurate than the string-match
|
|
31
|
+
* useCan does over the cached useMyPermissions map.
|
|
32
|
+
*/
|
|
33
|
+
authorizeBatch(buId: string, permissions: readonly string[]): Promise<Record<string, boolean>>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* JWT-source contract. A plain string is accepted for backward-compat,
|
|
37
|
+
* but integrators should pass a getter so token rotation in the host's
|
|
38
|
+
* auth state is picked up on every request without forcing a re-render
|
|
39
|
+
* of <RoadProvider>.
|
|
40
|
+
*/
|
|
41
|
+
export type JwtSource = string | (() => string | Promise<string> | null | undefined);
|
|
42
|
+
/**
|
|
43
|
+
* How the SDK authenticates against the Road API.
|
|
44
|
+
*
|
|
45
|
+
* - `'cookie'` — true BFF mode. The browser holds an opaque session
|
|
46
|
+
* cookie; the BFF (Laravel / NestJS) proxies requests to Road and
|
|
47
|
+
* attaches the Auth Server JWT server-side. The SDK sends
|
|
48
|
+
* `credentials: 'include'` and **no** `Authorization` header. On
|
|
49
|
+
* non-GETs the SDK echoes a CSRF token from a cookie into the
|
|
50
|
+
* matching header. This is the default and the recommended path per
|
|
51
|
+
* the IETF OAuth-for-Browser-Apps BCP.
|
|
52
|
+
* - `'bearer'` — the host holds the JWT in JS (legacy SPAs, mobile
|
|
53
|
+
* webviews, headless / partner integrations). The SDK reads it from
|
|
54
|
+
* the `jwt` source and sends `Authorization: Bearer …` on every
|
|
55
|
+
* request. No cookie semantics.
|
|
56
|
+
*
|
|
57
|
+
* Picking the mode is a deployment-shape decision, not a runtime
|
|
58
|
+
* switch — flipping it mid-app changes nothing useful and breaks the
|
|
59
|
+
* server-side auth pipeline.
|
|
60
|
+
*/
|
|
61
|
+
export type RoadAuthMode = "cookie" | "bearer";
|
|
62
|
+
export interface RetryConfig {
|
|
63
|
+
/** Max attempts including the first try. Default 3. */
|
|
64
|
+
maxAttempts?: number;
|
|
65
|
+
/** Base delay in ms for the exponential backoff. Default 250. */
|
|
66
|
+
baseDelay?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface TelemetryHooks {
|
|
69
|
+
/** Fires after a request completes successfully. */
|
|
70
|
+
onRequest?: (event: TelemetryRequestEvent) => void;
|
|
71
|
+
/** Fires after a request fails (after retries are exhausted). */
|
|
72
|
+
onError?: (error: RoadApiError, event: TelemetryRequestEvent) => void;
|
|
73
|
+
}
|
|
74
|
+
export interface TelemetryRequestEvent {
|
|
75
|
+
method: string;
|
|
76
|
+
path: string;
|
|
77
|
+
status: number;
|
|
78
|
+
durationMs: number;
|
|
79
|
+
/** From the Road response's `x-request-id` header; undefined when the call never reached Road. */
|
|
80
|
+
requestId?: string;
|
|
81
|
+
/** From the Road response's `traceparent` header; undefined when absent or the call never reached Road. */
|
|
82
|
+
traceId?: string;
|
|
83
|
+
/** Total attempts made before resolving (1 when no retries occurred). */
|
|
84
|
+
attempts: number;
|
|
85
|
+
}
|
|
86
|
+
export interface HttpClientConfig {
|
|
87
|
+
apiBaseUrl: string;
|
|
88
|
+
/**
|
|
89
|
+
* Transport shape. Defaults to `'cookie'` — the BFF holds the JWT and
|
|
90
|
+
* the browser ships a session cookie. Pass `'bearer'` only when the
|
|
91
|
+
* host actually holds the JWT (mobile/headless/partner integrations).
|
|
92
|
+
*/
|
|
93
|
+
authMode?: RoadAuthMode;
|
|
94
|
+
/** Required iff `authMode === 'bearer'`. Ignored in cookie mode. */
|
|
95
|
+
jwt?: JwtSource;
|
|
96
|
+
/**
|
|
97
|
+
* Fired once per terminal 401 with a 1s debounce. In cookie mode the
|
|
98
|
+
* BFF owns refresh; the SDK can't recover and the handler should
|
|
99
|
+
* redirect to the BFF's login URL. In bearer mode this fires after the
|
|
100
|
+
* existing rotation attempt (if any) also fails.
|
|
101
|
+
*/
|
|
102
|
+
onUnauthenticated?: () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Cookie name the SDK reads to source a CSRF token on cookie-mode
|
|
105
|
+
* mutations. Default: `'XSRF-TOKEN'` (Laravel's convention; the
|
|
106
|
+
* NestJS BFF adopts the same).
|
|
107
|
+
*/
|
|
108
|
+
csrfCookieName?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Header name the SDK writes the CSRF token into on cookie-mode
|
|
111
|
+
* mutations. Default: `'X-XSRF-TOKEN'`.
|
|
112
|
+
*/
|
|
113
|
+
csrfHeaderName?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Cross-origin opt-in for cookie mode. Default `false` — same-origin
|
|
116
|
+
* is the recommended deployment. When `true` the BFF must respond
|
|
117
|
+
* with `Access-Control-Allow-Credentials: true` and matching CORS.
|
|
118
|
+
* Ignored in bearer mode.
|
|
119
|
+
*/
|
|
120
|
+
withCredentials?: boolean;
|
|
121
|
+
/** Per-call retry policy for idempotent (GET) + idempotency-keyed requests. */
|
|
122
|
+
retry?: RetryConfig;
|
|
123
|
+
/** Observability hooks for the host app. */
|
|
124
|
+
telemetry?: TelemetryHooks;
|
|
125
|
+
}
|
|
126
|
+
export declare class HttpRoadClient implements RoadClient {
|
|
127
|
+
private readonly config;
|
|
128
|
+
/**
|
|
129
|
+
* buId → iamScopeId cache. Populated lazily by resolveScopeId(); the
|
|
130
|
+
* IAM role / permission endpoints are scope-keyed, but the SDK exposes
|
|
131
|
+
* BU-keyed APIs to integrators — this map bridges the two without
|
|
132
|
+
* forcing every call site to fetch the BU detail first.
|
|
133
|
+
*/
|
|
134
|
+
private readonly scopeIdByBuId;
|
|
135
|
+
/**
|
|
136
|
+
* Timestamp of the last terminal-401 callback. Used to debounce
|
|
137
|
+
* `onUnauthenticated` so parallel in-flight requests that all 401 at
|
|
138
|
+
* once trigger a single redirect rather than a stampede.
|
|
139
|
+
*/
|
|
140
|
+
private lastUnauthAt;
|
|
141
|
+
constructor(config: HttpClientConfig);
|
|
142
|
+
/**
|
|
143
|
+
* Fire the integrator's onUnauthenticated handler at most once per
|
|
144
|
+
* 1 second. The window is intentionally short — long enough to absorb
|
|
145
|
+
* parallel request bursts, short enough that the next manual user
|
|
146
|
+
* action sees a fresh callback if needed. The handler runs inside a
|
|
147
|
+
* try/catch so a broken integrator callback can't break the SDK.
|
|
148
|
+
*/
|
|
149
|
+
private notifyUnauthenticated;
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the JWT for this request. Supports the legacy `string`
|
|
152
|
+
* form for backward-compat, plus the recommended getter form so the
|
|
153
|
+
* host can rotate tokens without re-mounting <RoadProvider>.
|
|
154
|
+
*/
|
|
155
|
+
private resolveJwt;
|
|
156
|
+
private request;
|
|
157
|
+
private executeRequest;
|
|
158
|
+
/** Fetch & unwrap a `{ data: T }` envelope. */
|
|
159
|
+
private fetchData;
|
|
160
|
+
/**
|
|
161
|
+
* Fetch a paginated list from a `{ data: T[], meta: { pagination } }`
|
|
162
|
+
* response. The RequestIdInterceptor on the API side moves the
|
|
163
|
+
* controller's top-level `pagination` field into `meta` so every
|
|
164
|
+
* paginated endpoint converges on a single response shape — this
|
|
165
|
+
* helper centralizes the unwrap.
|
|
166
|
+
*
|
|
167
|
+
* Falls back to a single-page response (cursor=null, hasMore=false)
|
|
168
|
+
* when the response has no pagination metadata, so callers that hit
|
|
169
|
+
* legacy endpoints still get a valid `PaginatedList<T>`.
|
|
170
|
+
*/
|
|
171
|
+
private fetchPaginated;
|
|
172
|
+
/** Resolve buId → iamScopeId, fetching the BU detail once and caching. */
|
|
173
|
+
private resolveScopeId;
|
|
174
|
+
/**
|
|
175
|
+
* Cookie mode calls Road's canonical `GET /me/profile` (the single
|
|
176
|
+
* server-authoritative profile endpoint). Bearer mode instead decodes the
|
|
177
|
+
* profile from the JWT's payload claims — the Auth Server's JWT already
|
|
178
|
+
* carries sub / email / name / picture, so we read them client-side and
|
|
179
|
+
* avoid an unnecessary round-trip. The id is the JWT `sub` (the stable
|
|
180
|
+
* Auth Server user id, which matches Road's user references everywhere).
|
|
181
|
+
*
|
|
182
|
+
* **Trust model — read carefully (bearer mode).** This path does NOT verify
|
|
183
|
+
* the JWT signature. The claims are decoded with `atob` and returned as-is.
|
|
184
|
+
* A tampered token can produce a `CurrentUser` with arbitrary
|
|
185
|
+
* `name`/`email`/`avatarUrl` — but cannot perform any action against Road,
|
|
186
|
+
* because the API validates the signature on every data call via the Auth
|
|
187
|
+
* Server's JWKS endpoint and rejects forged tokens with 401. The risk is
|
|
188
|
+
* purely cosmetic (the switcher / member rows might display a wrong identity
|
|
189
|
+
* label).
|
|
190
|
+
*
|
|
191
|
+
* If your security review requires server-authoritative identity in bearer
|
|
192
|
+
* mode too, call `GET /me/profile` directly instead of this method.
|
|
193
|
+
*
|
|
194
|
+
* See the SDK README "Security model" section for the full discussion.
|
|
195
|
+
*/
|
|
196
|
+
getCurrentUser(): Promise<CurrentUser>;
|
|
197
|
+
/** GET /me/business-units — server wraps in `{data}` like every other endpoint. */
|
|
198
|
+
getMyBusinessUnits(): Promise<MyBusinessUnits>;
|
|
199
|
+
/**
|
|
200
|
+
* Effective permissions across every membership the user has. Issues a
|
|
201
|
+
* single bulk request to `GET /iam/authorization/me/permissions?scopes=`
|
|
202
|
+
* — the API returns `{ [scopeId]: PermissionTuple[] }` which we flatten
|
|
203
|
+
* into the SDK's `Record<buId, string[]>` shape that useCan /
|
|
204
|
+
* useMyPermissions consume.
|
|
205
|
+
*
|
|
206
|
+
* The mutator already server-side-expands `manage` into the CRUD set,
|
|
207
|
+
* so each BU's list is complete. Wildcards (`*:*`) collapse to `"*"`
|
|
208
|
+
* for compatibility with useCan's wildcard-short-circuit.
|
|
209
|
+
*
|
|
210
|
+
* Resolving every BU's iamScopeId still requires a getBusinessUnit
|
|
211
|
+
* fetch per BU (cached after first call), but that runs in parallel —
|
|
212
|
+
* total round-trips: N parallel BU details + 1 batched permissions.
|
|
213
|
+
* The pre-bulk implementation needed 2N sequential calls.
|
|
214
|
+
*/
|
|
215
|
+
getMyPermissions(): Promise<MyPermissions>;
|
|
216
|
+
getBusinessUnit(buId: string): Promise<BusinessUnitDetail>;
|
|
217
|
+
createBusinessUnit(input: CreateBusinessUnitInput): Promise<BusinessUnitDetail>;
|
|
218
|
+
updateBusinessUnit(buId: string, input: UpdateBusinessUnitInput): Promise<BusinessUnitDetail>;
|
|
219
|
+
/**
|
|
220
|
+
* GET .../members — cursor-paginated. Returns `{ items, pagination }` so
|
|
221
|
+
* callers can drive infinite scroll. The API ships paginated lists as
|
|
222
|
+
* `{ data: Member[], meta: { pagination: Pagination, … } }` (the
|
|
223
|
+
* RequestIdInterceptor absorbs the controller's top-level `pagination`
|
|
224
|
+
* into `meta`). We normalize that into the SDK's `PaginatedList<T>`.
|
|
225
|
+
*/
|
|
226
|
+
listMembers(buId: string, pagination?: PaginationInput): Promise<PaginatedList<Member>>;
|
|
227
|
+
suspendMember(buId: string, memberId: string): Promise<void>;
|
|
228
|
+
reinstateMember(buId: string, memberId: string): Promise<void>;
|
|
229
|
+
removeMember(buId: string, memberId: string): Promise<void>;
|
|
230
|
+
listInvitations(buId: string, pagination?: PaginationInput): Promise<PaginatedList<Invitation>>;
|
|
231
|
+
createInvitation(buId: string, input: CreateInvitationInput): Promise<Invitation>;
|
|
232
|
+
cancelInvitation(buId: string, invitationId: string): Promise<void>;
|
|
233
|
+
resendInvitation(buId: string, invitationId: string): Promise<void>;
|
|
234
|
+
acceptInvitation(invitationId: string): Promise<void>;
|
|
235
|
+
rejectInvitation(invitationId: string): Promise<void>;
|
|
236
|
+
listRoles(buId: string, pagination?: PaginationInput): Promise<PaginatedList<Role>>;
|
|
237
|
+
getRole(buId: string, roleId: string): Promise<Role>;
|
|
238
|
+
createRole(buId: string, input: CreateRoleInput): Promise<Role>;
|
|
239
|
+
updateRole(buId: string, roleId: string, input: UpdateRoleInput): Promise<Role>;
|
|
240
|
+
deleteRole(buId: string, roleId: string): Promise<void>;
|
|
241
|
+
listPermissions(buId: string): Promise<Permission[]>;
|
|
242
|
+
authorizeBatch(buId: string, permissions: readonly string[]): Promise<Record<string, boolean>>;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Decode a JWT's payload claims without signature verification — used only
|
|
246
|
+
* to surface the user's identity claims to the widget UI. The actual auth
|
|
247
|
+
* decisions are made server-side; a tampered token will be rejected by
|
|
248
|
+
* Road's API on any data call.
|
|
249
|
+
*
|
|
250
|
+
* Uses `atob` because the React SDK is browser-only. A node-targeted SDK
|
|
251
|
+
* would swap this for a Buffer-based implementation.
|
|
252
|
+
*/
|
|
253
|
+
export declare function decodeJwtPayload(jwt: string | undefined): Record<string, unknown> | null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|