@gusnips/react 0.9.30 → 0.9.32

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 CHANGED
@@ -696,10 +696,19 @@ your scripts signs in on a machine with no browser, it prints a short code, and
696
696
  it on your device page. `@gusnips/server/oauth` is the server half. These two hooks are the
697
697
  logic for the two pages. The pages themselves are yours.
698
698
 
699
+ ```ts
700
+ import { createOAuthConsoleApi } from "@gusnips/react/oauth";
701
+
702
+ export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
703
+ ```
704
+
705
+ That is both pages' calls to the server's `oauthConsoleRoutes`, mounted at `/auth/oauth` behind
706
+ your sign-in. `api` is your `createApiClient` client, or any object with its `get` and `post`.
707
+
699
708
  ```tsx
700
709
  import { useConsentRequest } from "@gusnips/react/oauth";
701
710
 
702
- const consent = useConsentRequest(api.oauth, useLocation().search);
711
+ const consent = useConsentRequest(oauthApi.consent, useLocation().search);
703
712
 
704
713
  if (consent.state.status === "ready")
705
714
  return (
@@ -709,9 +718,16 @@ if (consent.state.status === "ready")
709
718
  );
710
719
  ```
711
720
 
712
- `api.oauth` is two calls to your own API: `describe(query)` and `approve(query, choice)`. They
713
- reach the server's `describe` and `approve` through routes behind your sign-in. `state.status` is
714
- `loading`, `refused`, `failed` or `ready`:
721
+ If your routes are your own, pass your own two calls instead: `describe(query)` and
722
+ `approve(query, choice)`. The choice is anything your page adds, such as a workspace, and it reaches
723
+ the server's approver. With no choice, call `approve()`.
724
+
725
+ When your server adds fields to the view, type them after the choice:
726
+ `createOAuthConsoleApi<string, { spaces: Space[] }>(api, "/auth/oauth")`, and
727
+ `state.view.spaces` is typed on the consent page. A third type does the same for the device page.
728
+ With your own calls, it is `ConsentApi<Choice, { spaces: Space[] }>`.
729
+
730
+ `state.status` is `loading`, `refused`, `failed` or `ready`:
715
731
 
716
732
  - **`refused`** means the request is not one to answer: an unknown app, or a return address it
717
733
  never registered. Show `refusal.message` (or your own words for `refusal.reason`) and a link
@@ -726,13 +742,23 @@ for a second press. Approve sends the browser on with `window.location.assign`,
726
742
  an app's own scheme such as `cursor://`.
727
743
 
728
744
  ```tsx
729
- import { useDeviceApproval } from "@gusnips/react/oauth";
745
+ import { useDeviceApproval, useDeviceCodeField } from "@gusnips/react/oauth";
730
746
 
731
- const device = useDeviceApproval(api.device, useLocation().search);
747
+ const device = useDeviceApproval(oauthApi.device, useLocation().search);
748
+ const field = useDeviceCodeField(device);
732
749
 
733
- <input value={device.code} onChange={(event) => device.setCode(event.target.value)} />;
750
+ <label htmlFor={field.inputProps.id}>Code</label>
751
+ <input {...field.inputProps} />
752
+ <p {...field.statusProps}>{device.invalidLetter ? "Codes have no vowels." : null}</p>
734
753
  ```
735
754
 
755
+ `useDeviceCodeField` is the field's wiring: the input's id, value and change handler, the
756
+ attributes a typed code needs (no autocomplete, capitals, no spell check), `aria-invalid`, and the
757
+ status line as the input's description, read out when it changes. It focuses the field only when it
758
+ starts empty. It also keeps the code in the address as `?code=`, so a reload or a shared link comes
759
+ back to the same code. It replaces the address and never pushes, so Back still leaves the page.
760
+ Once the person decides, the code leaves the address, because the server no longer knows it.
761
+
736
762
  The link the script prints carries `?code=`, so the field usually starts full. The field shows
737
763
  what was typed as `WDJB-MJHT`, however it was typed. `invalidLetter` turns on as soon as a letter
738
764
  no code uses (a vowel) is typed, so you can say so before all eight are in. `state.status` adds
@@ -743,6 +769,10 @@ and send a stranger the link, and that sentence is the only guard against it. On
743
769
  page stays decided: it never asks the server again, because the server no longer knows the code,
744
770
  and asking on the next focus would tell someone who just said yes that their code is wrong.
745
771
 
772
+ `approve(choice)` sends a choice with the yes, as on the consent page; `deny()` sends none.
773
+ `pending` is `"approve"` or `"deny"` from the press until the server answers, so the spinner goes
774
+ on the button that was pressed.
775
+
746
776
  ## A new API key, shown once
747
777
 
748
778
  ```ts
@@ -891,7 +921,7 @@ lives behind a subpath, so you install a dependency only if you import the thing
891
921
  | `@gusnips/react/contract` | two prerender names | nothing |
892
922
  | `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
893
923
  | `@gusnips/react/commands` | slash matching and `useSlashCommands` | react |
894
- | `@gusnips/react/oauth` | `useConsentRequest`, `useDeviceApproval` | @tanstack/react-query |
924
+ | `@gusnips/react/oauth` | the consent and device page hooks | @tanstack/react-query |
895
925
  | `@gusnips/react/keys` | `createKeyLifecycle`, `useKeyReveals` | react |
896
926
 
897
927
  The rule behind that table: **a peer marked optional must not be reachable from the main entry
@@ -902,4 +932,4 @@ about it.
902
932
  `@tanstack/react-query`, `i18next` and `react-i18next` are optional and stay in the main entry,
903
933
  because only their types are used and types erase.
904
934
 
905
- MIT · part of [frontkit](https://github.com/gusnips/frontkit)
935
+ MIT · part of [kit](https://github.com/gusnips/kit)
package/dist/oauth.d.ts CHANGED
@@ -1,10 +1,13 @@
1
+ import { type ChangeEvent } from "react";
2
+ import type { ApiClient } from "./api-client.ts";
1
3
  /**
2
4
  * The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
3
5
  * person to connect, and the device page, where a script's short code is typed and approved.
4
6
  * `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
5
7
  *
6
8
  * Each hook takes your two API calls, so your client, your session and your envelope stay yours,
7
- * and returns one state to draw. What they settle once:
9
+ * and returns one state to draw. `createOAuthConsoleApi` writes those calls for the server's
10
+ * `oauthConsoleRoutes`. What they settle once:
8
11
  *
9
12
  * - A refusal (an unknown app, a return address it never registered) is a STATE, never a
10
13
  * redirect: sending the browser anywhere on a bad request is how an open redirect is built.
@@ -22,7 +25,8 @@ export interface OAuthRefusal {
22
25
  /** An English fallback a person can read. */
23
26
  message: string;
24
27
  }
25
- export interface ConsentView {
28
+ /** `Extra` is anything your server adds to the view, such as the workspaces a person may pick. */
29
+ export type ConsentView<Extra extends object = object> = Extra & {
26
30
  ok: true;
27
31
  /** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
28
32
  clientName: string;
@@ -32,8 +36,8 @@ export interface ConsentView {
32
36
  keyName: string;
33
37
  /** Where "Cancel" goes: back to the app, which is told the person said no. */
34
38
  cancelUrl: string;
35
- }
36
- export interface DeviceView {
39
+ };
40
+ export type DeviceView<Extra extends object = object> = Extra & {
37
41
  ok: true;
38
42
  clientName: string;
39
43
  /** As the script printed it: `WDJB-MJHT`. */
@@ -41,17 +45,17 @@ export interface DeviceView {
41
45
  keyName: string;
42
46
  /** ISO time: JSON carries the server's `Date` as a string. */
43
47
  expiresAt: string;
44
- }
45
- export interface ConsentApi<Choice> {
48
+ };
49
+ export interface ConsentApi<Choice = void, Extra extends object = object> {
46
50
  /** Your API's call to the server's `describe`, with the query string as it came. */
47
- describe(query: string): Promise<ConsentView | OAuthRefusal>;
51
+ describe(query: string): Promise<ConsentView<Extra> | OAuthRefusal>;
48
52
  /** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
49
53
  approve(query: string, choice: Choice): Promise<{
50
54
  ok: true;
51
55
  redirectTo: string;
52
56
  } | OAuthRefusal>;
53
57
  }
54
- export type ConsentState = {
58
+ export type ConsentState<Extra extends object = object> = {
55
59
  status: "loading";
56
60
  }
57
61
  /** Show `refusal` and a way home. Never a link back to the app. */
@@ -66,10 +70,10 @@ export type ConsentState = {
66
70
  retry: () => void;
67
71
  } | {
68
72
  status: "ready";
69
- view: ConsentView;
73
+ view: ConsentView<Extra>;
70
74
  };
71
- export interface ConsentRequest<Choice> {
72
- state: ConsentState;
75
+ export interface ConsentRequest<Choice = void, Extra extends object = object> {
76
+ state: ConsentState<Extra>;
73
77
  approve(choice: Choice): void;
74
78
  /** From the press until the browser has left. Keep the button busy the whole time. */
75
79
  leaving: boolean;
@@ -80,16 +84,16 @@ export interface ConsentRequest<Choice> {
80
84
  * const consent = useConsentRequest(api.oauth, useLocation().search);
81
85
  * if (consent.state.status === "ready") ... consent.approve(workspaceId)
82
86
  */
83
- export declare function useConsentRequest<Choice = void>(api: ConsentApi<Choice>, search: string): ConsentRequest<Choice>;
84
- export interface DeviceApi {
87
+ export declare function useConsentRequest<Choice = void, Extra extends object = object>(api: ConsentApi<Choice, Extra>, search: string): ConsentRequest<Choice, Extra>;
88
+ export interface DeviceApi<Choice = void, Extra extends object = object> {
85
89
  /** Your API's call to the server's `describeDevice`. */
86
- describe(code: string): Promise<DeviceView | OAuthRefusal>;
87
- /** Your API's call to `decideDevice`. */
88
- decide(code: string, decision: "approve" | "deny"): Promise<{
90
+ describe(code: string): Promise<DeviceView<Extra> | OAuthRefusal>;
91
+ /** Your API's call to `decideDevice`. `choice` comes with a yes only, as on the consent page. */
92
+ decide(code: string, decision: "approve" | "deny", choice?: Choice): Promise<{
89
93
  ok: true;
90
94
  } | OAuthRefusal>;
91
95
  }
92
- export type DeviceState =
96
+ export type DeviceState<Extra extends object = object> =
93
97
  /** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
94
98
  {
95
99
  status: "typing";
@@ -112,7 +116,7 @@ export type DeviceState =
112
116
  */
113
117
  | {
114
118
  status: "ready";
115
- view: DeviceView;
119
+ view: DeviceView<Extra>;
116
120
  }
117
121
  /** Done. Tell them to go back to the app, which carries on by itself. */
118
122
  | {
@@ -120,16 +124,18 @@ export type DeviceState =
120
124
  } | {
121
125
  status: "denied";
122
126
  };
123
- export interface DeviceApproval {
124
- state: DeviceState;
127
+ export interface DeviceApproval<Choice = void, Extra extends object = object> {
128
+ state: DeviceState<Extra>;
125
129
  /** What the field shows, formatted as they type: `WDJB-MJ`. */
126
130
  code: string;
127
131
  setCode(input: string): void;
128
132
  /** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
129
133
  invalidLetter: boolean;
130
- approve(): void;
134
+ approve(choice: Choice): void;
131
135
  deny(): void;
132
136
  deciding: boolean;
137
+ /** Which button was pressed, until the server answers: put the spinner on that one. */
138
+ pending: "approve" | "deny" | null;
133
139
  /** The decision did not answer. Show it beside the buttons; pressing again retries. */
134
140
  decideError: unknown;
135
141
  }
@@ -141,5 +147,54 @@ export declare function formatUserCode(input: string): string;
141
147
  *
142
148
  * The link the script prints carries `?code=`, so the field usually starts full.
143
149
  */
144
- export declare function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval;
150
+ export declare function useDeviceApproval<Choice = void, Extra extends object = object>(api: DeviceApi<Choice, Extra>, search: string): DeviceApproval<Choice, Extra>;
151
+ export interface DeviceCodeField {
152
+ /** Spread on the code's `<input>`. Point your `<label htmlFor>` at its `id`. */
153
+ inputProps: {
154
+ id: string;
155
+ value: string;
156
+ onChange(event: ChangeEvent<HTMLInputElement>): void;
157
+ placeholder: string;
158
+ autoComplete: "off";
159
+ autoCapitalize: "characters";
160
+ spellCheck: false;
161
+ /** Only when the field starts empty: a code from the link is there to be read first. */
162
+ autoFocus: boolean;
163
+ disabled: boolean;
164
+ "aria-invalid": boolean;
165
+ "aria-describedby": string;
166
+ };
167
+ /** Spread on the line under the field that says what is happening: a vowel, checking, refused. */
168
+ statusProps: {
169
+ id: string;
170
+ "aria-live": "polite";
171
+ };
172
+ }
173
+ /**
174
+ * const field = useDeviceCodeField(device);
175
+ * <label htmlFor={field.inputProps.id}>Code</label>
176
+ * <input {...field.inputProps} />
177
+ * <p {...field.statusProps}>{device.invalidLetter ? "Codes have no vowels." : null}</p>
178
+ *
179
+ * The field's wiring, and the code kept in the address as `?code=`, so a reload or a shared link
180
+ * comes back to the same code. The address is replaced, never pushed, so Back still leaves the
181
+ * page, and the router's own state rides along. Once decided the code leaves the address: the
182
+ * server no longer knows it, and a reload would only tell the person it is wrong.
183
+ */
184
+ export declare function useDeviceCodeField<Choice, Extra extends object>(device: DeviceApproval<Choice, Extra>): DeviceCodeField;
185
+ /** The calls the console API makes. `createApiClient`'s client fits as it is. */
186
+ export type OAuthConsoleHttp = Pick<ApiClient, "get" | "post">;
187
+ /**
188
+ * export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
189
+ * useConsentRequest(oauthApi.consent, search);
190
+ * useDeviceApproval(oauthApi.device, search);
191
+ *
192
+ * Both pages' calls to the server's `oauthConsoleRoutes`, mounted at `basePath` behind your
193
+ * sign-in. A choice travels as `choice`, which is where those routes hand it to your approver.
194
+ * `ConsentExtra` and `DeviceExtra` are the fields your server adds to each page's view.
195
+ */
196
+ export declare function createOAuthConsoleApi<Choice = void, ConsentExtra extends object = object, DeviceExtra extends object = object>(http: OAuthConsoleHttp, basePath: string): {
197
+ consent: ConsentApi<Choice, ConsentExtra>;
198
+ device: DeviceApi<Choice, DeviceExtra>;
199
+ };
145
200
  //# sourceMappingURL=oauth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/dist/oauth.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useMutation, useQuery } from "@tanstack/react-query";
2
- import { useState } from "react";
2
+ import { useEffect, useId, useState } from "react";
3
3
  /**
4
4
  * const consent = useConsentRequest(api.oauth, useLocation().search);
5
5
  * if (consent.state.status === "ready") ... consent.approve(workspaceId)
@@ -59,9 +59,9 @@ export function useDeviceApproval(api, search) {
59
59
  const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
60
60
  const complete = letters.length === LENGTH && !invalidLetter;
61
61
  const decision = useMutation({
62
- mutationFn: (choice) => api.decide(letters, choice),
62
+ mutationFn: (pressed) => api.decide(letters, pressed.decision, pressed.choice),
63
63
  });
64
- const decided = decision.data?.ok === true ? decision.variables : undefined;
64
+ const decided = decision.isSuccess && decision.data.ok ? decision.variables.decision : undefined;
65
65
  const request = useQuery({
66
66
  queryKey: ["@gusnips/react/oauth", "device", letters],
67
67
  queryFn: () => api.describe(letters),
@@ -90,10 +90,74 @@ export function useDeviceApproval(api, search) {
90
90
  setTyped(formatUserCode(input));
91
91
  },
92
92
  invalidLetter,
93
- approve: () => decision.mutate("approve"),
94
- deny: () => decision.mutate("deny"),
93
+ approve: (choice) => decision.mutate({ decision: "approve", choice }),
94
+ deny: () => decision.mutate({ decision: "deny" }),
95
95
  deciding: decision.isPending,
96
+ pending: decision.isPending ? decision.variables.decision : null,
96
97
  decideError: decision.error,
97
98
  };
98
99
  }
100
+ /**
101
+ * const field = useDeviceCodeField(device);
102
+ * <label htmlFor={field.inputProps.id}>Code</label>
103
+ * <input {...field.inputProps} />
104
+ * <p {...field.statusProps}>{device.invalidLetter ? "Codes have no vowels." : null}</p>
105
+ *
106
+ * The field's wiring, and the code kept in the address as `?code=`, so a reload or a shared link
107
+ * comes back to the same code. The address is replaced, never pushed, so Back still leaves the
108
+ * page, and the router's own state rides along. Once decided the code leaves the address: the
109
+ * server no longer knows it, and a reload would only tell the person it is wrong.
110
+ */
111
+ export function useDeviceCodeField(device) {
112
+ const id = useId();
113
+ const statusId = `${id}-status`;
114
+ const { status } = device.state;
115
+ const inAddress = status === "approved" || status === "denied" ? "" : device.code;
116
+ useEffect(() => {
117
+ const url = new URL(window.location.href);
118
+ if (inAddress)
119
+ url.searchParams.set("code", inAddress);
120
+ else
121
+ url.searchParams.delete("code");
122
+ if (url.href !== window.location.href)
123
+ window.history.replaceState(window.history.state, "", url);
124
+ }, [inAddress]);
125
+ return {
126
+ inputProps: {
127
+ id,
128
+ value: device.code,
129
+ onChange: (event) => device.setCode(event.target.value),
130
+ placeholder: "WDJB-MJHT",
131
+ autoComplete: "off",
132
+ autoCapitalize: "characters",
133
+ spellCheck: false,
134
+ autoFocus: device.code === "",
135
+ disabled: device.deciding,
136
+ "aria-invalid": device.invalidLetter || status === "refused",
137
+ "aria-describedby": statusId,
138
+ },
139
+ statusProps: { id: statusId, "aria-live": "polite" },
140
+ };
141
+ }
142
+ /**
143
+ * export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
144
+ * useConsentRequest(oauthApi.consent, search);
145
+ * useDeviceApproval(oauthApi.device, search);
146
+ *
147
+ * Both pages' calls to the server's `oauthConsoleRoutes`, mounted at `basePath` behind your
148
+ * sign-in. A choice travels as `choice`, which is where those routes hand it to your approver.
149
+ * `ConsentExtra` and `DeviceExtra` are the fields your server adds to each page's view.
150
+ */
151
+ export function createOAuthConsoleApi(http, basePath) {
152
+ return {
153
+ consent: {
154
+ describe: (query) => http.get(`${basePath}/request?${query}`),
155
+ approve: (query, choice) => http.post(`${basePath}/approve`, { query, choice }),
156
+ },
157
+ device: {
158
+ describe: (code) => http.get(`${basePath}/device?code=${encodeURIComponent(code)}`),
159
+ decide: (code, decision, choice) => http.post(`${basePath}/device`, { code, decision, choice }),
160
+ },
161
+ };
162
+ }
99
163
  //# sourceMappingURL=oauth.js.map
package/dist/oauth.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
4
4
  "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Gustavo Salomé",
8
- "homepage": "https://github.com/gusnips/frontkit/tree/main/react#readme",
8
+ "homepage": "https://github.com/gusnips/kit/tree/main/react#readme",
9
9
  "bugs": {
10
- "url": "https://github.com/gusnips/frontkit/issues"
10
+ "url": "https://github.com/gusnips/kit/issues"
11
11
  },
12
12
  "exports": {
13
13
  ".": {
@@ -153,7 +153,7 @@
153
153
  },
154
154
  "repository": {
155
155
  "type": "git",
156
- "url": "git+https://github.com/gusnips/frontkit.git",
156
+ "url": "git+https://github.com/gusnips/kit.git",
157
157
  "directory": "react"
158
158
  }
159
159
  }
package/src/oauth.test.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
- import { act, createElement } from "react";
3
+ import { act, createElement, type ChangeEvent } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { createApiClient } from "./api-client.ts";
6
7
  import {
8
+ createOAuthConsoleApi,
7
9
  formatUserCode,
8
10
  useConsentRequest,
9
11
  useDeviceApproval,
12
+ useDeviceCodeField,
10
13
  type ConsentApi,
11
14
  type ConsentView,
12
15
  type DeviceApi,
@@ -97,6 +100,17 @@ describe("useConsentRequest", () => {
97
100
  expect(page.current.leaving).toBe(true);
98
101
  });
99
102
 
103
+ it("hands the page whatever its server adds to the view, typed", async () => {
104
+ const api: ConsentApi<string, { spaces: string[] }> = {
105
+ describe: async () => ({ ...VIEW, spaces: ["ws-1", "ws-2"] }),
106
+ approve: async () => ({ ok: true, redirectTo: "https://claude.ai/cb" }),
107
+ };
108
+ const page = mount(() => useConsentRequest(api, "?client_id=x"));
109
+ await settle();
110
+ const { state } = page.current;
111
+ expect(state.status === "ready" ? state.view.spaces : null).toEqual(["ws-1", "ws-2"]);
112
+ });
113
+
100
114
  it("shows a refusal as a state and never redirects on it", async () => {
101
115
  const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
102
116
  const page = mount(() =>
@@ -173,14 +187,14 @@ const DEVICE: DeviceView = {
173
187
  expiresAt: "2026-10-16T12:10:00.000Z",
174
188
  };
175
189
 
176
- describe("useDeviceApproval", () => {
177
- function deviceApi(overrides: Partial<DeviceApi> = {}) {
178
- return {
179
- describe: vi.fn<DeviceApi["describe"]>(overrides.describe ?? (async () => DEVICE)),
180
- decide: vi.fn<DeviceApi["decide"]>(overrides.decide ?? (async () => ({ ok: true }))),
181
- };
182
- }
190
+ function deviceApi(overrides: Partial<DeviceApi> = {}) {
191
+ return {
192
+ describe: vi.fn<DeviceApi["describe"]>(overrides.describe ?? (async () => DEVICE)),
193
+ decide: vi.fn<DeviceApi["decide"]>(overrides.decide ?? (async () => ({ ok: true }))),
194
+ };
195
+ }
183
196
 
197
+ describe("useDeviceApproval", () => {
184
198
  it("starts from the link's code, asks the server, and stays approved after the yes", async () => {
185
199
  const api = deviceApi();
186
200
  const page = mount(() => useDeviceApproval(api, "?code=wdjb-mjht"));
@@ -191,7 +205,7 @@ describe("useDeviceApproval", () => {
191
205
 
192
206
  act(() => page.current.approve());
193
207
  await settle();
194
- expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve");
208
+ expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve", undefined);
195
209
  expect(page.current.state).toEqual({ status: "approved" });
196
210
 
197
211
  // A refetch on focus would now hear "unknown code". It must not be asked.
@@ -218,11 +232,13 @@ describe("useDeviceApproval", () => {
218
232
  });
219
233
 
220
234
  it("says no, and shows a code the server does not know as refused", async () => {
221
- const said = mount(() => useDeviceApproval(deviceApi(), "?code=WDJBMJHT"));
235
+ const api = deviceApi();
236
+ const said = mount(() => useDeviceApproval(api, "?code=WDJBMJHT"));
222
237
  await settle();
223
238
  act(() => said.current.deny());
224
239
  await settle();
225
240
  expect(said.current.state).toEqual({ status: "denied" });
241
+ expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "deny", undefined);
226
242
 
227
243
  const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
228
244
  const gone = mount(() =>
@@ -246,6 +262,163 @@ describe("useDeviceApproval", () => {
246
262
  });
247
263
  });
248
264
 
265
+ describe("useDeviceApproval with a choice", () => {
266
+ it("sends the page's choice with the yes, and names the pressed button until it answers", async () => {
267
+ let release!: () => void;
268
+ const decide = vi.fn<DeviceApi<string>["decide"]>(
269
+ () =>
270
+ new Promise((resolve) => {
271
+ release = () => resolve({ ok: true });
272
+ }),
273
+ );
274
+ const page = mount(() =>
275
+ useDeviceApproval({ describe: async () => DEVICE, decide }, "?code=WDJBMJHT"),
276
+ );
277
+ await settle();
278
+ expect(page.current.pending).toBeNull();
279
+
280
+ act(() => page.current.approve("ws-1"));
281
+ await settle();
282
+ expect(decide).toHaveBeenCalledWith("WDJBMJHT", "approve", "ws-1");
283
+ expect(page.current.pending).toBe("approve");
284
+
285
+ release();
286
+ await settle();
287
+ expect(page.current.pending).toBeNull();
288
+ expect(page.current.state).toEqual({ status: "approved" });
289
+ });
290
+ });
291
+
292
+ /** The one part of a change event the field reads. A test-only cast: the hook takes React's own. */
293
+ const changeTo = (value: string) => ({ target: { value } }) as ChangeEvent<HTMLInputElement>;
294
+
295
+ describe("useDeviceCodeField", () => {
296
+ it("wires the field to its status line and keeps the code in the address until decided", async () => {
297
+ window.history.replaceState({ idx: 3 }, "", "/device?from=mail&code=wdjbmjht");
298
+ const api = deviceApi();
299
+ const page = mount(() => {
300
+ const device = useDeviceApproval(api, window.location.search);
301
+ return { device, field: useDeviceCodeField(device) };
302
+ });
303
+ await settle();
304
+ const { inputProps, statusProps } = page.current.field;
305
+ expect(inputProps).toMatchObject({
306
+ value: "WDJB-MJHT",
307
+ autoFocus: false,
308
+ disabled: false,
309
+ "aria-invalid": false,
310
+ "aria-describedby": statusProps.id,
311
+ });
312
+ expect(inputProps.id).not.toBe(statusProps.id);
313
+ expect(window.location.search).toBe("?from=mail&code=WDJB-MJHT");
314
+ // Replaced with the router's own entry state, or its next Back lands on the wrong page.
315
+ expect(window.history.state).toEqual({ idx: 3 });
316
+ expect(window.history.length).toBe(1);
317
+
318
+ act(() => page.current.field.inputProps.onChange(changeTo("bcda")));
319
+ expect(page.current.field.inputProps["aria-invalid"]).toBe(true);
320
+ expect(window.location.search).toBe("?from=mail&code=BCDA");
321
+
322
+ act(() => page.current.field.inputProps.onChange(changeTo("wdjb mjht")));
323
+ await settle();
324
+ act(() => page.current.device.approve());
325
+ await settle();
326
+ expect(page.current.device.state).toEqual({ status: "approved" });
327
+ // The server no longer knows the code: a reload must not ask about it and say it is wrong.
328
+ expect(window.location.search).toBe("?from=mail");
329
+ });
330
+
331
+ it("focuses the field only when it starts empty", () => {
332
+ window.history.replaceState(null, "", "/device");
333
+ const page = mount(() => useDeviceCodeField(useDeviceApproval(deviceApi(), "")));
334
+ expect(page.current.inputProps.autoFocus).toBe(true);
335
+ expect(window.location.search).toBe("");
336
+ });
337
+ });
338
+
339
+ describe("createOAuthConsoleApi", () => {
340
+ function client(answer: (url: string) => unknown = () => ({ ok: true })) {
341
+ const sent: { url: string; method?: string; body?: BodyInit | null }[] = [];
342
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
343
+ sent.push({ url: String(url), method: init?.method, body: init?.body });
344
+ return new Response(JSON.stringify({ data: answer(String(url)) }), {
345
+ headers: { "Content-Type": "application/json" },
346
+ });
347
+ });
348
+ const http = createApiClient({
349
+ baseUrl: "https://api.test",
350
+ session: {
351
+ getToken: async () => "token",
352
+ refresh: async () => ({ token: "token", reachedAuth: true }),
353
+ signOut: async () => {},
354
+ },
355
+ onSessionDead: () => {},
356
+ });
357
+ return { http, sent };
358
+ }
359
+
360
+ it("calls the server's console routes through the app's own client", async () => {
361
+ const { http, sent } = client();
362
+ const api = createOAuthConsoleApi<string>(http, "/auth/oauth");
363
+ await api.consent.describe("client_id=x&state=s");
364
+ await api.consent.approve("client_id=x&state=s", "ws-1");
365
+ await api.device.describe("WDJBMJHT");
366
+ await api.device.decide("WDJBMJHT", "approve", "ws-1");
367
+ await api.device.decide("WDJBMJHT", "deny");
368
+ expect(sent).toEqual([
369
+ { url: "https://api.test/auth/oauth/request?client_id=x&state=s", method: "GET" },
370
+ {
371
+ url: "https://api.test/auth/oauth/approve",
372
+ method: "POST",
373
+ body: '{"query":"client_id=x&state=s","choice":"ws-1"}',
374
+ },
375
+ { url: "https://api.test/auth/oauth/device?code=WDJBMJHT", method: "GET" },
376
+ {
377
+ url: "https://api.test/auth/oauth/device",
378
+ method: "POST",
379
+ body: '{"code":"WDJBMJHT","decision":"approve","choice":"ws-1"}',
380
+ },
381
+ {
382
+ url: "https://api.test/auth/oauth/device",
383
+ method: "POST",
384
+ body: '{"code":"WDJBMJHT","decision":"deny"}',
385
+ },
386
+ ]);
387
+ });
388
+
389
+ it("sends no choice when the page has none", async () => {
390
+ const { http, sent } = client();
391
+ await createOAuthConsoleApi(http, "/app/oauth").consent.approve("client_id=x");
392
+ expect(sent[0]?.body).toBe('{"query":"client_id=x"}');
393
+ });
394
+
395
+ it("keeps the fields your server adds to each page's view", async () => {
396
+ type Workspaces = { workspaces: { id: string; name: string }[] };
397
+ type Account = { accountEmail: string };
398
+ const { http } = client((url) =>
399
+ url.includes("/request")
400
+ ? { ...VIEW, workspaces: [{ id: "ws-1", name: "Acme" }] }
401
+ : { ...DEVICE, accountEmail: "ana@example.com" },
402
+ );
403
+ const api = createOAuthConsoleApi<string, Workspaces, Account>(http, "/auth/oauth");
404
+ const consent = mount(() => useConsentRequest(api.consent, "?client_id=x"));
405
+ const device = mount(() => useDeviceApproval(api.device, "?code=WDJB-MJHT"));
406
+ await settle();
407
+ const consentState = consent.current.state;
408
+ const deviceState = device.current.state;
409
+ if (consentState.status !== "ready" || deviceState.status !== "ready")
410
+ throw new Error("A view never loaded");
411
+
412
+ // These read with no cast: the page keeps the types its server adds.
413
+ expect(consentState.view.workspaces[0]?.name).toBe("Acme");
414
+ expect(deviceState.view.accountEmail).toBe("ana@example.com");
415
+ // @ts-expect-error: the server sends no such field.
416
+ expect(consentState.view.plan).toBeUndefined();
417
+ // @ts-expect-error: the consent page's fields are not the device page's.
418
+ expect(deviceState.view.workspaces).toBeUndefined();
419
+ });
420
+ });
421
+
249
422
  describe("formatUserCode", () => {
250
423
  it("writes what the field shows", () => {
251
424
  expect(formatUserCode("")).toBe("");
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,107 @@ 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
+ * `ConsentExtra` and `DeviceExtra` are the fields your server adds to each page's view.
308
+ */
309
+ export function createOAuthConsoleApi<
310
+ Choice = void,
311
+ ConsentExtra extends object = object,
312
+ DeviceExtra extends object = object,
313
+ >(
314
+ http: OAuthConsoleHttp,
315
+ basePath: string,
316
+ ): { consent: ConsentApi<Choice, ConsentExtra>; device: DeviceApi<Choice, DeviceExtra> } {
317
+ return {
318
+ consent: {
319
+ describe: (query) => http.get(`${basePath}/request?${query}`),
320
+ approve: (query, choice) => http.post(`${basePath}/approve`, { query, choice }),
321
+ },
322
+ device: {
323
+ describe: (code) => http.get(`${basePath}/device?code=${encodeURIComponent(code)}`),
324
+ decide: (code, decision, choice) =>
325
+ http.post(`${basePath}/device`, { code, decision, choice }),
326
+ },
327
+ };
328
+ }