import { type ChangeEvent } from "react"; import type { ApiClient } from "./api-client.ts"; /** * The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a * person to connect, and the device page, where a script's short code is typed and approved. * `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's. * * Each hook takes your two API calls, so your client, your session and your envelope stay yours, * and returns one state to draw. `createOAuthConsoleApi` writes those calls for the server's * `oauthConsoleRoutes`. What they settle once: * * - A refusal (an unknown app, a return address it never registered) is a STATE, never a * redirect: sending the browser anywhere on a bad request is how an open redirect is built. * - Approve stays busy from the press until the browser has left. The page is going away, so the * button must never come back and invite a second press. * - A device code is read however it is typed (`wdjb mjht`, `WDJB-MJHT`), and once decided the * page stays decided. Asked again, the server no longer knows the code, and a page that asked on * the next focus would tell someone who just approved that their code is wrong. */ export type OAuthRefusalReason = "unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code"; /** What the server sends instead of a view. Word your own page for `reason`. */ export interface OAuthRefusal { ok: false; reason: OAuthRefusalReason; /** An English fallback a person can read. */ message: string; } /** `Extra` is anything your server adds to the view, such as the workspaces a person may pick. */ export type ConsentView = Extra & { ok: true; /** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */ clientName: string; /** Where the browser goes after the yes: a host, or an app's own scheme (`cursor:`). */ redirectHost: string; /** The key's name on your keys page. */ keyName: string; /** Where "Cancel" goes: back to the app, which is told the person said no. */ cancelUrl: string; }; export type DeviceView = Extra & { ok: true; clientName: string; /** As the script printed it: `WDJB-MJHT`. */ userCode: string; keyName: string; /** ISO time: JSON carries the server's `Date` as a string. */ expiresAt: string; }; export interface ConsentApi { /** Your API's call to the server's `describe`, with the query string as it came. */ describe(query: string): Promise | OAuthRefusal>; /** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */ approve(query: string, choice: Choice): Promise<{ ok: true; redirectTo: string; } | OAuthRefusal>; } export type ConsentState = { status: "loading"; } /** Show `refusal` and a way home. Never a link back to the app. */ | { status: "refused"; refusal: OAuthRefusal; } /** The request failed to arrive or answer. Show what failed, and `retry`. */ | { status: "failed"; error: unknown; retry: () => void; } | { status: "ready"; view: ConsentView; }; export interface ConsentRequest { state: ConsentState; approve(choice: Choice): void; /** From the press until the browser has left. Keep the button busy the whole time. */ leaving: boolean; /** Approve did not answer. Show it beside the button; pressing again retries. */ approveError: unknown; } /** * const consent = useConsentRequest(api.oauth, useLocation().search); * if (consent.state.status === "ready") ... consent.approve(workspaceId) */ export declare function useConsentRequest(api: ConsentApi, search: string): ConsentRequest; export interface DeviceApi { /** Your API's call to the server's `describeDevice`. */ describe(code: string): Promise | OAuthRefusal>; /** Your API's call to `decideDevice`. `choice` comes with a yes only, as on the consent page. */ decide(code: string, decision: "approve" | "deny", choice?: Choice): Promise<{ ok: true; } | OAuthRefusal>; } export type DeviceState = /** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */ { status: "typing"; } | { status: "loading"; } /** Unknown, expired or already decided: ask them to start again from the app. */ | { status: "refused"; refusal: OAuthRefusal; } | { status: "failed"; error: unknown; retry: () => void; } /** * Show `view.userCode` and say: go on only if you started this sign-in yourself, just now, and * this is the code on your screen. Somebody can run the real script and send a stranger the * link, and that sentence is the only guard against it (RFC 8628 ยง5.4). */ | { status: "ready"; view: DeviceView; } /** Done. Tell them to go back to the app, which carries on by itself. */ | { status: "approved"; } | { status: "denied"; }; export interface DeviceApproval { state: DeviceState; /** What the field shows, formatted as they type: `WDJB-MJ`. */ code: string; setCode(input: string): void; /** A typed letter no code uses: a vowel. Say so now, not after eight letters. */ invalidLetter: boolean; approve(choice: Choice): void; deny(): void; deciding: boolean; /** Which button was pressed, until the server answers: put the spinner on that one. */ pending: "approve" | "deny" | null; /** The decision did not answer. Show it beside the buttons; pressing again retries. */ decideError: unknown; } /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */ export declare function formatUserCode(input: string): string; /** * const device = useDeviceApproval(api.device, useLocation().search); * device.setCode(e.target.value)} /> * * The link the script prints carries `?code=`, so the field usually starts full. */ export declare function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval; export interface DeviceCodeField { /** Spread on the code's ``. Point your `