Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions claude/CLAUDE-KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,9 @@ A: Use a strict root `postinstall` script that rewrites only Next `>=16` app-pag

Q: Why can Turbo-pruned Docker builds fail with `Cannot find module /app/scripts/postinstall-patch-next-async-debug-info.mjs` during `pnpm install`?
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).

Q: What is the simple custom-page DX for CLI auth confirmation?
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.

Q: How should the CLI auth login URL be constructed in template tests?
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.
2 changes: 1 addition & 1 deletion packages/stack-shared/src/interface/handler-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type HandlerPageUrls = Record<
| "magicLinkCallback"
| "accountSettings"
| "teamInvitation"
| "cliAuthConfirm"
| "mfa"
| "error"
| "onboarding",
Expand Down Expand Up @@ -45,4 +46,3 @@ export {
type PageVersionEntry,
type PageVersions
} from "./page-component-versions";

53 changes: 53 additions & 0 deletions packages/stack-shared/src/interface/page-component-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,59 @@ export function getCustomPagePrompts(): Record<PageComponentKey, CustomPagePromp
`,
versions: {},
}),
cliAuthConfirm: createCustomPagePrompt({
key: "cliAuthConfirm",
title: "CLI Auth Confirmation",
minSdkVersion: "0.0.1",
structure: deindent`
- Use \`useCliAuthConfirmation()\`.
- If \`status === "invalid"\`, show an invalid-link state.
- If \`status === "success"\`, tell the user they can close the browser and return to the CLI.
- If \`status === "error"\`, show the error and a retry action.
- Otherwise, show a confirmation step that calls \`authorize()\`.
- Use \`isLoading\` to disable or show loading on the confirmation action while the hook is authorizing or redirecting.
`,
reactExample: deindent`
export default function CustomCliAuthConfirmPage() {
const cliAuth = useCliAuthConfirmation();

if (cliAuth.status === "invalid") {
return <MessageCard title="Invalid CLI authorization link" />;
}

if (cliAuth.status === "success") {
return <MessageCard title="CLI authorized">You can close this window and return to the command line.</MessageCard>;
}

if (cliAuth.status === "error") {
return (
<MessageCard
title="CLI authorization failed"
primaryButtonText="Try again"
primaryAction={cliAuth.retry}
>
{cliAuth.error?.message}
</MessageCard>
);
}

return (
<MessageCard
title="Authorize CLI application"
primaryButtonText={cliAuth.isLoading ? "Authorizing..." : "Authorize"}
primaryAction={cliAuth.authorize}
>
A command line application is requesting access to your account.
</MessageCard>
);
}
`,
notes: deindent`
- Be explicit about the account being authorized. CLI auth grants a refresh token to the command line application.
- The hook owns the protocol details: reading \`login_code\`, preserving confirmed state across redirects, claiming anonymous sessions, and completing authorization.
`,
versions: {},
}),
mfa: createCustomPagePrompt({
key: "mfa",
title: "MFA",
Expand Down
200 changes: 200 additions & 0 deletions packages/template/src/components-page/cli-auth-confirm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// @vitest-environment jsdom

import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StackClientApp } from "../lib/stack-app/apps/interfaces/client-app";
import { stackAppInternalsSymbol } from "../lib/stack-app/common";
import { StackContext } from "../providers/stack-context";
import { useCliAuthConfirmation } from "./cli-auth-confirm";

const previousActEnvironment = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT");

function responseJson(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
status: init?.status ?? 200,
headers: { "Content-Type": "application/json" },
});
}

function createAppTestDouble(options: {
user: unknown,
sendRequest: (path: string, requestOptions: RequestInit) => Promise<Response>,
signInWithTokens?: (tokens: { accessToken: string, refreshToken: string }) => Promise<void>,
redirectToSignIn?: (options: { replace: true }) => Promise<void>,
redirectToSignUp?: (options: { replace: true }) => Promise<void>,
}) {
const app = {
useUser: () => options.user,
redirectToSignIn: options.redirectToSignIn ?? vi.fn(async () => {}),
redirectToSignUp: options.redirectToSignUp ?? vi.fn(async () => {}),
[stackAppInternalsSymbol]: {
sendRequest: options.sendRequest,
signInWithTokens: options.signInWithTokens ?? vi.fn(async () => {}),
},
};

// This test double intentionally implements only the StackClientApp surface
// that useCliAuthConfirmation touches.
return app as unknown as StackClientApp<true>;
}

