Merge branch 'dev' into promptless/document-cloud-run-deployment

This commit is contained in:
promptless[bot] 2026-04-28 22:30:41 +00:00
commit ebcdafcf08
16 changed files with 461 additions and 68 deletions

View File

@ -14,12 +14,14 @@ export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
const postHogKey = getPublicEnvVar('NEXT_PUBLIC_POSTHOG_KEY') ?? "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k";
if (postHogKey.length > 5) {
posthog.init(postHogKey, {
session_recording: {
maskAllInputs: false,
maskInputOptions: {
password: true,
},
},
// We use Sentry's Replay integration below for error debugging. Keep
// PostHog session recording off to avoid loading its lazy recorder, which
// is the source of Sentry issue STACK-SERVER-1NK:
// "Called on script loaded before session recording is available".
// PostHog documents `disable_session_recording: true` as the config-level
// way to prevent automatic web session recording.
// Source: https://posthog.com/docs/session-replay/how-to-control-which-sessions-you-record
disable_session_recording: true,
defaults: '2025-11-30',
api_host: "/consume",
ui_host: "https://eu.i.posthog.com",

View File

@ -392,3 +392,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.

View File

@ -15,6 +15,7 @@ export type HandlerPageUrls = Record<
| "magicLinkCallback"
| "accountSettings"
| "teamInvitation"
| "cliAuthConfirm"
| "mfa"
| "error"
| "onboarding",
@ -45,4 +46,3 @@ export {
type PageVersionEntry,
type PageVersions
} from "./page-component-versions";

View File

@ -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",

View File

@ -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>
</>
);
}
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");
});
});

View File

