@gusnips/react 0.9.29 → 0.9.31
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 +102 -9
- package/dist/keys.d.ts +58 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +94 -0
- package/dist/keys.js.map +1 -0
- package/dist/oauth.d.ts +76 -22
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +68 -5
- package/dist/oauth.js.map +1 -1
- package/package.json +8 -4
- package/src/keys.test.ts +253 -0
- package/src/keys.ts +144 -0
- package/src/oauth.test.ts +157 -10
- package/src/oauth.ts +137 -31
- package/src/supabase-session.browser.test.ts +6 -1
package/src/oauth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useEffect, useId, useState, type ChangeEvent } from "react";
|
|
3
|
+
import type { ApiClient } from "./api-client.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
|
|
@@ -7,7 +8,8 @@ import { useState } from "react";
|
|
|
7
8
|
* `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
|
|
8
9
|
*
|
|
9
10
|
* Each hook takes your two API calls, so your client, your session and your envelope stay yours,
|
|
10
|
-
* and returns one state to draw.
|
|
11
|
+
* and returns one state to draw. `createOAuthConsoleApi` writes those calls for the server's
|
|
12
|
+
* `oauthConsoleRoutes`. What they settle once:
|
|
11
13
|
*
|
|
12
14
|
* - A refusal (an unknown app, a return address it never registered) is a STATE, never a
|
|
13
15
|
* redirect: sending the browser anywhere on a bad request is how an open redirect is built.
|
|
@@ -29,7 +31,8 @@ export interface OAuthRefusal {
|
|
|
29
31
|
message: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
/** `Extra` is anything your server adds to the view, such as the workspaces a person may pick. */
|
|
35
|
+
export type ConsentView<Extra extends object = object> = Extra & {
|
|
33
36
|
ok: true;
|
|
34
37
|
/** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
|
|
35
38
|
clientName: string;
|
|
@@ -39,9 +42,9 @@ export interface ConsentView {
|
|
|
39
42
|
keyName: string;
|
|
40
43
|
/** Where "Cancel" goes: back to the app, which is told the person said no. */
|
|
41
44
|
cancelUrl: string;
|
|
42
|
-
}
|
|
45
|
+
};
|
|
43
46
|
|
|
44
|
-
export
|
|
47
|
+
export type DeviceView<Extra extends object = object> = Extra & {
|
|
45
48
|
ok: true;
|
|
46
49
|
clientName: string;
|
|
47
50
|
/** As the script printed it: `WDJB-MJHT`. */
|
|
@@ -49,25 +52,25 @@ export interface DeviceView {
|
|
|
49
52
|
keyName: string;
|
|
50
53
|
/** ISO time: JSON carries the server's `Date` as a string. */
|
|
51
54
|
expiresAt: string;
|
|
52
|
-
}
|
|
55
|
+
};
|
|
53
56
|
|
|
54
|
-
export interface ConsentApi<Choice> {
|
|
57
|
+
export interface ConsentApi<Choice = void, Extra extends object = object> {
|
|
55
58
|
/** Your API's call to the server's `describe`, with the query string as it came. */
|
|
56
|
-
describe(query: string): Promise<ConsentView | OAuthRefusal>;
|
|
59
|
+
describe(query: string): Promise<ConsentView<Extra> | OAuthRefusal>;
|
|
57
60
|
/** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
|
|
58
61
|
approve(query: string, choice: Choice): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
export type ConsentState =
|
|
64
|
+
export type ConsentState<Extra extends object = object> =
|
|
62
65
|
| { status: "loading" }
|
|
63
66
|
/** Show `refusal` and a way home. Never a link back to the app. */
|
|
64
67
|
| { status: "refused"; refusal: OAuthRefusal }
|
|
65
68
|
/** The request failed to arrive or answer. Show what failed, and `retry`. */
|
|
66
69
|
| { status: "failed"; error: unknown; retry: () => void }
|
|
67
|
-
| { status: "ready"; view: ConsentView };
|
|
70
|
+
| { status: "ready"; view: ConsentView<Extra> };
|
|
68
71
|
|
|
69
|
-
export interface ConsentRequest<Choice> {
|
|
70
|
-
state: ConsentState
|
|
72
|
+
export interface ConsentRequest<Choice = void, Extra extends object = object> {
|
|
73
|
+
state: ConsentState<Extra>;
|
|
71
74
|
approve(choice: Choice): void;
|
|
72
75
|
/** From the press until the browser has left. Keep the button busy the whole time. */
|
|
73
76
|
leaving: boolean;
|
|
@@ -79,10 +82,10 @@ export interface ConsentRequest<Choice> {
|
|
|
79
82
|
* const consent = useConsentRequest(api.oauth, useLocation().search);
|
|
80
83
|
* if (consent.state.status === "ready") ... consent.approve(workspaceId)
|
|
81
84
|
*/
|
|
82
|
-
export function useConsentRequest<Choice = void>(
|
|
83
|
-
api: ConsentApi<Choice>,
|
|
85
|
+
export function useConsentRequest<Choice = void, Extra extends object = object>(
|
|
86
|
+
api: ConsentApi<Choice, Extra>,
|
|
84
87
|
search: string,
|
|
85
|
-
): ConsentRequest<Choice> {
|
|
88
|
+
): ConsentRequest<Choice, Extra> {
|
|
86
89
|
const query = search.replace(/^\?/, "");
|
|
87
90
|
const request = useQuery({
|
|
88
91
|
queryKey: ["@gusnips/react/oauth", "consent", query],
|
|
@@ -99,7 +102,7 @@ export function useConsentRequest<Choice = void>(
|
|
|
99
102
|
const answer = request.data;
|
|
100
103
|
const refusal =
|
|
101
104
|
approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
|
|
102
|
-
let state: ConsentState
|
|
105
|
+
let state: ConsentState<Extra>;
|
|
103
106
|
if (refusal) state = { status: "refused", refusal };
|
|
104
107
|
else if (answer?.ok === true) state = { status: "ready", view: answer };
|
|
105
108
|
else if (request.isError)
|
|
@@ -114,14 +117,18 @@ export function useConsentRequest<Choice = void>(
|
|
|
114
117
|
};
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
export interface DeviceApi {
|
|
120
|
+
export interface DeviceApi<Choice = void, Extra extends object = object> {
|
|
118
121
|
/** Your API's call to the server's `describeDevice`. */
|
|
119
|
-
describe(code: string): Promise<DeviceView | OAuthRefusal>;
|
|
120
|
-
/** Your API's call to `decideDevice`. */
|
|
121
|
-
decide(
|
|
122
|
+
describe(code: string): Promise<DeviceView<Extra> | OAuthRefusal>;
|
|
123
|
+
/** Your API's call to `decideDevice`. `choice` comes with a yes only, as on the consent page. */
|
|
124
|
+
decide(
|
|
125
|
+
code: string,
|
|
126
|
+
decision: "approve" | "deny",
|
|
127
|
+
choice?: Choice,
|
|
128
|
+
): Promise<{ ok: true } | OAuthRefusal>;
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
export type DeviceState =
|
|
131
|
+
export type DeviceState<Extra extends object = object> =
|
|
125
132
|
/** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
|
|
126
133
|
| { status: "typing" }
|
|
127
134
|
| { status: "loading" }
|
|
@@ -133,21 +140,23 @@ export type DeviceState =
|
|
|
133
140
|
* this is the code on your screen. Somebody can run the real script and send a stranger the
|
|
134
141
|
* link, and that sentence is the only guard against it (RFC 8628 §5.4).
|
|
135
142
|
*/
|
|
136
|
-
| { status: "ready"; view: DeviceView }
|
|
143
|
+
| { status: "ready"; view: DeviceView<Extra> }
|
|
137
144
|
/** Done. Tell them to go back to the app, which carries on by itself. */
|
|
138
145
|
| { status: "approved" }
|
|
139
146
|
| { status: "denied" };
|
|
140
147
|
|
|
141
|
-
export interface DeviceApproval {
|
|
142
|
-
state: DeviceState
|
|
148
|
+
export interface DeviceApproval<Choice = void, Extra extends object = object> {
|
|
149
|
+
state: DeviceState<Extra>;
|
|
143
150
|
/** What the field shows, formatted as they type: `WDJB-MJ`. */
|
|
144
151
|
code: string;
|
|
145
152
|
setCode(input: string): void;
|
|
146
153
|
/** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
|
|
147
154
|
invalidLetter: boolean;
|
|
148
|
-
approve(): void;
|
|
155
|
+
approve(choice: Choice): void;
|
|
149
156
|
deny(): void;
|
|
150
157
|
deciding: boolean;
|
|
158
|
+
/** Which button was pressed, until the server answers: put the spinner on that one. */
|
|
159
|
+
pending: "approve" | "deny" | null;
|
|
151
160
|
/** The decision did not answer. Show it beside the buttons; pressing again retries. */
|
|
152
161
|
decideError: unknown;
|
|
153
162
|
}
|
|
@@ -171,7 +180,10 @@ export function formatUserCode(input: string): string {
|
|
|
171
180
|
*
|
|
172
181
|
* The link the script prints carries `?code=`, so the field usually starts full.
|
|
173
182
|
*/
|
|
174
|
-
export function useDeviceApproval
|
|
183
|
+
export function useDeviceApproval<Choice = void, Extra extends object = object>(
|
|
184
|
+
api: DeviceApi<Choice, Extra>,
|
|
185
|
+
search: string,
|
|
186
|
+
): DeviceApproval<Choice, Extra> {
|
|
175
187
|
const [code, setTyped] = useState(() =>
|
|
176
188
|
formatUserCode(new URLSearchParams(search).get("code") ?? ""),
|
|
177
189
|
);
|
|
@@ -180,9 +192,10 @@ export function useDeviceApproval(api: DeviceApi, search: string): DeviceApprova
|
|
|
180
192
|
const complete = letters.length === LENGTH && !invalidLetter;
|
|
181
193
|
|
|
182
194
|
const decision = useMutation({
|
|
183
|
-
mutationFn: (
|
|
195
|
+
mutationFn: (pressed: { decision: "approve" | "deny"; choice?: Choice }) =>
|
|
196
|
+
api.decide(letters, pressed.decision, pressed.choice),
|
|
184
197
|
});
|
|
185
|
-
const decided = decision.data
|
|
198
|
+
const decided = decision.isSuccess && decision.data.ok ? decision.variables.decision : undefined;
|
|
186
199
|
const request = useQuery({
|
|
187
200
|
queryKey: ["@gusnips/react/oauth", "device", letters],
|
|
188
201
|
queryFn: () => api.describe(letters),
|
|
@@ -192,7 +205,7 @@ export function useDeviceApproval(api: DeviceApi, search: string): DeviceApprova
|
|
|
192
205
|
const answer = request.data;
|
|
193
206
|
const refusal =
|
|
194
207
|
decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
|
|
195
|
-
let state: DeviceState
|
|
208
|
+
let state: DeviceState<Extra>;
|
|
196
209
|
if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
|
|
197
210
|
else if (!complete) state = { status: "typing" };
|
|
198
211
|
else if (refusal) state = { status: "refused", refusal };
|
|
@@ -209,9 +222,102 @@ export function useDeviceApproval(api: DeviceApi, search: string): DeviceApprova
|
|
|
209
222
|
setTyped(formatUserCode(input));
|
|
210
223
|
},
|
|
211
224
|
invalidLetter,
|
|
212
|
-
approve: () => decision.mutate("approve"),
|
|
213
|
-
deny: () => decision.mutate("deny"),
|
|
225
|
+
approve: (choice) => decision.mutate({ decision: "approve", choice }),
|
|
226
|
+
deny: () => decision.mutate({ decision: "deny" }),
|
|
214
227
|
deciding: decision.isPending,
|
|
228
|
+
pending: decision.isPending ? decision.variables.decision : null,
|
|
215
229
|
decideError: decision.error,
|
|
216
230
|
};
|
|
217
231
|
}
|
|
232
|
+
|
|
233
|
+
export interface DeviceCodeField {
|
|
234
|
+
/** Spread on the code's `<input>`. Point your `<label htmlFor>` at its `id`. */
|
|
235
|
+
inputProps: {
|
|
236
|
+
id: string;
|
|
237
|
+
value: string;
|
|
238
|
+
onChange(event: ChangeEvent<HTMLInputElement>): void;
|
|
239
|
+
placeholder: string;
|
|
240
|
+
autoComplete: "off";
|
|
241
|
+
autoCapitalize: "characters";
|
|
242
|
+
spellCheck: false;
|
|
243
|
+
/** Only when the field starts empty: a code from the link is there to be read first. */
|
|
244
|
+
autoFocus: boolean;
|
|
245
|
+
disabled: boolean;
|
|
246
|
+
"aria-invalid": boolean;
|
|
247
|
+
"aria-describedby": string;
|
|
248
|
+
};
|
|
249
|
+
/** Spread on the line under the field that says what is happening: a vowel, checking, refused. */
|
|
250
|
+
statusProps: { id: string; "aria-live": "polite" };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* const field = useDeviceCodeField(device);
|
|
255
|
+
* <label htmlFor={field.inputProps.id}>Code</label>
|
|
256
|
+
* <input {...field.inputProps} />
|
|
257
|
+
* <p {...field.statusProps}>{device.invalidLetter ? "Codes have no vowels." : null}</p>
|
|
258
|
+
*
|
|
259
|
+
* The field's wiring, and the code kept in the address as `?code=`, so a reload or a shared link
|
|
260
|
+
* comes back to the same code. The address is replaced, never pushed, so Back still leaves the
|
|
261
|
+
* page, and the router's own state rides along. Once decided the code leaves the address: the
|
|
262
|
+
* server no longer knows it, and a reload would only tell the person it is wrong.
|
|
263
|
+
*/
|
|
264
|
+
export function useDeviceCodeField<Choice, Extra extends object>(
|
|
265
|
+
device: DeviceApproval<Choice, Extra>,
|
|
266
|
+
): DeviceCodeField {
|
|
267
|
+
const id = useId();
|
|
268
|
+
const statusId = `${id}-status`;
|
|
269
|
+
const { status } = device.state;
|
|
270
|
+
const inAddress = status === "approved" || status === "denied" ? "" : device.code;
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const url = new URL(window.location.href);
|
|
273
|
+
if (inAddress) url.searchParams.set("code", inAddress);
|
|
274
|
+
else url.searchParams.delete("code");
|
|
275
|
+
if (url.href !== window.location.href)
|
|
276
|
+
window.history.replaceState(window.history.state, "", url);
|
|
277
|
+
}, [inAddress]);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
inputProps: {
|
|
281
|
+
id,
|
|
282
|
+
value: device.code,
|
|
283
|
+
onChange: (event) => device.setCode(event.target.value),
|
|
284
|
+
placeholder: "WDJB-MJHT",
|
|
285
|
+
autoComplete: "off",
|
|
286
|
+
autoCapitalize: "characters",
|
|
287
|
+
spellCheck: false,
|
|
288
|
+
autoFocus: device.code === "",
|
|
289
|
+
disabled: device.deciding,
|
|
290
|
+
"aria-invalid": device.invalidLetter || status === "refused",
|
|
291
|
+
"aria-describedby": statusId,
|
|
292
|
+
},
|
|
293
|
+
statusProps: { id: statusId, "aria-live": "polite" },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The calls the console API makes. `createApiClient`'s client fits as it is. */
|
|
298
|
+
export type OAuthConsoleHttp = Pick<ApiClient, "get" | "post">;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
|
|
302
|
+
* useConsentRequest(oauthApi.consent, search);
|
|
303
|
+
* useDeviceApproval(oauthApi.device, search);
|
|
304
|
+
*
|
|
305
|
+
* Both pages' calls to the server's `oauthConsoleRoutes`, mounted at `basePath` behind your
|
|
306
|
+
* sign-in. A choice travels as `choice`, which is where those routes hand it to your approver.
|
|
307
|
+
*/
|
|
308
|
+
export function createOAuthConsoleApi<Choice = void>(
|
|
309
|
+
http: OAuthConsoleHttp,
|
|
310
|
+
basePath: string,
|
|
311
|
+
): { consent: ConsentApi<Choice>; device: DeviceApi<Choice> } {
|
|
312
|
+
return {
|
|
313
|
+
consent: {
|
|
314
|
+
describe: (query) => http.get(`${basePath}/request?${query}`),
|
|
315
|
+
approve: (query, choice) => http.post(`${basePath}/approve`, { query, choice }),
|
|
316
|
+
},
|
|
317
|
+
device: {
|
|
318
|
+
describe: (code) => http.get(`${basePath}/device?code=${encodeURIComponent(code)}`),
|
|
319
|
+
decide: (code, decision, choice) =>
|
|
320
|
+
http.post(`${basePath}/device`, { code, decision, choice }),
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { createClient } from "@supabase/supabase-js";
|
|
3
|
-
import {
|
|
3
|
+
import { Window } from "happy-dom";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
5
|
import { supabaseAuthStorage } from "./supabase-session.ts";
|
|
5
6
|
|
|
6
7
|
// A real browser `localStorage` and a real auth-js, so what is measured is the choice supabase-js
|
|
7
8
|
// makes for itself, not this file's idea of it. The Node tests beside this one can only stub it.
|
|
9
|
+
// Node 26 has a `localStorage` of its own, `undefined` without `--localstorage-file`, and vitest
|
|
10
|
+
// leaves it in front of happy-dom's (`window` is the global, so it reads Node's too). A browser
|
|
11
|
+
// has one, so take a happy-dom window's and put it where a browser keeps it.
|
|
12
|
+
vi.stubGlobal("localStorage", new Window().localStorage);
|
|
8
13
|
const PROJECT_URL = "https://abcdefghijklmnop.supabase.co";
|
|
9
14
|
const USER_ID = "11111111-1111-4111-8111-111111111111";
|
|
10
15
|
|