ab-ecosystem-sso 0.1.18 → 0.2.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/README.md +475 -15
- package/dist/ab-ecosystem-sso.cjs +28 -26
- package/dist/ab-ecosystem-sso.js +5817 -4114
- package/dist/index.d.ts +974 -18
- package/dist/style.css +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,11 @@ Drop-in React auth & account UI for the partner-ecosystem **Auth-as-a-Service**
|
|
|
4
4
|
|
|
5
5
|
- ⚛️ React 18 / 19, shipped as ESM + CJS with full TypeScript types.
|
|
6
6
|
- 🎨 Self-contained, theme-aware CSS that **won't leak into or be overridden by** your app.
|
|
7
|
+
- 🌗 Built-in **light and dark mode** — per component, via a single `mode` prop.
|
|
7
8
|
- 🔌 Use it declaratively (`<AccountSettings />`) or imperatively (`openAccountSettings()`).
|
|
9
|
+
- 👥 **Multi-account aware** — account picker, account switching, invitations, roles & members.
|
|
10
|
+
- 🔑 **Stores the session token for you** and falls back to it everywhere, so `token` props are optional.
|
|
11
|
+
- 🧭 **Ships its own routes** — spread them into your router, or let the package match the path.
|
|
8
12
|
- 🧩 Every network call also available as a plain function for custom UIs.
|
|
9
13
|
|
|
10
14
|
---
|
|
@@ -13,14 +17,20 @@ Drop-in React auth & account UI for the partner-ecosystem **Auth-as-a-Service**
|
|
|
13
17
|
|
|
14
18
|
- [Installation](#installation)
|
|
15
19
|
- [Configuration basics](#configuration-basics)
|
|
20
|
+
- [Token handling](#token-handling) — storage, `getAuthHeader`, `authFetch`
|
|
21
|
+
- [Routes](#routes) — `ssoRoutes` · `SsoAuthRoutes` · `SsoAuthPage`
|
|
16
22
|
- [Auth screens](#auth-screens) — `Login` · `Signup` · `ForgotPassword` · `VerifyEmail`
|
|
17
23
|
- [Session management](#session-management) — `SessionProvider`, enforce subscription
|
|
24
|
+
- [Multiple accounts](#multiple-accounts) — account picker, switching, revoked access
|
|
25
|
+
- [Invitations](#invitations) — `AcceptInvite`, invite links
|
|
26
|
+
- [Team administration](#team-administration) — members · roles · invitations
|
|
18
27
|
- [Account Settings modal](#account-settings-modal) — `openAccountSettings` / `AccountSettings`
|
|
19
28
|
- [Logout](#logout) — `logout` · `LogoutButton` · `logoutApi`
|
|
20
29
|
- [Fetching the current user](#fetching-the-current-user) — `getUser`
|
|
21
30
|
- [Fetching user data](#fetching-user-data) — `getUserProfile` · `getUserWallet` · `getUserCreditWallet` · `getUserSubscription`
|
|
22
31
|
- [Two-factor setup](#two-factor-setup)
|
|
23
32
|
- [Hooks](#hooks)
|
|
33
|
+
- [Light & dark mode](#light--dark-mode)
|
|
24
34
|
- [Theming](#theming)
|
|
25
35
|
- [API reference](#api-reference)
|
|
26
36
|
- [TypeScript](#typescript)
|
|
@@ -59,6 +69,155 @@ You own the token: persist it (e.g. `localStorage`) on `onSuccess`, pass it back
|
|
|
59
69
|
|
|
60
70
|
---
|
|
61
71
|
|
|
72
|
+
## Token handling
|
|
73
|
+
|
|
74
|
+
**The package stores the session token for you.** Every screen that receives one — login, 2FA, account selection, account switching, signup, invite acceptance, magic links — writes it to `localStorage`, and every call the package makes falls back to it. So `token` props are optional now, and this is enough:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
<Login projectId="your-project-id" /> // token stored on success
|
|
78
|
+
<AccountSettings open onOpenChange={setOpen} projectId="your-project-id" />
|
|
79
|
+
<SessionProvider projectId="your-project-id">{children}</SessionProvider>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It's cleared on `logout()`, on `LogoutButton`, and whenever a session turns out to be invalid. Account switching **replaces** it, so later calls can't keep acting as the previous account.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { configureTokenStorage } from "ab-ecosystem-sso";
|
|
86
|
+
|
|
87
|
+
configureTokenStorage({ key: "authToken" }); // match an existing app's key
|
|
88
|
+
configureTokenStorage({ storage: "sessionStorage" }); // per-tab instead of persistent
|
|
89
|
+
configureTokenStorage({ enabled: false }); // opt out; you handle the token
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| Option | Default | Notes |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `key` | `"ab-sso:accessToken"` | Namespaced on purpose — a bare `accessToken` collides with whatever your app already keeps under that name. |
|
|
95
|
+
| `storage` | `"localStorage"` | Or `"sessionStorage"`, `"memory"`, `"none"`. |
|
|
96
|
+
| `enabled` | `true` | `false` turns off both reading and writing. |
|
|
97
|
+
|
|
98
|
+
Per component, `persistToken={false}` skips the write for that screen and `tokenStorageKey` overrides the key.
|
|
99
|
+
|
|
100
|
+
> A bearer token in `localStorage` is readable by any script on your origin. That's the same thing most hosts were already doing by hand, but it is now the **default** — switch to `sessionStorage`, or turn it off, if your threat model says otherwise.
|
|
101
|
+
|
|
102
|
+
Reading it back, and the helpers for your own calls:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { getStoredToken, clearStoredToken, onStoredTokenChange } from "ab-ecosystem-sso";
|
|
106
|
+
|
|
107
|
+
getStoredToken(); // "" when signed out
|
|
108
|
+
clearStoredToken(); // sign out locally
|
|
109
|
+
onStoredTokenChange((t) => …); // fires on login / switch / logout (this tab)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Storage access throws in private windows and with site data blocked, so every read and write is guarded and falls back to an in-memory value for the life of the page — it degrades, it never throws at you.
|
|
113
|
+
|
|
114
|
+
### Sending the token to your own backend
|
|
115
|
+
|
|
116
|
+
The package **does not** patch your app's HTTP client, and that's deliberate: a blanket `fetch` patch would attach your bearer token to every outbound request on the page, third-party scripts included. Three explicit options instead, in order of how much they touch:
|
|
117
|
+
|
|
118
|
+
**1. `getAuthHeader()`** — one line in whatever client you already have:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { getAuthHeader } from "ab-ecosystem-sso";
|
|
122
|
+
|
|
123
|
+
axios.interceptors.request.use((cfg) => {
|
|
124
|
+
cfg.headers = { ...cfg.headers, ...getAuthHeader() };
|
|
125
|
+
return cfg;
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**2. `authFetch()`** — a `fetch` wrapper that also handles the 401 split:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { authFetch, configureAuthHandlers } from "ab-ecosystem-sso";
|
|
133
|
+
|
|
134
|
+
configureAuthHandlers({
|
|
135
|
+
onUnauthorized: () => navigate("/login"), // session gone
|
|
136
|
+
onAccountAccessLost: () => showAccountPicker(), // A403: this account only
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const res = await authFetch("/api/orders");
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
That second handler is the real win here: it's what makes ["access to one account was revoked"](#when-access-to-one-account-is-revoked) behave correctly for *your* endpoints too, not just ours. An `Authorization` header you set yourself is never overwritten, and the response body reaches you unread.
|
|
143
|
+
|
|
144
|
+
**3. `installAuthInterceptor({ origins })`** — for apps with too many call sites to touch. It patches `fetch` and `XMLHttpRequest`, but only for an explicit allowlist:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { installAuthInterceptor } from "ab-ecosystem-sso";
|
|
148
|
+
|
|
149
|
+
const stop = installAuthInterceptor({
|
|
150
|
+
origins: ["self", "https://api.example.com"], // "self" = your own origin (covers /api/…)
|
|
151
|
+
onUnauthorized: () => navigate("/login"),
|
|
152
|
+
onAccountAccessLost: () => showAccountPicker(),
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
There is no wildcard and the allowlist is **required** — it throws without one. Anything not listed is left completely alone, which is what keeps the token away from analytics, CDNs and payment widgets. `stop()` restores the originals.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Routes
|
|
161
|
+
|
|
162
|
+
Six routes are load-bearing for any app embedding this package:
|
|
163
|
+
|
|
164
|
+
| Path | Screen | Reached by |
|
|
165
|
+
| --- | --- | --- |
|
|
166
|
+
| `/login` | `Login` | your app, and the package's own redirects |
|
|
167
|
+
| `/signup` | `Signup` | the login screen |
|
|
168
|
+
| `/forgot-password` | `ForgotPassword` | the login screen |
|
|
169
|
+
| `/reset-password` | `ForgotPassword` (reset step) | the emailed reset link |
|
|
170
|
+
| `/verify/:token` | `VerifyEmail` | the emailed verification link |
|
|
171
|
+
| `/accept-invite` | `AcceptInvite` | invite links (`inviteLinkBase`) |
|
|
172
|
+
|
|
173
|
+
A library can't discover your app's routes — react-router keeps no readable registry at runtime, and Next's routes are filesystem and build-time — so the package publishes what it expects and gives you three ways to satisfy it.
|
|
174
|
+
|
|
175
|
+
**Already have a router?** Spread the route objects in, so the screens sit inside your layouts and guards:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
import { ssoRoutes } from "ab-ecosystem-sso";
|
|
179
|
+
import { useNavigate } from "react-router-dom";
|
|
180
|
+
|
|
181
|
+
const navigate = useNavigate();
|
|
182
|
+
createBrowserRouter([
|
|
183
|
+
...ssoRoutes({ projectId: "your-project-id", onNavigate: navigate, redirectTo: "/dashboard" }),
|
|
184
|
+
...appRoutes,
|
|
185
|
+
]);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Pass `onNavigate` so the screens move through *your* router — the default `history.pushState` won't re-render it. Token-bearing routes are registered both bare and with a `:token` segment, since links arrive both ways.
|
|
189
|
+
|
|
190
|
+
**No router?** Mount the whole set and let the package match the path:
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import { SsoAuthRoutes } from "ab-ecosystem-sso";
|
|
194
|
+
|
|
195
|
+
<SsoAuthRoutes projectId="your-project-id" redirectTo="/dashboard" fallback={<App />} />
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**File-based routing (Next)?** One tiny file per route:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
// app/login/page.tsx
|
|
202
|
+
import { SsoAuthPage } from "ab-ecosystem-sso";
|
|
203
|
+
export default () => <SsoAuthPage screen="login" projectId="your-project-id" />;
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
All three wire the screens to each other, store the token on success, and then leave for `redirectTo` (default `/`). A `?next=` parameter wins over `redirectTo` — and is rejected unless it's a same-site path, so the login screen can't be turned into an open redirect. Replace the whole behaviour with `onAuthenticated`.
|
|
207
|
+
|
|
208
|
+
Paths aren't fixed:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { configureSsoRoutes } from "ab-ecosystem-sso";
|
|
212
|
+
configureSsoRoutes({ login: "/sign-in", acceptInvite: "/invite" });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`basePath="/auth"` mounts everything under a prefix. `getSsoRoutes()` returns what's in effect, and `SessionProvider` uses it for its default redirect (with `?next=` back to where the user was), so a remapped login route is honoured everywhere.
|
|
216
|
+
|
|
217
|
+
> ⚠️ **Your server has to serve the app shell for these paths.** If `/accept-invite` isn't rewritten to `index.html`, the user gets a 404 before any JavaScript runs, and no client-side package can fix that. For a static SPA host that's a catch-all rewrite to `/index.html`; on nginx, `try_files $uri /index.html;`.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
62
221
|
## Auth screens
|
|
63
222
|
|
|
64
223
|
### `Login`
|
|
@@ -84,6 +243,8 @@ function LoginPage() {
|
|
|
84
243
|
}
|
|
85
244
|
```
|
|
86
245
|
|
|
246
|
+
The password field has a **show/hide (eye) toggle** — it always starts masked, and the same control appears on `Signup`, the `ForgotPassword` reset step, and the Account Settings change-password modal.
|
|
247
|
+
|
|
87
248
|
`Login` first fetches the project's `auth-config` (captcha provider/site-key, branding, which links to show) and renders accordingly. If the account has **2FA** enabled it automatically shows a **segmented 6-digit OTP** step (with captcha when the project requires it), and only calls `onSuccess` after verification succeeds.
|
|
88
249
|
|
|
89
250
|
**Magic-link sign-in** works automatically: point your magic link at `/login#<access_token>` (a bare `/login#access_token=<token>` is also accepted). On mount, `Login` reads the token from the URL hash, validates it with a `/me` call, and — if valid — strips it from the URL and fires `onSuccess({ token, user })`, exactly like a password sign-in. While it validates, a loading state shows; if the token is invalid or expired, it falls back to the normal sign-in form with a short notice (and calls `onError`). No extra wiring needed beyond routing the link to your login page.
|
|
@@ -177,8 +338,11 @@ On success it calls `onVerified` (send the user to login) and shows a **Continue
|
|
|
177
338
|
| `onSuccess` | `({ token, user }) => void` | On successful auth. |
|
|
178
339
|
| `onError` | `(error: Error) => void` | On failure. |
|
|
179
340
|
| `classNames` | `AuthClassNames` | Per-element class overrides: `card`, `input`, `label`, `button`, `link`, `captcha`, `error`, … |
|
|
341
|
+
| `mode` | `"light" \| "dark"` | Colour scheme. Default `"light"`. See [Light & dark mode](#light--dark-mode). |
|
|
342
|
+
| `persistToken` | `boolean` | Store the issued token. Default `true`. See [Token handling](#token-handling). |
|
|
343
|
+
| `tokenStorageKey` | `string` | Storage key override for this screen. |
|
|
180
344
|
|
|
181
|
-
Per-screen extras: `Login` adds `onForgotPassword` / `onSignupClick
|
|
345
|
+
Per-screen extras: `Login` adds `onForgotPassword` / `onSignupClick` / `client` (see [Multiple accounts](#multiple-accounts)); `Signup` adds `inviteToken` / `inviteEmail` (see [Invitations](#invitations)); `Signup` adds `onLoginClick` / `countries`; `ForgotPassword` adds `onBackToLogin` / `resetToken` / `onResetSuccess`; `VerifyEmail` adds `token` / `onVerified` / `onLoginClick` / `onSignupClick` (and has no `onSuccess`, since no session is created).
|
|
182
346
|
|
|
183
347
|
---
|
|
184
348
|
|
|
@@ -189,12 +353,9 @@ Wrap authenticated areas in `SessionProvider`. It validates the token against `/
|
|
|
189
353
|
```tsx
|
|
190
354
|
import { SessionProvider } from "ab-ecosystem-sso";
|
|
191
355
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
apiBaseUrl="https://your-api"
|
|
196
|
-
onSessionInvalid={() => navigate("/login")}
|
|
197
|
-
>
|
|
356
|
+
// `token` and `onSessionInvalid` are both optional: it reads the stored token
|
|
357
|
+
// and falls back to redirecting to the login route.
|
|
358
|
+
<SessionProvider projectId="your-project-id" apiBaseUrl="https://your-api">
|
|
198
359
|
<Dashboard />
|
|
199
360
|
</SessionProvider>
|
|
200
361
|
```
|
|
@@ -202,10 +363,13 @@ import { SessionProvider } from "ab-ecosystem-sso";
|
|
|
202
363
|
| Prop | Type | Notes |
|
|
203
364
|
| --- | --- | --- |
|
|
204
365
|
| `projectId` | `string` | **Required.** |
|
|
205
|
-
| `token` | `string \| null` |
|
|
206
|
-
| `onSessionInvalid` | `() => void` | Called on invalid/expired session or logout. |
|
|
366
|
+
| `token` | `string \| null` | **Omit** to use the stored token; `null` states explicitly that there's no session. |
|
|
367
|
+
| `onSessionInvalid` | `() => void` | Called on invalid/expired session or logout. Optional — defaults to redirecting to the login route with `?next=`. |
|
|
207
368
|
| `apiBaseUrl` | `string` | Optional. |
|
|
208
369
|
| `enforceActiveSubscription` | `boolean` | See below. |
|
|
370
|
+
| `onTokenChange` | `(token: string) => void` | Receives a fresh token when the user switches account from the built-in picker. |
|
|
371
|
+
| `onAccountAccessLost` | `({ message, code }) => void` | Access to *this* account was revoked/expired (`A403`) — handle it instead of the built-in picker. |
|
|
372
|
+
| `mode` | `"light" \| "dark"` | Colour scheme for the built-in picker. |
|
|
209
373
|
|
|
210
374
|
### Enforce an active subscription
|
|
211
375
|
|
|
@@ -219,9 +383,167 @@ With `enforceActiveSubscription`, once authenticated the provider checks the use
|
|
|
219
383
|
|
|
220
384
|
---
|
|
221
385
|
|
|
386
|
+
## Multiple accounts
|
|
387
|
+
|
|
388
|
+
A login can belong to more than one account (their own, plus any they've been invited to). When it does, `/auth/login` and `/auth/verify-2fa` return **no token** — they return an account picker instead:
|
|
389
|
+
|
|
390
|
+
```json
|
|
391
|
+
{
|
|
392
|
+
"success": true,
|
|
393
|
+
"accountSelectionRequired": true,
|
|
394
|
+
"accounts": [{ "account_id": "66f…b2", "name": "Bob's Co", "is_owner": false, "role": "Manager", "access_expiry": null }],
|
|
395
|
+
"selectionTicket": "eyJ…"
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**`Login` handles this for you.** It renders the picker as a third step — exactly like the existing 2FA step — exchanges the chosen account's `selectionTicket` via `/auth/select-account`, and only then calls `onSuccess`, with the same `data.accessToken` shape a single-account login gives you (plus the chosen `account`). No change is needed in your `onSuccess`:
|
|
400
|
+
|
|
401
|
+
```tsx
|
|
402
|
+
<Login
|
|
403
|
+
projectId="your-project-id"
|
|
404
|
+
client="webapp" // optional: recorded on the session
|
|
405
|
+
onSuccess={(res) => save(res.data.accessToken)}
|
|
406
|
+
/>
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
> ⚠️ If you were reading `data.accessToken` straight out of the login response in your own code, add the `accountSelectionRequired` branch — on that branch there is no token, and the `selectionTicket` is only valid for **five minutes**.
|
|
410
|
+
|
|
411
|
+
### Switching account on a live session
|
|
412
|
+
|
|
413
|
+
`AccountPicker` is also exported on its own. Given a `token` instead of a ticket it lists the accounts from `GET /accounts` and switches via `/auth/switch-account`, handing you a **fresh token** to store in place of the old one (the previous token stays valid until it expires or is logged out):
|
|
414
|
+
|
|
415
|
+
```tsx
|
|
416
|
+
import { AccountPicker } from "ab-ecosystem-sso";
|
|
417
|
+
|
|
418
|
+
<AccountPicker
|
|
419
|
+
projectId="your-project-id"
|
|
420
|
+
token={token}
|
|
421
|
+
variant="list" // no card chrome — drops into a dropdown or panel
|
|
422
|
+
currentAccountId={accountId} // marks the current one
|
|
423
|
+
onSelect={({ accessToken, account }) => save(accessToken, account.account_id)}
|
|
424
|
+
/>
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The Account Settings modal has this built in as an **Accounts** section — see [`onAccountSwitch`](#props).
|
|
428
|
+
|
|
429
|
+
| Prop | Type | Notes |
|
|
430
|
+
| --- | --- | --- |
|
|
431
|
+
| `projectId` | `string` | **Required.** |
|
|
432
|
+
| `selectionTicket` | `string` | Ticket mode (post-login). Pass with `accounts`. |
|
|
433
|
+
| `accounts` | `AccountOption[]` | The list to choose from; fetched automatically in session mode. |
|
|
434
|
+
| `token` | `string` | Session mode — picking switches account instead. |
|
|
435
|
+
| `client` | `string` | Recorded on the session by select-account, e.g. `"webapp"`. |
|
|
436
|
+
| `variant` | `"card" \| "list"` | Full auth card (default) or a bare list. |
|
|
437
|
+
| `currentAccountId` | `string` | Marks the account the session is already on. |
|
|
438
|
+
| `onSelect` | `({ accessToken, account }) => void` | Called with the new token. |
|
|
439
|
+
| `onCancel` / `cancelLabel` | `() => void` / `string` | Renders a secondary action under the list. |
|
|
440
|
+
| `title` / `description` | `string` | Card copy overrides. |
|
|
441
|
+
| `onError`, `classNames`, `mode`, `config`, `apiBaseUrl` | | As elsewhere. |
|
|
442
|
+
|
|
443
|
+
### When access to one account is revoked
|
|
444
|
+
|
|
445
|
+
If an owner revokes or expires a member's access, that member's **next request to any endpoint** fails with `401` and code `A403`. Their login is still fine — only that one account became unreachable — so this is **not** a logout.
|
|
446
|
+
|
|
447
|
+
`SessionProvider` tells the two apart. Give it `onTokenChange` and it renders the account picker itself; give it `onAccountAccessLost` to handle the case yourself:
|
|
448
|
+
|
|
449
|
+
```tsx
|
|
450
|
+
<SessionProvider
|
|
451
|
+
projectId="your-project-id"
|
|
452
|
+
token={token}
|
|
453
|
+
onTokenChange={save} // picker appears, new token lands here
|
|
454
|
+
onSessionInvalid={() => navigate("/login")} // genuine logout
|
|
455
|
+
>
|
|
456
|
+
<Dashboard />
|
|
457
|
+
</SessionProvider>
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`useSession()` also exposes `accountAccessLost` (`{ message, code } | null`). For your own fetch wrappers, `isAccountAccessError(err)` returns true for exactly this case:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import { isAccountAccessError } from "ab-ecosystem-sso";
|
|
464
|
+
|
|
465
|
+
catch (err) {
|
|
466
|
+
if (isAccountAccessError(err)) showAccountPicker();
|
|
467
|
+
else if (err.status === 401) logout();
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Invitations
|
|
474
|
+
|
|
475
|
+
An owner invites someone by email, and the backend returns a **link token** — it **sends no email**, so the link is yours to deliver. Build the invitee's route with `AcceptInvite`:
|
|
476
|
+
|
|
477
|
+
```tsx
|
|
478
|
+
import { AcceptInvite } from "ab-ecosystem-sso";
|
|
479
|
+
|
|
480
|
+
// e.g. /accept-invite?token=…
|
|
481
|
+
<AcceptInvite
|
|
482
|
+
projectId="your-project-id"
|
|
483
|
+
inviteToken={new URLSearchParams(location.search).get("token") ?? ""}
|
|
484
|
+
token={session} // if they're already signed in
|
|
485
|
+
onAccepted={({ accessToken }) => accessToken && save(accessToken)}
|
|
486
|
+
onNeedLogin={() => navigate("/login?next=/accept-invite" + location.search)}
|
|
487
|
+
onSuccess={(res) => save(res.data.accessToken)} // the signup branch
|
|
488
|
+
/>
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
It resolves the token first (`GET /invitations/:token`) and then renders whichever branch the backend asked for:
|
|
492
|
+
|
|
493
|
+
- **`mode: "signup"`** — they have no login here yet. It renders `Signup` with `inviteToken` set and the invited address prefilled and read-only. This works even when the project has signups **disabled**, and the account is created already verified and active (no verification email).
|
|
494
|
+
- **`mode: "accept"`** — they already log in here. With a `token` it shows the invite details and an accept button, then switches the session onto the joined account. Without one it calls `onNeedLogin` so you can route them through your own login and come back.
|
|
495
|
+
|
|
496
|
+
| Prop | Type | Notes |
|
|
497
|
+
| --- | --- | --- |
|
|
498
|
+
| `projectId` | `string` | **Required.** |
|
|
499
|
+
| `inviteToken` | `string` | **Required** — the `token` from the link. |
|
|
500
|
+
| `token` | `string` | A live session token; required by the `"accept"` branch. |
|
|
501
|
+
| `switchOnAccept` | `boolean` | Switch onto the joined account after accepting. Default `true`. |
|
|
502
|
+
| `onAccepted` | `({ accountId, accessToken }) => void` | After accepting. `accessToken` is the switched session. |
|
|
503
|
+
| `onNeedLogin` | `(invitation) => void` | The invite needs an existing login and none was given. |
|
|
504
|
+
| `onSuccess` | `(res) => void` | Forwarded to `Signup` on the signup branch. |
|
|
505
|
+
| `countries`, `onError`, `classNames`, `mode`, `config`, `apiBaseUrl` | | As elsewhere. |
|
|
506
|
+
|
|
507
|
+
The email is always rendered read-only: on signup the server takes it from the invite record and ignores anything posted, and on accept a mismatch is rejected ("This invitation was sent to a different email").
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## Team administration
|
|
512
|
+
|
|
513
|
+
Owners get three more Account Settings sections — **Members**, **Roles** and **Invitations** — on top of the usual ones. They need live API mode (`projectId` + `token`):
|
|
514
|
+
|
|
515
|
+
```tsx
|
|
516
|
+
openAccountSettings({
|
|
517
|
+
projectId: "your-project-id",
|
|
518
|
+
token,
|
|
519
|
+
initialSection: "members",
|
|
520
|
+
inviteLinkBase: "https://app.example.com/accept-invite", // where invite links point
|
|
521
|
+
currentAccountId: accountId,
|
|
522
|
+
onAccountSwitch: ({ accessToken }) => save(accessToken), // also enables the Accounts tab
|
|
523
|
+
});
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
- **Members** — list active or revoked members; edit a member's role and access expiry; revoke access. Revoking leaves their login alone: only this account becomes unreachable, from their next request onward, and re-inviting the same person restores the membership.
|
|
527
|
+
- **Roles** — create, edit and delete roles with a permission list (one per line). The server trims, uppercases, dedupes and sorts permissions (max 200), and they aren't validated against a catalogue yet, so a misspelled string simply grants nothing. **System** roles are assignable but neither editable nor deletable, and a role still held by anyone can't be deleted.
|
|
528
|
+
- **Invitations** — create invites (email, member/user, role, access expiry), filter by status, and revoke pending ones. After creating one, the link is shown **once** with a copy button — the token is never returned again and no email is sent.
|
|
529
|
+
|
|
530
|
+
Every endpoint behind these is **owner-only**. A non-owner gets `"Only the account owner can do this"`, which the sections display as-is rather than guessing at permissions client-side. Pass `isOwner={false}` to hide the tabs outright.
|
|
531
|
+
|
|
532
|
+
The three panels are also exported for use in your own admin page:
|
|
533
|
+
|
|
534
|
+
```tsx
|
|
535
|
+
import { MembersSection, RolesSection, InvitationsSection } from "ab-ecosystem-sso";
|
|
536
|
+
|
|
537
|
+
<MembersSection projectId="your-project-id" token={token} />
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
> They're styled for the settings modal, so render them inside an element carrying `data-aas` (and `data-aas-theme` for dark mode — see [Light & dark mode](#light--dark-mode)).
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
222
544
|
## Account Settings modal
|
|
223
545
|
|
|
224
|
-
A complete settings surface with these sections: **General** (profile), **Account** (email, change password, 2FA, sessions), **Wallet & Balance** (balances + transaction history), **Plans & Add-ons** (subscribe, multi-subscribe add-ons, markdown plan details),
|
|
546
|
+
A complete settings surface with these sections: **General** (profile), **Account** (email, change password, 2FA, sessions), **Wallet & Balance** (balances + transaction history), **Plans & Add-ons** (subscribe, multi-subscribe add-ons, markdown plan details), **Credit Wallets** (per-wallet balance, logs, top-up), plus the owner-only **Members**, **Roles** and **Invitations** sections and an optional **Accounts** switcher (see [Team administration](#team-administration)). It loads and saves via the live API whenever `projectId` + `token` are supplied (otherwise it renders sample data).
|
|
225
547
|
|
|
226
548
|
### Imperative (recommended)
|
|
227
549
|
|
|
@@ -268,11 +590,17 @@ const [open, setOpen] = useState(false);
|
|
|
268
590
|
| `open` | `boolean` | **Required** (declarative). |
|
|
269
591
|
| `onOpenChange` | `(open: boolean) => void` | **Required** (declarative). |
|
|
270
592
|
| `projectId` | `string` | Enables live API mode (with `token`). |
|
|
271
|
-
| `token` | `string` | Bearer token. |
|
|
593
|
+
| `token` | `string` | Bearer token. Optional — falls back to the stored one. |
|
|
272
594
|
| `apiBaseUrl` | `string` | Optional API origin. |
|
|
273
|
-
| `initialSection` | `"general"
|
|
595
|
+
| `initialSection` | `AccountSettingsSection` | `"general"`, `"account"`, `"wallet"`, `"plans"`, `"credits"`, `"accounts"`, `"members"`, `"roles"` or `"invitations"`. Default `"general"`. |
|
|
596
|
+
| `customComponents` | `AccountSettingsCustomPanel[]` | Add your own panels to the modal. See [Custom panels](#custom-panels). |
|
|
274
597
|
| `syncUrlHash` | `boolean` | Mirror open state + active section in the URL hash. Default `true`. See [URL hash & refresh](#url-hash--refresh). |
|
|
275
598
|
| `title` | `string` | Modal heading. Default `"Settings"`. |
|
|
599
|
+
| `mode` | `"light" \| "dark"` | Colour scheme. Default `"light"`. See [Light & dark mode](#light--dark-mode). |
|
|
600
|
+
| `isOwner` | `boolean` | Pass `false` to hide the Members / Roles / Invitations sections. Omitted ⇒ shown, and the API refuses non-owners with its own message. |
|
|
601
|
+
| `inviteLinkBase` | `string` | Where invite links point; the token is appended as `?token=…`. Default `<origin>/accept-invite`. |
|
|
602
|
+
| `onAccountSwitch` | `({ accessToken, accountId }) => void` | Enables the **Accounts** switcher. Store the fresh token in place of the old one. |
|
|
603
|
+
| `currentAccountId` | `string` | Marked "Current" in the switcher. |
|
|
276
604
|
| `data` | `Partial<AccountSettingsData>` | Override sample data (non-API mode / previews). |
|
|
277
605
|
| `onLogout` | `() => void` | Sign-out action (closes the modal, then runs this). |
|
|
278
606
|
| `onSaveProfile` | `(profile) => void` | Fires alongside the profile save. |
|
|
@@ -282,6 +610,45 @@ const [open, setOpen] = useState(false);
|
|
|
282
610
|
|
|
283
611
|
> In live mode the modal performs the API calls itself (save profile, change password, subscribe, recharge, etc.); the callbacks are observation hooks, not required wiring.
|
|
284
612
|
|
|
613
|
+
### Custom panels
|
|
614
|
+
|
|
615
|
+
Add your own screens to the modal via `customComponents` — an array of `AccountSettingsCustomPanel`. Each entry renders a **trigger** (in the sidebar, pinned to the sidebar bottom, or in the header next to the close button) and shows its `component` in the modal body when activated.
|
|
616
|
+
|
|
617
|
+
```tsx
|
|
618
|
+
<AccountSettings
|
|
619
|
+
open={open}
|
|
620
|
+
onOpenChange={setOpen}
|
|
621
|
+
projectId="your-project-id"
|
|
622
|
+
token={token}
|
|
623
|
+
customComponents={[
|
|
624
|
+
{
|
|
625
|
+
id: "referrals",
|
|
626
|
+
location: "sidebar", // "sidebar" | "sidebar-bottom" | "header"
|
|
627
|
+
label: "Referrals",
|
|
628
|
+
icon: <GiftIcon />, // any node (an inline SVG works best)
|
|
629
|
+
component: <ReferralsPanel />, // rendered in the modal body
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
id: "help",
|
|
633
|
+
location: "header",
|
|
634
|
+
label: "Help",
|
|
635
|
+
icon: <HelpIcon />,
|
|
636
|
+
component: <HelpCenter />,
|
|
637
|
+
},
|
|
638
|
+
]}
|
|
639
|
+
/>
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
| Field | Type | Notes |
|
|
643
|
+
| --- | --- | --- |
|
|
644
|
+
| `id` | `string` | **Required**, unique. Tracks which panel is active. |
|
|
645
|
+
| `location` | `"sidebar" \| "sidebar-bottom" \| "header"` | Where the trigger appears. Default `"sidebar"`. |
|
|
646
|
+
| `label` | `string` | Sidebar text; header button tooltip + `aria-label`. |
|
|
647
|
+
| `icon` | `ReactNode` | Optional trigger icon. |
|
|
648
|
+
| `component` | `ReactNode` | Rendered in the body when the panel is active. |
|
|
649
|
+
|
|
650
|
+
The same `customComponents` array works with the imperative `openAccountSettings({ customComponents: [...] })`. Panels are host-owned — they don't affect the URL hash, and activating one doesn't run the built-in section's API calls.
|
|
651
|
+
|
|
285
652
|
### URL hash & refresh
|
|
286
653
|
|
|
287
654
|
While the modal is open it reflects the active section in the URL hash as `#settings-<section>` (e.g. `#settings-general`, `#settings-plans`), updating as the user switches tabs and clearing it on close. This means a **page refresh can re-open the modal on the same section**. It uses `history.replaceState`, so it never adds browser-history entries and never clobbers an unrelated `#anchor`. Opt out with `syncUrlHash={false}`.
|
|
@@ -329,7 +696,7 @@ onClick={() => logout({
|
|
|
329
696
|
await logoutApi("your-project-id", token, "https://your-api");
|
|
330
697
|
```
|
|
331
698
|
|
|
332
|
-
`logout` POSTs to `/auth/logout` and **always runs `onSuccess` once the request settles** — so the user is logged out client-side even if the server call fails (`onError` fires for observability). Inside a `SessionProvider`, `<LogoutButton />` with no props uses the provider's logout instead.
|
|
699
|
+
`<LogoutButton />` also takes `mode="dark"`. `logout` POSTs to `/auth/logout` and **always runs `onSuccess` once the request settles** — so the user is logged out client-side even if the server call fails (`onError` fires for observability). Inside a `SessionProvider`, `<LogoutButton />` with no props uses the provider's logout instead.
|
|
333
700
|
|
|
334
701
|
---
|
|
335
702
|
|
|
@@ -389,6 +756,7 @@ import { TwoFactorSetup } from "ab-ecosystem-sso";
|
|
|
389
756
|
projectId="your-project-id"
|
|
390
757
|
token={token}
|
|
391
758
|
apiBaseUrl="https://your-api"
|
|
759
|
+
mode="dark" // optional — "light" (default) or "dark"
|
|
392
760
|
onEnabled={() => …}
|
|
393
761
|
onDisabled={() => …}
|
|
394
762
|
onError={(err) => …}
|
|
@@ -407,6 +775,58 @@ import { TwoFactorSetup } from "ab-ecosystem-sso";
|
|
|
407
775
|
|
|
408
776
|
---
|
|
409
777
|
|
|
778
|
+
## Light & dark mode
|
|
779
|
+
|
|
780
|
+
Every component renders in **light mode by default**. Pass `mode="dark"` to switch:
|
|
781
|
+
|
|
782
|
+
```tsx
|
|
783
|
+
<Login projectId="…" mode="dark" onSuccess={handleAuth} />
|
|
784
|
+
<Signup projectId="…" mode="dark" onLoginClick={() => navigate("/login")} />
|
|
785
|
+
<ForgotPassword projectId="…" mode="dark" />
|
|
786
|
+
<VerifyEmail projectId="…" mode="dark" />
|
|
787
|
+
<AccountSettings open={open} onOpenChange={setOpen} mode="dark" />
|
|
788
|
+
<TwoFactorSetup projectId="…" token={token} mode="dark" />
|
|
789
|
+
<LogoutButton mode="dark" />
|
|
790
|
+
|
|
791
|
+
openAccountSettings({ projectId, token, mode: "dark" });
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
`mode` is **per component**, not global — each component root carries a
|
|
795
|
+
`data-aas-theme="light" | "dark"` attribute and the palette inherits from there, so a dark
|
|
796
|
+
login card and a light settings modal can coexist on the same page.
|
|
797
|
+
|
|
798
|
+
Follow your app's own theme by passing the value you already track:
|
|
799
|
+
|
|
800
|
+
```tsx
|
|
801
|
+
const { theme } = useTheme(); // your app's theme state
|
|
802
|
+
<Login projectId="…" mode={theme === "dark" ? "dark" : "light"} />
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
To follow the OS setting instead:
|
|
806
|
+
|
|
807
|
+
```tsx
|
|
808
|
+
import type { AuthMode } from "ab-ecosystem-sso";
|
|
809
|
+
|
|
810
|
+
const [mode, setMode] = useState<AuthMode>(
|
|
811
|
+
() => (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
useEffect(() => {
|
|
815
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
816
|
+
const onChange = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light");
|
|
817
|
+
mq.addEventListener("change", onChange);
|
|
818
|
+
return () => mq.removeEventListener("change", onChange);
|
|
819
|
+
}, []);
|
|
820
|
+
|
|
821
|
+
<Login projectId="…" mode={mode} />
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
> The package deliberately does **not** read `prefers-color-scheme` itself — an auth screen
|
|
825
|
+
> that silently disagrees with the host app's theme is worse than one that follows it, and only
|
|
826
|
+
> your app knows which theme it's in.
|
|
827
|
+
|
|
828
|
+
---
|
|
829
|
+
|
|
410
830
|
## Theming
|
|
411
831
|
|
|
412
832
|
The UI reads `--aas-*` CSS custom properties, so you can retheme without touching the components. Override them on `:root` (or any ancestor) after importing the stylesheet:
|
|
@@ -424,6 +844,28 @@ The UI reads `--aas-*` CSS custom properties, so you can retheme without touchin
|
|
|
424
844
|
}
|
|
425
845
|
```
|
|
426
846
|
|
|
847
|
+
Dark mode is the same variables redeclared on `[data-aas-theme='dark']`, so retheming dark is symmetrical — override there and light stays untouched:
|
|
848
|
+
|
|
849
|
+
```css
|
|
850
|
+
[data-aas-theme="dark"] {
|
|
851
|
+
--aas-background: oklch(0.16 0.01 265);
|
|
852
|
+
--aas-card: oklch(0.21 0.01 265);
|
|
853
|
+
--aas-primary: oklch(0.72 0.17 265);
|
|
854
|
+
--aas-primary-foreground: oklch(0.16 0.01 265);
|
|
855
|
+
}
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
Beyond the shadcn-style tokens above, a few extra ones exist so dark mode is a pure variable
|
|
859
|
+
swap — override them to fine-tune either palette:
|
|
860
|
+
|
|
861
|
+
| Token | Used for |
|
|
862
|
+
| --- | --- |
|
|
863
|
+
| `--aas-sidebar` | Account Settings sidebar panel. |
|
|
864
|
+
| `--aas-hover` / `--aas-hover-strong` | Nav/icon-button hover and active fills. |
|
|
865
|
+
| `--aas-overlay` / `--aas-overlay-strong` | The blur overlays on loading cards and settings sections. |
|
|
866
|
+
| `--aas-success` / `--aas-success-bg` | "Verified" badges and credit amounts. |
|
|
867
|
+
| `--aas-switch-track`, `--aas-switch-knob`, `--aas-switch-knob-on` | The settings toggle switch. |
|
|
868
|
+
|
|
427
869
|
The styles are theme-aware and scoped to the components — they won't affect the rest of your app, and your app's global resets can't override them.
|
|
428
870
|
|
|
429
871
|
---
|
|
@@ -443,6 +885,15 @@ Beyond the components, these functions are exported for building custom UIs:
|
|
|
443
885
|
| `getUserWallet(projectId, token, apiBaseUrl?)` | `GET /me` → `UserWallets` |
|
|
444
886
|
| `getUserCreditWallet(projectId, token, apiBaseUrl?)` | `GET /credit-wallets` → `CreditWallet[]` |
|
|
445
887
|
| `getUserSubscription(projectId, token, apiBaseUrl?)` | `GET /subscription` → raw subscription object |
|
|
888
|
+
| `selectAccountApi(projectId, { selection_ticket, account_id, client? }, apiBaseUrl?)` | `POST /auth/select-account` → `accessToken` (no auth; the ticket is the credential) |
|
|
889
|
+
| `switchAccountApi(projectId, accountId, token, apiBaseUrl?)` | `POST /auth/switch-account` → a fresh `accessToken` |
|
|
890
|
+
| `listAccountsApi(projectId, token, apiBaseUrl?)` | `GET /accounts` → `AccountOption[]` |
|
|
891
|
+
| `getInvitationApi(projectId, inviteToken, apiBaseUrl?)` | `GET /invitations/:token` → `InvitationLookup` (no auth) |
|
|
892
|
+
| `acceptInvitationApi(projectId, inviteToken, token, apiBaseUrl?)` | `POST /invitations/:token/accept` → `account_id` |
|
|
893
|
+
| `listRolesApi` · `createRoleApi` · `updateRoleApi` · `deleteRoleApi` | `/account/roles` (owner only) |
|
|
894
|
+
| `listInvitationsApi` · `createInvitationApi` · `revokeInvitationApi` | `/account/invites` (owner only) |
|
|
895
|
+
| `listMembersApi` · `updateMemberApi` · `revokeMemberApi` | `/account/members` (owner only) |
|
|
896
|
+
| `validateSession(projectId, token, apiBaseUrl?)` | `GET /me` → `{ valid, accountAccessLost, message, code }` |
|
|
446
897
|
| `logout(options)` | `POST /auth/logout` + callbacks |
|
|
447
898
|
| `logoutApi(projectId, token, apiBaseUrl?)` | `POST /auth/logout` |
|
|
448
899
|
|
|
@@ -456,15 +907,24 @@ Everything is fully typed. Notable exports:
|
|
|
456
907
|
|
|
457
908
|
```ts
|
|
458
909
|
import type {
|
|
459
|
-
LoginProps, SignupProps, ForgotPasswordProps, VerifyEmailProps, AuthClassNames,
|
|
910
|
+
LoginProps, SignupProps, ForgotPasswordProps, VerifyEmailProps, AuthClassNames, AuthMode,
|
|
460
911
|
AccountSettingsProps, AccountSettingsSection, AccountSettingsData, ProfileFormValues,
|
|
461
912
|
OpenAccountSettingsOptions, AccountSettingsHandle,
|
|
462
913
|
AuthConfig, CaptchaConfig, CardLayoutConfig,
|
|
463
914
|
LoginRequest, SignupRequest, ForgotPasswordRequest, AuthResponse, LogoutOptions,
|
|
464
915
|
MappedProfile, WalletInfo, UserWallets, CreditWallet, MappedSubscription,
|
|
916
|
+
AccountPickerProps, AcceptInviteProps,
|
|
917
|
+
AccountOption, SelectAccountRequest, AccessTokenResponse, InvitationLookup, SessionValidation,
|
|
918
|
+
Role, CreateRolePayload, UpdateRolePayload,
|
|
919
|
+
Invitation, InvitationStatus, CreateInvitePayload, CreatedInvite,
|
|
920
|
+
Member, MemberStatus, UpdateMemberPayload,
|
|
921
|
+
TokenStorageConfig, TokenStorageKind,
|
|
922
|
+
AuthFetchInit, AuthHandlers, AuthInterceptorOptions, UnauthorizedInfo,
|
|
923
|
+
SsoRoutePaths, SsoScreen, SsoRouteMatch, SsoRouteObject,
|
|
924
|
+
SsoScreenProps, SsoAuthPageProps, SsoAuthRoutesProps,
|
|
465
925
|
} from "ab-ecosystem-sso";
|
|
466
926
|
|
|
467
|
-
import { AuthApiError } from "ab-ecosystem-sso";
|
|
927
|
+
import { AuthApiError, isAccountAccessError } from "ab-ecosystem-sso";
|
|
468
928
|
```
|
|
469
929
|
|
|
470
930
|
---
|