Skip to content

Commit a362f4d

Browse files
committed
Add useCliAuthConfirmation hook and customizable cliAuthConfirm URL target
- Extract CLI auth confirmation logic into useCliAuthConfirmation() so users building custom pages can drive the flow via status/error/isLoading/ authorize()/retry() instead of reimplementing the protocol. - Treat cliAuthConfirm as a normal handler URL target: resolve it via resolveHandlerUrls, allow custom URLs, and build the CLI login URL with a new buildCliAuthConfirmUrl() helper so promptCliLogin honors the resolved target. - Move StackContext to its own module so the hook can be unit-tested with a test double without dragging in the full client-app implementation (which trips the compile-time client-version sentinel). - Register cliAuthConfirm in custom-page prompts and the dev tool components tab, and export the new hook + types from the template entry point.
1 parent 3b8667d commit a362f4d

15 files changed

Lines changed: 431 additions & 65 deletions

File tree

claude/CLAUDE-KNOWLEDGE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,9 @@ A: Use a strict root `postinstall` script that rewrites only Next `>=16` app-pag
386386

387387
Q: Why can Turbo-pruned Docker builds fail with `Cannot find module /app/scripts/postinstall-patch-next-async-debug-info.mjs` during `pnpm install`?
388388
A: In pruned builder stages, we copy `/app/out/json` and run `pnpm install` before copying `/app/out/full`. The root `package.json` still runs `postinstall: node ./scripts/postinstall-patch-next-async-debug-info.mjs`, but that script is not present yet. Fix by copying `scripts/postinstall-patch-next-async-debug-info.mjs` into the builder stage before `pnpm install` (for all Dockerfiles using the prune pattern).
389+
390+
Q: What is the simple custom-page DX for CLI auth confirmation?
391+
A: Add `cliAuthConfirm` as a normal handler URL target and expose `useCliAuthConfirmation()` from the template package. Custom pages should consume the hook's `status`, `error`, `isLoading`, `authorize()`, and `retry()` instead of calling `/auth/cli/complete` directly. The hook owns reading `login_code`, preserving `confirmed=true`, claiming anonymous CLI sessions, redirecting through sign-in/sign-up, and completing authorization with the current refresh token.
392+
393+
Q: How should the CLI auth login URL be constructed in template tests?
394+
A: Do not import the concrete template `_StackClientAppImpl` directly from Vitest just to test `promptCliLogin`; it trips the compile-time client-version sentinel. Put the URL construction in a small helper such as `buildCliAuthConfirmUrl()` and have `promptCliLogin` call that helper. Then unit-test the helper with relative/custom `cliAuthConfirm` targets.

packages/stack-shared/src/interface/handler-urls.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type HandlerPageUrls = Record<
1515
| "magicLinkCallback"
1616
| "accountSettings"
1717
| "teamInvitation"
18+
| "cliAuthConfirm"
1819
| "mfa"
1920
| "error"
2021
| "onboarding",
@@ -45,4 +46,3 @@ export {
4546
type PageVersionEntry,
4647
type PageVersions
4748
} from "./page-component-versions";
48-

