@atlasauth/react 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # @atlasauth/react changelog
2
2
 
3
+ ## 0.5.0
4
+ - **`<SignUp>` now creates accounts.** Its first step is a create-account form (the instance's
5
+ configured sign-up fields, email, password and the legal-consent row) that posts
6
+ `POST /v1/client/sign_ups`; the emailed-code step and completion then run through the shared flow.
7
+ Previously `<SignUp>` rendered the sign-in identifier step and could not create an account.
8
+ - **Captcha renders automatically.** When the instance turns captcha on for a flow, `<SignUp>` and
9
+ `<SignIn>` show the provider's widget (Turnstile, hCaptcha, reCAPTCHA v2/v3/Enterprise) in the
10
+ configured mode (`visible`, `managed`, or `invisible` — run on submit), send its token as
11
+ `captcha_token`, answer the sign-in `needs_captcha` step (continuing as soon as it is solved), and on
12
+ `CAPTCHA_REQUIRED` / `CAPTCHA_FAILED` show a localized message and a fresh widget.
13
+ - **Added** `useAtlas().instanceSettings: InstanceSettings | null` — the instance's public
14
+ `captchaFlows`, `signUpFields` and `legal` settings from `GET /v1/appearance`. The provider now
15
+ fetches `/v1/appearance` even with `appearance.instanceDesign: false` (the design is still not
16
+ applied then).
17
+ - **Added types** `InstanceSettings`, `PublicSignUpField`, and re-exported `FlowCaptcha`,
18
+ `CaptchaFlowKey`, `CaptchaWidgetMode` from `@atlasauth/js`.
19
+ - **Added catalog keys** (en-US): `signUp.emailLabel`, `signUp.passwordLabel`, `signUp.optionalField`,
20
+ `signUp.selectPlaceholder`, `signUp.consentAgree`, `signUp.consentNotice`, `signUp.terms`,
21
+ `signUp.privacy`, `signUp.and`, `captcha.prompt`, `captcha.required`, `captcha.failed`.
22
+ - `InstanceSettings`, `PublicSignUpField` and `localizeCaptchaErrors` now come from `@atlasauth/js`
23
+ (same shapes; one implementation shared with the other framework SDKs).
24
+ - **Added base classes** `atlas-captcha`, `atlas-consent`, `atlas-choices`, `atlas-choice` (new `ATLAS_FLOW_CSS` export, appended to `ATLAS_STYLES_CSS`; `ATLAS_BASE_CSS` is unchanged). The flow's inputs now carry `name` attributes.
25
+ - **Added base classes** `atlas-captcha`, `atlas-consent`, `atlas-choices`, `atlas-choice` (new `ATLAS_FLOW_CSS` export, appended to `ATLAS_STYLES_CSS`). The flow's inputs now carry `name` attributes.
26
+ - **`frontendApi` is validated at render.** A missing (`undefined`/`null`), non-string, blank or malformed value now
27
+ throws an **`AtlasConfigError`** (exported; `code: 'FRONTEND_API_REQUIRED' | 'FRONTEND_API_INVALID'`, type
28
+ `AtlasConfigErrorCode`) instead of silently sending every request to relative URLs on the app's own
29
+ origin. Accepted: an absolute `https:` URL (`http:` only for localhost / local development hosts), or
30
+ `"same-origin"` (exported as `SAME_ORIGIN_FRONTEND_API`) / a root-relative path such as `"/atlas"` for a
31
+ reverse proxy.
32
+ - **Deprecated:** `frontendApi=""` (the original same-origin spelling) still works as same-origin, with a one-time
33
+ console warning; use `"same-origin"` instead.
34
+ - **`signOut()` now resolves `SignOutResult`** — `{ revoked: boolean; activeSessionId?: string }` (was
35
+ `Promise<void>`; `await signOut()` keeps working). Changed on both `useAtlas()` and `useAuth()`
36
+ (`UseAuthReturn.signOut`).
37
+ - The revoke is retried once. If it still fails, the user is signed out locally anyway and the session id
38
+ is remembered (this tab + sibling tabs via the session broadcast), so focus, a sibling tab's signal or a
39
+ failed token refresh never re-adopt it from the still-valid refresh cookie; the next re-check retries the
40
+ revoke in the background.
41
+ - The revoke runs even when no token is cached (it falls back to the booted session, or asks the Frontend
42
+ API which session the browser's cookies carry).
43
+ - When the server promotes another account signed in on this browser, the provider adopts it and
44
+ `activeSessionId` names it.
45
+ - **New option `signOut({ strict: true })`** rejects with a **`SignOutError`** (exported;
46
+ `code: 'SIGN_OUT_REVOKE_FAILED'`, `sessionId`, `status`) after the local sign-out when the revoke fails.
47
+ - `sessions` is now cleared on sign-out too.
48
+ - **New `locale` prop on `<AtlasProvider>`** (`SupportedLocale | string`): `'en'`, `'en-US'`, `'es'`, `'es-ES'`,
49
+ `'fr'`, `'fr-FR'`, `'de'`, `'de-DE'`, `'pt'`, `'pt-BR'`, `'ja'`, `'ja-JP'`, `'nl'`, `'nl-NL'`, and regional
50
+ variants that fall back to their language. Unknown → English with a console warning. `localization` still
51
+ overrides key by key on top. Re-exported from `@atlasauth/localizations`: `resolveLocale`,
52
+ `LOCALIZATION_KEYS`, `missingKeys`, type `SupportedLocale`. New dependency: `@atlasauth/localizations`
53
+ (bundled statically: the catalogs are small, and a synchronous lookup means no English flash and identical
54
+ SSR output).
55
+ - **New context method `broadcastSessionChange()`** on `useAtlas()`: tells sibling tabs to re-check. The
56
+ built-in flows call it after completing a sign-in, so other tabs update immediately instead of on focus.
57
+ - **Popup OAuth:** a popup that closes without posting back but left a session behind (the browser severed
58
+ `window.opener`) now completes the sign-in in the opener (re-boot, `afterUrl`, sibling tabs) instead of
59
+ being treated as a cancel.
60
+ - **Accessibility / appearance:**
61
+ - New `appearance.variables.colorTextOnPrimary` (→ `--atlas-color-text-on-primary`): primary button and avatar
62
+ text. Defaults to white or near-black, whichever contrasts more with the resolved `colorPrimary` (was a
63
+ fixed `#fff`). The `dark` and `neobrutalist` themes now get dark button text.
64
+ - New `appearance.variables.colorFocusRing` (→ `--atlas-color-focus-ring`): a solid 2px `:focus-visible`
65
+ outline (2px offset) on inputs, buttons, social buttons, menu items, tabs and the avatar, replacing the
66
+ translucent glow. Defaults to the primary (`neobrutalist`: black).
67
+ - With a design active, explicit `colorTextOnPrimary` / `colorFocusRing` still win over the design's ink
68
+ and ring; design-mode focus is a solid outline too.
69
+ - Inputs are at least 16px (base and design mode), so iOS Safari no longer zooms on focus.
70
+ - `DesignTokens`, `DEFAULT_TOKENS` and `THEMES` gain both keys. The base stylesheet (`ATLAS_BASE_CSS`)
71
+ changed deliberately for these fixes.
72
+ - **`<ApiKeys/>` and `useApiKeys()`.** Let signed-in users manage their own API keys: list, create
73
+ (prompts for the password and retries when the server asks for a recent sign-in), show the new
74
+ secret once with a copy button, rename, and revoke after a confirmation. Props: `organizationId`,
75
+ `renderClaimsFields` (alias `claimsForm`) for extra fields that go into the key's `claims`,
76
+ `onCreate`, and `expiryOptionsDays`. Shows a key's `limits` when present.
77
+ - New exported types: `ApiKeysProps`, `ApiKeysStatus`, `ApiKeyClaimsFieldsContext`,
78
+ `UseApiKeysResult`, and re-exports of `UserApiKey`, `CreatedUserApiKey`, `CreateUserApiKeyInput`.
79
+ - New en-US catalog keys under `apiKeys.*` (32 keys).
80
+
81
+ ## 0.4.1
82
+ - **Front-channel logout on sign-out.** `signOut()` now loads the `frontchannel_logout_urls` the
83
+ Frontend API returns (apps signed in with "Sign in with Atlas" through this session) in hidden,
84
+ sandboxed iframes, so those apps clear their sessions in this browser too. It waits until the frames
85
+ load or 5 s pass, never fails the sign-out, only loads `https:` URLs (and `http://localhost`), and
86
+ does nothing during SSR. Pass `signOut({ frontchannel: false })` to skip it.
87
+
3
88
  ## 0.4.0
4
89
  - **Design system.** Components now adopt the design configured in the Atlas dashboard
5
90
  automatically (fetched from the Frontend API; opt out with `appearance.instanceDesign: false`).
@@ -26,6 +111,16 @@
26
111
  - `useAuth()` has an exported return type, `UseAuthReturn`; annotate with it and TypeScript
27
112
  rejects fields that don't exist.
28
113
 
114
+ ## 0.3.0
115
+ - **Popup sign-in for "Sign in with Atlas".** New `signInWithAtlasPopup(opts)` signs in from a
116
+ popup with no full-page redirect and no callback page; the code is exchanged in the page with
117
+ PKCE. It rejects with `popup_closed` if the user closes the popup, and falls back to the
118
+ redirect flow when the popup is blocked.
119
+ - `<SignInWithAtlasButton>` accepts `uxMode: 'redirect' | 'popup'` (default `'redirect'`) and
120
+ `onSuccess(result)` for popup mode.
121
+ - The sign-in result (`AtlasIdResult`) now also includes `credential` (the id_token).
122
+ - Changed: the default issuer is `https://atlasauth.net`.
123
+
29
124
  ## 0.2.0
30
125
  - **New: `<SignInWithAtlasButton>` + `completeSignInWithAtlas()`** — "Sign in with Atlas" for any
31
126
  website via OIDC authorization-code + PKCE against `https://atlasauth.net`. Standalone: no
@@ -53,6 +148,9 @@
53
148
  but never read; it now redirects after the session is established. No-op when unset.
54
149
  - **Provider icons render at a real size** (D12). Brand marks (GitHub especially) carried only a
55
150
  `viewBox` and collapsed to 0×0 in the SDK; they now have an intrinsic size plus a CSS rule.
151
+ - **`frontendApi` is now a REQUIRED prop on `<AtlasProvider>`**. It was silently
152
+ - **`<SignIn afterUrl>` / `<SignUp afterUrl>` now navigate** on completion. It was typed
153
+ - **Provider icons render at a real size**. Brand marks (GitHub especially) carried only a
56
154
 
57
155
  ## 0.1.29
58
156
  - **Fix — the invisible Google overlay is no longer rendered at exactly `opacity:0`.** GSI's
@@ -117,6 +215,35 @@
117
215
  Redirect for Google requires a `client_secret` to be configured on the provider.
118
216
  - **Add `hideTitle` prop** to `<SignIn>` / `<SignUp>`. Set it when your page already renders its own "Sign in" heading above the panel, so the title doesn't appear twice.
119
217
 
218
+ ## 0.1.23
219
+ - The headless `<SocialSignInButton provider="google">` (and `useSignIn()`) now use the same
220
+ in-page Google button as the built-in `<SignIn>`, so Google sign-in completes without a
221
+ redirect or a stranded tab. Other providers keep the popup / redirect flow.
222
+
223
+ ## 0.1.22
224
+ - Social buttons in `<SignIn>` now show a provider icon: the built-in brand mark, or a custom
225
+ icon URL configured for the provider in the dashboard.
226
+
227
+ ## 0.1.21
228
+ - Added: `onSignedIn(user)` prop on `<AtlasProvider>`. Fires once on a real signed-out →
229
+ signed-in transition (including after a popup sign-in), never when a session is restored on
230
+ page load.
231
+
232
+ ## 0.1.20
233
+ - Social buttons in `<SignIn>` now use the per-instance button label configured in the
234
+ dashboard when one is set, instead of always "Continue with {provider}".
235
+
236
+ ## 0.1.19
237
+ - Added: popup-mode social / OIDC sign-in. Set `oauthMode: 'popup' | 'redirect'` on
238
+ `<AtlasProvider>` as the app default or on `<SignIn>` to override; the default stays
239
+ `'redirect'`. A blocked popup falls back to redirect.
240
+ - Added: `useSignIn().startOauth({ provider, mode })` and a headless `<SocialSignInButton>`
241
+ with `onOpen`, `onError` and `onBlockedFallback` callbacks.
242
+
243
+ ## 0.1.18
244
+ - Added: `hideTitle` prop on `<SignIn>` / `<SignUp>` to hide the panel's own heading when the
245
+ page already shows one.
246
+
120
247
  ## 0.1.17
121
248
  - **Fix — SSR-safe base styles.** The base stylesheet is now rendered as a `<style id="atlas-base-styles">` in `AtlasProvider`'s own output, so prebuilt components are styled in server-rendered HTML **and** on the client (no flash of unstyled content on SSR/Next.js). `ATLAS_BASE_CSS` and `injectAtlasStyles` are exported for manual/advanced use.
122
249
  - **Use `>= 0.1.17`** if you rely on the prebuilt components styling themselves. 0.1.15/0.1.16 did not ship usable CSS (see below).
@@ -130,3 +257,79 @@
130
257
  ---
131
258
 
132
259
  Components are styled out of the box from 0.1.17 with no CSS import — see the guide: https://atlasauth.net/docs/react-appearance-and-styling
260
+ ## 0.1.14
261
+ - Added: billing hooks `useBilling()`, `useCheckout()` and `useSubscription()` (user- or
262
+ organization-scoped via `{ orgId }`) and a `<PricingTable>` component; checkout and the billing
263
+ portal open on Stripe-hosted pages. New `CheckoutParams`, `AtlasBillingPlan` and
264
+ `AtlasBillingSubscription` types.
265
+
266
+ ## 0.1.13
267
+ - Added: multiple signed-in accounts per browser. `useSessionList()` lists them and
268
+ `setActive({ session })` switches; `<UserButton>` lists other accounts for one-click
269
+ switching; `<MultisessionAppSupport>` remounts its children on a switch. New
270
+ `AtlasSessionResource` type.
271
+ - Added: extension points for prebuilt components — `<UserButton.MenuItems>` with
272
+ `<UserButton.Action>` / `<UserButton.Link>`, and `<UserProfile.Page>` / `<UserProfile.Link>`.
273
+
274
+ ## 0.1.12
275
+ - Added: `useOrganizationList()`, `<CreateOrganization>` and `<OrganizationList>`.
276
+ - Added: `<Waitlist>` and `useWaitlist()`.
277
+ - Added: `useReverification()` — headless wrapper for step-up re-authentication.
278
+
279
+ ## 0.1.11
280
+ - Added: `<SignInButton>`, `<SignUpButton>` and `<SignOutButton>` (new `AuthButtonProps` type),
281
+ `<RedirectToSignIn>` / `<RedirectToSignUp>`, `<UserAvatar>`, and the headless `useSignUp()`.
282
+
283
+ ## 0.1.10
284
+ - Fixed: republished with resolved dependency versions; 0.1.9 was published with unresolved
285
+ workspace dependencies and could not be installed (deprecated on npm).
286
+
287
+ ## 0.1.9
288
+ - Added: `<ManagePasskeys>` — list, add and remove passkeys; included in `<UserProfile>`. Shows a
289
+ "not supported" note when the browser has no WebAuthn.
290
+
291
+ ## 0.1.8
292
+ - Added: `<ManageMfa>` — enrol an authenticator app (TOTP), verify, show backup codes and turn
293
+ 2FA off; included in `<UserProfile>`.
294
+
295
+ ## 0.1.7
296
+ - Added: `<ProfileCard>` (editable name, email addresses) and `<ActiveSessions>` (devices with
297
+ per-device sign-out and "sign out of all other devices"). `<UserProfile>` now composes them.
298
+
299
+ ## 0.1.6
300
+ - `<SignIn>` now shows a button for every enabled social provider: Google in-page, the others
301
+ via redirect back to the current page.
302
+
303
+ ## 0.1.5
304
+ - Sign-in, sign-out and organization switches now propagate to other open tabs, and a tab
305
+ re-checks the session on focus, so a tab that started a sign-in elsewhere updates itself.
306
+
307
+ ## 0.1.4
308
+ - The Google button in `<SignIn>` now has default styling from the `--atlas-*` appearance
309
+ variables, so it looks like a button without any custom CSS.
310
+
311
+ ## 0.1.3
312
+ - Changed: the Google button in `<SignIn>` is now your themed button (the `socialButton`
313
+ appearance element) with Google's real button overlaid invisibly, instead of Google's own
314
+ fixed-style button.
315
+
316
+ ## 0.1.2
317
+ - Added: `<SignIn>` shows a native in-page Google button when the Google connection is enabled
318
+ with a client id; One Tap is opt-in via `googleOneTap`.
319
+ - Added: headless `useSignIn()` for apps that render their own Google button.
320
+
321
+ ## 0.1.1
322
+ - First published release.
323
+ - `<AtlasProvider>` with `useAtlas()`, `useAuth()`, `useUser()`, `useSession()`,
324
+ `useOrganization()`.
325
+ - Components: `<SignIn>`, `<SignUp>`, `<UserButton>`, `<UserProfile>`,
326
+ `<OrganizationSwitcher>`, `<OrganizationProfile>`, `<SignedIn>`, `<SignedOut>`, `<Protect>`,
327
+ `<AtlasLoading>`, `<AtlasLoaded>`.
328
+ - Theming via `appearance` (design tokens, themes, per-element classes) and i18n.
329
+ - Silent cross-site session check: `useSilentAuth()`, plus `checkSession` /
330
+ `handleSilentCallback` re-exported from `@atlasauth/js`.
331
+
332
+ ---
333
+
334
+ Components are styled out of the box from 0.1.17 with no CSS import — see the guide: https://atlasauth.net/docs/react-appearance-and-styling
335
+
@@ -1,7 +1,9 @@
1
1
  import { type ReactNode } from 'react';
2
- import { type ProviderToken } from '@atlasauth/js';
2
+ import { type ProviderToken, type SignOutOptions, type SignOutResult } from '@atlasauth/js';
3
+ import { type SupportedLocale } from '@atlasauth/localizations';
3
4
  import { type Appearance } from './appearance';
4
5
  import { type Catalog } from './i18n';
6
+ import { type InstanceSettings } from './instance-settings';
5
7
  import type { AtlasOrganizationMembership, AtlasSession, AtlasSessionResource, AtlasUser, SessionClaims } from './types';
6
8
  /**
7
9
  * §10.1 `<AtlasProvider publishableKey>`.
@@ -27,6 +29,13 @@ interface AtlasContextValue {
27
29
  claims: SessionClaims | null;
28
30
  memberships: AtlasOrganizationMembership[];
29
31
  appearance?: Appearance;
32
+ /**
33
+ * The instance's public sign-in / sign-up settings (per-flow captcha, sign-up
34
+ * fields, legal consent) from `GET /v1/appearance`. Null until loaded, or when
35
+ * the server could not be reached — the components then fall back to their
36
+ * defaults and the server still enforces every gate.
37
+ */
38
+ instanceSettings: InstanceSettings | null;
30
39
  /** §oauth popup mode — app-wide default for social/OIDC sign-in (see AtlasProviderProps). */
31
40
  oauthMode?: 'popup' | 'redirect';
32
41
  catalog: Catalog;
@@ -48,7 +57,24 @@ interface AtlasContextValue {
48
57
  * server refreshes a stale token on read; the refresh token is never exposed.
49
58
  */
50
59
  getProviderToken(provider: string): Promise<ProviderToken | null>;
51
- signOut(): Promise<void>;
60
+ /**
61
+ * Sign out of the active session: revoke it on the Frontend API (retried
62
+ * once), run front-channel logout, drop local state and sign sibling tabs out.
63
+ *
64
+ * Resolves `{ revoked, activeSessionId? }`. `revoked: false` means the server
65
+ * could not confirm the revoke; the user is still signed out locally and this
66
+ * browser never re-adopts that session. `activeSessionId` is set when another
67
+ * account signed in on this browser was promoted (multi-session) — the
68
+ * provider adopts it. With `{ strict: true }` a failed revoke rejects with a
69
+ * `SignOutError` (after the local sign-out).
70
+ */
71
+ signOut(options?: SignOutOptions): Promise<SignOutResult>;
72
+ /**
73
+ * Tell other tabs of this app that auth changed here, so they re-check.
74
+ * `reload()` deliberately does not do this (a received signal must not loop);
75
+ * call it after completing a sign-in yourself.
76
+ */
77
+ broadcastSessionChange(): void;
52
78
  setActiveOrganization(organizationId: string | null): Promise<void>;
53
79
  /**
54
80
  * §L1 switch which signed-in account is active on this browser (Clerk
@@ -74,15 +100,30 @@ export interface AtlasProviderProps {
74
100
  * - on the hosted fleet, before you verify a custom domain, the shared host
75
101
  * `https://atlasauth.net` (FAPI resolves the instance from the key);
76
102
  * - once a custom domain is verified, that host, e.g. `https://auth.acme.com`;
77
- * - `""` for a same-origin / reverse-proxy setup where you serve FAPI under
78
- * your own origin (requests then go to relative paths).
103
+ * - `"same-origin"` (or a path such as `"/atlas"`) for a reverse-proxy setup
104
+ * where you serve FAPI under your own origin (requests then go to
105
+ * relative paths).
79
106
  *
80
- * It is required (not optional) so that omitting it is a COMPILE error rather
81
- * than a silent fall-through to relative URLs that hit the app's own origin and
82
- * fail. Pass `""` deliberately for the same-origin case.
107
+ * `""` is the deprecated same-origin spelling: it still works, with a
108
+ * one-time console warning. Missing or malformed values throw an `AtlasConfigError`
109
+ * (`code: 'FRONTEND_API_REQUIRED'` / `'FRONTEND_API_INVALID'`) when the
110
+ * provider renders, instead of silently sending requests to the app's own
111
+ * origin. Plain `http:` is accepted only for localhost and local development
112
+ * hosts.
83
113
  */
84
114
  frontendApi: string;
85
115
  appearance?: Appearance;
116
+ /**
117
+ * The UI language, as a BCP-47 tag: `"en"`, `"en-US"`, `"es"`, `"es-ES"`,
118
+ * `"fr"`, `"fr-FR"`, `"de"`, `"de-DE"`, `"pt-BR"`, `"ja"`, `"nl"`, … A region
119
+ * the catalogs don't have falls back to its language (`"es-MX"` → Spanish);
120
+ * an unknown language falls back to English. Defaults to English.
121
+ */
122
+ locale?: SupportedLocale | (string & {});
123
+ /**
124
+ * String overrides, key by key (`{ 'signIn.title': 'Welcome back' }`), or a
125
+ * whole bundle from `@atlasauth/localizations`. Applied on top of `locale`.
126
+ */
86
127
  localization?: Catalog;
87
128
  /**
88
129
  * §oauth popup mode — the app-wide default for social/OIDC sign-in. `"popup"`
@@ -5,16 +5,20 @@ exports.useAtlas = useAtlas;
5
5
  const jsx_runtime_1 = require("react/jsx-runtime");
6
6
  const react_1 = require("react");
7
7
  const js_1 = require("@atlasauth/js");
8
+ const localizations_1 = require("@atlasauth/localizations");
8
9
  const appearance_1 = require("./appearance");
9
10
  const styles_1 = require("./styles");
10
11
  const i18n_1 = require("./i18n");
12
+ const instance_settings_1 = require("./instance-settings");
11
13
  const AtlasContext = (0, react_1.createContext)(null);
12
14
  /** §R3.2 the cross-tab "auth changed here, re-check" signal. Carries no token —
13
15
  * a receiving tab re-boots from the shared cookies, never from the channel. */
14
16
  const SESSION_SIGNAL = 'atlas:session';
15
17
  function AtlasProvider(props) {
16
- const { publishableKey, children, appearance, localization } = props;
17
- const baseUrl = props.frontendApi ?? '';
18
+ const { publishableKey, children, appearance, localization, locale } = props;
19
+ // Throws an AtlasConfigError at render for a missing/malformed value — the
20
+ // old silent `?? ''` fallback sent every request to the app's own origin.
21
+ const baseUrl = (0, react_1.useMemo)(() => (0, js_1.resolveFrontendApi)(props.frontendApi, { component: '<AtlasProvider>' }), [props.frontendApi]);
18
22
  const doFetch = props.fetchImpl ?? fetch;
19
23
  const [status, setStatus] = (0, react_1.useState)('loading');
20
24
  const [user, setUser] = (0, react_1.useState)(null);
@@ -22,6 +26,8 @@ function AtlasProvider(props) {
22
26
  const [sessions, setSessions] = (0, react_1.useState)([]);
23
27
  const [claims, setClaims] = (0, react_1.useState)(null);
24
28
  const [memberships, setMemberships] = (0, react_1.useState)([]);
29
+ // Filled from GET /v1/appearance (see the effect beside the dashboard design).
30
+ const [instanceSettings, setInstanceSettings] = (0, react_1.useState)(null);
25
31
  const cache = (0, react_1.useRef)(new js_1.TokenCache());
26
32
  const timer = (0, react_1.useRef)(null);
27
33
  // §R3.2 cross-tab session channel + refs the tab-propagation effect reads
@@ -29,6 +35,12 @@ function AtlasProvider(props) {
29
35
  const channel = (0, react_1.useRef)(null);
30
36
  const statusRef = (0, react_1.useRef)('loading');
31
37
  const reloadRef = (0, react_1.useRef)(() => Promise.resolve());
38
+ // Sessions this tab signed out of: never re-adopted from a still-valid
39
+ // refresh cookie, even when the revoke did not reach the server.
40
+ const tombstones = (0, react_1.useRef)(null);
41
+ if (!tombstones.current)
42
+ tombstones.current = new js_1.SessionTombstones();
43
+ const sessionIdRef = (0, react_1.useRef)(null);
32
44
  /**
33
45
  * Captured synchronously during the first render (not in an effect) so it is
34
46
  * already in context before <SignIn> reads it — a needs_second_factor redirect
@@ -56,6 +68,10 @@ function AtlasProvider(props) {
56
68
  });
57
69
  }, [baseUrl, doFetch, publishableKey]);
58
70
  const applyToken = (0, react_1.useCallback)((jwt, sessionId) => {
71
+ // A refresh that was in flight when the user signed out must not put the
72
+ // signed-out session's token back.
73
+ if (tombstones.current?.has(sessionId))
74
+ return;
59
75
  const expiresAt = (0, js_1.readExpiry)(jwt);
60
76
  if (!expiresAt)
61
77
  return;
@@ -68,7 +84,16 @@ function AtlasProvider(props) {
68
84
  if (!response.ok)
69
85
  throw new Error('boot failed');
70
86
  const body = (await response.json());
71
- if (!body.session || !body.user) {
87
+ const tombstoned = body.session ? tombstones.current?.has(body.session.id) : false;
88
+ if (tombstoned && body.session) {
89
+ // The browser still holds a refresh cookie for a session this tab signed
90
+ // out of (the revoke never landed). Stay signed out, and try the revoke
91
+ // again in the background — when it lands the server clears the cookies
92
+ // (or promotes a parked account, which the next re-check adopts).
93
+ void (0, js_1.revokeSession)(call, body.session.id, { retries: 0 });
94
+ }
95
+ if (!body.session || !body.user || tombstoned) {
96
+ sessionIdRef.current = null;
72
97
  cache.current.clear();
73
98
  setStatus('signed_out');
74
99
  setUser(null);
@@ -78,6 +103,7 @@ function AtlasProvider(props) {
78
103
  setMemberships([]);
79
104
  return;
80
105
  }
106
+ sessionIdRef.current = body.session.id;
81
107
  setUser(body.user);
82
108
  setSession(body.session);
83
109
  // §L1 the multi-session list, active first. Older servers omit it; fall
@@ -138,9 +164,13 @@ function AtlasProvider(props) {
138
164
  };
139
165
  }, [status, claims, refresh]);
140
166
  /** §R3.2 tell sibling tabs that auth changed here, so they re-check. */
141
- const announceSessionChange = (0, react_1.useCallback)(() => {
142
- channel.current?.post({ type: SESSION_SIGNAL });
167
+ const announceSessionChange = (0, react_1.useCallback)((signedOut) => {
168
+ const message = signedOut?.length
169
+ ? { type: SESSION_SIGNAL, signedOut }
170
+ : { type: SESSION_SIGNAL };
171
+ channel.current?.post(message);
143
172
  }, []);
173
+ const broadcastSessionChange = (0, react_1.useCallback)(() => announceSessionChange(), [announceSessionChange]);
144
174
  // Keep refs current for the once-mounted tab-propagation effect below, so it
145
175
  // reads live `status`/`reload` without re-subscribing every render.
146
176
  (0, react_1.useEffect)(() => {
@@ -172,8 +202,18 @@ function AtlasProvider(props) {
172
202
  const ch = (0, js_1.bestChannel)('atlas-session');
173
203
  channel.current = ch;
174
204
  const unsub = ch?.subscribe((msg) => {
175
- if (msg?.type === SESSION_SIGNAL)
176
- void reloadRef.current();
205
+ const signal = msg;
206
+ if (signal?.type !== SESSION_SIGNAL)
207
+ return;
208
+ // Learn the sessions a sibling signed out of BEFORE re-checking, so this
209
+ // tab cannot re-adopt one from a refresh cookie the revoke did not clear.
210
+ if (Array.isArray(signal.signedOut)) {
211
+ const ids = signal.signedOut.filter((id) => typeof id === 'string');
212
+ tombstones.current?.add(...ids);
213
+ if (sessionIdRef.current && ids.includes(sessionIdRef.current))
214
+ cache.current.clear();
215
+ }
216
+ void reloadRef.current();
177
217
  });
178
218
  const onVisible = () => {
179
219
  if (document.visibilityState === 'visible' && statusRef.current !== 'signed_in') {
@@ -236,21 +276,57 @@ function AtlasProvider(props) {
236
276
  */
237
277
  const fapiClient = (0, react_1.useMemo)(() => new js_1.FapiClient({ publishableKey, baseUrl, fetchImpl: doFetch }), [publishableKey, baseUrl, doFetch]);
238
278
  const getProviderToken = (0, react_1.useCallback)((provider) => (0, js_1.getProviderToken)(fapiClient, provider), [fapiClient]);
239
- const signOut = (0, react_1.useCallback)(async () => {
240
- const current = cache.current.peek();
241
- if (current) {
242
- await call(`/v1/client/sessions/${current.sessionId}/revoke`, { method: 'POST' }).catch(() => undefined);
279
+ const signOut = (0, react_1.useCallback)(async (options) => {
280
+ // The session to end: from the token cache, else the booted session, else
281
+ // (no state yet — e.g. called before boot finished) whatever session the
282
+ // browser's cookies carry. The revoke route works from the cookies, so a
283
+ // missing in-memory token never skips the server-side sign-out.
284
+ let sessionId = cache.current.peek()?.sessionId ?? sessionIdRef.current;
285
+ let outcome = { revoked: true, status: 200 };
286
+ if (!sessionId) {
287
+ try {
288
+ const response = await call('/v1/client');
289
+ if (!response.ok)
290
+ throw new Error('boot failed');
291
+ const body = (await response.json());
292
+ sessionId = body.session?.id ?? null;
293
+ }
294
+ catch {
295
+ outcome = { revoked: false, status: 0 };
296
+ }
297
+ }
298
+ if (sessionId) {
299
+ // Remember it first: from here on nothing in this tab may re-adopt it.
300
+ tombstones.current?.add(sessionId);
301
+ outcome = await (0, js_1.revokeSession)(call, sessionId);
302
+ // OIDC Front-Channel Logout: let the apps signed in through this session
303
+ // clear theirs in this browser (hidden iframes, capped at 5 s). Best-effort.
304
+ if (options?.frontchannel !== false)
305
+ await (0, js_1.runFrontChannelLogoutFromResponse)(outcome.response);
243
306
  }
307
+ sessionIdRef.current = null;
244
308
  cache.current.clear();
245
309
  setStatus('signed_out');
246
310
  setUser(null);
247
311
  setSession(null);
312
+ setSessions([]);
248
313
  setClaims(null);
249
314
  setMemberships([]);
315
+ // §L1 the server promoted another account signed in on this browser: adopt
316
+ // it deliberately (the revoke response already set its cookies).
317
+ if (outcome.activeSessionId && !tombstones.current?.has(outcome.activeSessionId)) {
318
+ await reload();
319
+ }
250
320
  // §R3.2 sign every sibling tab out too, so a shared device doesn't leave a
251
321
  // live-looking session in another tab after the user signed out here.
252
- announceSessionChange();
253
- }, [announceSessionChange, call]);
322
+ announceSessionChange(sessionId ? [sessionId] : undefined);
323
+ if (!outcome.revoked && options?.strict) {
324
+ throw new js_1.SignOutError(sessionId ?? '', outcome.status);
325
+ }
326
+ return outcome.activeSessionId
327
+ ? { revoked: outcome.revoked, activeSessionId: outcome.activeSessionId }
328
+ : { revoked: outcome.revoked };
329
+ }, [announceSessionChange, call, reload]);
254
330
  const setActiveOrganization = (0, react_1.useCallback)(async (organizationId) => {
255
331
  const current = cache.current.peek();
256
332
  if (!current)
@@ -283,7 +359,15 @@ function AtlasProvider(props) {
283
359
  // §R3.2 the active account is session state other tabs reflect — nudge them.
284
360
  announceSessionChange();
285
361
  }, [announceSessionChange, applyToken, call, reload]);
286
- const catalog = (0, react_1.useMemo)(() => (localization ? (0, i18n_1.withFallback)(localization) : i18n_1.EN_US), [localization]);
362
+ const catalog = (0, react_1.useMemo)(() => {
363
+ // A built-in language bundle, then the app's own overrides key by key.
364
+ const base = locale ? (0, localizations_1.resolveLocale)(locale) : undefined;
365
+ if (locale && !base)
366
+ warnUnknownLocale(locale);
367
+ if (!base && !localization)
368
+ return i18n_1.EN_US;
369
+ return (0, i18n_1.withFallback)({ ...base, ...localization });
370
+ }, [locale, localization]);
287
371
  // §onSignedIn: fire once on a genuine signed_out → signed_in transition (a NEW
288
372
  // sign-in, including the post-popup re-boot), never on a cached-boot load. Uses
289
373
  // a ref for the callback so a new function identity each render can't re-fire it.
@@ -308,17 +392,21 @@ function AtlasProvider(props) {
308
392
  claims,
309
393
  memberships,
310
394
  appearance,
395
+ instanceSettings,
311
396
  catalog,
312
397
  pendingAttempt,
313
398
  getToken,
314
399
  getProviderToken,
315
400
  signOut,
401
+ broadcastSessionChange,
316
402
  setActiveOrganization,
317
403
  setActiveSession,
318
404
  reload,
319
405
  }), [
320
406
  appearance,
407
+ instanceSettings,
321
408
  baseUrl,
409
+ broadcastSessionChange,
322
410
  publishableKey,
323
411
  props.oauthMode,
324
412
  catalog,
@@ -344,20 +432,24 @@ function AtlasProvider(props) {
344
432
  const [instanceDesign, setInstanceDesign] = (0, react_1.useState)(null);
345
433
  const wantsInstanceDesign = appearance?.instanceDesign !== false;
346
434
  (0, react_1.useEffect)(() => {
347
- if (!wantsInstanceDesign)
348
- return;
349
435
  let alive = true;
350
436
  void (async () => {
351
437
  try {
352
438
  const response = await call('/v1/appearance');
353
439
  if (!response.ok)
354
440
  return;
355
- const design = (0, appearance_1.instanceDesignFrom)(await response.json());
356
- if (alive)
357
- setInstanceDesign(design);
441
+ const body = await response.json();
442
+ if (!alive)
443
+ return;
444
+ // The sign-in / sign-up settings (captcha, fields, consent) are read even
445
+ // when the app opted out of the dashboard DESIGN: they are behaviour, not
446
+ // styling, and the server enforces them either way.
447
+ setInstanceSettings((0, instance_settings_1.instanceSettingsFrom)(body));
448
+ if (wantsInstanceDesign)
449
+ setInstanceDesign((0, appearance_1.instanceDesignFrom)(body));
358
450
  }
359
451
  catch {
360
- // No dashboard design is the safe fallback.
452
+ // No dashboard design / default settings are the safe fallback.
361
453
  }
362
454
  })();
363
455
  return () => {
@@ -380,6 +472,13 @@ function useAtlas() {
380
472
  }
381
473
  return value;
382
474
  }
475
+ const warnedLocales = new Set();
476
+ function warnUnknownLocale(locale) {
477
+ if (warnedLocales.has(locale) || typeof console === 'undefined')
478
+ return;
479
+ warnedLocales.add(locale);
480
+ console.warn(`[Atlas] No built-in strings for locale "${locale}"; falling back to English.`);
481
+ }
383
482
  function decodeClaims(jwt) {
384
483
  const parts = jwt.split('.');
385
484
  if (parts.length !== 3)