@object-ui/auth 17.5.0 → 17.7.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 +600 -0
- package/README.md +245 -15
- package/dist/ActiveOrganizationStorage.d.ts +199 -0
- package/dist/ActiveOrganizationStorage.d.ts.map +1 -0
- package/dist/ActiveOrganizationStorage.js +584 -0
- package/dist/AuthContext.d.ts +23 -3
- package/dist/AuthContext.d.ts.map +1 -1
- package/dist/AuthGuard.d.ts +8 -1
- package/dist/AuthGuard.d.ts.map +1 -1
- package/dist/AuthGuard.js +16 -3
- package/dist/AuthProvider.d.ts +1 -1
- package/dist/AuthProvider.d.ts.map +1 -1
- package/dist/AuthProvider.js +274 -9
- package/dist/ForgotPasswordForm.d.ts +1 -1
- package/dist/ForgotPasswordForm.d.ts.map +1 -1
- package/dist/ForgotPasswordForm.js +5 -5
- package/dist/LoginForm.d.ts +11 -2
- package/dist/LoginForm.d.ts.map +1 -1
- package/dist/LoginForm.js +5 -6
- package/dist/PreviewBanner.js +1 -1
- package/dist/RegisterForm.d.ts +22 -3
- package/dist/RegisterForm.d.ts.map +1 -1
- package/dist/RegisterForm.js +6 -7
- package/dist/SocialSignInButtons.d.ts +9 -2
- package/dist/SocialSignInButtons.d.ts.map +1 -1
- package/dist/SocialSignInButtons.js +5 -5
- package/dist/UserMenu.d.ts +1 -1
- package/dist/UserMenu.js +3 -3
- package/dist/createAuthClient.d.ts +1 -1
- package/dist/createAuthClient.d.ts.map +1 -1
- package/dist/createAuthClient.js +42 -4
- package/dist/createAuthenticatedFetch.d.ts +9 -9
- package/dist/createAuthenticatedFetch.d.ts.map +1 -1
- package/dist/createAuthenticatedFetch.js +50 -38
- package/dist/index.d.ts +21 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -17
- package/dist/invitation-status.d.ts +59 -0
- package/dist/invitation-status.d.ts.map +1 -0
- package/dist/invitation-status.js +32 -0
- package/dist/types.d.ts +25 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/useAuth.d.ts +1 -1
- package/dist/useAuth.d.ts.map +1 -1
- package/dist/useAuth.js +5 -1
- package/dist/useWorkspaceAdminStatus.d.ts +134 -0
- package/dist/useWorkspaceAdminStatus.d.ts.map +1 -0
- package/dist/useWorkspaceAdminStatus.js +208 -0
- package/package.json +6 -7
- package/dist/useIsWorkspaceAdmin.d.ts +0 -21
- package/dist/useIsWorkspaceAdmin.d.ts.map +0 -1
- package/dist/useIsWorkspaceAdmin.js +0 -47
package/README.md
CHANGED
|
@@ -25,16 +25,18 @@ npm install @object-ui/auth
|
|
|
25
25
|
## Quick Start
|
|
26
26
|
|
|
27
27
|
```tsx
|
|
28
|
-
import {
|
|
29
|
-
import { createAuthClient } from '@object-ui/auth';
|
|
28
|
+
import type { ReactNode } from 'react';
|
|
29
|
+
import { AuthProvider, AuthGuard, createAuthClient, useAuth } from '@object-ui/auth';
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// Your app supplies the sign-in page shown to unauthenticated visitors.
|
|
32
|
+
declare const LoginPage: () => ReactNode;
|
|
33
|
+
|
|
34
|
+
const authUrl = 'https://api.example.com/auth';
|
|
35
|
+
const authClient = createAuthClient({ baseURL: authUrl });
|
|
34
36
|
|
|
35
37
|
function App() {
|
|
36
38
|
return (
|
|
37
|
-
<AuthProvider client={authClient}>
|
|
39
|
+
<AuthProvider authUrl={authUrl} client={authClient}>
|
|
38
40
|
<AuthGuard fallback={<LoginPage />}>
|
|
39
41
|
<Dashboard />
|
|
40
42
|
</AuthGuard>
|
|
@@ -57,12 +59,21 @@ function Dashboard() {
|
|
|
57
59
|
|
|
58
60
|
### AuthProvider
|
|
59
61
|
|
|
60
|
-
Wraps your application with authentication context
|
|
62
|
+
Wraps your application with authentication context. `authUrl` is **required**;
|
|
63
|
+
`client` is optional, and when you pass one it is used instead of the client
|
|
64
|
+
`AuthProvider` would otherwise build from `authUrl`:
|
|
61
65
|
|
|
62
66
|
```tsx
|
|
63
|
-
|
|
67
|
+
import type { ReactNode } from 'react';
|
|
68
|
+
import { AuthProvider } from '@object-ui/auth';
|
|
69
|
+
import type { AuthClient } from '@object-ui/auth';
|
|
70
|
+
|
|
71
|
+
declare const authClient: AuthClient;
|
|
72
|
+
declare const App: () => ReactNode;
|
|
73
|
+
|
|
74
|
+
<AuthProvider authUrl="/api/v1/auth" client={authClient}>
|
|
64
75
|
<App />
|
|
65
|
-
</AuthProvider
|
|
76
|
+
</AuthProvider>;
|
|
66
77
|
```
|
|
67
78
|
|
|
68
79
|
### useAuth
|
|
@@ -70,6 +81,8 @@ Wraps your application with authentication context:
|
|
|
70
81
|
Hook for accessing auth state and methods:
|
|
71
82
|
|
|
72
83
|
```tsx
|
|
84
|
+
import { useAuth } from '@object-ui/auth';
|
|
85
|
+
|
|
73
86
|
const {
|
|
74
87
|
user,
|
|
75
88
|
session,
|
|
@@ -100,9 +113,14 @@ const {
|
|
|
100
113
|
Protects children from unauthenticated access:
|
|
101
114
|
|
|
102
115
|
```tsx
|
|
116
|
+
import type { ReactNode } from 'react';
|
|
117
|
+
import { AuthGuard, LoginForm } from '@object-ui/auth';
|
|
118
|
+
|
|
119
|
+
declare const ProtectedContent: () => ReactNode;
|
|
120
|
+
|
|
103
121
|
<AuthGuard fallback={<LoginForm />}>
|
|
104
122
|
<ProtectedContent />
|
|
105
|
-
</AuthGuard
|
|
123
|
+
</AuthGuard>;
|
|
106
124
|
```
|
|
107
125
|
|
|
108
126
|
### LoginForm / RegisterForm / ForgotPasswordForm
|
|
@@ -110,9 +128,40 @@ Protects children from unauthenticated access:
|
|
|
110
128
|
Pre-built authentication form components:
|
|
111
129
|
|
|
112
130
|
```tsx
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
import { ForgotPasswordForm, LoginForm, RegisterForm } from '@object-ui/auth';
|
|
132
|
+
|
|
133
|
+
declare const navigate: (to: string) => void;
|
|
134
|
+
|
|
135
|
+
<LoginForm onSuccess={() => navigate('/dashboard')} />;
|
|
136
|
+
<RegisterForm onSuccess={() => navigate('/welcome')} />;
|
|
137
|
+
<ForgotPasswordForm onSuccess={() => navigate('/check-email')} />;
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`labels` is how the forms take translated text, and it reaches the social
|
|
141
|
+
provider buttons the server's `/auth/config` turns on: `socialButton`
|
|
142
|
+
is a template whose `{provider}` is replaced with the provider's display name
|
|
143
|
+
(defaults "Continue with {provider}" on `LoginForm`, "Sign up with {provider}"
|
|
144
|
+
on `RegisterForm`), and `orText` is the divider under the buttons (default
|
|
145
|
+
"or continue with email").
|
|
146
|
+
|
|
147
|
+
When the server requires email verification, a registration ends in the user's
|
|
148
|
+
inbox, and the verification link is the only way back. `verificationCallbackURL`
|
|
149
|
+
says where that link lands, e.g. the invitation the user signed up from. It is
|
|
150
|
+
sent as better-auth's sign-up `callbackURL` (the same value as the fourth
|
|
151
|
+
argument of `useAuth().signUp`). The server accepts only a root-relative path
|
|
152
|
+
or an absolute URL on a trusted origin, and refuses a document-relative `./…`
|
|
153
|
+
with `INVALID_CALLBACK_URL`, which fails the sign-up. Resolve an SPA route
|
|
154
|
+
against its mount first. Omitted, the server default (`/`) applies.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { RegisterForm } from '@object-ui/auth';
|
|
158
|
+
|
|
159
|
+
declare const navigate: (to: string) => void;
|
|
160
|
+
|
|
161
|
+
<RegisterForm
|
|
162
|
+
verificationCallbackURL="/_console/accept-invitation/inv_123"
|
|
163
|
+
onVerificationRequired={(email) => navigate(`/verify-email-prompt?email=${encodeURIComponent(email)}`)}
|
|
164
|
+
/>;
|
|
116
165
|
```
|
|
117
166
|
|
|
118
167
|
### UserMenu
|
|
@@ -120,7 +169,9 @@ Pre-built authentication form components:
|
|
|
120
169
|
Displays current user info with avatar and sign-out:
|
|
121
170
|
|
|
122
171
|
```tsx
|
|
123
|
-
|
|
172
|
+
import { UserMenu } from '@object-ui/auth';
|
|
173
|
+
|
|
174
|
+
<UserMenu />;
|
|
124
175
|
```
|
|
125
176
|
|
|
126
177
|
### createAuthenticatedFetch
|
|
@@ -129,6 +180,8 @@ Creates a fetch wrapper that injects the stored Bearer token (plus
|
|
|
129
180
|
`X-Tenant-ID` and `Accept-Language`) into API requests:
|
|
130
181
|
|
|
131
182
|
```tsx
|
|
183
|
+
import { createAuthenticatedFetch } from '@object-ui/auth';
|
|
184
|
+
|
|
132
185
|
const authedFetch = createAuthenticatedFetch();
|
|
133
186
|
|
|
134
187
|
// For fetches whose target URL comes from view metadata (`provider: 'api'`
|
|
@@ -137,6 +190,178 @@ const authedFetch = createAuthenticatedFetch();
|
|
|
137
190
|
const apiProviderFetch = createAuthenticatedFetch({ sameOriginOnly: true });
|
|
138
191
|
```
|
|
139
192
|
|
|
193
|
+
### getSessionOwnerChangeCount / subscribeSessionOwnerChange
|
|
194
|
+
|
|
195
|
+
When the session resolves to a different user than the one this browser last
|
|
196
|
+
held, `AuthProvider` drops the previous user's client state (objectui#5664).
|
|
197
|
+
That clears storage only. State already derived from that storage and held in
|
|
198
|
+
memory, such as the UI language the boot resolved, has to be re-derived by
|
|
199
|
+
whoever owns it. These two exports tell that owner a change happened in this
|
|
200
|
+
page-load (objectui#10193). The count lets an owner that mounts after the purge
|
|
201
|
+
still see it:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
import { useSyncExternalStore } from 'react';
|
|
205
|
+
import { getSessionOwnerChangeCount, subscribeSessionOwnerChange } from '@object-ui/auth';
|
|
206
|
+
|
|
207
|
+
const ownerChanges = useSyncExternalStore(
|
|
208
|
+
subscribeSessionOwnerChange,
|
|
209
|
+
getSessionOwnerChangeCount,
|
|
210
|
+
getSessionOwnerChangeCount,
|
|
211
|
+
);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The count lives in memory for one page-load and is never persisted.
|
|
215
|
+
`@object-ui/app-shell`'s `useSignedInUserLocale` is the in-repo consumer.
|
|
216
|
+
|
|
217
|
+
## The `X-Tenant-ID` edge contract
|
|
218
|
+
|
|
219
|
+
`createAuthenticatedFetch` stamps `X-Tenant-ID` on the requests it wraps. This section is
|
|
220
|
+
that header's contract: what it means, who stamps it, who reads it, what a reader may
|
|
221
|
+
assume, and the one window in which it is not sent at all.
|
|
222
|
+
|
|
223
|
+
It is written down because **a grep cannot answer any of those questions from this
|
|
224
|
+
repository.** The header's only non-CORS consumer lives in the **cloud** repository, so a
|
|
225
|
+
search confined to this repo and the framework (`objectstack`) finds zero readers and
|
|
226
|
+
reads as *"nothing consumes this stamp"*. That reading is what
|
|
227
|
+
[objectui#5279](https://github.com/objectstack-ai/objectui/issues/5279) was filed on, and
|
|
228
|
+
it is false. Do not act on it.
|
|
229
|
+
|
|
230
|
+
### What it means
|
|
231
|
+
|
|
232
|
+
A **routing hint** for the hosting edge — *this request belongs to tenant `<id>`*:
|
|
233
|
+
|
|
234
|
+
```http
|
|
235
|
+
X-Tenant-ID: <better-auth activeOrganizationId>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The value is the active **organization** id, the same id the session carries as
|
|
239
|
+
`session.activeOrganizationId`. The header is **not** an identity claim, **not** an
|
|
240
|
+
authorization input, and **not** what scopes rows.
|
|
241
|
+
|
|
242
|
+
### Who stamps it
|
|
243
|
+
|
|
244
|
+
| | |
|
|
245
|
+
| --- | --- |
|
|
246
|
+
| Stamped by | `createAuthenticatedFetch` ([`src/createAuthenticatedFetch.ts`](src/createAuthenticatedFetch.ts)) |
|
|
247
|
+
| Value read from | `ActiveOrganizationStorage` ([`src/ActiveOrganizationStorage.ts`](src/ActiveOrganizationStorage.ts)) — `localStorage`, key `auth-active-organization-id:u:$userId` (per-user since objectui#5664; the bare key is the retired pre-#5664 spelling) |
|
|
248
|
+
| Condition | that storage holds a non-empty value |
|
|
249
|
+
| *Not* conditioned on | the URL being an `/api/` call. `Authorization` and `Accept-Language` are; this is not |
|
|
250
|
+
| Suppressed by | `createAuthenticatedFetch({ sameOriginOnly: true })` for cross-origin URLs — it returns before any header work |
|
|
251
|
+
| Precedence | overwrites an `X-Tenant-ID` the caller passed in `init.headers` |
|
|
252
|
+
|
|
253
|
+
`ActiveOrganizationStorage` has exactly one writer, [`AuthProvider`](src/AuthProvider.tsx):
|
|
254
|
+
|
|
255
|
+
| Event | Effect |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| `refreshOrganizations` — after `getSession` → `listOrganizations` → `getActiveOrganization` resolves (including the ADR-0081 single-membership repair) | set |
|
|
258
|
+
| `switchOrganization` — the org switcher | set, or clear when the server returns no org |
|
|
259
|
+
| `deleteOrganization` / `leaveOrganization`, when the active org is the one going away | clear |
|
|
260
|
+
| sign-out | clear |
|
|
261
|
+
|
|
262
|
+
### Who reads it
|
|
263
|
+
|
|
264
|
+
**The cloud edge — yes.** The non-test readers are recorded on objectui#5279:
|
|
265
|
+
`packages/service-tenant/src/tenant-context.ts` (header resolution) and
|
|
266
|
+
`packages/tenant-router/src/spec/turso-multi-tenant.zod.ts`, both in the `cloud`
|
|
267
|
+
repository. That repository is not readable from this one, so those paths are cited as the
|
|
268
|
+
recorded reading rather than re-derived here.
|
|
269
|
+
|
|
270
|
+
**Its configuration contract, though, IS readable from here**, because this package
|
|
271
|
+
depends on `@objectstack/spec`. `TenantRoutingConfigSchema` (`@objectstack/spec/cloud`) is
|
|
272
|
+
what configures a tenant resolver; parsing an empty config on the version this package
|
|
273
|
+
resolves today (17.1.0) yields:
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
enabled: false
|
|
277
|
+
identificationSources: ["subdomain", "header", "jwt_claim"]
|
|
278
|
+
tenantHeaderName: "X-Tenant-ID"
|
|
279
|
+
jwtOrganizationClaim: "organizationId"
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Two consequences matter to a client author:
|
|
283
|
+
|
|
284
|
+
- **The header name is configurable.** `X-Tenant-ID` is its default, not a constant.
|
|
285
|
+
- **The header is one of six identification sources** (`subdomain`, `custom_domain`,
|
|
286
|
+
`header`, `jwt_claim`, `session`, `default`), and in the default precedence it ranks
|
|
287
|
+
**second, behind `subdomain`**. On a subdomain-routed deployment it is not the thing
|
|
288
|
+
that picks the tenant, and a deployment may leave it out of `identificationSources`
|
|
289
|
+
entirely.
|
|
290
|
+
|
|
291
|
+
**The framework (`objectstack`) — no.** `resolveAuthzContext`
|
|
292
|
+
(`packages/core/src/security/resolve-authz-context.ts`) derives `tenantId` from the
|
|
293
|
+
API-key principal or from `session.activeOrganizationId`, and from no header;
|
|
294
|
+
`packages/verify/src/harness.org-context.test.ts` pins it — *"`session.activeOrganizationId`
|
|
295
|
+
is the ONE field `resolveAuthzContext` reads into `tenantId`"*. Environment and kernel
|
|
296
|
+
routing read the hostname and `X-Environment-Id`. The framework's only other mentions are
|
|
297
|
+
the CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS`, whose comment describes
|
|
298
|
+
`X-Tenant-ID` / `X-Environment-Id` as what routes "a request to its environment") and
|
|
299
|
+
`plugin-sharing`, which records that trusting `x-tenant-id` as identity **was a
|
|
300
|
+
vulnerability**: its secure default stopped reading identity from headers because doing so
|
|
301
|
+
let a client forge attribution and enumerate or revoke other users' links.
|
|
302
|
+
|
|
303
|
+
### What a reader may assume
|
|
304
|
+
|
|
305
|
+
A reader **may**:
|
|
306
|
+
|
|
307
|
+
- use it to select the tenant database or environment to route to, subject to its own
|
|
308
|
+
`identificationSources` precedence;
|
|
309
|
+
- treat it as a hint that may be absent, stale, or contradicted by the session.
|
|
310
|
+
|
|
311
|
+
A reader **may not**:
|
|
312
|
+
|
|
313
|
+
- treat it as authenticated identity, or as an authorization decision. It is
|
|
314
|
+
client-controlled — any caller can send any value, and `plugin-sharing` is the recorded
|
|
315
|
+
precedent for what happens to a server that trusts it;
|
|
316
|
+
- assume the row scoping it sees downstream came from this header. It did not: the
|
|
317
|
+
framework scopes from the session;
|
|
318
|
+
- assume the header is present. See below.
|
|
319
|
+
|
|
320
|
+
### The unstamped-first-request gap
|
|
321
|
+
|
|
322
|
+
The stamp reads storage that `AuthProvider` fills only **after** `getSession` →
|
|
323
|
+
`listOrganizations` → `getActiveOrganization` resolves. Every request that leaves
|
|
324
|
+
before that chain completes carries **no** `X-Tenant-ID` at all.
|
|
325
|
+
|
|
326
|
+
The window opens in five situations:
|
|
327
|
+
|
|
328
|
+
1. a browser that has never signed in, or whose site data was cleared, including a private
|
|
329
|
+
window;
|
|
330
|
+
2. the page load in which the user signs in — from the sign-in response until the
|
|
331
|
+
organization chain resolves;
|
|
332
|
+
3. after sign-out, which clears the storage, until a new organization resolves;
|
|
333
|
+
4. outside a browser (SSR, tests, a worker), where `localStorage` is unavailable and the
|
|
334
|
+
storage falls back to a per-process in-memory value that starts empty on every cold
|
|
335
|
+
start;
|
|
336
|
+
5. a browser where `localStorage` exists but **rejects writes** (Safari private browsing,
|
|
337
|
+
quota exhaustion). There the window never closes — the in-memory fallback is written
|
|
338
|
+
but never read back, because `get()` only falls back when reading itself *throws*, and
|
|
339
|
+
a rejected write leaves reads working and returning `null`. Reported on objectui#5279
|
|
340
|
+
as a separate defect; it is not fixed here.
|
|
341
|
+
|
|
342
|
+
Case 1 is not theoretical in this codebase: `app-shell`'s `MetadataProvider` issues its
|
|
343
|
+
eager `app` metadata fetch inside exactly this window, which is why its first-boot cache
|
|
344
|
+
scope needed its own reasoning
|
|
345
|
+
([`packages/app-shell/src/providers/MetadataProvider.tsx`](../app-shell/src/providers/MetadataProvider.tsx),
|
|
346
|
+
pinned by `MetadataProvider.firstBootOrgScope.test.tsx`).
|
|
347
|
+
|
|
348
|
+
**What a reader observes in the window:** the header is **absent**, never
|
|
349
|
+
present-and-empty. A resolver must fall through to its next configured identification
|
|
350
|
+
source — `subdomain` already outranks it by default, with `jwt_claim`, `session` and
|
|
351
|
+
`defaultTenantId` behind it — rather than fail closed on the absence. It must also not
|
|
352
|
+
cache a routing decision taken inside the window as *the* tenant for the session: the very
|
|
353
|
+
next request will normally carry the header.
|
|
354
|
+
|
|
355
|
+
**Why the gap does not corrupt data scoping.** A response computed inside the window is
|
|
356
|
+
still computed for the right tenant, because the framework takes `tenantId` from the
|
|
357
|
+
session rather than from the header. The gap is a *routing-input* gap, not a scoping gap.
|
|
358
|
+
This is the same reasoning objectui#5243 relied on when it relabelled a metadata cache
|
|
359
|
+
entry that had been written in the window.
|
|
360
|
+
|
|
361
|
+
**Closing the gap is a separate decision, not an omission.** The cloud readers observe the
|
|
362
|
+
current behaviour, so changing when the header first appears changes what they see. If it
|
|
363
|
+
should be closed, it needs its own card.
|
|
364
|
+
|
|
140
365
|
## Server Feature Flags (`GET /auth/config`)
|
|
141
366
|
|
|
142
367
|
`createAuthClient().getConfig()` fetches the server's public auth configuration. The
|
|
@@ -177,13 +402,16 @@ not a gap.
|
|
|
177
402
|
|
|
178
403
|
Preview mode allows visitors (e.g. marketplace customers) to explore the platform without registering or logging in. The `AuthProvider` auto-authenticates with a simulated user identity and bypasses login/registration screens.
|
|
179
404
|
|
|
180
|
-
This
|
|
405
|
+
This capability is host-supplied and has no `@objectstack/spec` anchor. It aligned with `PreviewModeConfig` from `@objectstack/spec/kernel` until that symbol was retired upstream (objectstack#11846), which removed it together with the `RuntimeMode` value `'preview'`; the spec this package resolves no longer exports it. The `previewMode` prop below is unaffected — it is the host's to supply, and always was.
|
|
181
406
|
|
|
182
407
|
### Usage
|
|
183
408
|
|
|
184
409
|
```tsx
|
|
410
|
+
import type { ReactNode } from 'react';
|
|
185
411
|
import { AuthProvider, PreviewBanner } from '@object-ui/auth';
|
|
186
412
|
|
|
413
|
+
declare const Dashboard: () => ReactNode;
|
|
414
|
+
|
|
187
415
|
function App() {
|
|
188
416
|
return (
|
|
189
417
|
<AuthProvider
|
|
@@ -229,6 +457,8 @@ import { PreviewBanner } from '@object-ui/auth';
|
|
|
229
457
|
Use the `useAuth` hook to check if the app is in preview mode:
|
|
230
458
|
|
|
231
459
|
```tsx
|
|
460
|
+
import { useAuth } from '@object-ui/auth';
|
|
461
|
+
|
|
232
462
|
function MyComponent() {
|
|
233
463
|
const { isPreviewMode, previewMode } = useAuth();
|
|
234
464
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Drop the outgoing user's client state wholesale — part 3 of the fix.
|
|
10
|
+
*
|
|
11
|
+
* Order is load-bearing and mirrors `clear()`'s: the in-memory active org goes
|
|
12
|
+
* FIRST, because `ActiveOrganizationStorage.get()` falls through to
|
|
13
|
+
* `_memoryValue` whenever the persisted read is null, which is exactly the
|
|
14
|
+
* state this function leaves behind (objectui#5703). Sweeping storage first
|
|
15
|
+
* would leave the outgoing user's org id live in memory for the rest of the
|
|
16
|
+
* page-load — the SPA sign-out-then-sign-in path never reloads.
|
|
17
|
+
*
|
|
18
|
+
* Both stores are swept. `sessionStorage` is per-TAB, not per-session, so the
|
|
19
|
+
* previous user's `objectui:metadata:*` seed (their PERMISSION-FILTERED app
|
|
20
|
+
* list) and their `objectui-ctx-*` scope picks survive into whoever signs in
|
|
21
|
+
* next in that tab unless something removes them.
|
|
22
|
+
*/
|
|
23
|
+
export declare function purgePreviousUserClientState(): void;
|
|
24
|
+
/**
|
|
25
|
+
* The number of owner changes {@link SessionUserScope.adopt} has performed in
|
|
26
|
+
* this page-load — `0` until a DIFFERENT user than the one the browser
|
|
27
|
+
* previously held is adopted.
|
|
28
|
+
*
|
|
29
|
+
* ## Why a count and not only an event (objectui#10193)
|
|
30
|
+
*
|
|
31
|
+
* The purge sweeps the previous owner's STORAGE, but state that was already
|
|
32
|
+
* derived from that storage before the purge — the UI language the boot
|
|
33
|
+
* resolved out of the previous owner's language slots, above all — lives in
|
|
34
|
+
* memory and is untouched by it. Whoever owns such state has to re-derive it,
|
|
35
|
+
* and the component that does may mount AFTER the purge happened (the shell
|
|
36
|
+
* mounts behind the session gate, the purge fires as the session resolves). A
|
|
37
|
+
* bare event would be missed by it; a count lets a late reader compare against
|
|
38
|
+
* what it has already handled. Shaped for React's `useSyncExternalStore`
|
|
39
|
+
* together with {@link subscribeSessionOwnerChange}.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getSessionOwnerChangeCount(): number;
|
|
42
|
+
/**
|
|
43
|
+
* Be told each time {@link SessionUserScope.adopt} drops a previous owner's
|
|
44
|
+
* state. Returns the unsubscribe function. A listener that throws is reported
|
|
45
|
+
* and skipped, never allowed to break the sign-in path that called `adopt`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function subscribeSessionOwnerChange(listener: () => void): () => void;
|
|
48
|
+
/**
|
|
49
|
+
* Which user's client state this browser holds, and the transition that drops
|
|
50
|
+
* the previous user's state.
|
|
51
|
+
*/
|
|
52
|
+
export declare const SessionUserScope: {
|
|
53
|
+
/**
|
|
54
|
+
* Memory copy of the pointer. Read FIRST by {@link current}: once this
|
|
55
|
+
* page-load has adopted a user, that answer is authoritative for this tab
|
|
56
|
+
* even if another tab has since written a different id, and it is the only
|
|
57
|
+
* copy in a browser whose `localStorage` rejects writes.
|
|
58
|
+
*/
|
|
59
|
+
_userId: string | null;
|
|
60
|
+
/** The user whose state this browser holds, or `null` if it has never held one. */
|
|
61
|
+
current(): string | null;
|
|
62
|
+
/**
|
|
63
|
+
* Record `userId` as the owner of this browser's client state, dropping the
|
|
64
|
+
* previous owner's state wholesale when it changes.
|
|
65
|
+
*
|
|
66
|
+
* Returns `true` when it dropped a previous owner's state, `false`
|
|
67
|
+
* otherwise (same user, first-ever owner, empty id). A drop is also reported
|
|
68
|
+
* to {@link subscribeSessionOwnerChange} listeners and counted by
|
|
69
|
+
* {@link getSessionOwnerChangeCount} (objectui#10193): the purge clears
|
|
70
|
+
* storage, and anything already derived from that storage in memory is the
|
|
71
|
+
* listener's to re-derive.
|
|
72
|
+
*
|
|
73
|
+
* Idempotent: re-adopting the same user purges nothing. Called from
|
|
74
|
+
* `AuthProvider` for real sessions only — the synthetic `preview-user` /
|
|
75
|
+
* `guest` identities never reach it, so a preview mount cannot purge a real
|
|
76
|
+
* user's state.
|
|
77
|
+
*
|
|
78
|
+
* Because the decision reads the PERSISTED pointer, anything that deletes
|
|
79
|
+
* that pointer without deleting the state it describes would silently retire
|
|
80
|
+
* this invariant. `apps/console/src/lib/auth-preflight.ts` is the one place
|
|
81
|
+
* that sweeps auth keys before render, and it says so in its own comment:
|
|
82
|
+
* it removes every active-org spelling and leaves `auth-session-user-id`
|
|
83
|
+
* alone on purpose.
|
|
84
|
+
*/
|
|
85
|
+
adopt(userId: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Forget the in-memory pointer. Test seam only — production has no reason to
|
|
88
|
+
* un-know who is signed in, and sign-out deliberately KEEPS the persisted
|
|
89
|
+
* pointer so that the next sign-in by a DIFFERENT user still sees a previous
|
|
90
|
+
* owner to purge.
|
|
91
|
+
*/
|
|
92
|
+
_resetForTests(): void;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Get/set the active organization id that {@link createAuthenticatedFetch}
|
|
96
|
+
* stamps as `X-Tenant-ID`.
|
|
97
|
+
*
|
|
98
|
+
* `AuthProvider` is the only writer, at four moments: `refreshOrganizations`
|
|
99
|
+
* sets it once the `getSession` -> `listOrganizations` ->
|
|
100
|
+
* `getActiveOrganization` chain resolves (including the ADR-0081
|
|
101
|
+
* single-membership repair), `switchOrganization` sets or clears it,
|
|
102
|
+
* `deleteOrganization` / `leaveOrganization` clear it when the active org is
|
|
103
|
+
* the one going away, and sign-out clears it.
|
|
104
|
+
*
|
|
105
|
+
* Because the first of those is asynchronous, this reads EMPTY for the first
|
|
106
|
+
* stretch of a boot, and every request that leaves in that window carries no
|
|
107
|
+
* tenant header at all. That window is a documented part of the header's
|
|
108
|
+
* contract, not an accident to paper over — see the "unstamped-first-request
|
|
109
|
+
* gap" section of this package's README (objectui#5279).
|
|
110
|
+
*/
|
|
111
|
+
export declare const ActiveOrganizationStorage: {
|
|
112
|
+
_memoryValue: string | null;
|
|
113
|
+
/**
|
|
114
|
+
* Keys whose removal by {@link clear} did not stick — the value was still
|
|
115
|
+
* readable afterwards (objectui#5731).
|
|
116
|
+
*
|
|
117
|
+
* {@link get} refuses to answer from the persisted layer for a key in here,
|
|
118
|
+
* which is the mechanism that keeps a cleared organization cleared. Held in
|
|
119
|
+
* memory, so it lasts exactly one page-load: a browser that starts fresh
|
|
120
|
+
* re-measures rather than inheriting a verdict.
|
|
121
|
+
*/
|
|
122
|
+
_unremovedKeys: Set<string>;
|
|
123
|
+
/**
|
|
124
|
+
* The active org id for the CURRENT user, or `null`.
|
|
125
|
+
*
|
|
126
|
+
* READ ORDER — a non-null `localStorage` read wins; anything else falls
|
|
127
|
+
* through to `_memoryValue`. Returning the `localStorage` read
|
|
128
|
+
* UNCONDITIONALLY (what this did before objectui#5703) left the fallback
|
|
129
|
+
* reachable only when the read THREW, and there is a real browser state
|
|
130
|
+
* where the read does not throw and the fallback is still the only copy:
|
|
131
|
+
* `localStorage` present and readable but REJECTING WRITES — Safari private
|
|
132
|
+
* browsing, and any quota-exhausted origin, where `setItem` throws
|
|
133
|
+
* `QuotaExceededError`. `set()` swallows that failure into `_memoryValue`
|
|
134
|
+
* (correctly — the fallback is right there), and `get()` then never
|
|
135
|
+
* consulted it: the value was stored and could not be read back, so
|
|
136
|
+
* `X-Tenant-ID` went unstamped for the whole session, not just the early
|
|
137
|
+
* requests.
|
|
138
|
+
*
|
|
139
|
+
* WHY FALLING BACK ON `null` DOES NOT RESURRECT A CLEARED ORG. After
|
|
140
|
+
* `clear()` the `localStorage` read is null by construction, so this
|
|
141
|
+
* fallback fires — and it must answer `null`, because sign-out is one of
|
|
142
|
+
* `clear()`'s callers. It does, because `clear()` nulls `_memoryValue` too.
|
|
143
|
+
* That property is what makes this read order safe rather than an
|
|
144
|
+
* incidental detail of `clear()`'s body, so it is pinned by test
|
|
145
|
+
* (`__tests__/activeOrgStorageFallback-5703.test.tsx`) instead of being
|
|
146
|
+
* re-derived by whoever edits `clear()` next.
|
|
147
|
+
*
|
|
148
|
+
* The same reasoning is why a change of session user purges through
|
|
149
|
+
* {@link purgePreviousUserClientState} rather than by removing keys: the
|
|
150
|
+
* outgoing user's id in `_memoryValue` would otherwise outlive their
|
|
151
|
+
* persisted key on the SPA sign-out-then-sign-in path (objectui#5664).
|
|
152
|
+
*/
|
|
153
|
+
get(): string | null;
|
|
154
|
+
/**
|
|
155
|
+
* With no session user known this writes to memory ONLY. That is not a
|
|
156
|
+
* degraded path to apologise for — it is the fix: a persisted write with no
|
|
157
|
+
* owner is the un-namespaced key this card retired.
|
|
158
|
+
*/
|
|
159
|
+
set(orgId: string): void;
|
|
160
|
+
/**
|
|
161
|
+
* Drop the active organization, from memory and from the persisted key.
|
|
162
|
+
*
|
|
163
|
+
* POSTCONDITION: {@link get} answers `null` afterwards. Sign-out is one of
|
|
164
|
+
* the callers, so that is a security-relevant guarantee rather than a
|
|
165
|
+
* convenience — and before objectui#5731 it was not one, because a
|
|
166
|
+
* `removeItem` that failed was swallowed and the surviving key won `get()`'s
|
|
167
|
+
* read order.
|
|
168
|
+
*
|
|
169
|
+
* ## Why a failed removal is neither thrown nor returned (objectui#5731)
|
|
170
|
+
*
|
|
171
|
+
* Every caller arrives here AFTER the transition it is following up on has
|
|
172
|
+
* already happened, and not one of them can act on a storage failure:
|
|
173
|
+
*
|
|
174
|
+
* - `AuthProvider`'s `purgeSignedOutClientCaches` — the session is already
|
|
175
|
+
* ended. Sign-out cannot be refused because a key would not delete.
|
|
176
|
+
* - `switchOrganization` — the server already switched or cleared the
|
|
177
|
+
* active org; a throw would report a successful switch as a failure and
|
|
178
|
+
* route the caller into its error branch.
|
|
179
|
+
* - `deleteOrganization` / `leaveOrganization` — the org is already deleted
|
|
180
|
+
* or already left.
|
|
181
|
+
* - {@link purgePreviousUserClientState}, via {@link SessionUserScope.adopt}
|
|
182
|
+
* — runs on the SIGN-IN path inside an `AuthProvider` effect, where a
|
|
183
|
+
* throw breaks the boot of the ARRIVING user.
|
|
184
|
+
*
|
|
185
|
+
* A `boolean` return only moves the problem one step: all five call sites
|
|
186
|
+
* would have nothing to write in the failure branch, and a return value that
|
|
187
|
+
* every caller ignores reads as handled when it is not. So the invariant is
|
|
188
|
+
* restored HERE, where it can be, and the failure is reported to the console,
|
|
189
|
+
* where a human can find it.
|
|
190
|
+
*
|
|
191
|
+
* The third shape considered and rejected was re-writing the key with an
|
|
192
|
+
* empty value. That relocates the fix into every consumer's truthiness test
|
|
193
|
+
* (`createAuthenticatedFetch` does `if (activeOrgId)`), which is the lenient
|
|
194
|
+
* consumer AGENTS.md #0.1 forbids, and it leaves a signed-out browser holding
|
|
195
|
+
* a live key.
|
|
196
|
+
*/
|
|
197
|
+
clear(): void;
|
|
198
|
+
};
|
|
199
|
+
//# sourceMappingURL=ActiveOrganizationStorage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ActiveOrganizationStorage.d.ts","sourceRoot":"","sources":["../src/ActiveOrganizationStorage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAyOH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,4BAA4B,IAAI,IAAI,CAInD;AAkBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAEnD;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAK5E;AAaD;;;GAGG;AACH,eAAO,MAAM,gBAAgB;IAC3B;;;;;OAKG;aACc,MAAM,GAAG,IAAI;IAE9B,mFAAmF;eACxE,MAAM,GAAG,IAAI;IAKxB;;;;;;;;;;;;;;;;;;;;;;OAsBG;kBACW,MAAM,GAAG,OAAO;IA+B9B;;;;;OAKG;sBACe,IAAI;CAGvB,CAAC;AAqBF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,yBAAyB;kBACd,MAAM,GAAG,IAAI;IAEnC;;;;;;;;OAQG;;IAGH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;WACI,MAAM,GAAG,IAAI;IAsBpB;;;;OAIG;eACQ,MAAM,GAAG,IAAI;IAOxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;aACM,IAAI;CAyCd,CAAC"}
|