@gusnips/react 0.9.33 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -6
- 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/package.json +1 -1
- package/src/oauth.test.ts +726 -26
- package/src/oauth.ts +217 -47
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
|
|
@@ -54,11 +62,87 @@ export type DeviceView<Extra extends object = object> = Extra & {
|
|
|
54
62
|
expiresAt: string;
|
|
55
63
|
};
|
|
56
64
|
|
|
65
|
+
/** Capture this from your app's identity store on each account or workspace generation. */
|
|
66
|
+
export interface OAuthScope {
|
|
67
|
+
/** Query-key prefix, including account, workspace and the identity generation. Never a token. */
|
|
68
|
+
readonly key: QueryKey;
|
|
69
|
+
/** Compare the captured generation with the live store, even before React renders again. */
|
|
70
|
+
isCurrent(): boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A decision outlived its account, request or page. Aborting does not undo a server-side decision. */
|
|
74
|
+
export class OAuthOperationChangedError extends Error {
|
|
75
|
+
constructor() {
|
|
76
|
+
super(
|
|
77
|
+
"The account, sign-in request or page changed before the decision finished. Check connected apps in the account you used before trying again.",
|
|
78
|
+
);
|
|
79
|
+
this.name = "OAuthOperationChangedError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface OAuthOperation {
|
|
84
|
+
identity: string;
|
|
85
|
+
signal: AbortSignal;
|
|
86
|
+
isCurrent(): boolean;
|
|
87
|
+
release(): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertCurrent(operation: OAuthOperation): void {
|
|
91
|
+
if (!operation.isCurrent()) throw new OAuthOperationChangedError();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createOperationLifetime(identity: string) {
|
|
95
|
+
let mounted = false;
|
|
96
|
+
let generation = 0;
|
|
97
|
+
const controllers = new Set<AbortController>();
|
|
98
|
+
return {
|
|
99
|
+
identity,
|
|
100
|
+
invalidate() {
|
|
101
|
+
generation++;
|
|
102
|
+
for (const controller of controllers) controller.abort();
|
|
103
|
+
controllers.clear();
|
|
104
|
+
},
|
|
105
|
+
mount() {
|
|
106
|
+
mounted = true;
|
|
107
|
+
return () => {
|
|
108
|
+
mounted = false;
|
|
109
|
+
this.invalidate();
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
capture(isCurrent: () => boolean): OAuthOperation {
|
|
113
|
+
const started = generation;
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
controllers.add(controller);
|
|
116
|
+
return {
|
|
117
|
+
identity,
|
|
118
|
+
signal: controller.signal,
|
|
119
|
+
isCurrent: () => mounted && started === generation && isCurrent(),
|
|
120
|
+
release: () => controllers.delete(controller),
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Both pages reject late answers, even when the adapter ignores abort or the cache was cleared. */
|
|
127
|
+
function useOAuthOperation(scope: OAuthScope, kind: "consent" | "device", input: string) {
|
|
128
|
+
const key = [...scope.key, "@gusnips/react/oauth", kind, input];
|
|
129
|
+
const identity = hashKey(key);
|
|
130
|
+
const lifetime = useMemo(() => createOperationLifetime(identity), [identity]);
|
|
131
|
+
// Invalidate by the commit, before passive cleanup can leave a late redirect window.
|
|
132
|
+
// Layout effects work on native too and do not run during SSR. StrictMode can mount again.
|
|
133
|
+
useLayoutEffect(() => lifetime.mount(), [lifetime]);
|
|
134
|
+
return { key, lifetime, capture: () => lifetime.capture(() => scope.isCurrent()) };
|
|
135
|
+
}
|
|
136
|
+
|
|
57
137
|
export interface ConsentApi<Choice = void, Extra extends object = object> {
|
|
58
|
-
/**
|
|
59
|
-
describe(query: string): Promise<ConsentView<Extra> | OAuthRefusal>;
|
|
138
|
+
/** Forward `options`, including its signal, to your HTTP client. */
|
|
139
|
+
describe(query: string, options?: RequestOptions): Promise<ConsentView<Extra> | OAuthRefusal>;
|
|
60
140
|
/** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
|
|
61
|
-
approve(
|
|
141
|
+
approve(
|
|
142
|
+
query: string,
|
|
143
|
+
choice: Choice,
|
|
144
|
+
options?: RequestOptions,
|
|
145
|
+
): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
|
|
62
146
|
}
|
|
63
147
|
|
|
64
148
|
export type ConsentState<Extra extends object = object> =
|
|
@@ -79,29 +163,59 @@ export interface ConsentRequest<Choice = void, Extra extends object = object> {
|
|
|
79
163
|
}
|
|
80
164
|
|
|
81
165
|
/**
|
|
82
|
-
* const consent = useConsentRequest(api.oauth, useLocation().search);
|
|
166
|
+
* const consent = useConsentRequest(api.oauth, useLocation().search, scope);
|
|
83
167
|
* if (consent.state.status === "ready") ... consent.approve(workspaceId)
|
|
84
168
|
*/
|
|
85
169
|
export function useConsentRequest<Choice = void, Extra extends object = object>(
|
|
86
170
|
api: ConsentApi<Choice, Extra>,
|
|
87
171
|
search: string,
|
|
172
|
+
scope: OAuthScope,
|
|
88
173
|
): ConsentRequest<Choice, Extra> {
|
|
89
174
|
const query = search.replace(/^\?/, "");
|
|
175
|
+
const { key, lifetime, capture } = useOAuthOperation(scope, "consent", query);
|
|
90
176
|
const request = useQuery({
|
|
91
|
-
queryKey:
|
|
92
|
-
queryFn: () => api.describe(query),
|
|
177
|
+
queryKey: key,
|
|
178
|
+
queryFn: ({ signal }) => api.describe(query, { signal }),
|
|
179
|
+
// A previous description is never a safe stand-in for this approval target.
|
|
180
|
+
placeholderData: () => undefined,
|
|
181
|
+
retry: false,
|
|
182
|
+
refetchOnWindowFocus: false,
|
|
93
183
|
});
|
|
94
184
|
const approval = useMutation({
|
|
95
|
-
mutationFn: (
|
|
96
|
-
|
|
97
|
-
|
|
185
|
+
mutationFn: async (pressed: {
|
|
186
|
+
api: ConsentApi<Choice, Extra>;
|
|
187
|
+
query: string;
|
|
188
|
+
choice: Choice;
|
|
189
|
+
operation: OAuthOperation;
|
|
190
|
+
}) => {
|
|
191
|
+
const { api, query, choice, operation } = pressed;
|
|
192
|
+
try {
|
|
193
|
+
assertCurrent(operation);
|
|
194
|
+
const answer = await api.approve(query, choice, { signal: operation.signal });
|
|
195
|
+
// Before returning: stale success data must never enter the mutation cache.
|
|
196
|
+
assertCurrent(operation);
|
|
197
|
+
return answer;
|
|
198
|
+
} finally {
|
|
199
|
+
operation.release();
|
|
200
|
+
}
|
|
98
201
|
},
|
|
202
|
+
onSettled: (answer, error, { operation }) => {
|
|
203
|
+
// Global success and settlement callbacks may await an identity change. Check last.
|
|
204
|
+
if (error !== null) return;
|
|
205
|
+
assertCurrent(operation);
|
|
206
|
+
if (answer?.ok) window.location.assign(answer.redirectTo);
|
|
207
|
+
},
|
|
208
|
+
retry: false,
|
|
209
|
+
networkMode: "always",
|
|
99
210
|
});
|
|
211
|
+
const currentApproval =
|
|
212
|
+
approval.variables?.operation.identity === lifetime.identity &&
|
|
213
|
+
approval.variables.operation.isCurrent();
|
|
100
214
|
|
|
101
215
|
// A refusal from approve (the request went stale between the two calls) outranks the view.
|
|
102
216
|
const answer = request.data;
|
|
103
|
-
|
|
104
|
-
|
|
217
|
+
let refusal = answer?.ok === false ? answer : null;
|
|
218
|
+
if (currentApproval && approval.data?.ok === false) refusal = approval.data;
|
|
105
219
|
let state: ConsentState<Extra>;
|
|
106
220
|
if (refusal) state = { status: "refused", refusal };
|
|
107
221
|
else if (answer?.ok === true) state = { status: "ready", view: answer };
|
|
@@ -111,20 +225,21 @@ export function useConsentRequest<Choice = void, Extra extends object = object>(
|
|
|
111
225
|
|
|
112
226
|
return {
|
|
113
227
|
state,
|
|
114
|
-
approve: (choice) => approval.mutate(choice),
|
|
115
|
-
leaving: approval.isPending || approval.data?.ok === true,
|
|
116
|
-
approveError: approval.error,
|
|
228
|
+
approve: (choice) => approval.mutate({ api, query, choice, operation: capture() }),
|
|
229
|
+
leaving: currentApproval && (approval.isPending || approval.data?.ok === true),
|
|
230
|
+
approveError: currentApproval ? approval.error : null,
|
|
117
231
|
};
|
|
118
232
|
}
|
|
119
233
|
|
|
120
234
|
export interface DeviceApi<Choice = void, Extra extends object = object> {
|
|
121
|
-
/**
|
|
122
|
-
describe(code: string): Promise<DeviceView<Extra> | OAuthRefusal>;
|
|
235
|
+
/** Forward `options`, including its signal, to your HTTP client. */
|
|
236
|
+
describe(code: string, options?: RequestOptions): Promise<DeviceView<Extra> | OAuthRefusal>;
|
|
123
237
|
/** Your API's call to `decideDevice`. `choice` comes with a yes only, as on the consent page. */
|
|
124
238
|
decide(
|
|
125
239
|
code: string,
|
|
126
240
|
decision: "approve" | "deny",
|
|
127
241
|
choice?: Choice,
|
|
242
|
+
options?: RequestOptions,
|
|
128
243
|
): Promise<{ ok: true } | OAuthRefusal>;
|
|
129
244
|
}
|
|
130
245
|
|
|
@@ -165,6 +280,9 @@ export interface DeviceApproval<Choice = void, Extra extends object = object> {
|
|
|
165
280
|
const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
166
281
|
const LENGTH = 8;
|
|
167
282
|
|
|
283
|
+
// Private wiring between the approval and field hooks, without adding to the public page API.
|
|
284
|
+
const deviceAddressWrites = new WeakMap<object, (code: string) => void>();
|
|
285
|
+
|
|
168
286
|
/** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
|
|
169
287
|
export function formatUserCode(input: string): string {
|
|
170
288
|
const letters = input
|
|
@@ -175,7 +293,7 @@ export function formatUserCode(input: string): string {
|
|
|
175
293
|
}
|
|
176
294
|
|
|
177
295
|
/**
|
|
178
|
-
* const device = useDeviceApproval(api.device, useLocation().search);
|
|
296
|
+
* const device = useDeviceApproval(api.device, useLocation().search, scope);
|
|
179
297
|
* <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
|
|
180
298
|
*
|
|
181
299
|
* The link the script prints carries `?code=`, so the field usually starts full.
|
|
@@ -183,28 +301,69 @@ export function formatUserCode(input: string): string {
|
|
|
183
301
|
export function useDeviceApproval<Choice = void, Extra extends object = object>(
|
|
184
302
|
api: DeviceApi<Choice, Extra>,
|
|
185
303
|
search: string,
|
|
304
|
+
scope: OAuthScope,
|
|
186
305
|
): DeviceApproval<Choice, Extra> {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
306
|
+
const fromLink = formatUserCode(new URLSearchParams(search).get("code") ?? "");
|
|
307
|
+
const [input, setInput] = useState<{
|
|
308
|
+
fromLink: string;
|
|
309
|
+
code: string;
|
|
310
|
+
ownAddress?: string;
|
|
311
|
+
}>({ fromLink, code: fromLink });
|
|
312
|
+
const recordAddressWrite = useCallback((ownAddress: string) => {
|
|
313
|
+
setInput((current) => ({ ...current, ownAddress }));
|
|
314
|
+
}, []);
|
|
315
|
+
// Only external links replace input. The field also writes the address while typing and
|
|
316
|
+
// clears it after a decision; neither write should replace input or invalidate the decision.
|
|
317
|
+
const code = input.fromLink === fromLink || input.ownAddress === fromLink ? input.code : fromLink;
|
|
318
|
+
if (input.fromLink !== fromLink) setInput({ fromLink, code });
|
|
190
319
|
const letters = code.replace("-", "");
|
|
191
320
|
const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
|
|
192
321
|
const complete = letters.length === LENGTH && !invalidLetter;
|
|
322
|
+
const { key, lifetime, capture } = useOAuthOperation(scope, "device", letters);
|
|
193
323
|
|
|
194
324
|
const decision = useMutation({
|
|
195
|
-
mutationFn: (pressed: {
|
|
196
|
-
api
|
|
325
|
+
mutationFn: async (pressed: {
|
|
326
|
+
api: DeviceApi<Choice, Extra>;
|
|
327
|
+
code: string;
|
|
328
|
+
decision: "approve" | "deny";
|
|
329
|
+
choice?: Choice;
|
|
330
|
+
operation: OAuthOperation;
|
|
331
|
+
}) => {
|
|
332
|
+
const { api, code, decision, choice, operation } = pressed;
|
|
333
|
+
try {
|
|
334
|
+
assertCurrent(operation);
|
|
335
|
+
const answer = await api.decide(code, decision, choice, { signal: operation.signal });
|
|
336
|
+
assertCurrent(operation);
|
|
337
|
+
return answer;
|
|
338
|
+
} finally {
|
|
339
|
+
operation.release();
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
onSettled: (_answer, error, { operation }) => {
|
|
343
|
+
if (error === null) assertCurrent(operation);
|
|
344
|
+
},
|
|
345
|
+
retry: false,
|
|
346
|
+
networkMode: "always",
|
|
197
347
|
});
|
|
198
|
-
const
|
|
348
|
+
const currentDecision =
|
|
349
|
+
decision.variables?.operation.identity === lifetime.identity &&
|
|
350
|
+
decision.variables.operation.isCurrent();
|
|
351
|
+
const decided =
|
|
352
|
+
currentDecision && decision.isSuccess && decision.data.ok
|
|
353
|
+
? decision.variables.decision
|
|
354
|
+
: undefined;
|
|
199
355
|
const request = useQuery({
|
|
200
|
-
queryKey:
|
|
201
|
-
queryFn: () => api.describe(letters),
|
|
356
|
+
queryKey: key,
|
|
357
|
+
queryFn: ({ signal }) => api.describe(letters, { signal }),
|
|
202
358
|
enabled: complete && decided === undefined,
|
|
359
|
+
placeholderData: () => undefined,
|
|
360
|
+
retry: false,
|
|
361
|
+
refetchOnWindowFocus: false,
|
|
203
362
|
});
|
|
204
363
|
|
|
205
364
|
const answer = request.data;
|
|
206
|
-
|
|
207
|
-
|
|
365
|
+
let refusal = answer?.ok === false ? answer : null;
|
|
366
|
+
if (currentDecision && decision.data?.ok === false) refusal = decision.data;
|
|
208
367
|
let state: DeviceState<Extra>;
|
|
209
368
|
if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
|
|
210
369
|
else if (!complete) state = { status: "typing" };
|
|
@@ -214,20 +373,26 @@ export function useDeviceApproval<Choice = void, Extra extends object = object>(
|
|
|
214
373
|
state = { status: "failed", error: request.error, retry: () => void request.refetch() };
|
|
215
374
|
else state = { status: "loading" };
|
|
216
375
|
|
|
217
|
-
|
|
376
|
+
const device: DeviceApproval<Choice, Extra> = {
|
|
218
377
|
state,
|
|
219
378
|
code,
|
|
220
|
-
setCode: (
|
|
379
|
+
setCode: (value) => {
|
|
380
|
+
const next = formatUserCode(value);
|
|
381
|
+
if (next === code) return;
|
|
382
|
+
lifetime.invalidate();
|
|
221
383
|
decision.reset();
|
|
222
|
-
|
|
384
|
+
setInput((current) => ({ ...current, code: next }));
|
|
223
385
|
},
|
|
224
386
|
invalidLetter,
|
|
225
|
-
approve: (choice) =>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
387
|
+
approve: (choice) =>
|
|
388
|
+
decision.mutate({ api, code: letters, decision: "approve", choice, operation: capture() }),
|
|
389
|
+
deny: () => decision.mutate({ api, code: letters, decision: "deny", operation: capture() }),
|
|
390
|
+
deciding: currentDecision && decision.isPending,
|
|
391
|
+
pending: currentDecision && decision.isPending ? decision.variables.decision : null,
|
|
392
|
+
decideError: currentDecision ? decision.error : null,
|
|
230
393
|
};
|
|
394
|
+
deviceAddressWrites.set(device, recordAddressWrite);
|
|
395
|
+
return device;
|
|
231
396
|
}
|
|
232
397
|
|
|
233
398
|
export interface DeviceCodeField {
|
|
@@ -268,13 +433,16 @@ export function useDeviceCodeField<Choice, Extra extends object>(
|
|
|
268
433
|
const statusId = `${id}-status`;
|
|
269
434
|
const { status } = device.state;
|
|
270
435
|
const inAddress = status === "approved" || status === "denied" ? "" : device.code;
|
|
436
|
+
const recordAddressWrite = deviceAddressWrites.get(device);
|
|
271
437
|
useEffect(() => {
|
|
272
438
|
const url = new URL(window.location.href);
|
|
273
439
|
if (inAddress) url.searchParams.set("code", inAddress);
|
|
274
440
|
else url.searchParams.delete("code");
|
|
275
|
-
if (url.href !== window.location.href)
|
|
441
|
+
if (url.href !== window.location.href) {
|
|
442
|
+
recordAddressWrite?.(inAddress);
|
|
276
443
|
window.history.replaceState(window.history.state, "", url);
|
|
277
|
-
|
|
444
|
+
}
|
|
445
|
+
}, [inAddress, recordAddressWrite]);
|
|
278
446
|
|
|
279
447
|
return {
|
|
280
448
|
inputProps: {
|
|
@@ -299,8 +467,8 @@ export type OAuthConsoleHttp = Pick<ApiClient, "get" | "post">;
|
|
|
299
467
|
|
|
300
468
|
/**
|
|
301
469
|
* export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
|
|
302
|
-
* useConsentRequest(oauthApi.consent, search);
|
|
303
|
-
* useDeviceApproval(oauthApi.device, search);
|
|
470
|
+
* useConsentRequest(oauthApi.consent, search, scope);
|
|
471
|
+
* useDeviceApproval(oauthApi.device, search, scope);
|
|
304
472
|
*
|
|
305
473
|
* Both pages' calls to the server's `oauthConsoleRoutes`, mounted at `basePath` behind your
|
|
306
474
|
* sign-in. A choice travels as `choice`, which is where those routes hand it to your approver.
|
|
@@ -316,13 +484,15 @@ export function createOAuthConsoleApi<
|
|
|
316
484
|
): { consent: ConsentApi<Choice, ConsentExtra>; device: DeviceApi<Choice, DeviceExtra> } {
|
|
317
485
|
return {
|
|
318
486
|
consent: {
|
|
319
|
-
describe: (query) => http.get(`${basePath}/request?${query}
|
|
320
|
-
approve: (query, choice) =>
|
|
487
|
+
describe: (query, options) => http.get(`${basePath}/request?${query}`, options),
|
|
488
|
+
approve: (query, choice, options) =>
|
|
489
|
+
http.post(`${basePath}/approve`, { query, choice }, options),
|
|
321
490
|
},
|
|
322
491
|
device: {
|
|
323
|
-
describe: (code) =>
|
|
324
|
-
|
|
325
|
-
|
|
492
|
+
describe: (code, options) =>
|
|
493
|
+
http.get(`${basePath}/device?code=${encodeURIComponent(code)}`, options),
|
|
494
|
+
decide: (code, decision, choice, options) =>
|
|
495
|
+
http.post(`${basePath}/device`, { code, decision, choice }, options),
|
|
326
496
|
},
|
|
327
497
|
};
|
|
328
498
|
}
|