@gusnips/react 0.9.33 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -10
- package/dist/api-client.js.map +1 -1
- package/dist/api-error.d.ts.map +1 -1
- package/dist/api-error.js.map +1 -1
- package/dist/copy.js.map +1 -1
- package/dist/error-boundary.d.ts.map +1 -1
- package/dist/guards.d.ts.map +1 -1
- package/dist/guards.js.map +1 -1
- package/dist/i18n.d.ts +3 -3
- package/dist/i18n.d.ts.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/oauth.d.ts +25 -13
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +164 -37
- package/dist/oauth.js.map +1 -1
- package/dist/query.js.map +1 -1
- package/dist/supabase-session.d.ts +6 -0
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +24 -11
- package/dist/supabase-session.js.map +1 -1
- package/dist/ui/combobox.d.ts +9 -9
- package/dist/ui/combobox.d.ts.map +1 -1
- package/dist/ui/combobox.js.map +1 -1
- package/dist/ui/dialog.d.ts +6 -6
- package/dist/ui/dialog.d.ts.map +1 -1
- package/dist/ui/menu.d.ts +9 -9
- package/dist/ui/menu.d.ts.map +1 -1
- package/dist/ui/select.js +2 -6
- package/dist/ui/select.js.map +1 -1
- package/package.json +3 -3
- package/src/guards.tsx +3 -1
- package/src/oauth.test.ts +726 -26
- package/src/oauth.ts +222 -48
- package/src/supabase-session.test.ts +31 -0
- package/src/supabase-session.ts +30 -12
- package/src/ui/combobox.tsx +2 -1
package/src/oauth.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { hashKey, useMutation, useQuery, type QueryKey } from "@tanstack/react-query";
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type ChangeEvent,
|
|
10
|
+
} from "react";
|
|
11
|
+
import type { ApiClient, RequestOptions } from "./api-client.ts";
|
|
4
12
|
|
|
5
13
|
/**
|
|
6
14
|
* The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
|
|
@@ -21,7 +29,11 @@ import type { ApiClient } from "./api-client.ts";
|
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
export type OAuthRefusalReason =
|
|
24
|
-
|
|
32
|
+
| "unknown-client"
|
|
33
|
+
| "redirect-mismatch"
|
|
34
|
+
| "invalid-request"
|
|
35
|
+
| "wrong-resource"
|
|
36
|
+
| "unknown-code";
|
|
25
37
|
|
|
26
38
|
/** What the server sends instead of a view. Word your own page for `reason`. */
|
|
27
39
|
export interface OAuthRefusal {
|
|
@@ -54,11 +66,87 @@ export type DeviceView<Extra extends object = object> = Extra & {
|
|
|
54
66
|
expiresAt: string;
|
|
55
67
|
};
|
|
56
68
|
|
|
69
|
+
/** Capture this from your app's identity store on each account or workspace generation. */
|
|
70
|
+
export interface OAuthScope {
|
|
71
|
+
/** Query-key prefix, including account, workspace and the identity generation. Never a token. */
|
|
72
|
+
readonly key: QueryKey;
|
|
73
|
+
/** Compare the captured generation with the live store, even before React renders again. */
|
|
74
|
+
isCurrent(): boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A decision outlived its account, request or page. Aborting does not undo a server-side decision. */
|
|
78
|
+
export class OAuthOperationChangedError extends Error {
|
|
79
|
+
constructor() {
|
|
80
|
+
super(
|
|
81
|
+
"The account, sign-in request or page changed before the decision finished. Check connected apps in the account you used before trying again.",
|
|
82
|
+
);
|
|
83
|
+
this.name = "OAuthOperationChangedError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface OAuthOperation {
|
|
88
|
+
identity: string;
|
|
89
|
+
signal: AbortSignal;
|
|
90
|
+
isCurrent(): boolean;
|
|
91
|
+
release(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertCurrent(operation: OAuthOperation): void {
|
|
95
|
+
if (!operation.isCurrent()) throw new OAuthOperationChangedError();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createOperationLifetime(identity: string) {
|
|
99
|
+
let mounted = false;
|
|
100
|
+
let generation = 0;
|
|
101
|
+
const controllers = new Set<AbortController>();
|
|
102
|
+
return {
|
|
103
|
+
identity,
|
|
104
|
+
invalidate() {
|
|
105
|
+
generation++;
|
|
106
|
+
for (const controller of controllers) controller.abort();
|
|
107
|
+
controllers.clear();
|
|
108
|
+
},
|
|
109
|
+
mount() {
|
|
110
|
+
mounted = true;
|
|
111
|
+
return () => {
|
|
112
|
+
mounted = false;
|
|
113
|
+
this.invalidate();
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
capture(isCurrent: () => boolean): OAuthOperation {
|
|
117
|
+
const started = generation;
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
controllers.add(controller);
|
|
120
|
+
return {
|
|
121
|
+
identity,
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
isCurrent: () => mounted && started === generation && isCurrent(),
|
|
124
|
+
release: () => controllers.delete(controller),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Both pages reject late answers, even when the adapter ignores abort or the cache was cleared. */
|
|
131
|
+
function useOAuthOperation(scope: OAuthScope, kind: "consent" | "device", input: string) {
|
|
132
|
+
const key = [...scope.key, "@gusnips/react/oauth", kind, input];
|
|
133
|
+
const identity = hashKey(key);
|
|
134
|
+
const lifetime = useMemo(() => createOperationLifetime(identity), [identity]);
|
|
135
|
+
// Invalidate by the commit, before passive cleanup can leave a late redirect window.
|
|
136
|
+
// Layout effects work on native too and do not run during SSR. StrictMode can mount again.
|
|
137
|
+
useLayoutEffect(() => lifetime.mount(), [lifetime]);
|
|
138
|
+
return { key, lifetime, capture: () => lifetime.capture(() => scope.isCurrent()) };
|
|
139
|
+
}
|
|
140
|
+
|
|
57
141
|
export interface ConsentApi<Choice = void, Extra extends object = object> {
|
|
58
|
-
/**
|
|
59
|
-
describe(query: string): Promise<ConsentView<Extra> | OAuthRefusal>;
|
|
142
|
+
/** Forward `options`, including its signal, to your HTTP client. */
|
|
143
|
+
describe(query: string, options?: RequestOptions): Promise<ConsentView<Extra> | OAuthRefusal>;
|
|
60
144
|
/** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
|
|
61
|
-
approve(
|
|
145
|
+
approve(
|
|
146
|
+
query: string,
|
|
147
|
+
choice: Choice,
|
|
148
|
+
options?: RequestOptions,
|
|
149
|
+
): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
|
|
62
150
|
}
|
|
63
151
|
|
|
64
152
|
export type ConsentState<Extra extends object = object> =
|
|
@@ -79,29 +167,59 @@ export interface ConsentRequest<Choice = void, Extra extends object = object> {
|
|
|
79
167
|
}
|
|
80
168
|
|
|
81
169
|
/**
|
|
82
|
-
* const consent = useConsentRequest(api.oauth, useLocation().search);
|
|
170
|
+
* const consent = useConsentRequest(api.oauth, useLocation().search, scope);
|
|
83
171
|
* if (consent.state.status === "ready") ... consent.approve(workspaceId)
|
|
84
172
|
*/
|
|
85
173
|
export function useConsentRequest<Choice = void, Extra extends object = object>(
|
|
86
174
|
api: ConsentApi<Choice, Extra>,
|
|
87
175
|
search: string,
|
|
176
|
+
scope: OAuthScope,
|
|
88
177
|
): ConsentRequest<Choice, Extra> {
|
|
89
178
|
const query = search.replace(/^\?/, "");
|
|
179
|
+
const { key, lifetime, capture } = useOAuthOperation(scope, "consent", query);
|
|
90
180
|
const request = useQuery({
|
|
91
|
-
queryKey:
|
|
92
|
-
queryFn: () => api.describe(query),
|
|
181
|
+
queryKey: key,
|
|
182
|
+
queryFn: ({ signal }) => api.describe(query, { signal }),
|
|
183
|
+
// A previous description is never a safe stand-in for this approval target.
|
|
184
|
+
placeholderData: () => undefined,
|
|
185
|
+
retry: false,
|
|
186
|
+
refetchOnWindowFocus: false,
|
|
93
187
|
});
|
|
94
188
|
const approval = useMutation({
|
|
95
|
-
mutationFn: (
|
|
96
|
-
|
|
97
|
-
|
|
189
|
+
mutationFn: async (pressed: {
|
|
190
|
+
api: ConsentApi<Choice, Extra>;
|
|
191
|
+
query: string;
|
|
192
|
+
choice: Choice;
|
|
193
|
+
operation: OAuthOperation;
|
|
194
|
+
}) => {
|
|
195
|
+
const { api, query, choice, operation } = pressed;
|
|
196
|
+
try {
|
|
197
|
+
assertCurrent(operation);
|
|
198
|
+
const answer = await api.approve(query, choice, { signal: operation.signal });
|
|
199
|
+
// Before returning: stale success data must never enter the mutation cache.
|
|
200
|
+
assertCurrent(operation);
|
|
201
|
+
return answer;
|
|
202
|
+
} finally {
|
|
203
|
+
operation.release();
|
|
204
|
+
}
|
|
98
205
|
},
|
|
206
|
+
onSettled: (answer, error, { operation }) => {
|
|
207
|
+
// Global success and settlement callbacks may await an identity change. Check last.
|
|
208
|
+
if (error !== null) return;
|
|
209
|
+
assertCurrent(operation);
|
|
210
|
+
if (answer?.ok) window.location.assign(answer.redirectTo);
|
|
211
|
+
},
|
|
212
|
+
retry: false,
|
|
213
|
+
networkMode: "always",
|
|
99
214
|
});
|
|
215
|
+
const currentApproval =
|
|
216
|
+
approval.variables?.operation.identity === lifetime.identity &&
|
|
217
|
+
approval.variables.operation.isCurrent();
|
|
100
218
|
|
|
101
219
|
// A refusal from approve (the request went stale between the two calls) outranks the view.
|
|
102
220
|
const answer = request.data;
|
|
103
|
-
|
|
104
|
-
|
|
221
|
+
let refusal = answer?.ok === false ? answer : null;
|
|
222
|
+
if (currentApproval && approval.data?.ok === false) refusal = approval.data;
|
|
105
223
|
let state: ConsentState<Extra>;
|
|
106
224
|
if (refusal) state = { status: "refused", refusal };
|
|
107
225
|
else if (answer?.ok === true) state = { status: "ready", view: answer };
|
|
@@ -111,20 +229,21 @@ export function useConsentRequest<Choice = void, Extra extends object = object>(
|
|
|
111
229
|
|
|
112
230
|
return {
|
|
113
231
|
state,
|
|
114
|
-
approve: (choice) => approval.mutate(choice),
|
|
115
|
-
leaving: approval.isPending || approval.data?.ok === true,
|
|
116
|
-
approveError: approval.error,
|
|
232
|
+
approve: (choice) => approval.mutate({ api, query, choice, operation: capture() }),
|
|
233
|
+
leaving: currentApproval && (approval.isPending || approval.data?.ok === true),
|
|
234
|
+
approveError: currentApproval ? approval.error : null,
|
|
117
235
|
};
|
|
118
236
|
}
|
|
119
237
|
|
|
120
238
|
export interface DeviceApi<Choice = void, Extra extends object = object> {
|
|
121
|
-
/**
|
|
122
|
-
describe(code: string): Promise<DeviceView<Extra> | OAuthRefusal>;
|
|
239
|
+
/** Forward `options`, including its signal, to your HTTP client. */
|
|
240
|
+
describe(code: string, options?: RequestOptions): Promise<DeviceView<Extra> | OAuthRefusal>;
|
|
123
241
|
/** Your API's call to `decideDevice`. `choice` comes with a yes only, as on the consent page. */
|
|
124
242
|
decide(
|
|
125
243
|
code: string,
|
|
126
244
|
decision: "approve" | "deny",
|
|
127
245
|
choice?: Choice,
|
|
246
|
+
options?: RequestOptions,
|
|
128
247
|
): Promise<{ ok: true } | OAuthRefusal>;
|
|
129
248
|
}
|
|
130
249
|
|
|
@@ -165,6 +284,9 @@ export interface DeviceApproval<Choice = void, Extra extends object = object> {
|
|
|
165
284
|
const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
166
285
|
const LENGTH = 8;
|
|
167
286
|
|
|
287
|
+
// Private wiring between the approval and field hooks, without adding to the public page API.
|
|
288
|
+
const deviceAddressWrites = new WeakMap<object, (code: string) => void>();
|
|
289
|
+
|
|
168
290
|
/** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
|
|
169
291
|
export function formatUserCode(input: string): string {
|
|
170
292
|
const letters = input
|
|
@@ -175,7 +297,7 @@ export function formatUserCode(input: string): string {
|
|
|
175
297
|
}
|
|
176
298
|
|
|
177
299
|
/**
|
|
178
|
-
* const device = useDeviceApproval(api.device, useLocation().search);
|
|
300
|
+
* const device = useDeviceApproval(api.device, useLocation().search, scope);
|
|
179
301
|
* <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
|
|
180
302
|
*
|
|
181
303
|
* The link the script prints carries `?code=`, so the field usually starts full.
|
|
@@ -183,28 +305,69 @@ export function formatUserCode(input: string): string {
|
|
|
183
305
|
export function useDeviceApproval<Choice = void, Extra extends object = object>(
|
|
184
306
|
api: DeviceApi<Choice, Extra>,
|
|
185
307
|
search: string,
|
|
308
|
+
scope: OAuthScope,
|
|
186
309
|
): DeviceApproval<Choice, Extra> {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
310
|
+
const fromLink = formatUserCode(new URLSearchParams(search).get("code") ?? "");
|
|
311
|
+
const [input, setInput] = useState<{
|
|
312
|
+
fromLink: string;
|
|
313
|
+
code: string;
|
|
314
|
+
ownAddress?: string;
|
|
315
|
+
}>({ fromLink, code: fromLink });
|
|
316
|
+
const recordAddressWrite = useCallback((ownAddress: string) => {
|
|
317
|
+
setInput((current) => ({ ...current, ownAddress }));
|
|
318
|
+
}, []);
|
|
319
|
+
// Only external links replace input. The field also writes the address while typing and
|
|
320
|
+
// clears it after a decision; neither write should replace input or invalidate the decision.
|
|
321
|
+
const code = input.fromLink === fromLink || input.ownAddress === fromLink ? input.code : fromLink;
|
|
322
|
+
if (input.fromLink !== fromLink) setInput({ fromLink, code });
|
|
190
323
|
const letters = code.replace("-", "");
|
|
191
324
|
const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
|
|
192
325
|
const complete = letters.length === LENGTH && !invalidLetter;
|
|
326
|
+
const { key, lifetime, capture } = useOAuthOperation(scope, "device", letters);
|
|
193
327
|
|
|
194
328
|
const decision = useMutation({
|
|
195
|
-
mutationFn: (pressed: {
|
|
196
|
-
api
|
|
329
|
+
mutationFn: async (pressed: {
|
|
330
|
+
api: DeviceApi<Choice, Extra>;
|
|
331
|
+
code: string;
|
|
332
|
+
decision: "approve" | "deny";
|
|
333
|
+
choice?: Choice;
|
|
334
|
+
operation: OAuthOperation;
|
|
335
|
+
}) => {
|
|
336
|
+
const { api, code, decision, choice, operation } = pressed;
|
|
337
|
+
try {
|
|
338
|
+
assertCurrent(operation);
|
|
339
|
+
const answer = await api.decide(code, decision, choice, { signal: operation.signal });
|
|
340
|
+
assertCurrent(operation);
|
|
341
|
+
return answer;
|
|
342
|
+
} finally {
|
|
343
|
+
operation.release();
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
onSettled: (_answer, error, { operation }) => {
|
|
347
|
+
if (error === null) assertCurrent(operation);
|
|
348
|
+
},
|
|
349
|
+
retry: false,
|
|
350
|
+
networkMode: "always",
|
|
197
351
|
});
|
|
198
|
-
const
|
|
352
|
+
const currentDecision =
|
|
353
|
+
decision.variables?.operation.identity === lifetime.identity &&
|
|
354
|
+
decision.variables.operation.isCurrent();
|
|
355
|
+
const decided =
|
|
356
|
+
currentDecision && decision.isSuccess && decision.data.ok
|
|
357
|
+
? decision.variables.decision
|
|
358
|
+
: undefined;
|
|
199
359
|
const request = useQuery({
|
|
200
|
-
queryKey:
|
|
201
|
-
queryFn: () => api.describe(letters),
|
|
360
|
+
queryKey: key,
|
|
361
|
+
queryFn: ({ signal }) => api.describe(letters, { signal }),
|
|
202
362
|
enabled: complete && decided === undefined,
|
|
363
|
+
placeholderData: () => undefined,
|
|
364
|
+
retry: false,
|
|
365
|
+
refetchOnWindowFocus: false,
|
|
203
366
|
});
|
|
204
367
|
|
|
205
368
|
const answer = request.data;
|
|
206
|
-
|
|
207
|
-
|
|
369
|
+
let refusal = answer?.ok === false ? answer : null;
|
|
370
|
+
if (currentDecision && decision.data?.ok === false) refusal = decision.data;
|
|
208
371
|
let state: DeviceState<Extra>;
|
|
209
372
|
if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
|
|
210
373
|
else if (!complete) state = { status: "typing" };
|
|
@@ -214,20 +377,26 @@ export function useDeviceApproval<Choice = void, Extra extends object = object>(
|
|
|
214
377
|
state = { status: "failed", error: request.error, retry: () => void request.refetch() };
|
|
215
378
|
else state = { status: "loading" };
|
|
216
379
|
|
|
217
|
-
|
|
380
|
+
const device: DeviceApproval<Choice, Extra> = {
|
|
218
381
|
state,
|
|
219
382
|
code,
|
|
220
|
-
setCode: (
|
|
383
|
+
setCode: (value) => {
|
|
384
|
+
const next = formatUserCode(value);
|
|
385
|
+
if (next === code) return;
|
|
386
|
+
lifetime.invalidate();
|
|
221
387
|
decision.reset();
|
|
222
|
-
|
|
388
|
+
setInput((current) => ({ ...current, code: next }));
|
|
223
389
|
},
|
|
224
390
|
invalidLetter,
|
|
225
|
-
approve: (choice) =>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
391
|
+
approve: (choice) =>
|
|
392
|
+
decision.mutate({ api, code: letters, decision: "approve", choice, operation: capture() }),
|
|
393
|
+
deny: () => decision.mutate({ api, code: letters, decision: "deny", operation: capture() }),
|
|
394
|
+
deciding: currentDecision && decision.isPending,
|
|
395
|
+
pending: currentDecision && decision.isPending ? decision.variables.decision : null,
|
|
396
|
+
decideError: currentDecision ? decision.error : null,
|
|
230
397
|
};
|
|
398
|
+
deviceAddressWrites.set(device, recordAddressWrite);
|
|
399
|
+
return device;
|
|
231
400
|
}
|
|
232
401
|
|
|
233
402
|
export interface DeviceCodeField {
|
|
@@ -268,13 +437,16 @@ export function useDeviceCodeField<Choice, Extra extends object>(
|
|
|
268
437
|
const statusId = `${id}-status`;
|
|
269
438
|
const { status } = device.state;
|
|
270
439
|
const inAddress = status === "approved" || status === "denied" ? "" : device.code;
|
|
440
|
+
const recordAddressWrite = deviceAddressWrites.get(device);
|
|
271
441
|
useEffect(() => {
|
|
272
442
|
const url = new URL(window.location.href);
|
|
273
443
|
if (inAddress) url.searchParams.set("code", inAddress);
|
|
274
444
|
else url.searchParams.delete("code");
|
|
275
|
-
if (url.href !== window.location.href)
|
|
445
|
+
if (url.href !== window.location.href) {
|
|
446
|
+
recordAddressWrite?.(inAddress);
|
|
276
447
|
window.history.replaceState(window.history.state, "", url);
|
|
277
|
-
|
|
448
|
+
}
|
|
449
|
+
}, [inAddress, recordAddressWrite]);
|
|
278
450
|
|
|
279
451
|
return {
|
|
280
452
|
inputProps: {
|
|
@@ -299,8 +471,8 @@ export type OAuthConsoleHttp = Pick<ApiClient, "get" | "post">;
|
|
|
299
471
|
|
|
300
472
|
/**
|
|
301
473
|
* export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
|
|
302
|
-
* useConsentRequest(oauthApi.consent, search);
|
|
303
|
-
* useDeviceApproval(oauthApi.device, search);
|
|
474
|
+
* useConsentRequest(oauthApi.consent, search, scope);
|
|
475
|
+
* useDeviceApproval(oauthApi.device, search, scope);
|
|
304
476
|
*
|
|
305
477
|
* Both pages' calls to the server's `oauthConsoleRoutes`, mounted at `basePath` behind your
|
|
306
478
|
* sign-in. A choice travels as `choice`, which is where those routes hand it to your approver.
|
|
@@ -316,13 +488,15 @@ export function createOAuthConsoleApi<
|
|
|
316
488
|
): { consent: ConsentApi<Choice, ConsentExtra>; device: DeviceApi<Choice, DeviceExtra> } {
|
|
317
489
|
return {
|
|
318
490
|
consent: {
|
|
319
|
-
describe: (query) => http.get(`${basePath}/request?${query}
|
|
320
|
-
approve: (query, choice) =>
|
|
491
|
+
describe: (query, options) => http.get(`${basePath}/request?${query}`, options),
|
|
492
|
+
approve: (query, choice, options) =>
|
|
493
|
+
http.post(`${basePath}/approve`, { query, choice }, options),
|
|
321
494
|
},
|
|
322
495
|
device: {
|
|
323
|
-
describe: (code) =>
|
|
324
|
-
|
|
325
|
-
|
|
496
|
+
describe: (code, options) =>
|
|
497
|
+
http.get(`${basePath}/device?code=${encodeURIComponent(code)}`, options),
|
|
498
|
+
decide: (code, decision, choice, options) =>
|
|
499
|
+
http.post(`${basePath}/device`, { code, decision, choice }, options),
|
|
326
500
|
},
|
|
327
501
|
};
|
|
328
502
|
}
|
|
@@ -135,6 +135,37 @@ describe("createSupabaseSessionAdapter", () => {
|
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
it("shares one refresh between callers that overlap, then refreshes again", async () => {
|
|
139
|
+
let calls = 0;
|
|
140
|
+
let release: () => void = () => {};
|
|
141
|
+
const adapter = createSupabaseSessionAdapter(
|
|
142
|
+
auth({
|
|
143
|
+
refreshSession: async () => {
|
|
144
|
+
calls += 1;
|
|
145
|
+
await new Promise<void>((resolve) => {
|
|
146
|
+
release = resolve;
|
|
147
|
+
});
|
|
148
|
+
return { data: { session: { access_token: `token-${calls}` } }, error: null };
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// The API client and a hand-opened stream asking at the same moment.
|
|
154
|
+
const first = adapter.refresh();
|
|
155
|
+
const second = adapter.refresh();
|
|
156
|
+
release();
|
|
157
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
158
|
+
{ token: "token-1", reachedAuth: true },
|
|
159
|
+
{ token: "token-1", reachedAuth: true },
|
|
160
|
+
]);
|
|
161
|
+
expect(calls).toBe(1);
|
|
162
|
+
|
|
163
|
+
const later = adapter.refresh();
|
|
164
|
+
release();
|
|
165
|
+
await expect(later).resolves.toEqual({ token: "token-2", reachedAuth: true });
|
|
166
|
+
expect(calls).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
138
169
|
it("keeps the session when a refresh never reached auth", async () => {
|
|
139
170
|
const retryable = new AuthRetryableFetchError("offline", 0);
|
|
140
171
|
const returned = createSupabaseSessionAdapter(
|
package/src/supabase-session.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type SupabaseClientOptions,
|
|
7
7
|
type SupportedStorage,
|
|
8
8
|
} from "@supabase/supabase-js";
|
|
9
|
-
import { SIGN_OUT_TIMEOUT_MS, type SessionAdapter } from "./api-client.ts";
|
|
9
|
+
import { SIGN_OUT_TIMEOUT_MS, type RefreshResult, type SessionAdapter } from "./api-client.ts";
|
|
10
10
|
|
|
11
11
|
interface SupabaseSessionResult {
|
|
12
12
|
data: { session: { access_token: string } | null };
|
|
@@ -283,26 +283,39 @@ async function withinSignOutDeadline(
|
|
|
283
283
|
* `stored` is optional here but not at a sign-out button. The API client signs out only after
|
|
284
284
|
* auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
|
|
285
285
|
* a moment earlier, and what `stored` covers is the connection dropping in between.
|
|
286
|
+
*
|
|
287
|
+
* `refresh` is single-flight here as well as inside the API client. The client's gate only covers
|
|
288
|
+
* the client: a stream opened by hand (React Native's SSE over XHR, which cannot go through
|
|
289
|
+
* `apiStream`) refreshes through this adapter directly. GoTrue rotates the refresh token on use,
|
|
290
|
+
* so a stream starting while the boot queries refresh would race them, and the loser signs the
|
|
291
|
+
* person out. Sharing the call here closes that for every caller of the adapter.
|
|
286
292
|
*/
|
|
287
293
|
export function createSupabaseSessionAdapter(
|
|
288
294
|
auth: SupabaseSessionAuth,
|
|
289
295
|
stored?: SupabaseSessionStorage,
|
|
290
296
|
): SessionAdapter {
|
|
297
|
+
let refreshing: Promise<RefreshResult> | null = null;
|
|
298
|
+
const refreshOnce = async (): Promise<RefreshResult> => {
|
|
299
|
+
try {
|
|
300
|
+
const { data, error } = await auth.refreshSession();
|
|
301
|
+
return {
|
|
302
|
+
token: data.session?.access_token ?? null,
|
|
303
|
+
reachedAuth: error === null || reachedAuth(error),
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return { token: null, reachedAuth: reachedAuth(error) };
|
|
307
|
+
}
|
|
308
|
+
};
|
|
291
309
|
return {
|
|
292
310
|
getToken: async () => {
|
|
293
311
|
const { data } = await auth.getSession();
|
|
294
312
|
return data.session?.access_token ?? null;
|
|
295
313
|
},
|
|
296
|
-
refresh:
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
reachedAuth: error === null || reachedAuth(error),
|
|
302
|
-
};
|
|
303
|
-
} catch (error) {
|
|
304
|
-
return { token: null, reachedAuth: reachedAuth(error) };
|
|
305
|
-
}
|
|
314
|
+
refresh: () => {
|
|
315
|
+
refreshing ??= refreshOnce().finally(() => {
|
|
316
|
+
refreshing = null;
|
|
317
|
+
});
|
|
318
|
+
return refreshing;
|
|
306
319
|
},
|
|
307
320
|
signOut: async () => {
|
|
308
321
|
const { error } = stored
|
|
@@ -327,7 +340,12 @@ export function createSupabaseSessionAdapter(
|
|
|
327
340
|
* after.
|
|
328
341
|
*/
|
|
329
342
|
export type EmailLinkType =
|
|
330
|
-
|
|
343
|
+
| "signup"
|
|
344
|
+
| "invite"
|
|
345
|
+
| "magiclink"
|
|
346
|
+
| "recovery"
|
|
347
|
+
| "email_change"
|
|
348
|
+
| "email";
|
|
331
349
|
|
|
332
350
|
const EMAIL_LINK_TYPES: Record<EmailLinkType, true> = {
|
|
333
351
|
signup: true,
|
package/src/ui/combobox.tsx
CHANGED
|
@@ -39,7 +39,8 @@ export function comboboxInputValue<T>(
|
|
|
39
39
|
|
|
40
40
|
/** Pass text for the loading state. There is no default text or English fallback. */
|
|
41
41
|
type ComboboxLoadingState =
|
|
42
|
-
{ loading: boolean; loadingHint: ReactNode }
|
|
42
|
+
| { loading: boolean; loadingHint: ReactNode }
|
|
43
|
+
| { loading?: undefined; loadingHint?: undefined };
|
|
43
44
|
|
|
44
45
|
export type ComboboxProps<T> = Omit<
|
|
45
46
|
Primitive.Root.Props<T, false, T>,
|