@gusnips/react 0.9.28 → 0.9.29

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
@@ -689,6 +689,60 @@ silently become model messages. It returns `false` for ordinary text. The hook n
689
689
  message, clears a draft or changes attached files; those stay with the app. It also has no timer
690
690
  or scheduler: offer a repeat command only when your app has real scheduling behind it.
691
691
 
692
+ ## Signing an agent in
693
+
694
+ When Claude or ChatGPT connects to your API, it sends the person to your consent page. When one of
695
+ your scripts signs in on a machine with no browser, it prints a short code, and the person types
696
+ it on your device page. `@gusnips/server/oauth` is the server half. These two hooks are the
697
+ logic for the two pages. The pages themselves are yours.
698
+
699
+ ```tsx
700
+ import { useConsentRequest } from "@gusnips/react/oauth";
701
+
702
+ const consent = useConsentRequest(api.oauth, useLocation().search);
703
+
704
+ if (consent.state.status === "ready")
705
+ return (
706
+ <Button onClick={() => consent.approve(workspaceId)} loading={consent.leaving}>
707
+ Allow {consent.state.view.clientName}
708
+ </Button>
709
+ );
710
+ ```
711
+
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`:
715
+
716
+ - **`refused`** means the request is not one to answer: an unknown app, or a return address it
717
+ never registered. Show `refusal.message` (or your own words for `refusal.reason`) and a link
718
+ home. Never a link back to the app, because sending the browser anywhere on a bad request is
719
+ how an open redirect is built.
720
+ - **`failed`** means the request didn't arrive or answer. Show what failed and `state.retry`.
721
+ - **`ready`** has the app's name and `redirectHost`, where the browser goes after the yes. Show
722
+ both: anyone can register any name, and the host is the part a stranger can't pick.
723
+
724
+ `leaving` stays `true` from the press until the browser has left, so the button never comes back
725
+ for a second press. Approve sends the browser on with `window.location.assign`, which also opens
726
+ an app's own scheme such as `cursor://`.
727
+
728
+ ```tsx
729
+ import { useDeviceApproval } from "@gusnips/react/oauth";
730
+
731
+ const device = useDeviceApproval(api.device, useLocation().search);
732
+
733
+ <input value={device.code} onChange={(event) => device.setCode(event.target.value)} />;
734
+ ```
735
+
736
+ The link the script prints carries `?code=`, so the field usually starts full. The field shows
737
+ what was typed as `WDJB-MJHT`, however it was typed. `invalidLetter` turns on as soon as a letter
738
+ no code uses (a vowel) is typed, so you can say so before all eight are in. `state.status` adds
739
+ three to the consent page's four: `typing` until the code is complete, then `approved` or
740
+ `denied`. When it is `ready`, show `view.userCode` and say: go on only if you started this
741
+ sign-in yourself, just now, and this is the code on your screen. Somebody can run the real script
742
+ and send a stranger the link, and that sentence is the only guard against it. Once decided, the
743
+ page stays decided: it never asks the server again, because the server no longer knows the code,
744
+ and asking on the next focus would tell someone who just said yes that their code is wrong.
745
+
692
746
  ## Also here
693
747
 
694
748
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
@@ -770,6 +824,7 @@ lives behind a subpath, so you install a dependency only if you import the thing
770
824
  | `@gusnips/react/contract` | two prerender names | nothing |