@ -2,10 +2,12 @@
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Typography } from "@stackframe/stack-ui";
import { useEffect, useRef, useState } from "react";
import { type StackClientApp, stackAppInternalsSymbol, useStackApp } from "..";
import { useCallback, useEffect, useRef, useState } from "react";
import { MessageCard } from "../components/message-cards/message-card";
import { useTranslation } from "../lib/translations";
import { stackAppInternalsSymbol } from "../lib/stack-app/common";
import type { StackClientApp } from "../lib/stack-app/apps/interfaces/client-app";
import { useStackApp } from "../lib/hooks";
async function postCliAuthComplete(app: StackClientApp, body: Record<string, unknown>) {
return await app[stackAppInternalsSymbol].sendRequest("/auth/cli/complete", {
@ -32,15 +34,45 @@ function markUrlConfirmed() {
window.history.replaceState({}, "", url.toString());
}
export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) {
const { t } = useTranslation();
function getError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}
function getObjectField(data: unknown, fieldName: string): unknown {
return typeof data === "object" && data !== null && fieldName in data
? data[fieldName as keyof typeof data]
: undefined;
}
function getStringField(data: unknown, fieldName: string): string | undefined {
const value = getObjectField(data, fieldName);
return typeof value === "string" ? value : undefined;
}
export type CliAuthConfirmationStatus =
| "idle"
| "invalid"
| "authorizing"
| "redirecting"
| "success"
| "error";
export type CliAuthConfirmationState = {
status: CliAuthConfirmationStatus,
loginCode: string | null,
error: Error | null,
isLoading: boolean,
authorize: () => Promise<void>,
retry: () => void,
};
export function useCliAuthConfirmation(): CliAuthConfirmationState {
const app = useStackApp();
const user = app.useUser({ includeRestricted: true });
const [authorizing, setAuthorizing] = useState(false);
const [success, setSuccess] = useState(false);
const [status, setStatus] = useState<Exclude<CliAuthConfirmationStatus, "invalid">>("idle");
const [error, setError] = useState<Error | null>(null);
const autoCompleteRef = useRef(false);
const authorizeInProgressRef = useRef(false);
const [loginCode] = useState(() => {
if (typeof window === 'undefined') return null;
return new URLSearchParams(window.location.search).get("login_code");
@ -50,48 +82,55 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
return new URLSearchParams(window.location.search).get("confirmed") === "true";
});
const completeWithCurrentUser = useCallback(async () => {
if (!loginCode) {
throw new Error("Missing login code in URL parameters");
}
if (!user) {
throw new Error("Cannot complete CLI authorization without a signed-in user");
}
const refreshToken = (await user.currentSession.getTokens()).refreshToken;
if (!refreshToken) {
throw new Error("Could not retrieve session token");
}
await completeCliAuthWithRefreshToken(app, loginCode, refreshToken);
}, [app, loginCode, user]);
useEffect(() => {
if (!confirmed || !user || autoCompleteRef.current) {
return;
}
autoCompleteRef.current = true;
runAsynchronouslyWithAlert(async () => {
setAuthorizing(true);
setStatus("authorizing");
try {
if (!loginCode) {
throw new Error("Missing login code in URL parameters");
}
const refreshToken = (await user.currentSession.getTokens()).refreshToken;
if (!refreshToken) {
throw new Error("Could not retrieve session token");
}
await completeCliAuthWithRefreshToken(app, loginCode, refreshToken);
setSuccess(true);
await completeWithCurrentUser();
setStatus("success");
} catch (err) {
setError(err as Error);
} finally {
setAuthorizing(false);
setError(getError(err));
setStatus("error");
}
});
}, [confirmed, user, loginCode, app]);
}, [confirmed, user, completeWithCurrentUser]);
const handleAuthorize = async () => {
if (authorizing) {
const authorize = useCallback(async () => {
if (authorizeInProgressRef.current) {
return;
}
setAuthorizing(true);
authorizeInProgressRef.current = true;
try {
if (!loginCode) {
throw new Error("Missing login code in URL parameters");
setError(new Error("Missing login code in URL parameters"));
setStatus("error");
return;
}
setError(null);
setStatus("authorizing");
if (user) {
const refreshToken = (await user.currentSession.getTokens()).refreshToken;
if (!refreshToken) {
throw new Error("Could not retrieve session token");
}
await completeCliAuthWithRefreshToken(app, loginCode, refreshToken);
setSuccess(true);
await completeWithCurrentUser();
setStatus("success");
return;
}
@ -99,8 +138,8 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
if (!checkResult.ok) {
throw new Error(`Failed to verify login code: ${checkResult.status} ${await checkResult.text()}`);
}
const checkData = await checkResult.json();
const cliSessionState: string | null = checkData.cli_session_state ?? null;
const checkData: unknown = await checkResult.json();
const cliSessionState = getStringField(checkData, "cli_session_state") ?? null;
if (cliSessionState === "anonymous") {
const claimResult = await postCliAuthComplete(app, { login_code: loginCode, mode: "claim-anon-session" });
@ -109,30 +148,59 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
throw new Error(`Failed to claim anonymous session: ${claimResult.status} ${await claimResult.text()}`);
}
const tokens = await claimResult.json();
const tokens: unknown = await claimResult.json();
const accessToken = getStringField(tokens, "access_token");
const refreshToken = getStringField(tokens, "refresh_token");
if (!accessToken || !refreshToken) {
throw new Error("Anonymous CLI session claim did not return tokens");
}
await app[stackAppInternalsSymbol].signInWithTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
accessToken,
refreshToken,
});
// Only mark the URL as confirmed once the anon session is actually
// bound to the browser; otherwise a failure above would leave a stale
// confirmed=true in the URL and the auto-complete effect would later
// bind the CLI to whichever user happens to be signed in.
markUrlConfirmed();
setStatus("redirecting");
await app.redirectToSignUp({ replace: true });
return;
}
markUrlConfirmed();
setStatus("redirecting");
await app.redirectToSignIn({ replace: true });
} catch (err) {
setError(err as Error);
setError(getError(err));
setStatus("error");
} finally {
setAuthorizing(false);
authorizeInProgressRef.current = false;
}
};
}, [app, completeWithCurrentUser, loginCode, user]);
if (success) {
const retry = useCallback(() => {
setError(null);
autoCompleteRef.current = false;
setStatus("idle");
}, []);
const visibleStatus = loginCode == null ? "invalid" : status;
return {
status: visibleStatus,
loginCode,
error,
isLoading: visibleStatus === "authorizing" || visibleStatus === "redirecting",
authorize,
retry,
};
}
export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) {
const { t } = useTranslation();
const cliAuth = useCliAuthConfirmation();
if (cliAuth.status === "success") {
return (
<MessageCard title={t("CLI Authorization Successful")} fullPage={fullPage}>
<Typography>
@ -142,28 +210,35 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
);
}
if (error) {
if (cliAuth.status === "error") {
return (
<MessageCard
title={t("Authorization Failed")}
fullPage={fullPage}
primaryButtonText={t("Try Again")}
primaryAction={() => {
setError(null);
autoCompleteRef.current = false;
}}
primaryAction={cliAuth.retry}
>
<Typography className="text-red-600">
{t("Failed to authorize the CLI application:")}
</Typography>
<Typography className="text-red-600">
{error.message}
{cliAuth.error?.message}
</Typography>
</MessageCard>
);
}
if (confirmed && authorizing) {
if (cliAuth.status === "invalid") {
return (
<MessageCard title={t("Invalid CLI Authorization Link")} fullPage={fullPage}>
<Typography className="text-red-600">
{t("This CLI authorization link is missing a login code. Please return to the command line and start the login process again.")}
</Typography>
</MessageCard>
);
}
if (cliAuth.status === "authorizing" || cliAuth.status === "redirecting") {
return (
<MessageCard title={t("Completing Authorization...")} fullPage={fullPage}>
<Typography>
@ -177,8 +252,8 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
<MessageCard
title={t("Authorize CLI Application")}
fullPage={fullPage}
primaryButtonText={authorizing ? t("Authorizing...") : t("Authorize")}
primaryAction={handleAuthorize}
primaryButtonText={cliAuth.isLoading ? t("Authorizing...") : t("Authorize")}
primaryAction={cliAuth.authorize}
>
<Typography>
{t("A command line application is requesting access to your account. Click the button below to authorize it.")}

View File

@ -177,6 +177,7 @@ function renderComponent(props: {
/>;
}
case availablePaths.cliAuthConfirm: {
redirectIfNotHandler?.('cliAuthConfirm');
return <CliAuthConfirmation
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.CliAuthConfirmation)}

View File

@ -2007,6 +2007,7 @@ function createComponentsTab(app: StackClientApp<true>): HTMLElement {
{ key: 'emailVerification' as any, label: 'Email verification' },
{ key: 'accountSettings' as any, label: 'Account settings' },
{ key: 'teamInvitation' as any, label: 'Team invitation' },
{ key: 'cliAuthConfirm' as any, label: 'CLI auth confirmation' },
{ key: 'mfa' as any, label: 'MFA' },
{ key: 'onboarding' as any, label: 'Onboarding' },
{ key: 'error' as any, label: 'Error' },

View File

@ -12,7 +12,7 @@ export { StackTheme } from './providers/theme-provider';
export { AccountSettings } from "./components-page/account-settings";
export { AuthPage } from "./components-page/auth-page";
export { CliAuthConfirmation } from "./components-page/cli-auth-confirm";
export { CliAuthConfirmation, useCliAuthConfirmation, type CliAuthConfirmationState, type CliAuthConfirmationStatus } from "./components-page/cli-auth-confirm";
export { EmailVerification } from "./components-page/email-verification";
export { ForgotPassword } from "./components-page/forgot-password";
export { PasswordReset } from "./components-page/password-reset";

View File

@ -1,6 +1,6 @@
import { useContext } from "react";
import { StackContext } from "../providers/stack-provider-client";
import { GetUserOptions as AppGetUserOptions, CurrentInternalUser, CurrentUser, StackClientApp } from "./stack-app";
import { StackContext } from "../providers/stack-context";
import type { GetUserOptions as AppGetUserOptions, CurrentInternalUser, CurrentUser, StackClientApp } from "./stack-app";
type GetUserOptions = AppGetUserOptions<true> & {
projectIdMustMatch?: string,

View File

@ -53,7 +53,7 @@ import { NotificationCategory } from "../../notification-categories";
import { TeamPermission } from "../../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
import { isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
import { buildCliAuthConfirmUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
import { _StackAdminAppImplIncomplete } from "./admin-app-impl";
@ -2511,6 +2511,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
async redirectToAccountSettings(options?: RedirectToOptions) { return await this._redirectToHandler("accountSettings", options); }
async redirectToError(options?: RedirectToOptions) { return await this._redirectToHandler("error", options); }
async redirectToTeamInvitation(options?: RedirectToOptions) { return await this._redirectToHandler("teamInvitation", options); }
async redirectToCliAuthConfirm(options?: RedirectToOptions) { return await this._redirectToHandler("cliAuthConfirm", options); }
async redirectToMfa(options?: RedirectToOptions) { return await this._redirectToHandler("mfa", options); }
async sendForgotPasswordEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserNotFound"]>> {
@ -3073,7 +3074,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const loginCode = initResult.login_code;
// Step 2: Open the browser for the user to authenticate and display the verification code
const url = `${options.appUrl}/handler/cli-auth-confirm?login_code=${encodeURIComponent(loginCode)}`;
const url = buildCliAuthConfirmUrl({
cliAuthConfirmUrl: this.urls.cliAuthConfirm,
appUrl: options.appUrl,
loginCode,
});
if (options.promptLink) {
options.promptLink(url, loginCode);
} else {

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getPagePrompt, isLocalHandlerUrlTarget, resolveHandlerUrls, resolveUnknownHandlerPathFallbackUrl } from "./url-targets";
import { buildCliAuthConfirmUrl, getPagePrompt, isLocalHandlerUrlTarget, resolveHandlerUrls, resolveUnknownHandlerPathFallbackUrl } from "./url-targets";
describe("handler URL targets", () => {
afterEach(() => {
@ -93,6 +93,31 @@ describe("handler URL targets", () => {
expect(urls.signUp).toBe("/sign-up");
expect(urls.signIn).toBe("https://project-id.example-stack-hosted.test/handler/sign-in");
expect(urls.cliAuthConfirm).toBe("https://project-id.example-stack-hosted.test/handler/cli-auth-confirm");
});
it("supports custom CLI auth confirmation targets", () => {
const cliAuthConfirmPrompt = getPagePrompt("cliAuthConfirm");
if (cliAuthConfirmPrompt == null) {
throw new Error("Expected cliAuthConfirm prompt metadata to exist");
}
const urls = resolveHandlerUrls({
projectId: "project-id",
urls: {
cliAuthConfirm: { type: "custom", url: "/cli/authorize", version: cliAuthConfirmPrompt.latestVersion },
},
});
expect(urls.cliAuthConfirm).toBe("/cli/authorize");
});
it("builds CLI auth login URLs from the resolved confirmation target", () => {
expect(buildCliAuthConfirmUrl({
cliAuthConfirmUrl: "/cli/authorize",
appUrl: "https://app.example.test/base",
loginCode: "login-code",
})).toBe("https://app.example.test/cli/authorize?login_code=login-code");
});
it("uses default target for unknown /handler/* pages", () => {

View File

@ -77,6 +77,9 @@ const getHostedPagePathForHandlerName = (handlerName: keyof HandlerUrls): string
case "teamInvitation": {
return "team-invitation";
}
case "cliAuthConfirm": {
return "cli-auth-confirm";
}
case "mfa": {
return "mfa";
}
@ -306,6 +309,12 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine
handlerName: "teamInvitation",
projectId: options.projectId,
}),
cliAuthConfirm: resolveUrlTarget({
target: configuredUrls?.cliAuthConfirm ?? defaultTarget,
fallbackPath: joinHandlerComponentPath(handlerComponentBasePath, "cli-auth-confirm"),
handlerName: "cliAuthConfirm",
projectId: options.projectId,
}),
mfa: resolveUrlTarget({
target: configuredUrls?.mfa ?? defaultTarget,
fallbackPath: joinHandlerComponentPath(handlerComponentBasePath, "mfa"),
@ -327,6 +336,17 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine
};
};
export const buildCliAuthConfirmUrl = (options: {
cliAuthConfirmUrl: string,
/** Used as the base URL only when cliAuthConfirmUrl is relative. */
appUrl: string,
loginCode: string,
}): string => {
const url = new URL(options.cliAuthConfirmUrl, options.appUrl);
url.searchParams.set("login_code", options.loginCode);
return url.toString();
};
export const resolveUnknownHandlerPathFallbackUrl = (options: {
defaultTarget: DefaultHandlerUrlTarget | undefined,
projectId: string,

View File

@ -0,0 +1,8 @@
"use client";
import React from "react";
import type { StackClientApp } from "../lib/stack-app/apps/interfaces/client-app";
export const StackContext = React.createContext<null | {
app: StackClientApp<true>,
}>(null);

View File

@ -3,12 +3,9 @@
import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { globalVar } from "@stackframe/stack-shared/dist/utils/globals";
import React, { useEffect } from "react";
import { useStackApp } from "..";
import { useStackApp } from "../lib/hooks";
import { StackClientApp, StackClientAppJson, stackAppInternalsSymbol } from "../lib/stack-app";
export const StackContext = React.createContext<null | {
app: StackClientApp<true>,
}>(null);
import { StackContext } from "./stack-context";
export function StackProviderClient(props: {
app: StackClientAppJson<true, string> | StackClientApp<true>,

View File

@ -849,7 +849,7 @@ Implementation:
Body: { expires_in_millis?: number, anon_refresh_token?: string }
Response: { polling_code: string, login_code: string }
2. Build login URL: {appUrl}/handler/cli-auth-confirm?login_code={login_code}
2. Build login URL from the app's resolved `urls.cliAuthConfirm` target, using `appUrl` as the base URL when the target is relative, and append `login_code={login_code}`.
3. Call promptLink(url, loginCode) if provided, or print login code and URL
4. Poll for completion: