@object-ui/auth 17.3.0 → 17.6.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 +213 -0
- package/README.md +184 -13
- package/dist/ActiveOrganizationStorage.d.ts +168 -0
- package/dist/ActiveOrganizationStorage.d.ts.map +1 -0
- package/dist/ActiveOrganizationStorage.js +515 -0
- package/dist/AuthContext.d.ts +34 -1
- 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 +359 -32
- 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 +1 -1
- package/dist/LoginForm.d.ts.map +1 -1
- package/dist/LoginForm.js +4 -4
- package/dist/PreviewBanner.js +1 -1
- package/dist/RegisterForm.d.ts +1 -1
- package/dist/RegisterForm.d.ts.map +1 -1
- package/dist/RegisterForm.js +3 -3
- package/dist/SocialSignInButtons.js +2 -2
- package/dist/UserMenu.js +2 -2
- package/dist/createAuthClient.d.ts +23 -1
- package/dist/createAuthClient.d.ts.map +1 -1
- package/dist/createAuthClient.js +112 -25
- package/dist/createAuthenticatedFetch.d.ts +9 -9
- package/dist/createAuthenticatedFetch.d.ts.map +1 -1
- package/dist/createAuthenticatedFetch.js +82 -37
- package/dist/index.d.ts +20 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -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 +52 -4
- 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 +6 -1
- package/dist/useWorkspaceAdminStatus.d.ts +85 -0
- package/dist/useWorkspaceAdminStatus.d.ts.map +1 -0
- package/dist/useWorkspaceAdminStatus.js +102 -0
- package/package.json +5 -5
- package/dist/useIsWorkspaceAdmin.d.ts +0 -21
- package/dist/useIsWorkspaceAdmin.d.ts.map +0 -1
- package/dist/useIsWorkspaceAdmin.js +0 -47
|
@@ -0,0 +1,515 @@
|
|
|
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
|
+
* Per-user client state: WHOSE state this browser currently holds
|
|
10
|
+
* ({@link SessionUserScope}), and the active organization id scoped to that
|
|
11
|
+
* user ({@link ActiveOrganizationStorage}).
|
|
12
|
+
*
|
|
13
|
+
* # objectui#5664 — why this file exists
|
|
14
|
+
*
|
|
15
|
+
* `auth-active-organization-id` was a single un-namespaced `localStorage` key,
|
|
16
|
+
* while its siblings in `@object-ui/app-shell` were already user-scoped
|
|
17
|
+
* (`objectui-recent-items:u:<id>`, `objectui-favorites:u:<id>`,
|
|
18
|
+
* `flow-palette-recents:u:<id>`). On a browser handed from one account to
|
|
19
|
+
* another the new session read the PREVIOUS user's organization id: the header
|
|
20
|
+
* workspace chip rendered the previous user's workspace, and — the consequence
|
|
21
|
+
* that is not cosmetic — the polluted org context suppressed
|
|
22
|
+
* `RequireOrganization`'s routing into the guided "Create your workspace"
|
|
23
|
+
* first-run flow. A brand-new user on a shared browser silently never got the
|
|
24
|
+
* new-user flow.
|
|
25
|
+
*
|
|
26
|
+
* The server side was verified clean while the card was written: with the
|
|
27
|
+
* stale id, `get-full-organization` and `set-active` both answer
|
|
28
|
+
* `403 USER_IS_NOT_A_MEMBER` and `sys_environment` lists zero rows. Nothing
|
|
29
|
+
* about row visibility rode on this; the damage was entirely in what the
|
|
30
|
+
* client believed about itself.
|
|
31
|
+
*
|
|
32
|
+
* # The three parts, and which one closes the class
|
|
33
|
+
*
|
|
34
|
+
* 1. The key is namespaced per user (below).
|
|
35
|
+
* 2. `set()` can no longer write the un-namespaced key at all — see
|
|
36
|
+
* {@link scopedActiveOrgKey}. Namespacing that keeps a bare-key fallback
|
|
37
|
+
* re-opens the defect the first time a write happens before the user id is
|
|
38
|
+
* known.
|
|
39
|
+
* 3. {@link SessionUserScope.adopt} drops the previous user's UI state
|
|
40
|
+
* WHOLESALE when the session user changes. That is the load-bearing part:
|
|
41
|
+
* (1) and (2) fix one key, while (3) is what keeps the NEXT un-namespaced
|
|
42
|
+
* key — one nobody has written yet — from re-opening the same class. It is
|
|
43
|
+
* an allowlist sweep for exactly that reason: a denylist of the keys we
|
|
44
|
+
* know about today would not cover a key added tomorrow.
|
|
45
|
+
*
|
|
46
|
+
* # Resolving the scope with no `await`
|
|
47
|
+
*
|
|
48
|
+
* The user id is NOT known from React state at the moment this storage is
|
|
49
|
+
* first read. `createAuthenticatedFetch` reads it on every request including
|
|
50
|
+
* the very first `get-session`, and `MetadataProvider` reads it synchronously
|
|
51
|
+
* at mount to scope its seed cache — both before `AuthProvider`'s async
|
|
52
|
+
* session chain has resolved anything. So the scope is resolved from
|
|
53
|
+
* {@link SESSION_USER_STORAGE_KEY}, an ordinary `localStorage` pointer the
|
|
54
|
+
* PREVIOUS page-load wrote: one synchronous read, no await, and correct for
|
|
55
|
+
* every boot of a browser that has held a session before.
|
|
56
|
+
*
|
|
57
|
+
* The window where that pointer is stale — a boot whose cookie session belongs
|
|
58
|
+
* to someone other than the pointer — is bounded by `adopt()` firing as soon
|
|
59
|
+
* as `get-session` answers, and it is the same window the `X-Tenant-ID` edge
|
|
60
|
+
* contract already documents as "the header may be absent or not yet
|
|
61
|
+
* authoritative" (objectui#5279, this package's README). Requests that leave
|
|
62
|
+
* inside it are scoped by the SESSION server-side, not by the header.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* Pointer to the user whose client state this browser holds. Deliberately NOT
|
|
66
|
+
* user-scoped itself — it is the thing that makes user scoping resolvable, and
|
|
67
|
+
* it is overwritten rather than accumulated, so there is never more than one.
|
|
68
|
+
*/
|
|
69
|
+
const SESSION_USER_STORAGE_KEY = 'auth-session-user-id';
|
|
70
|
+
/** Base name the per-user active-org key is derived from. */
|
|
71
|
+
const ACTIVE_ORG_BASE_KEY = 'auth-active-organization-id';
|
|
72
|
+
/**
|
|
73
|
+
* The pre-#5664 spelling — the same string, un-suffixed. Every browser that
|
|
74
|
+
* has run an older build still has a value under it, and that value is
|
|
75
|
+
* UNATTRIBUTABLE: nothing recorded whose org id it is. It is therefore deleted
|
|
76
|
+
* rather than migrated. Migrating it is precisely the defect (on a handed-over
|
|
77
|
+
* browser it would hand the previous user's org id to the new one), and
|
|
78
|
+
* keeping it would leave a live bare key for a future reader to find.
|
|
79
|
+
*
|
|
80
|
+
* Dropping it costs a signed-in user nothing durable: `AuthProvider`'s
|
|
81
|
+
* `refreshOrganizations` asks the SERVER for the active organization whenever
|
|
82
|
+
* the list is non-empty and no active org is held, including the ADR-0081
|
|
83
|
+
* single-membership repair. The server is the authority for this value; the
|
|
84
|
+
* client copy is a routing-header cache. One boot re-supplies it.
|
|
85
|
+
*/
|
|
86
|
+
const LEGACY_ACTIVE_ORG_KEY = ACTIVE_ORG_BASE_KEY;
|
|
87
|
+
/**
|
|
88
|
+
* Keys that survive a change of session user, because they describe the DEVICE
|
|
89
|
+
* or the INCOMING session rather than the outgoing user's state.
|
|
90
|
+
*
|
|
91
|
+
* `auth-session-token` is spelled out rather than imported from
|
|
92
|
+
* `createAuthClient.ts` (where it is a module-private constant). The two ends
|
|
93
|
+
* are held together by BEHAVIOUR instead of by a shared symbol — the pin in
|
|
94
|
+
* `__tests__/sessionUserChangePurge-5664.test.tsx` fills it through the real
|
|
95
|
+
* `TokenStorage` and reads it back through `TokenStorage` after a purge, so a
|
|
96
|
+
* spelling that drifts on either side fails there rather than silently signing
|
|
97
|
+
* the incoming user out.
|
|
98
|
+
*
|
|
99
|
+
* `vite-ui-theme` is `@object-ui/app-shell`'s `ThemeProvider` default. An app
|
|
100
|
+
* that passes a custom `storageKey` loses its theme once on a user change and
|
|
101
|
+
* re-picks it on the next toggle — cosmetic and self-healing, which is the
|
|
102
|
+
* right side of an allowlist to err on.
|
|
103
|
+
*/
|
|
104
|
+
const DEVICE_SCOPED_KEYS = new Set([
|
|
105
|
+
'auth-session-token',
|
|
106
|
+
SESSION_USER_STORAGE_KEY,
|
|
107
|
+
'vite-ui-theme',
|
|
108
|
+
]);
|
|
109
|
+
/**
|
|
110
|
+
* `localStorage` / `sessionStorage`, or `undefined` where they are absent or
|
|
111
|
+
* blocked. Accessing the global itself can throw (partitioned iframes, some
|
|
112
|
+
* privacy modes), so the guard has to be a try/catch and not a `typeof` test
|
|
113
|
+
* alone.
|
|
114
|
+
*/
|
|
115
|
+
function safeStore(kind) {
|
|
116
|
+
try {
|
|
117
|
+
if (kind === 'local') {
|
|
118
|
+
return typeof localStorage !== 'undefined' ? localStorage : undefined;
|
|
119
|
+
}
|
|
120
|
+
return typeof sessionStorage !== 'undefined' ? sessionStorage : undefined;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function readPersisted(key) {
|
|
127
|
+
try {
|
|
128
|
+
return safeStore('local')?.getItem(key) ?? null;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function writePersisted(key, value) {
|
|
135
|
+
try {
|
|
136
|
+
safeStore('local')?.setItem(key, value);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* SSR / quota / private browsing — the caller keeps a memory copy */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Remove `key`, and report whether it is gone — `true` when a read-back can no
|
|
144
|
+
* longer produce a value for it.
|
|
145
|
+
*
|
|
146
|
+
* ## Why the verdict is a READ-BACK and not "did `removeItem` throw"
|
|
147
|
+
* (objectui#5731)
|
|
148
|
+
*
|
|
149
|
+
* `get()` can only answer with what `getItem` hands back, so "did this removal
|
|
150
|
+
* stick" is exactly the question "is the key still readable". Deciding it from
|
|
151
|
+
* the throw instead would be both too narrow and too wide:
|
|
152
|
+
*
|
|
153
|
+
* - TOO NARROW. A wrapped or proxied `localStorage` — an extension, a
|
|
154
|
+
* polyfill — whose `removeItem` is a silent no-op never throws, and leaves
|
|
155
|
+
* exactly the residue this guards against. objectui#5731 named the throwing
|
|
156
|
+
* variant; the no-op variant is the same defect, and a read-back catches
|
|
157
|
+
* both without having to enumerate them.
|
|
158
|
+
* - TOO WIDE. In SSR, and in a partitioned iframe where every operation
|
|
159
|
+
* throws, there is nothing readable to resurrect and the paths are already
|
|
160
|
+
* safe. A throw-based verdict would report those two states as failures,
|
|
161
|
+
* which is how a report earns a reputation for crying wolf.
|
|
162
|
+
*
|
|
163
|
+
* The throw is therefore still swallowed here: it is not the verdict.
|
|
164
|
+
*/
|
|
165
|
+
function removePersisted(key) {
|
|
166
|
+
try {
|
|
167
|
+
safeStore('local')?.removeItem(key);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* not the verdict — the read-back below is */
|
|
171
|
+
}
|
|
172
|
+
return readPersisted(key) === null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Delete every entry in `store` that is not device-scoped.
|
|
176
|
+
*
|
|
177
|
+
* Snapshot the keys first (`Object.keys`) — removing entries during a live
|
|
178
|
+
* index walk shifts the ones behind it and skips half of them. Same idiom as
|
|
179
|
+
* `AuthProvider`'s sign-out purge loop.
|
|
180
|
+
*
|
|
181
|
+
* ## The `try` guards the SNAPSHOT, not the walk (objectui#5763)
|
|
182
|
+
*
|
|
183
|
+
* `Object.keys(store)` can itself throw — partitioned iframes, some privacy
|
|
184
|
+
* modes — and that is the reason a guard exists here at all, so it stays.
|
|
185
|
+
* What it must NOT do is wrap the loop: a `removeItem` that throws on key `n`
|
|
186
|
+
* would abort the walk, leaving keys `n+1..end` unswept, with the failure
|
|
187
|
+
* swallowed so the caller believes the purge completed. This is an ALLOWLIST
|
|
188
|
+
* sweep (see the module doc, part 3) precisely so the next un-namespaced key
|
|
189
|
+
* cannot re-open the cross-user pollution class — a partial sweep is a
|
|
190
|
+
* partial allowlist. So each `removeItem` gets its own `try`: one
|
|
191
|
+
* uncooperative key costs exactly that key.
|
|
192
|
+
*
|
|
193
|
+
* ## Reported, not quarantined — unlike {@link ActiveOrganizationStorage.clear}
|
|
194
|
+
*
|
|
195
|
+
* `clear()` (objectui#5731) judges a removal by READ-BACK and quarantines a
|
|
196
|
+
* key that fails, because it owns every future read of that one key through
|
|
197
|
+
* {@link ActiveOrganizationStorage.get} — the quarantine is what keeps a
|
|
198
|
+
* failed `clear()` from handing the value straight back. `sweepStore` walks
|
|
199
|
+
* keys it does not own reads for (another package's recents cache, a
|
|
200
|
+
* metadata seed) — there is no `get()` here to guard, so there is nothing to
|
|
201
|
+
* quarantine, and adding a read-back verdict for keys this function does not
|
|
202
|
+
* otherwise touch would be exactly the "general storage-error-handling
|
|
203
|
+
* refactor" the card scopes this fix away from. What IS mirrored is the
|
|
204
|
+
* reporting channel: a key whose `removeItem` throws is named in a
|
|
205
|
+
* `console.warn`, same as `clear()`, so a partial sweep is discoverable
|
|
206
|
+
* instead of silent — the caller (`SessionUserScope.adopt`, on the sign-in
|
|
207
|
+
* path) still cannot act on it and must not throw either.
|
|
208
|
+
*/
|
|
209
|
+
function sweepStore(store) {
|
|
210
|
+
if (!store)
|
|
211
|
+
return;
|
|
212
|
+
let keys;
|
|
213
|
+
try {
|
|
214
|
+
keys = Object.keys(store);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return; /* storage unavailable */
|
|
218
|
+
}
|
|
219
|
+
const unswept = [];
|
|
220
|
+
for (const key of keys) {
|
|
221
|
+
if (DEVICE_SCOPED_KEYS.has(key))
|
|
222
|
+
continue;
|
|
223
|
+
try {
|
|
224
|
+
store.removeItem(key);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
unswept.push(key);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (unswept.length > 0) {
|
|
231
|
+
console.warn(`[purgePreviousUserClientState] could not remove ${unswept.length} key(s) from storage: ` +
|
|
232
|
+
`${unswept.join(', ')}. The previous user's state may still be readable under these keys.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Drop the outgoing user's client state wholesale — part 3 of the fix.
|
|
237
|
+
*
|
|
238
|
+
* Order is load-bearing and mirrors `clear()`'s: the in-memory active org goes
|
|
239
|
+
* FIRST, because `ActiveOrganizationStorage.get()` falls through to
|
|
240
|
+
* `_memoryValue` whenever the persisted read is null, which is exactly the
|
|
241
|
+
* state this function leaves behind (objectui#5703). Sweeping storage first
|
|
242
|
+
* would leave the outgoing user's org id live in memory for the rest of the
|
|
243
|
+
* page-load — the SPA sign-out-then-sign-in path never reloads.
|
|
244
|
+
*
|
|
245
|
+
* Both stores are swept. `sessionStorage` is per-TAB, not per-session, so the
|
|
246
|
+
* previous user's `objectui:metadata:*` seed (their PERMISSION-FILTERED app
|
|
247
|
+
* list) and their `objectui-ctx-*` scope picks survive into whoever signs in
|
|
248
|
+
* next in that tab unless something removes them.
|
|
249
|
+
*/
|
|
250
|
+
export function purgePreviousUserClientState() {
|
|
251
|
+
ActiveOrganizationStorage.clear();
|
|
252
|
+
sweepStore(safeStore('local'));
|
|
253
|
+
sweepStore(safeStore('session'));
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Which user's client state this browser holds, and the transition that drops
|
|
257
|
+
* the previous user's state.
|
|
258
|
+
*/
|
|
259
|
+
export const SessionUserScope = {
|
|
260
|
+
/**
|
|
261
|
+
* Memory copy of the pointer. Read FIRST by {@link current}: once this
|
|
262
|
+
* page-load has adopted a user, that answer is authoritative for this tab
|
|
263
|
+
* even if another tab has since written a different id, and it is the only
|
|
264
|
+
* copy in a browser whose `localStorage` rejects writes.
|
|
265
|
+
*/
|
|
266
|
+
_userId: null,
|
|
267
|
+
/** The user whose state this browser holds, or `null` if it has never held one. */
|
|
268
|
+
current() {
|
|
269
|
+
if (this._userId !== null)
|
|
270
|
+
return this._userId;
|
|
271
|
+
return readPersisted(SESSION_USER_STORAGE_KEY);
|
|
272
|
+
},
|
|
273
|
+
/**
|
|
274
|
+
* Record `userId` as the owner of this browser's client state, dropping the
|
|
275
|
+
* previous owner's state wholesale when it changes.
|
|
276
|
+
*
|
|
277
|
+
* Idempotent: re-adopting the same user purges nothing. Called from
|
|
278
|
+
* `AuthProvider` for real sessions only — the synthetic `preview-user` /
|
|
279
|
+
* `guest` identities never reach it, so a preview mount cannot purge a real
|
|
280
|
+
* user's state.
|
|
281
|
+
*
|
|
282
|
+
* Because the decision reads the PERSISTED pointer, anything that deletes
|
|
283
|
+
* that pointer without deleting the state it describes would silently retire
|
|
284
|
+
* this invariant. `apps/console/src/lib/auth-preflight.ts` is the one place
|
|
285
|
+
* that sweeps auth keys before render, and it says so in its own comment:
|
|
286
|
+
* it removes every active-org spelling and leaves `auth-session-user-id`
|
|
287
|
+
* alone on purpose.
|
|
288
|
+
*/
|
|
289
|
+
adopt(userId) {
|
|
290
|
+
if (!userId)
|
|
291
|
+
return;
|
|
292
|
+
// Already this page-load's owner. Short-circuits before the storage read
|
|
293
|
+
// below, which matters in a browser that REJECTS WRITES: there the pointer
|
|
294
|
+
// write further down silently fails, so every later call would re-read a
|
|
295
|
+
// stale persisted owner and purge again.
|
|
296
|
+
if (this._userId === userId)
|
|
297
|
+
return;
|
|
298
|
+
// WHOSE RESIDUE IS IN THIS STORE — read from storage, deliberately not
|
|
299
|
+
// from {@link current}. The two questions are different and take different
|
|
300
|
+
// authorities. `current()` answers "which key do I use right now", and
|
|
301
|
+
// memory is this tab's truth for that. This answers "is there another
|
|
302
|
+
// user's state PERSISTED here", and the persisted pointer is the only
|
|
303
|
+
// witness to that: a browser that never persisted anything has no residue
|
|
304
|
+
// to drop, and purging on the strength of an in-memory id would delete
|
|
305
|
+
// state that was written for the arriving user.
|
|
306
|
+
const persistedPrevious = readPersisted(SESSION_USER_STORAGE_KEY);
|
|
307
|
+
if (persistedPrevious !== null && persistedPrevious !== userId) {
|
|
308
|
+
purgePreviousUserClientState();
|
|
309
|
+
}
|
|
310
|
+
this._userId = userId;
|
|
311
|
+
writePersisted(SESSION_USER_STORAGE_KEY, userId);
|
|
312
|
+
// Unattributable legacy state, cleaned up on any adopt rather than only on
|
|
313
|
+
// a change — see LEGACY_ACTIVE_ORG_KEY.
|
|
314
|
+
removePersisted(LEGACY_ACTIVE_ORG_KEY);
|
|
315
|
+
},
|
|
316
|
+
/**
|
|
317
|
+
* Forget the in-memory pointer. Test seam only — production has no reason to
|
|
318
|
+
* un-know who is signed in, and sign-out deliberately KEEPS the persisted
|
|
319
|
+
* pointer so that the next sign-in by a DIFFERENT user still sees a previous
|
|
320
|
+
* owner to purge.
|
|
321
|
+
*/
|
|
322
|
+
_resetForTests() {
|
|
323
|
+
this._userId = null;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* The `localStorage` key holding the active org id for the current user, or
|
|
328
|
+
* `null` when no user is known yet.
|
|
329
|
+
*
|
|
330
|
+
* `null` — not the bare base key — is the whole point. `@object-ui/app-shell`'s
|
|
331
|
+
* `scopedKey(base, userId)` falls back to the un-namespaced key for
|
|
332
|
+
* "no user id yet", and that is correct THERE: its callers persist recents and
|
|
333
|
+
* favourites, where a signed-out bucket leaking is cosmetic. Here the bare key
|
|
334
|
+
* IS the defect, so "no user id yet" means "there is no key to persist under"
|
|
335
|
+
* and the value lives in memory for this page-load only. The convention for
|
|
336
|
+
* the scoped half is copied exactly (`${base}:u:${userId}`) — the symbol
|
|
337
|
+
* cannot travel, because `@object-ui/app-shell` depends on `@object-ui/auth`
|
|
338
|
+
* and not the other way round.
|
|
339
|
+
*/
|
|
340
|
+
function scopedActiveOrgKey() {
|
|
341
|
+
const userId = SessionUserScope.current();
|
|
342
|
+
return userId ? `${ACTIVE_ORG_BASE_KEY}:u:${userId}` : null;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Get/set the active organization id that {@link createAuthenticatedFetch}
|
|
346
|
+
* stamps as `X-Tenant-ID`.
|
|
347
|
+
*
|
|
348
|
+
* `AuthProvider` is the only writer, at four moments: `refreshOrganizations`
|
|
349
|
+
* sets it once the `getSession` -> `listOrganizations` ->
|
|
350
|
+
* `getActiveOrganization` chain resolves (including the ADR-0081
|
|
351
|
+
* single-membership repair), `switchOrganization` sets or clears it,
|
|
352
|
+
* `deleteOrganization` / `leaveOrganization` clear it when the active org is
|
|
353
|
+
* the one going away, and sign-out clears it.
|
|
354
|
+
*
|
|
355
|
+
* Because the first of those is asynchronous, this reads EMPTY for the first
|
|
356
|
+
* stretch of a boot, and every request that leaves in that window carries no
|
|
357
|
+
* tenant header at all. That window is a documented part of the header's
|
|
358
|
+
* contract, not an accident to paper over — see the "unstamped-first-request
|
|
359
|
+
* gap" section of this package's README (objectui#5279).
|
|
360
|
+
*/
|
|
361
|
+
export const ActiveOrganizationStorage = {
|
|
362
|
+
_memoryValue: null,
|
|
363
|
+
/**
|
|
364
|
+
* Keys whose removal by {@link clear} did not stick — the value was still
|
|
365
|
+
* readable afterwards (objectui#5731).
|
|
366
|
+
*
|
|
367
|
+
* {@link get} refuses to answer from the persisted layer for a key in here,
|
|
368
|
+
* which is the mechanism that keeps a cleared organization cleared. Held in
|
|
369
|
+
* memory, so it lasts exactly one page-load: a browser that starts fresh
|
|
370
|
+
* re-measures rather than inheriting a verdict.
|
|
371
|
+
*/
|
|
372
|
+
_unremovedKeys: new Set(),
|
|
373
|
+
/**
|
|
374
|
+
* The active org id for the CURRENT user, or `null`.
|
|
375
|
+
*
|
|
376
|
+
* READ ORDER — a non-null `localStorage` read wins; anything else falls
|
|
377
|
+
* through to `_memoryValue`. Returning the `localStorage` read
|
|
378
|
+
* UNCONDITIONALLY (what this did before objectui#5703) left the fallback
|
|
379
|
+
* reachable only when the read THREW, and there is a real browser state
|
|
380
|
+
* where the read does not throw and the fallback is still the only copy:
|
|
381
|
+
* `localStorage` present and readable but REJECTING WRITES — Safari private
|
|
382
|
+
* browsing, and any quota-exhausted origin, where `setItem` throws
|
|
383
|
+
* `QuotaExceededError`. `set()` swallows that failure into `_memoryValue`
|
|
384
|
+
* (correctly — the fallback is right there), and `get()` then never
|
|
385
|
+
* consulted it: the value was stored and could not be read back, so
|
|
386
|
+
* `X-Tenant-ID` went unstamped for the whole session, not just the early
|
|
387
|
+
* requests.
|
|
388
|
+
*
|
|
389
|
+
* WHY FALLING BACK ON `null` DOES NOT RESURRECT A CLEARED ORG. After
|
|
390
|
+
* `clear()` the `localStorage` read is null by construction, so this
|
|
391
|
+
* fallback fires — and it must answer `null`, because sign-out is one of
|
|
392
|
+
* `clear()`'s callers. It does, because `clear()` nulls `_memoryValue` too.
|
|
393
|
+
* That property is what makes this read order safe rather than an
|
|
394
|
+
* incidental detail of `clear()`'s body, so it is pinned by test
|
|
395
|
+
* (`__tests__/activeOrgStorageFallback-5703.test.tsx`) instead of being
|
|
396
|
+
* re-derived by whoever edits `clear()` next.
|
|
397
|
+
*
|
|
398
|
+
* The same reasoning is why a change of session user purges through
|
|
399
|
+
* {@link purgePreviousUserClientState} rather than by removing keys: the
|
|
400
|
+
* outgoing user's id in `_memoryValue` would otherwise outlive their
|
|
401
|
+
* persisted key on the SPA sign-out-then-sign-in path (objectui#5664).
|
|
402
|
+
*/
|
|
403
|
+
get() {
|
|
404
|
+
const key = scopedActiveOrgKey();
|
|
405
|
+
// A key {@link clear} could not remove is QUARANTINED for the rest of this
|
|
406
|
+
// page-load, and skipping the persisted branch is what stops the cleared
|
|
407
|
+
// org from being handed straight back (objectui#5731). `_memoryValue` then
|
|
408
|
+
// answers, and for that key it is the only copy this page-load can trust:
|
|
409
|
+
// `clear()` nulled it, and a later `set()` refills it with the value that
|
|
410
|
+
// write was meant to persist — so this stays correct in both directions
|
|
411
|
+
// with no release step of its own. What it gives up is cross-tab freshness
|
|
412
|
+
// for one key, in a browser that has just demonstrated it cannot delete
|
|
413
|
+
// from storage. An unstamped `X-Tenant-ID` is a documented state of the
|
|
414
|
+
// edge contract (see `createAuthenticatedFetch`); a re-stamped signed-out
|
|
415
|
+
// org is not.
|
|
416
|
+
if (key && !this._unremovedKeys.has(key)) {
|
|
417
|
+
try {
|
|
418
|
+
const persisted = safeStore('local')?.getItem(key) ?? null;
|
|
419
|
+
if (persisted !== null)
|
|
420
|
+
return persisted;
|
|
421
|
+
}
|
|
422
|
+
catch { /* SSR / test */ }
|
|
423
|
+
}
|
|
424
|
+
return this._memoryValue;
|
|
425
|
+
},
|
|
426
|
+
/**
|
|
427
|
+
* With no session user known this writes to memory ONLY. That is not a
|
|
428
|
+
* degraded path to apologise for — it is the fix: a persisted write with no
|
|
429
|
+
* owner is the un-namespaced key this card retired.
|
|
430
|
+
*/
|
|
431
|
+
set(orgId) {
|
|
432
|
+
this._memoryValue = orgId;
|
|
433
|
+
const key = scopedActiveOrgKey();
|
|
434
|
+
if (!key)
|
|
435
|
+
return;
|
|
436
|
+
writePersisted(key, orgId);
|
|
437
|
+
},
|
|
438
|
+
/**
|
|
439
|
+
* Drop the active organization, from memory and from the persisted key.
|
|
440
|
+
*
|
|
441
|
+
* POSTCONDITION: {@link get} answers `null` afterwards. Sign-out is one of
|
|
442
|
+
* the callers, so that is a security-relevant guarantee rather than a
|
|
443
|
+
* convenience — and before objectui#5731 it was not one, because a
|
|
444
|
+
* `removeItem` that failed was swallowed and the surviving key won `get()`'s
|
|
445
|
+
* read order.
|
|
446
|
+
*
|
|
447
|
+
* ## Why a failed removal is neither thrown nor returned (objectui#5731)
|
|
448
|
+
*
|
|
449
|
+
* Every caller arrives here AFTER the transition it is following up on has
|
|
450
|
+
* already happened, and not one of them can act on a storage failure:
|
|
451
|
+
*
|
|
452
|
+
* - `AuthProvider`'s `purgeSignedOutClientCaches` — the session is already
|
|
453
|
+
* ended. Sign-out cannot be refused because a key would not delete.
|
|
454
|
+
* - `switchOrganization` — the server already switched or cleared the
|
|
455
|
+
* active org; a throw would report a successful switch as a failure and
|
|
456
|
+
* route the caller into its error branch.
|
|
457
|
+
* - `deleteOrganization` / `leaveOrganization` — the org is already deleted
|
|
458
|
+
* or already left.
|
|
459
|
+
* - {@link purgePreviousUserClientState}, via {@link SessionUserScope.adopt}
|
|
460
|
+
* — runs on the SIGN-IN path inside an `AuthProvider` effect, where a
|
|
461
|
+
* throw breaks the boot of the ARRIVING user.
|
|
462
|
+
*
|
|
463
|
+
* A `boolean` return only moves the problem one step: all five call sites
|
|
464
|
+
* would have nothing to write in the failure branch, and a return value that
|
|
465
|
+
* every caller ignores reads as handled when it is not. So the invariant is
|
|
466
|
+
* restored HERE, where it can be, and the failure is reported to the console,
|
|
467
|
+
* where a human can find it.
|
|
468
|
+
*
|
|
469
|
+
* The third shape considered and rejected was re-writing the key with an
|
|
470
|
+
* empty value. That relocates the fix into every consumer's truthiness test
|
|
471
|
+
* (`createAuthenticatedFetch` does `if (activeOrgId)`), which is the lenient
|
|
472
|
+
* consumer AGENTS.md #0.1 forbids, and it leaves a signed-out browser holding
|
|
473
|
+
* a live key.
|
|
474
|
+
*/
|
|
475
|
+
clear() {
|
|
476
|
+
// Nulling the fallback is SECURITY-RELEVANT, not bookkeeping: `get()`
|
|
477
|
+
// falls through to `_memoryValue` whenever the `localStorage` read comes
|
|
478
|
+
// back null, which is exactly the state this method leaves behind. Drop
|
|
479
|
+
// this line and sign-out's clear stops sticking — the removed key reads
|
|
480
|
+
// null, the fallback fires, and the cleared org goes back on the wire as
|
|
481
|
+
// `X-Tenant-ID`. Pinned by `__tests__/activeOrgStorageFallback-5703.test.tsx`.
|
|
482
|
+
this._memoryValue = null;
|
|
483
|
+
const key = scopedActiveOrgKey();
|
|
484
|
+
if (key) {
|
|
485
|
+
if (removePersisted(key)) {
|
|
486
|
+
// Confirmed gone. Recomputed on every call rather than left alone, so
|
|
487
|
+
// a key quarantined by an earlier failure is RELEASED the moment a
|
|
488
|
+
// removal sticks: the quarantine describes the last attempt, not a
|
|
489
|
+
// permanent verdict on the browser.
|
|
490
|
+
this._unremovedKeys.delete(key);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
// The removal did not stick and the value is still readable, so
|
|
494
|
+
// `get()` would hand the signed-out org straight back. Two things
|
|
495
|
+
// happen here and they are deliberately separable: the stale read is
|
|
496
|
+
// SUPPRESSED, which restores the postcondition without any caller's
|
|
497
|
+
// help, and the failure is REPORTED, because "sign-out did not fully
|
|
498
|
+
// stick" is something whoever is reading a console has to be able to
|
|
499
|
+
// find. Neither half substitutes for the other — a report alone leaves
|
|
500
|
+
// the org on the wire, and suppression alone is the same silence this
|
|
501
|
+
// card was filed about, just with a better outcome.
|
|
502
|
+
this._unremovedKeys.add(key);
|
|
503
|
+
console.warn(`[ActiveOrganizationStorage] clear() could not remove "${key}" from localStorage — ` +
|
|
504
|
+
'it is still readable. Reads for it are answered from memory for the rest of this ' +
|
|
505
|
+
'page-load, so the cleared organization is not re-stamped as X-Tenant-ID.');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Also drop the pre-#5664 bare key, so a `clear()` on a browser upgrading
|
|
509
|
+
// from an older build leaves nothing behind under the retired spelling.
|
|
510
|
+
// Its verdict is deliberately ignored: `scopedActiveOrgKey()` never returns
|
|
511
|
+
// the bare spelling, so nothing reads it and a survival here cannot
|
|
512
|
+
// resurrect an org through `get()`.
|
|
513
|
+
removePersisted(LEGACY_ACTIVE_ORG_KEY);
|
|
514
|
+
},
|
|
515
|
+
};
|
package/dist/AuthContext.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
import type { AuthUser, AuthClientSession, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions, DelegableScope } from './types';
|
|
8
|
+
import type { AuthUser, AuthClientSession, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions, DelegableScope } from './types.js';
|
|
9
9
|
export interface AuthContextValue {
|
|
10
10
|
/** Current authenticated user */
|
|
11
11
|
user: AuthUser | null;
|
|
@@ -39,6 +39,21 @@ export interface AuthContextValue {
|
|
|
39
39
|
}>;
|
|
40
40
|
/** Sign out the current user */
|
|
41
41
|
signOut: () => Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* objectui#4467 — re-resolve `user`/`session` from the server, in place.
|
|
44
|
+
*
|
|
45
|
+
* The same loader the provider runs on mount, exposed for the transitions
|
|
46
|
+
* that change WHO the session is without going through `signIn`/`signOut`:
|
|
47
|
+
* starting and stopping impersonation. Deliberately does NOT raise
|
|
48
|
+
* `isLoading` — a refresh must not blank the console it is running under.
|
|
49
|
+
*
|
|
50
|
+
* Callers rarely need it: a session rotation observed on the wire refreshes
|
|
51
|
+
* identity on its own (see `TokenStorage.subscribeRotation`). Call it when
|
|
52
|
+
* you must AWAIT the new identity before deciding what to show — the
|
|
53
|
+
* impersonation banner's exit does, so a stop that did not restore the
|
|
54
|
+
* administrator can fail loudly instead of appearing to succeed.
|
|
55
|
+
*/
|
|
56
|
+
refreshSession: () => Promise<void>;
|
|
42
57
|
/** Update user profile */
|
|
43
58
|
updateUser: (data: Partial<AuthUser>) => Promise<void>;
|
|
44
59
|
/** Request password reset */
|
|
@@ -106,6 +121,24 @@ export interface AuthContextValue {
|
|
|
106
121
|
activeMember: AuthOrganizationMember | null;
|
|
107
122
|
/** Whether organizations are loading */
|
|
108
123
|
isOrganizationsLoading: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* objectui#5619 — whether `organizations` / `activeOrganization` /
|
|
126
|
+
* `activeMember` MEAN what they say yet.
|
|
127
|
+
*
|
|
128
|
+
* Those three are `[]` / `null` / `null` both before the pipeline starts and
|
|
129
|
+
* after it finds nothing, and `isOrganizationsLoading` is `false` on both
|
|
130
|
+
* sides of the request — so no combination of the fields above can tell
|
|
131
|
+
* "not asked yet" from "asked, and there is none". That ambiguity is what let
|
|
132
|
+
* `useWorkspaceAdminStatus` answer "not an admin" about an administrator
|
|
133
|
+
* whose adminship lives only in the member row.
|
|
134
|
+
*
|
|
135
|
+
* True once the pipeline has reached a terminal state: the org list came back
|
|
136
|
+
* AND the active-member row either landed, failed, or was established not to
|
|
137
|
+
* exist (no active organization). Also true immediately where there is no
|
|
138
|
+
* pipeline to run at all — preview / auth-disabled mode, and outside an
|
|
139
|
+
* AuthProvider — so a gate on this can never hang.
|
|
140
|
+
*/
|
|
141
|
+
isMembershipResolved: boolean;
|
|
109
142
|
/** Switch the active organization (workspace) */
|
|
110
143
|
switchOrganization: (orgId: string) => Promise<void>;
|
|
111
144
|
/** Create a new organization */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AuthContext.d.ts","sourceRoot":"","sources":["../src/AuthContext.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,cAAc,EAAE,gBAAgB,EAAE,yBAAyB,EAAE,cAAc,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"AuthContext.d.ts","sourceRoot":"","sources":["../src/AuthContext.ts"],"names":[],"mappings":"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"}
|
package/dist/AuthGuard.d.ts
CHANGED
|
@@ -9,7 +9,14 @@ import React from 'react';
|
|
|
9
9
|
export interface AuthGuardProps {
|
|
10
10
|
/** Content to render when user is not authenticated */
|
|
11
11
|
fallback?: React.ReactNode;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Required position/role names — the user must hold at least one.
|
|
14
|
+
*
|
|
15
|
+
* Matched against `user.positions`, the one published spelling (framework
|
|
16
|
+
* ADR-0090 D3), falling back to the single `user.role` scalar only when the
|
|
17
|
+
* session carries no `positions` key at all (synthetic guest / legacy
|
|
18
|
+
* identities). The retired `roles` spelling is not read.
|
|
19
|
+
*/
|
|
13
20
|
requiredRoles?: string[];
|
|
14
21
|
/** Required permissions (user must have all) */
|
|
15
22
|
requiredPermissions?: string[];
|
package/dist/AuthGuard.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AuthGuard.d.ts","sourceRoot":"","sources":["../src/AuthGuard.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,MAAM,WAAW,cAAc;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B
|
|
1
|
+
{"version":3,"file":"AuthGuard.d.ts","sourceRoot":"","sources":["../src/AuthGuard.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,MAAM,WAAW,cAAc;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,gDAAgD;IAChD,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,qCAAqC;IACrC,eAAe,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAClC,4CAA4C;IAC5C,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,EACxB,QAAe,EACf,aAAa,EACb,eAAe,EACf,QAAQ,GACT,EAAE,cAAc,qBAkChB"}
|
package/dist/AuthGuard.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useAuth } from './useAuth';
|
|
2
|
+
import { useAuth } from './useAuth.js';
|
|
3
3
|
/**
|
|
4
4
|
* Route guard component that conditionally renders children
|
|
5
5
|
* based on authentication and authorization state.
|
|
@@ -23,9 +23,22 @@ export function AuthGuard({ fallback = null, requiredRoles, loadingFallback, chi
|
|
|
23
23
|
if (!isAuthenticated) {
|
|
24
24
|
return _jsx(_Fragment, { children: fallback });
|
|
25
25
|
}
|
|
26
|
-
// Check role requirements
|
|
26
|
+
// Check role requirements.
|
|
27
|
+
//
|
|
28
|
+
// `positions` is the ONE published spelling (framework ADR-0090 D3 renamed
|
|
29
|
+
// `roles` → `positions` with no deprecation window; maintainer ruling on
|
|
30
|
+
// objectui#5424). When the session carries the key — protocol 17 always
|
|
31
|
+
// emits it — it is the whole answer, empty included: falling back to the
|
|
32
|
+
// coarse `user.role` scalar when the server said "no positions" would be
|
|
33
|
+
// exactly the over-admission this gate used to have (`role: 'admin'`
|
|
34
|
+
// passing a gate meant for an `admin` position). The scalar fallback exists
|
|
35
|
+
// for identities that lack the key entirely: the guest user the provider
|
|
36
|
+
// seeds when auth is disabled, and pre-positions deployments. The retired
|
|
37
|
+
// `roles` spelling is deliberately NOT read — resurrecting it as a fallback
|
|
38
|
+
// dialect is what ADR-0090 D3 forbids
|
|
39
|
+
// (`__tests__/authGuardPositions-5424.test.tsx` pins the refusal).
|
|
27
40
|
if (requiredRoles && requiredRoles.length > 0 && user) {
|
|
28
|
-
const userRoles = user.
|
|
41
|
+
const userRoles = user.positions ?? (user.role ? [user.role] : []);
|
|
29
42
|
const hasRole = requiredRoles.some((role) => userRoles.includes(role));
|
|
30
43
|
if (!hasRole) {
|
|
31
44
|
return _jsx(_Fragment, { children: fallback });
|
package/dist/AuthProvider.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import React from 'react';
|
|
9
|
-
import type { AuthProviderOptions, PreviewModeOptions } from './types';
|
|
9
|
+
import type { AuthProviderOptions, PreviewModeOptions } from './types.js';
|
|
10
10
|
export interface AuthProviderProps extends AuthProviderOptions {
|
|
11
11
|
children: React.ReactNode;
|
|
12
12
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AuthProvider.d.ts","sourceRoot":"","sources":["../src/AuthProvider.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"AuthProvider.d.ts","sourceRoot":"","sources":["../src/AuthProvider.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA4D,MAAM,OAAO,CAAC;AAEjF,OAAO,KAAK,EAAwB,mBAAmB,EAAE,kBAAkB,EAAyG,MAAM,YAAY,CAAC;AA4GvM,MAAM,WAAW,iBAAkB,SAAQ,mBAAmB;IAC5D,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,kBAAkB,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,YAAY,CAAC,EAC3B,OAAO,EACP,MAAM,EAAE,cAAc,EACtB,iBAAiB,EACjB,OAAc,EACd,WAAW,EACX,QAAQ,GACT,EAAE,iBAAiB,qBA43BnB"}
|