771
825
  | `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
772
826
  | `@gusnips/react/commands` | slash matching and `useSlashCommands` | react |
827
+ | `@gusnips/react/oauth` | `useConsentRequest`, `useDeviceApproval` | @tanstack/react-query |
773
828
 
774
829
  The rule behind that table: **a peer marked optional must not be reachable from the main entry
775
830
  point.** An optional peer the barrel imports anyway is not optional: it is a required one whose
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  // @gusnips/react/supabase the session adapter → @supabase/supabase-js
16
16
  // @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
17
17
  // serializes it and must not load the rest
18
+ // @gusnips/react/oauth the sign-in pages' logic → @tanstack/react-query, at runtime
18
19
  //
19
20
  // `react-dom` is the one the second migration added, and it is the sharpest case yet: that
20
21
  // adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,0FAA0F;AAC1F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
3
+ * person to connect, and the device page, where a script's short code is typed and approved.
4
+ * `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
5
+ *
6
+ * 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:
8
+ *
9
+ * - A refusal (an unknown app, a return address it never registered) is a STATE, never a
10
+ * redirect: sending the browser anywhere on a bad request is how an open redirect is built.
11
+ * - Approve stays busy from the press until the browser has left. The page is going away, so the
12
+ * button must never come back and invite a second press.
13
+ * - A device code is read however it is typed (`wdjb mjht`, `WDJB-MJHT`), and once decided the
14
+ * page stays decided. Asked again, the server no longer knows the code, and a page that asked on
15
+ * the next focus would tell someone who just approved that their code is wrong.
16
+ */
17
+ export type OAuthRefusalReason = "unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code";
18
+ /** What the server sends instead of a view. Word your own page for `reason`. */
19
+ export interface OAuthRefusal {
20
+ ok: false;
21
+ reason: OAuthRefusalReason;
22
+ /** An English fallback a person can read. */
23
+ message: string;
24
+ }
25
+ export interface ConsentView {
26
+ ok: true;
27
+ /** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
28
+ clientName: string;
29
+ /** Where the browser goes after the yes: a host, or an app's own scheme (`cursor:`). */
30
+ redirectHost: string;
31
+ /** The key's name on your keys page. */
32
+ keyName: string;
33
+ /** Where "Cancel" goes: back to the app, which is told the person said no. */
34
+ cancelUrl: string;
35
+ }
36
+ export interface DeviceView {
37
+ ok: true;
38
+ clientName: string;
39
+ /** As the script printed it: `WDJB-MJHT`. */
40
+ userCode: string;
41
+ keyName: string;
42
+ /** ISO time: JSON carries the server's `Date` as a string. */
43
+ expiresAt: string;
44
+ }
45
+ export interface ConsentApi<Choice> {
46
+ /** Your API's call to the server's `describe`, with the query string as it came. */
47
+ describe(query: string): Promise<ConsentView | OAuthRefusal>;
48
+ /** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
49
+ approve(query: string, choice: Choice): Promise<{
50
+ ok: true;
51
+ redirectTo: string;
52
+ } | OAuthRefusal>;
53
+ }
54
+ export type ConsentState = {
55
+ status: "loading";
56
+ }
57
+ /** Show `refusal` and a way home. Never a link back to the app. */
58
+ | {
59
+ status: "refused";
60
+ refusal: OAuthRefusal;
61
+ }
62
+ /** The request failed to arrive or answer. Show what failed, and `retry`. */
63
+ | {
64
+ status: "failed";
65
+ error: unknown;
66
+ retry: () => void;
67
+ } | {
68
+ status: "ready";
69
+ view: ConsentView;
70
+ };
71
+ export interface ConsentRequest<Choice> {
72
+ state: ConsentState;
73
+ approve(choice: Choice): void;
74
+ /** From the press until the browser has left. Keep the button busy the whole time. */
75
+ leaving: boolean;
76
+ /** Approve did not answer. Show it beside the button; pressing again retries. */
77
+ approveError: unknown;
78
+ }
79
+ /**
80
+ * const consent = useConsentRequest(api.oauth, useLocation().search);
81
+ * if (consent.state.status === "ready") ... consent.approve(workspaceId)
82
+ */
83
+ export declare function useConsentRequest<Choice = void>(api: ConsentApi<Choice>, search: string): ConsentRequest<Choice>;
84
+ export interface DeviceApi {
85
+ /** 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<{
89
+ ok: true;
90
+ } | OAuthRefusal>;
91
+ }
92
+ export type DeviceState =
93
+ /** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
94
+ {
95
+ status: "typing";
96
+ } | {
97
+ status: "loading";
98
+ }
99
+ /** Unknown, expired or already decided: ask them to start again from the app. */
100
+ | {
101
+ status: "refused";
102
+ refusal: OAuthRefusal;
103
+ } | {
104
+ status: "failed";
105
+ error: unknown;
106
+ retry: () => void;
107
+ }
108
+ /**
109
+ * Show `view.userCode` and say: go on only if you started this sign-in yourself, just now, and
110
+ * this is the code on your screen. Somebody can run the real script and send a stranger the
111
+ * link, and that sentence is the only guard against it (RFC 8628 §5.4).
112
+ */
113
+ | {
114
+ status: "ready";
115
+ view: DeviceView;
116
+ }
117
+ /** Done. Tell them to go back to the app, which carries on by itself. */
118
+ | {
119
+ status: "approved";
120
+ } | {
121
+ status: "denied";
122
+ };
123
+ export interface DeviceApproval {
124
+ state: DeviceState;
125
+ /** What the field shows, formatted as they type: `WDJB-MJ`. */
126
+ code: string;
127
+ setCode(input: string): void;
128
+ /** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
129
+ invalidLetter: boolean;
130
+ approve(): void;
131
+ deny(): void;
132
+ deciding: boolean;
133
+ /** The decision did not answer. Show it beside the buttons; pressing again retries. */
134
+ decideError: unknown;
135
+ }
136
+ /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
137
+ export declare function formatUserCode(input: string): string;
138
+ /**
139
+ * const device = useDeviceApproval(api.device, useLocation().search);
140
+ * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
141
+ *
142
+ * The link the script prints carries `?code=`, so the field usually starts full.
143
+ */
144
+ export declare function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval;
145
+ //# sourceMappingURL=oauth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/dist/oauth.js ADDED
@@ -0,0 +1,99 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { useState } from "react";
3
+ /**
4
+ * const consent = useConsentRequest(api.oauth, useLocation().search);
5
+ * if (consent.state.status === "ready") ... consent.approve(workspaceId)
6
+ */
7
+ export function useConsentRequest(api, search) {
8
+ const query = search.replace(/^\?/, "");
9
+ const request = useQuery({
10
+ queryKey: ["@gusnips/react/oauth", "consent", query],
11
+ queryFn: () => api.describe(query),
12
+ });
13
+ const approval = useMutation({
14
+ mutationFn: (choice) => api.approve(query, choice),
15
+ onSuccess: (answer) => {
16
+ if (answer.ok)
17
+ window.location.assign(answer.redirectTo);
18
+ },
19
+ });
20
+ // A refusal from approve (the request went stale between the two calls) outranks the view.
21
+ const answer = request.data;
22
+ const refusal = approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
23
+ let state;
24
+ if (refusal)
25
+ state = { status: "refused", refusal };
26
+ else if (answer?.ok === true)
27
+ state = { status: "ready", view: answer };
28
+ else if (request.isError)
29
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
30
+ else
31
+ state = { status: "loading" };
32
+ return {
33
+ state,
34
+ approve: (choice) => approval.mutate(choice),
35
+ leaving: approval.isPending || approval.data?.ok === true,
36
+ approveError: approval.error,
37
+ };
38
+ }
39
+ /** The server's alphabet: no vowels, so a code never spells a word. */
40
+ const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
41
+ const LENGTH = 8;
42
+ /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
43
+ export function formatUserCode(input) {
44
+ const letters = input
45
+ .toUpperCase()
46
+ .replace(/[^A-Z]/g, "")
47
+ .slice(0, LENGTH);
48
+ return letters.length > 4 ? `${letters.slice(0, 4)}-${letters.slice(4)}` : letters;
49
+ }
50
+ /**
51
+ * const device = useDeviceApproval(api.device, useLocation().search);
52
+ * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
53
+ *
54
+ * The link the script prints carries `?code=`, so the field usually starts full.
55
+ */
56
+ export function useDeviceApproval(api, search) {
57
+ const [code, setTyped] = useState(() => formatUserCode(new URLSearchParams(search).get("code") ?? ""));
58
+ const letters = code.replace("-", "");
59
+ const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
60
+ const complete = letters.length === LENGTH && !invalidLetter;
61
+ const decision = useMutation({
62
+ mutationFn: (choice) => api.decide(letters, choice),
63
+ });
64
+ const decided = decision.data?.ok === true ? decision.variables : undefined;
65
+ const request = useQuery({
66
+ queryKey: ["@gusnips/react/oauth", "device", letters],
67
+ queryFn: () => api.describe(letters),
68
+ enabled: complete && decided === undefined,
69
+ });
70
+ const answer = request.data;
71
+ const refusal = decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
72
+ let state;
73
+ if (decided !== undefined)
74
+ state = { status: decided === "approve" ? "approved" : "denied" };
75
+ else if (!complete)
76
+ state = { status: "typing" };
77
+ else if (refusal)
78
+ state = { status: "refused", refusal };
79
+ else if (answer?.ok === true)
80
+ state = { status: "ready", view: answer };
81
+ else if (request.isError)
82
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
83
+ else
84
+ state = { status: "loading" };
85
+ return {
86
+ state,
87
+ code,
88
+ setCode: (input) => {
89
+ decision.reset();
90
+ setTyped(formatUserCode(input));
91
+ },
92
+ invalidLetter,
93
+ approve: () => decision.mutate("approve"),
94
+ deny: () => decision.mutate("deny"),
95
+ deciding: decision.isPending,
96
+ decideError: decision.error,
97
+ };
98
+ }
99
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
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",
@@ -45,6 +45,10 @@
45
45
  "./commands": {
46
46
  "types": "./dist/commands.d.ts",
47
47
  "default": "./dist/commands.js"
48
+ },
49
+ "./oauth": {
50
+ "types": "./dist/oauth.d.ts",
51
+ "default": "./dist/oauth.js"
48
52
  }
49
53
  },
50
54
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // @gusnips/react/supabase the session adapter → @supabase/supabase-js
16
16
  // @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
17
17
  // serializes it and must not load the rest
18
+ // @gusnips/react/oauth the sign-in pages' logic → @tanstack/react-query, at runtime
18
19
  //
19
20
  // `react-dom` is the one the second migration added, and it is the sharpest case yet: that
20
21
  // adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
@@ -0,0 +1,257 @@
1
+ // @vitest-environment happy-dom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { act, createElement } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ formatUserCode,
8
+ useConsentRequest,
9
+ useDeviceApproval,
10
+ type ConsentApi,
11
+ type ConsentView,
12
+ type DeviceApi,
13
+ type DeviceView,
14
+ type OAuthRefusal,
15
+ } from "./oauth.ts";
16
+
17
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
18
+
19
+ const roots = new Set<Root>();
20
+ afterEach(() => {
21
+ for (const root of roots) act(() => root.unmount());
22
+ roots.clear();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ /** Renders `hook` under a fresh query client and keeps its latest answer in `current`. */
27
+ function mount<T>(hook: () => T) {
28
+ const seen: T[] = [];
29
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
30
+ function Probe() {
31
+ seen.push(hook());
32
+ return null;
33
+ }
34
+ const root = createRoot(document.createElement("div"));
35
+ roots.add(root);
36
+ act(() => root.render(createElement(QueryClientProvider, { client }, createElement(Probe))));
37
+ return {
38
+ client,
39
+ get current(): T {
40
+ const last = seen.at(-1);
41
+ if (last === undefined) throw new Error("The hook never rendered");
42
+ return last;
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Lets pending promises and the renders they cause finish. react-query hands its updates to a
49
+ * scheduler that runs on a later task, so one tick is not always enough.
50
+ */
51
+ const settle = () =>
52
+ act(async () => {
53
+ for (let tick = 0; tick < 5; tick++) await new Promise((resolve) => setTimeout(resolve, 0));
54
+ });
55
+
56
+ const VIEW: ConsentView = {
57
+ ok: true,
58
+ clientName: "Claude",
59
+ redirectHost: "claude.ai",
60
+ keyName: "Claude (claude.ai)",
61
+ cancelUrl: "https://claude.ai/cb?error=access_denied",
62
+ };
63
+ const REFUSAL: OAuthRefusal = {
64
+ ok: false,
65
+ reason: "redirect-mismatch",
66
+ message: "This app's sign-in link is not valid.",
67
+ };
68
+
69
+ describe("useConsentRequest", () => {
70
+ it("loads the request, approves with the page's choice, and leaves for the redirect", async () => {
71
+ const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
72
+ let release!: () => void;
73
+ const api: ConsentApi<string> = {
74
+ describe: vi.fn(async () => VIEW),
75
+ approve: vi.fn(
76
+ () =>
77
+ new Promise<{ ok: true; redirectTo: string }>((resolve) => {
78
+ release = () => resolve({ ok: true, redirectTo: "https://claude.ai/cb?code=c1" });
79
+ }),
80
+ ),
81
+ };
82
+ const page = mount(() => useConsentRequest(api, "?client_id=x&state=s"));
83
+ expect(page.current.state).toEqual({ status: "loading" });
84
+ await settle();
85
+ expect(page.current.state).toEqual({ status: "ready", view: VIEW });
86
+ expect(api.describe).toHaveBeenCalledWith("client_id=x&state=s");
87
+
88
+ act(() => page.current.approve("ws-1"));
89
+ await settle();
90
+ expect(api.approve).toHaveBeenCalledWith("client_id=x&state=s", "ws-1");
91
+ expect(page.current.leaving).toBe(true);
92
+
93
+ release();
94
+ await settle();
95
+ expect(assign).toHaveBeenCalledWith("https://claude.ai/cb?code=c1");
96
+ // The page is going away: the button must not come back for a second press.
97
+ expect(page.current.leaving).toBe(true);
98
+ });
99
+
100
+ it("shows a refusal as a state and never redirects on it", async () => {
101
+ const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
102
+ const page = mount(() =>
103
+ useConsentRequest({ describe: async () => REFUSAL, approve: vi.fn() }, "?client_id=x"),
104
+ );
105
+ await settle();
106
+ expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
107
+ expect(assign).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it("turns a refusal from approve into the refused state, and stays put", async () => {
111
+ const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
112
+ const page = mount(() =>
113
+ useConsentRequest({ describe: async () => VIEW, approve: async () => REFUSAL }, ""),
114
+ );
115
+ await settle();
116
+ act(() => page.current.approve());
117
+ await settle();
118
+ expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
119
+ expect(page.current.leaving).toBe(false);
120
+ expect(assign).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("offers a retry when the request failed to answer, and recovers", async () => {
124
+ let calls = 0;
125
+ const page = mount(() =>
126
+ useConsentRequest(
127
+ {
128
+ describe: async () => {
129
+ calls += 1;
130
+ if (calls === 1) throw new TypeError("Failed to fetch");
131
+ return VIEW;
132
+ },
133
+ approve: vi.fn(),
134
+ },
135
+ "?a=1",
136
+ ),
137
+ );
138
+ await settle();
139
+ const failed = page.current.state;
140
+ if (failed.status !== "failed") throw new Error(`expected failed, got ${failed.status}`);
141
+ expect(failed.error).toBeInstanceOf(TypeError);
142
+ act(() => failed.retry());
143
+ await settle();
144
+ expect(page.current.state).toEqual({ status: "ready", view: VIEW });
145
+ });
146
+
147
+ it("keeps an approve that did not answer beside the button, ready to press again", async () => {
148
+ const page = mount(() =>
149
+ useConsentRequest(
150
+ {
151
+ describe: async () => VIEW,
152
+ approve: async () => {
153
+ throw new TypeError("Failed to fetch");
154
+ },
155
+ },
156
+ "",
157
+ ),
158
+ );
159
+ await settle();
160
+ act(() => page.current.approve());
161
+ await settle();
162
+ expect(page.current.approveError).toBeInstanceOf(TypeError);
163
+ expect(page.current.leaving).toBe(false);
164
+ expect(page.current.state.status).toBe("ready");
165
+ });
166
+ });
167
+
168
+ const DEVICE: DeviceView = {
169
+ ok: true,
170
+ clientName: "Acme CLI",
171
+ userCode: "WDJB-MJHT",
172
+ keyName: "Acme CLI (device sign-in)",
173
+ expiresAt: "2026-10-16T12:10:00.000Z",
174
+ };
175
+
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
+ }
183
+
184
+ it("starts from the link's code, asks the server, and stays approved after the yes", async () => {
185
+ const api = deviceApi();
186
+ const page = mount(() => useDeviceApproval(api, "?code=wdjb-mjht"));
187
+ expect(page.current.code).toBe("WDJB-MJHT");
188
+ await settle();
189
+ expect(page.current.state).toEqual({ status: "ready", view: DEVICE });
190
+ expect(api.describe).toHaveBeenCalledWith("WDJBMJHT");
191
+
192
+ act(() => page.current.approve());
193
+ await settle();
194
+ expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve");
195
+ expect(page.current.state).toEqual({ status: "approved" });
196
+
197
+ // A refetch on focus would now hear "unknown code". It must not be asked.
198
+ api.describe.mockResolvedValue({ ok: false, reason: "unknown-code", message: "gone" });
199
+ await act(async () => page.client.invalidateQueries());
200
+ await settle();
201
+ expect(page.current.state).toEqual({ status: "approved" });
202
+ expect(api.describe).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it("waits for all eight letters before asking, and flags a vowel at once", async () => {
206
+ const api = deviceApi();
207
+ const page = mount(() => useDeviceApproval(api, ""));
208
+ expect(page.current.state).toEqual({ status: "typing" });
209
+ act(() => page.current.setCode("wdjb m"));
210
+ expect(page.current.code).toBe("WDJB-M");
211
+ act(() => page.current.setCode("WDJA"));
212
+ expect(page.current.invalidLetter).toBe(true);
213
+ act(() => page.current.setCode("WDJB MJHTX"));
214
+ expect(page.current.code).toBe("WDJB-MJHT");
215
+ await settle();
216
+ expect(api.describe).toHaveBeenCalledTimes(1);
217
+ expect(page.current.state.status).toBe("ready");
218
+ });
219
+
220
+ it("says no, and shows a code the server does not know as refused", async () => {
221
+ const said = mount(() => useDeviceApproval(deviceApi(), "?code=WDJBMJHT"));
222
+ await settle();
223
+ act(() => said.current.deny());
224
+ await settle();
225
+ expect(said.current.state).toEqual({ status: "denied" });
226
+
227
+ const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
228
+ const gone = mount(() =>
229
+ useDeviceApproval(deviceApi({ describe: async () => unknown }), "?code=WDJBMJHT"),
230
+ );
231
+ await settle();
232
+ expect(gone.current.state).toEqual({ status: "refused", refusal: unknown });
233
+ });
234
+
235
+ it("shows a decision the server refused, and lets a new code start over", async () => {
236
+ const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
237
+ const api = deviceApi({ decide: async () => unknown });
238
+ const page = mount(() => useDeviceApproval(api, "?code=WDJBMJHT"));
239
+ await settle();
240
+ act(() => page.current.approve());
241
+ await settle();
242
+ expect(page.current.state).toEqual({ status: "refused", refusal: unknown });
243
+ act(() => page.current.setCode("BCDF GHJK"));
244
+ await settle();
245
+ expect(page.current.state.status).toBe("ready");
246
+ });
247
+ });
248
+
249
+ describe("formatUserCode", () => {
250
+ it("writes what the field shows", () => {
251
+ expect(formatUserCode("")).toBe("");
252
+ expect(formatUserCode("wdj")).toBe("WDJ");
253
+ expect(formatUserCode("wdjb")).toBe("WDJB");
254
+ expect(formatUserCode("wdjbm")).toBe("WDJB-M");
255
+ expect(formatUserCode(" w-d j b m j h t z ")).toBe("WDJB-MJHT");
256
+ });
257
+ });
package/src/oauth.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { useState } from "react";
3
+
4
+ /**
5
+ * The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
6
+ * person to connect, and the device page, where a script's short code is typed and approved.
7
+ * `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
8
+ *
9
+ * 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
+ *
12
+ * - A refusal (an unknown app, a return address it never registered) is a STATE, never a
13
+ * redirect: sending the browser anywhere on a bad request is how an open redirect is built.
14
+ * - Approve stays busy from the press until the browser has left. The page is going away, so the
15
+ * button must never come back and invite a second press.
16
+ * - A device code is read however it is typed (`wdjb mjht`, `WDJB-MJHT`), and once decided the
17
+ * page stays decided. Asked again, the server no longer knows the code, and a page that asked on
18
+ * the next focus would tell someone who just approved that their code is wrong.
19
+ */
20
+
21
+ export type OAuthRefusalReason =
22
+ "unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code";
23
+
24
+ /** What the server sends instead of a view. Word your own page for `reason`. */
25
+ export interface OAuthRefusal {
26
+ ok: false;
27
+ reason: OAuthRefusalReason;
28
+ /** An English fallback a person can read. */
29
+ message: string;
30
+ }
31
+
32
+ export interface ConsentView {
33
+ ok: true;
34
+ /** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
35
+ clientName: string;
36
+ /** Where the browser goes after the yes: a host, or an app's own scheme (`cursor:`). */
37
+ redirectHost: string;
38
+ /** The key's name on your keys page. */
39
+ keyName: string;
40
+ /** Where "Cancel" goes: back to the app, which is told the person said no. */
41
+ cancelUrl: string;
42
+ }
43
+
44
+ export interface DeviceView {
45
+ ok: true;
46
+ clientName: string;
47
+ /** As the script printed it: `WDJB-MJHT`. */
48
+ userCode: string;
49
+ keyName: string;
50
+ /** ISO time: JSON carries the server's `Date` as a string. */
51
+ expiresAt: string;
52
+ }
53
+
54
+ export interface ConsentApi<Choice> {
55
+ /** Your API's call to the server's `describe`, with the query string as it came. */
56
+ describe(query: string): Promise<ConsentView | OAuthRefusal>;
57
+ /** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
58
+ approve(query: string, choice: Choice): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
59
+ }
60
+
61
+ export type ConsentState =
62
+ | { status: "loading" }
63
+ /** Show `refusal` and a way home. Never a link back to the app. */
64
+ | { status: "refused"; refusal: OAuthRefusal }
65
+ /** The request failed to arrive or answer. Show what failed, and `retry`. */
66
+ | { status: "failed"; error: unknown; retry: () => void }
67
+ | { status: "ready"; view: ConsentView };
68
+
69
+ export interface ConsentRequest<Choice> {
70
+ state: ConsentState;
71
+ approve(choice: Choice): void;
72
+ /** From the press until the browser has left. Keep the button busy the whole time. */
73
+ leaving: boolean;
74
+ /** Approve did not answer. Show it beside the button; pressing again retries. */
75
+ approveError: unknown;
76
+ }
77
+
78
+ /**
79
+ * const consent = useConsentRequest(api.oauth, useLocation().search);
80
+ * if (consent.state.status === "ready") ... consent.approve(workspaceId)
81
+ */
82
+ export function useConsentRequest<Choice = void>(
83
+ api: ConsentApi<Choice>,
84
+ search: string,
85
+ ): ConsentRequest<Choice> {
86
+ const query = search.replace(/^\?/, "");
87
+ const request = useQuery({
88
+ queryKey: ["@gusnips/react/oauth", "consent", query],
89
+ queryFn: () => api.describe(query),
90
+ });
91
+ const approval = useMutation({
92
+ mutationFn: (choice: Choice) => api.approve(query, choice),
93
+ onSuccess: (answer) => {
94
+ if (answer.ok) window.location.assign(answer.redirectTo);
95
+ },
96
+ });
97
+
98
+ // A refusal from approve (the request went stale between the two calls) outranks the view.
99
+ const answer = request.data;
100
+ const refusal =
101
+ approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
102
+ let state: ConsentState;
103
+ if (refusal) state = { status: "refused", refusal };
104
+ else if (answer?.ok === true) state = { status: "ready", view: answer };
105
+ else if (request.isError)
106
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
107
+ else state = { status: "loading" };
108
+
109
+ return {
110
+ state,
111
+ approve: (choice) => approval.mutate(choice),
112
+ leaving: approval.isPending || approval.data?.ok === true,
113
+ approveError: approval.error,
114
+ };
115
+ }
116
+
117
+ export interface DeviceApi {
118
+ /** 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
+ }
123
+
124
+ export type DeviceState =
125
+ /** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
126
+ | { status: "typing" }
127
+ | { status: "loading" }
128
+ /** Unknown, expired or already decided: ask them to start again from the app. */
129
+ | { status: "refused"; refusal: OAuthRefusal }
130
+ | { status: "failed"; error: unknown; retry: () => void }
131
+ /**
132
+ * Show `view.userCode` and say: go on only if you started this sign-in yourself, just now, and
133
+ * this is the code on your screen. Somebody can run the real script and send a stranger the
134
+ * link, and that sentence is the only guard against it (RFC 8628 §5.4).
135
+ */
136
+ | { status: "ready"; view: DeviceView }
137
+ /** Done. Tell them to go back to the app, which carries on by itself. */
138
+ | { status: "approved" }
139
+ | { status: "denied" };
140
+
141
+ export interface DeviceApproval {
142
+ state: DeviceState;
143
+ /** What the field shows, formatted as they type: `WDJB-MJ`. */
144
+ code: string;
145
+ setCode(input: string): void;
146
+ /** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
147
+ invalidLetter: boolean;
148
+ approve(): void;
149
+ deny(): void;
150
+ deciding: boolean;
151
+ /** The decision did not answer. Show it beside the buttons; pressing again retries. */
152
+ decideError: unknown;
153
+ }
154
+
155
+ /** The server's alphabet: no vowels, so a code never spells a word. */
156
+ const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
157
+ const LENGTH = 8;
158
+
159
+ /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
160
+ export function formatUserCode(input: string): string {
161
+ const letters = input
162
+ .toUpperCase()
163
+ .replace(/[^A-Z]/g, "")
164
+ .slice(0, LENGTH);
165
+ return letters.length > 4 ? `${letters.slice(0, 4)}-${letters.slice(4)}` : letters;
166
+ }
167
+
168
+ /**
169
+ * const device = useDeviceApproval(api.device, useLocation().search);
170
+ * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
171
+ *
172
+ * The link the script prints carries `?code=`, so the field usually starts full.
173
+ */
174
+ export function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval {
175
+ const [code, setTyped] = useState(() =>
176
+ formatUserCode(new URLSearchParams(search).get("code") ?? ""),
177
+ );
178
+ const letters = code.replace("-", "");
179
+ const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
180
+ const complete = letters.length === LENGTH && !invalidLetter;
181
+
182
+ const decision = useMutation({
183
+ mutationFn: (choice: "approve" | "deny") => api.decide(letters, choice),
184
+ });
185
+ const decided = decision.data?.ok === true ? decision.variables : undefined;
186
+ const request = useQuery({
187
+ queryKey: ["@gusnips/react/oauth", "device", letters],
188
+ queryFn: () => api.describe(letters),
189
+ enabled: complete && decided === undefined,
190
+ });
191
+
192
+ const answer = request.data;
193
+ const refusal =
194
+ decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
195
+ let state: DeviceState;
196
+ if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
197
+ else if (!complete) state = { status: "typing" };
198
+ else if (refusal) state = { status: "refused", refusal };
199
+ else if (answer?.ok === true) state = { status: "ready", view: answer };
200
+ else if (request.isError)
201
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
202
+ else state = { status: "loading" };
203
+
204
+ return {
205
+ state,
206
+ code,
207
+ setCode: (input) => {
208
+ decision.reset();
209
+ setTyped(formatUserCode(input));
210
+ },
211
+ invalidLetter,
212
+ approve: () => decision.mutate("approve"),
213
+ deny: () => decision.mutate("deny"),
214
+ deciding: decision.isPending,
215
+ decideError: decision.error,
216
+ };
217
+ }