mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Add useCliAuthConfirmation hook and customizable cliAuthConfirm URL target (#1388)
## Summary - Extract CLI auth confirmation into a `useCliAuthConfirmation()` hook (status / error / isLoading / authorize / retry) so custom pages don't have to reimplement the protocol; `CliAuthConfirmation` now consumes the hook. - Make `cliAuthConfirm` a first-class handler URL target — resolved via `resolveHandlerUrls`, customizable per project, and used by `promptCliLogin` through a new `buildCliAuthConfirmUrl()` helper. - Move `StackContext` to its own module so the hook can be unit-tested with a test double without tripping the client-version sentinel; register `cliAuthConfirm` in custom-page prompts and the dev-tool components tab; export the hook + types from `@stackframe/stack`. ## Test plan - [ ] `pnpm typecheck` - [ ] `pnpm lint` - [ ] `pnpm --filter @stackframe/stack test cli-auth-confirm url-targets` - [ ] Manually verify default `/handler/cli-auth-confirm` flow + a project with a custom `cliAuthConfirm` URL <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Adds a CLI authentication confirmation page with clear states (invalid, authorizing, redirecting, success, error), retry action, and flows for signed-in and anonymous users. * CLI login URL generation now derives from the configured handler target and app base, improving reliability. * CLI confirmation page exposed in the components/dev UI for previewing. * **Tests** * End-to-end and unit tests covering confirmation behaviors and URL generation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
a82097db62
commit
9d1eee8ab8
@ -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.
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
200
packages/template/src/components-page/cli-auth-confirm.test.tsx
Normal file
200
packages/template/src/components-page/cli-auth-confirm.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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.")}
|
||||
|
||||
@ -177,6 +177,7 @@ function renderComponent(props: {
|
||||
/>;
|
||||
}
|
||||
case availablePaths.cliAuthConfirm: {
|
||||
redirectIfNotHandler?.('cliAuthConfirm');
|
||||
return <CliAuthConfirmation
|
||||
fullPage={fullPage}
|
||||
{...filterUndefinedINU(componentProps?.CliAuthConfirmation)}
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
8
packages/template/src/providers/stack-context.tsx
Normal file
8
packages/template/src/providers/stack-context.tsx
Normal 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);
|
||||
@ -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>,
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user