@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/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. What they settle once:
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
- export interface ConsentView {
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 interface DeviceView {
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(code: string, decision: "approve" | "deny"): Promise<{ ok: true } | OAuthRefusal>;
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(api: DeviceApi, search: string): DeviceApproval {
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: (choice: "approve" | "deny") => api.decide(letters, choice),
195
+ mutationFn: (pressed: { decision: "approve" | "deny"; choice?: Choice }) =>
196
+ api.decide(letters, pressed.decision, pressed.choice),
184
197
  });
185
- const decided = decision.data?.ok === true ? decision.variables : undefined;
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 { afterEach, describe, expect, it } from "vitest";
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