function HookProbe() {
const cliAuth = useCliAuthConfirmation();
return (
<>
<div data-testid="status">{cliAuth.status}</div>
<div data-testid="error">{cliAuth.error?.message}</div>
<button type="button" onClick={() => runAsynchronously(cliAuth.authorize)}>authorize</button>
<button onClick={cliAuth.retry}>retry</button>
</>
Comment thread
mantrakp04 marked this conversation as resolved.
);
}

let root: Root | null = null;
let container: HTMLDivElement | null = null;

async function renderWithApp(app: StackClientApp<true>) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(
<StackContext.Provider value={{ app }}>
<HookProbe />
</StackContext.Provider>
);
});
}

function getByTestId(testId: string): HTMLElement {
const element = container?.querySelector(`[data-testid="${testId}"]`);
if (!(element instanceof HTMLElement)) {
throw new Error(`Could not find test element ${testId}`);
}
return element;
}

function getButton(label: string): HTMLButtonElement {
const button = [...container?.querySelectorAll("button") ?? []]
.find((element) => element.textContent === label);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Could not find button ${label}`);
}
return button;
}

describe("useCliAuthConfirmation", () => {
beforeEach(() => {
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
});

afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
root = null;
container = null;
vi.restoreAllMocks();
window.history.replaceState({}, "", "/");
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
});

it("completes CLI auth with the current user's refresh token", async () => {
window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
const getTokens = vi.fn(async () => ({ refreshToken: "refresh-token" }));
const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }));
const app = createAppTestDouble({
user: { currentSession: { getTokens } },
sendRequest,
});

await renderWithApp(app);
await act(async () => {
getButton("authorize").click();
});

expect(getByTestId("status").textContent).toBe("success");
expect(getTokens).toHaveBeenCalledOnce();
expect(sendRequest).toHaveBeenCalledOnce();
expect(sendRequest.mock.calls[0][0]).toBe("/auth/cli/complete");
expect(JSON.parse(String(sendRequest.mock.calls[0][1].body))).toMatchInlineSnapshot(`
{
"login_code": "login-code",
"refresh_token": "refresh-token",
}
`);
});

it("ignores duplicate authorize clicks before React re-renders", async () => {
window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
const getTokens = vi.fn(async () => ({ refreshToken: "refresh-token" }));
const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }));
const app = createAppTestDouble({
user: { currentSession: { getTokens } },
sendRequest,
});

await renderWithApp(app);
await act(async () => {
const authorizeButton = getButton("authorize");
authorizeButton.click();
authorizeButton.click();
});

expect(sendRequest).toHaveBeenCalledOnce();
});

it("claims anonymous CLI sessions before redirecting to sign-up", async () => {
window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
const signInWithTokens = vi.fn(async (_tokens: { accessToken: string, refreshToken: string }) => {});
const redirectToSignUp = vi.fn(async (_options: { replace: true }) => {});
const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }))
.mockResolvedValueOnce(responseJson({ cli_session_state: "anonymous" }))
.mockResolvedValueOnce(responseJson({ access_token: "access-token", refresh_token: "refresh-token" }));
const app = createAppTestDouble({
user: null,
sendRequest,
signInWithTokens,
redirectToSignUp,
});

await renderWithApp(app);
await act(async () => {
getButton("authorize").click();
});

expect(redirectToSignUp).toHaveBeenCalledWith({ replace: true });
expect(signInWithTokens).toHaveBeenCalledWith({
accessToken: "access-token",
refreshToken: "refresh-token",
});
expect(new URL(window.location.href).searchParams.get("confirmed")).toBe("true");
expect(sendRequest.mock.calls.map(call => JSON.parse(String(call[1].body)))).toMatchInlineSnapshot(`
[
{
"login_code": "login-code",
"mode": "check",
},
{
"login_code": "login-code",
"mode": "claim-anon-session",
},
]
`);
});

it("reports invalid when the login code is missing", async () => {
window.history.replaceState({}, "", "/handler/cli-auth-confirm");
const app = createAppTestDouble({
user: null,
sendRequest: vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 })),
});

await renderWithApp(app);

expect(getByTestId("status").textContent).toBe("invalid");
});
});
Loading
Loading