diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 25d8af346f..0a1116d83b 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -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. diff --git a/packages/stack-shared/src/interface/handler-urls.ts b/packages/stack-shared/src/interface/handler-urls.ts index cdb2e11fdf..7ad921cfb4 100644 --- a/packages/stack-shared/src/interface/handler-urls.ts +++ b/packages/stack-shared/src/interface/handler-urls.ts @@ -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"; - diff --git a/packages/stack-shared/src/interface/page-component-versions.ts b/packages/stack-shared/src/interface/page-component-versions.ts index ed9b20e184..23ba0ffa7f 100644 --- a/packages/stack-shared/src/interface/page-component-versions.ts +++ b/packages/stack-shared/src/interface/page-component-versions.ts @@ -1419,6 +1419,59 @@ export function getCustomPagePrompts(): Record; + } + + if (cliAuth.status === "success") { + return You can close this window and return to the command line.; + } + + if (cliAuth.status === "error") { + return ( + + {cliAuth.error?.message} + + ); + } + + return ( + + A command line application is requesting access to your account. + + ); + } + `, + 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", diff --git a/packages/template/src/components-page/cli-auth-confirm.test.tsx b/packages/template/src/components-page/cli-auth-confirm.test.tsx new file mode 100644 index 0000000000..b35db29bd1 --- /dev/null +++ b/packages/template/src/components-page/cli-auth-confirm.test.tsx @@ -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, + signInWithTokens?: (tokens: { accessToken: string, refreshToken: string }) => Promise, + redirectToSignIn?: (options: { replace: true }) => Promise, + redirectToSignUp?: (options: { replace: true }) => Promise, +}) { + 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; +} + +function HookProbe() { + const cliAuth = useCliAuthConfirmation(); + return ( + <> +
{cliAuth.status}
+
{cliAuth.error?.message}
+ + + + ); +} + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +async function renderWithApp(app: StackClientApp) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + + ); + }); +} + +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"); + }); +}); diff --git a/packages/template/src/components-page/cli-auth-confirm.tsx b/packages/template/src/components-page/cli-auth-confirm.tsx index db82ff199e..5042f15447 100644 --- a/packages/template/src/components-page/cli-auth-confirm.tsx +++ b/packages/template/src/components-page/cli-auth-confirm.tsx @@ -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) { 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, + 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>("idle"); const [error, setError] = useState(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]); + + 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, }; +} - if (success) { +export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) { + const { t } = useTranslation(); + const cliAuth = useCliAuthConfirmation(); + + if (cliAuth.status === "success") { return ( @@ -142,28 +210,35 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) ); } - if (error) { + if (cliAuth.status === "error") { return ( { - setError(null); - autoCompleteRef.current = false; - }} + primaryAction={cliAuth.retry} > {t("Failed to authorize the CLI application:")} - {error.message} + {cliAuth.error?.message} + + + ); + } + + if (cliAuth.status === "invalid") { + return ( + + + {t("This CLI authorization link is missing a login code. Please return to the command line and start the login process again.")} ); } - if (confirmed && authorizing) { + if (cliAuth.status === "authorizing" || cliAuth.status === "redirecting") { return ( @@ -177,8 +252,8 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) {t("A command line application is requesting access to your account. Click the button below to authorize it.")} diff --git a/packages/template/src/components-page/stack-handler-client.tsx b/packages/template/src/components-page/stack-handler-client.tsx index d7af8fad29..82c28c93e9 100644 --- a/packages/template/src/components-page/stack-handler-client.tsx +++ b/packages/template/src/components-page/stack-handler-client.tsx @@ -177,6 +177,7 @@ function renderComponent(props: { />; } case availablePaths.cliAuthConfirm: { + redirectIfNotHandler?.('cliAuthConfirm'); return ): 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' }, diff --git a/packages/template/src/index.ts b/packages/template/src/index.ts index e62361c393..a948d93ac1 100644 --- a/packages/template/src/index.ts +++ b/packages/template/src/index.ts @@ -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"; diff --git a/packages/template/src/lib/hooks.tsx b/packages/template/src/lib/hooks.tsx index 4a2cc568c1..99e29bfa82 100644 --- a/packages/template/src/lib/hooks.tsx +++ b/packages/template/src/lib/hooks.tsx @@ -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 & { projectIdMustMatch?: string, diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 4422a824c2..1671e16fe3 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -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> { @@ -3073,7 +3074,11 @@ export class _StackClientAppImplIncomplete { 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", () => { diff --git a/packages/template/src/lib/stack-app/url-targets.ts b/packages/template/src/lib/stack-app/url-targets.ts index a5dffb8741..cb43f1ce34 100644 --- a/packages/template/src/lib/stack-app/url-targets.ts +++ b/packages/template/src/lib/stack-app/url-targets.ts @@ -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, diff --git a/packages/template/src/providers/stack-context.tsx b/packages/template/src/providers/stack-context.tsx new file mode 100644 index 0000000000..7f9cca3028 --- /dev/null +++ b/packages/template/src/providers/stack-context.tsx @@ -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); diff --git a/packages/template/src/providers/stack-provider-client.tsx b/packages/template/src/providers/stack-provider-client.tsx index 1e423dcaaa..a4615ea96c 100644 --- a/packages/template/src/providers/stack-provider-client.tsx +++ b/packages/template/src/providers/stack-provider-client.tsx @@ -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); +import { StackContext } from "./stack-context"; export function StackProviderClient(props: { app: StackClientAppJson | StackClientApp, diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 0c5c5d0ab0..3972a5a98e 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -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: