@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 +39 -9
- package/dist/oauth.d.ts +77 -22
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +69 -5
- package/dist/oauth.js.map +1 -1
- package/package.json +4 -4
- package/src/oauth.test.ts +183 -10
- package/src/oauth.ts +142 -31
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(
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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(
|
|
747
|
+
const device = useDeviceApproval(oauthApi.device, useLocation().search);
|
|
748
|
+
const field = useDeviceCodeField(device);
|
|
732
749
|
|
|
733
|
-
<
|
|
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` |
|
|
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 [
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/oauth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"
|
|
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: (
|
|
62
|
+
mutationFn: (pressed) => api.decide(letters, pressed.decision, pressed.choice),
|
|
63
63
|
});
|
|
64
|
-
const decided = decision.data
|
|
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":"AAAA,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,
|
|
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.
|
|
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/
|
|
8
|
+
"homepage": "https://github.com/gusnips/kit/tree/main/react#readme",
|
|
9
9
|
"bugs": {
|
|
10
|
-
"url": "https://github.com/gusnips/
|
|
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/
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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.
|
|
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,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
|
+
}
|