packages/stack-shared/src/interface/page-component-versions.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,59 @@ export function getCustomPagePrompts(): Record<PageComponentKey, CustomPagePromp
14191419
`,
14201420
versions: {},
14211421
}),
1422+
cliAuthConfirm: createCustomPagePrompt({
1423+
key: "cliAuthConfirm",
1424+
title: "CLI Auth Confirmation",
1425+
minSdkVersion: "0.0.1",
1426+
structure: deindent`
1427+
- Use \`useCliAuthConfirmation()\`.
1428+
- If \`status === "invalid"\`, show an invalid-link state.
1429+
- If \`status === "success"\`, tell the user they can close the browser and return to the CLI.
1430+
- If \`status === "error"\`, show the error and a retry action.
1431+
- Otherwise, show a confirmation step that calls \`authorize()\`.
1432+
- Use \`isLoading\` to disable or show loading on the confirmation action while the hook is authorizing or redirecting.
1433+
`,
1434+
reactExample: deindent`
1435+
export default function CustomCliAuthConfirmPage() {
1436+
const cliAuth = useCliAuthConfirmation();
1437+
1438+
if (cliAuth.status === "invalid") {
1439+
return <MessageCard title="Invalid CLI authorization link" />;
1440+
}
1441+
1442+
if (cliAuth.status === "success") {
1443+
return <MessageCard title="CLI authorized">You can close this window and return to the command line.</MessageCard>;
1444+
}
1445+
1446+
if (cliAuth.status === "error") {
1447+
return (
1448+
<MessageCard
1449+
title="CLI authorization failed"
1450+
primaryButtonText="Try again"
1451+
primaryAction={cliAuth.retry}
1452+
>
1453+
{cliAuth.error?.message}
1454+
</MessageCard>
1455+
);
1456+
}
1457+
1458+
return (
1459+
<MessageCard
1460+
title="Authorize CLI application"
1461+
primaryButtonText={cliAuth.isLoading ? "Authorizing..." : "Authorize"}
1462+
primaryAction={cliAuth.authorize}
1463+
>
1464+
A command line application is requesting access to your account.
1465+
</MessageCard>
1466+
);
1467+
}
1468+
`,
1469+
notes: deindent`
1470+
- Be explicit about the account being authorized. CLI auth grants a refresh token to the command line application.
1471+
- The hook owns the protocol details: reading \`login_code\`, preserving confirmed state across redirects, claiming anonymous sessions, and completing authorization.
1472+
`,
1473+
versions: {},
1474+
}),
14221475
mfa: createCustomPagePrompt({
14231476
key: "mfa",
14241477
title: "MFA",
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// @vitest-environment jsdom
2+
3+
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
4+
import React, { act } from "react";
5+
import { createRoot, type Root } from "react-dom/client";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
import type { StackClientApp } from "../lib/stack-app/apps/interfaces/client-app";
8+
import { stackAppInternalsSymbol } from "../lib/stack-app/common";
9+
import { StackContext } from "../providers/stack-context";
10+
import { useCliAuthConfirmation } from "./cli-auth-confirm";
11+
12+
const previousActEnvironment = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT");
13+
14+
function responseJson(data: unknown, init?: ResponseInit) {
15+
return new Response(JSON.stringify(data), {
16+
status: init?.status ?? 200,
17+
headers: { "Content-Type": "application/json" },
18+
});
19+
}
20+
21+
function createAppTestDouble(options: {
22+
user: unknown,
23+
sendRequest: (path: string, requestOptions: RequestInit) => Promise<Response>,
24+
signInWithTokens?: (tokens: { accessToken: string, refreshToken: string }) => Promise<void>,
25+
redirectToSignIn?: (options: { replace: true }) => Promise<void>,
26+
redirectToSignUp?: (options: { replace: true }) => Promise<void>,
27+
}) {
28+
const app = {
29+
useUser: () => options.user,
30+
redirectToSignIn: options.redirectToSignIn ?? vi.fn(async () => {}),
31+
redirectToSignUp: options.redirectToSignUp ?? vi.fn(async () => {}),
32+
[stackAppInternalsSymbol]: {
33+
sendRequest: options.sendRequest,
34+
signInWithTokens: options.signInWithTokens ?? vi.fn(async () => {}),
35+
},
36+
};
37+
38+
// This test double intentionally implements only the StackClientApp surface
39+
// that useCliAuthConfirmation touches.
40+
return app as unknown as StackClientApp<true>;
41+
}
42+
43+
function HookProbe() {
44+
const cliAuth = useCliAuthConfirmation();
45+
return (
46+
<>
47+
<div data-testid="status">{cliAuth.status}</div>
48+
<div data-testid="error">{cliAuth.error?.message}</div>
49+
<button type="button" onClick={() => runAsynchronously(cliAuth.authorize)}>authorize</button>
50+
<button onClick={cliAuth.retry}>retry</button>
51+
</>
52+
);
53+
}
54+
55+
let root: Root | null = null;
56+
let container: HTMLDivElement | null = null;
57+
58+
async function renderWithApp(app: StackClientApp<true>) {
59+
container = document.createElement("div");
60+
document.body.append(container);
61+
root = createRoot(container);
62+
await act(async () => {
63+
root?.render(
64+
<StackContext.Provider value={{ app }}>
65+
<HookProbe />
66+
</StackContext.Provider>
67+
);
68+
});
69+
}
70+
71+
function getByTestId(testId: string): HTMLElement {
72+
const element = container?.querySelector(`[data-testid="${testId}"]`);
73+
if (!(element instanceof HTMLElement)) {
74+
throw new Error(`Could not find test element ${testId}`);
75+
}
76+
return element;
77+
}
78+
79+
function getButton(label: string): HTMLButtonElement {
80+
const button = [...container?.querySelectorAll("button") ?? []]
81+
.find((element) => element.textContent === label);
82+
if (!(button instanceof HTMLButtonElement)) {
83+
throw new Error(`Could not find button ${label}`);
84+
}
85+
return button;
86+
}
87+
88+
describe("useCliAuthConfirmation", () => {
89+
beforeEach(() => {
90+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
91+
});
92+
93+
afterEach(() => {
94+
act(() => {
95+
root?.unmount();
96+
});
97+
container?.remove();
98+
root = null;
99+
container = null;
100+
vi.restoreAllMocks();
101+
window.history.replaceState({}, "", "/");
102+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
103+
});
104+
105+
it("completes CLI auth with the current user's refresh token", async () => {
106+
window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
107+
const getTokens = vi.fn(async () => ({ refreshToken: "refresh-token" }));
108+
const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }));
109+
const app = createAppTestDouble({
110+
user: { currentSession: { getTokens } },
111+
sendRequest,
112+
});
113+
114+
await renderWithApp(app);
115+
await act(async () => {
116+
getButton("authorize").click();
117+
});
118+
119+
expect(getByTestId("status").textContent).toBe("success");
120+
expect(getTokens).toHaveBeenCalledOnce();
121+
expect(sendRequest).toHaveBeenCalledOnce();
122+
expect(sendRequest.mock.calls[0][0]).toBe("/auth/cli/complete");
123+
expect(JSON.parse(String(sendRequest.mock.calls[0][1].body))).toMatchInlineSnapshot(`
124+
{
125+
"login_code": "login-code",
126+
"refresh_token": "refresh-token",
127+
}
128+
`);
129+
});
130+
131+
it("claims anonymous CLI sessions before redirecting to sign-up", async () => {
132+
window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
133+
const signInWithTokens = vi.fn(async (_tokens: { accessToken: string, refreshToken: string }) => {});
134+
const redirectToSignUp = vi.fn(async (_options: { replace: true }) => {});
135+
const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }))
136+
.mockResolvedValueOnce(responseJson({ cli_session_state: "anonymous" }))
137+
.mockResolvedValueOnce(responseJson({ access_token: "access-token", refresh_token: "refresh-token" }));
138+
const app = createAppTestDouble({
139+
user: null,
140+
sendRequest,
141+
signInWithTokens,
142+
redirectToSignUp,
143+
});
144+
145+
await renderWithApp(app);
146+
await act(async () => {
147+
getButton("authorize").click();
148+
});
149+
150+
expect(redirectToSignUp).toHaveBeenCalledWith({ replace: true });
151+
expect(signInWithTokens).toHaveBeenCalledWith({
152+
accessToken: "access-token",
153+
refreshToken: "refresh-token",
154+
});
155+
expect(new URL(window.location.href).searchParams.get("confirmed")).toBe("true");
156+
expect(sendRequest.mock.calls.map(call => JSON.parse(String(call[1].body)))).toMatchInlineSnapshot(`
157+
[
158+
{
159+
"login_code": "login-code",
160+
"mode": "check",
161+
},
162+
{
163+
"login_code": "login-code",
164+
"mode": "claim-anon-session",
165+
},
166+
]
167+
`);
168+
});
169+
170+
it("reports invalid when the login code is missing", async () => {
171+
window.history.replaceState({}, "", "/handler/cli-auth-confirm");
172+
const app = createAppTestDouble({
173+
user: null,
174+
sendRequest: vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 })),
175+
});
176+
177+
await renderWithApp(app);
178+
179+
expect(getByTestId("status").textContent).toBe("invalid");
180+
});
181+
});

0 commit comments

Comments
 (0)