diff --git a/packages/stack-shared/src/interface/handler-urls.ts b/packages/stack-shared/src/interface/handler-urls.ts index 7ad921cfb4..79f4ba053e 100644 --- a/packages/stack-shared/src/interface/handler-urls.ts +++ b/packages/stack-shared/src/interface/handler-urls.ts @@ -32,7 +32,33 @@ export type HandlerRedirectUrls = Record< export type HandlerUrls = HandlerPageUrls & HandlerRedirectUrls; export type HandlerUrlTarget = HandlerUrls[keyof HandlerUrls]; -export type DefaultHandlerUrlTarget = string | { type: "hosted" | "handler-component" }; + +/** + * The default handler URL target, applied to any key not explicitly set. + * + * - `{ type: "handler-component" }` — render the page inside the local `StackHandler` component (current default, may change in the next breaking version). + * - `{ type: "hosted" }` — redirect to Stack's hosted auth pages. + */ +export type DefaultHandlerUrlTarget = { type: "hosted" | "handler-component" }; + +/** + * Configuration for where each auth page/redirect lives. + * + * **`default`** — fallback target for every key not set individually: + * - `{ type: "handler-component" }` — use the local `StackHandler` (current default, may change in the next breaking version). + * - `{ type: "hosted" }` — use Stack's hosted auth pages. + * + * **Page keys** (`signIn`, `signUp`, `signOut`, `emailVerification`, `passwordReset`, + * `forgotPassword`, `oauthCallback`, `magicLinkCallback`, `accountSettings`, + * `teamInvitation`, `cliAuthConfirm`, `mfa`, `error`, `onboarding`, `handler`): + * - A URL string (e.g. `"/my-sign-in"`) — custom path. + * - `{ type: "custom", url: "...", version: 0 }` — custom URL with version tracking. + * - `{ type: "hosted" }` — Stack's hosted page. + * - `{ type: "handler-component" }` — local `StackHandler`. + * + * **Redirect keys** (`afterSignIn`, `afterSignUp`, `afterSignOut`, `home`): + * - A URL string (e.g. `"/dashboard"`) — where to redirect after the action. + */ export type HandlerUrlOptions = Partial & { default?: DefaultHandlerUrlTarget }; export type ResolvedHandlerUrls = { [K in keyof HandlerUrls]: string; diff --git a/packages/template/src/components-page/stack-handler-client.tsx b/packages/template/src/components-page/stack-handler-client.tsx index dcb929f96a..c06ffb3ef3 100644 --- a/packages/template/src/components-page/stack-handler-client.tsx +++ b/packages/template/src/components-page/stack-handler-client.tsx @@ -2,11 +2,12 @@ import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { FilterUndefined, filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { getRelativePart } from "@stackframe/stack-shared/dist/utils/urls"; import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; /* IF_PLATFORM react -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; // END_PLATFORM */ import { SignIn, SignUp, StackServerApp } from ".."; import { useStackApp } from "../lib/hooks"; @@ -25,9 +26,7 @@ import { PasswordReset } from "./password-reset"; import { SignOut } from "./sign-out"; import { TeamInvitation } from "./team-invitation"; -/* IF_PLATFORM react import { MessageCard } from "../components/message-cards/message-card"; -// END_PLATFORM react */ type Components = { SignIn: typeof SignIn, @@ -89,16 +88,16 @@ function renderComponent(props: { searchParams: Record, fullPage: boolean, componentProps?: BaseHandlerProps['componentProps'], - redirectIfNotHandler?: (name: keyof HandlerUrls) => void, + shouldRedirectToPage?: (name: keyof HandlerUrls) => boolean, getDefaultUnknownPathUrl?: (path: string) => string | null, onNotFound: () => any, app: StackClientApp | StackServerApp, }) { - const { path, searchParams, fullPage, componentProps, redirectIfNotHandler, getDefaultUnknownPathUrl, onNotFound, app } = props; + const { path, searchParams, fullPage, componentProps, shouldRedirectToPage, getDefaultUnknownPathUrl, onNotFound, app } = props; switch (path) { case availablePaths.signIn: { - redirectIfNotHandler?.('signIn'); + if (shouldRedirectToPage?.('signIn')) return { redirectToPage: 'signIn' as const }; return ; } case availablePaths.signUp: { - redirectIfNotHandler?.('signUp'); + if (shouldRedirectToPage?.('signUp')) return { redirectToPage: 'signUp' as const }; return ; } case availablePaths.emailVerification: { - redirectIfNotHandler?.('emailVerification'); + if (shouldRedirectToPage?.('emailVerification')) return { redirectToPage: 'emailVerification' as const }; return ; } case availablePaths.passwordReset: { - redirectIfNotHandler?.('passwordReset'); + if (shouldRedirectToPage?.('passwordReset')) return { redirectToPage: 'passwordReset' as const }; return ; } case availablePaths.forgotPassword: { - redirectIfNotHandler?.('forgotPassword'); + if (shouldRedirectToPage?.('forgotPassword')) return { redirectToPage: 'forgotPassword' as const }; return ; } case availablePaths.signOut: { - redirectIfNotHandler?.('signOut'); + if (shouldRedirectToPage?.('signOut')) return { redirectToPage: 'signOut' as const }; return ; } case availablePaths.oauthCallback: { - redirectIfNotHandler?.('oauthCallback'); + if (shouldRedirectToPage?.('oauthCallback')) return { redirectToPage: 'oauthCallback' as const }; return ; } case availablePaths.magicLinkCallback: { - redirectIfNotHandler?.('magicLinkCallback'); + if (shouldRedirectToPage?.('magicLinkCallback')) return { redirectToPage: 'magicLinkCallback' as const }; return ; } case availablePaths.teamInvitation: { - redirectIfNotHandler?.('teamInvitation'); + if (shouldRedirectToPage?.('teamInvitation')) return { redirectToPage: 'teamInvitation' as const }; return ; } case availablePaths.cliAuthConfirm: { - redirectIfNotHandler?.('cliAuthConfirm'); + if (shouldRedirectToPage?.('cliAuthConfirm')) return { redirectToPage: 'cliAuthConfirm' as const }; return ; } case availablePaths.mfa: { - redirectIfNotHandler?.('mfa'); + if (shouldRedirectToPage?.('mfa')) return { redirectToPage: 'mfa' as const }; return ; } case availablePaths.onboarding: { - redirectIfNotHandler?.('onboarding'); + if (shouldRedirectToPage?.('onboarding')) return { redirectToPage: 'onboarding' as const }; return }); }; - const redirectIfNotHandler = (name: keyof HandlerUrls) => { + const shouldRedirectToPage = (name: keyof HandlerUrls): boolean => { const url = stackApp.urls[name]; const isCrossDomainLocalOauthCallback = name === "oauthCallback" && searchParams.stack_cross_domain_auth === "1"; if (isCrossDomainLocalOauthCallback) { - return; + return false; } - const isLocalHandlerTarget = isLocalHandlerUrlTarget({ + return !isLocalHandlerUrlTarget({ targetUrl: url, handlerPath, currentOrigin: typeof window === "undefined" ? undefined : window.location.origin, }); - if (isLocalHandlerTarget) { - return; - } - - const urlObj = new URL(url, placeholderOrigin); - for (const [key, value] of Object.entries(searchParams)) { - urlObj.searchParams.set(key, value); - } - - // IF_PLATFORM next - redirect(toAbsoluteOrRelativeRedirectTarget(urlObj), RedirectType.replace); - /* ELSE_IF_PLATFORM react - redirectTargets.push(toAbsoluteOrRelativeRedirectTarget(urlObj)); - END_PLATFORM */ }; const result = renderComponent({ @@ -294,7 +279,7 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial searchParams, fullPage: props.fullPage, componentProps: props.componentProps, - redirectIfNotHandler, + shouldRedirectToPage, getDefaultUnknownPathUrl, onNotFound: () => // IF_PLATFORM next @@ -315,6 +300,21 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial app: stackApp, }); + const redirectToPage = (result != null && typeof result === 'object' && 'redirectToPage' in result) ? result.redirectToPage : undefined; + + useEffect(() => { + if (redirectToPage == null) return; + runAsynchronouslyWithAlert( + stackApp[stackAppInternalsSymbol].redirectToHandler(redirectToPage, { replace: true }) + ); + }, [redirectToPage, stackApp]); + + if (redirectToPage != null) { + return ( + + ); + } + if (result && 'redirect' in result) { // IF_PLATFORM next redirect(result.redirect, RedirectType.replace); 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 356cf83a39..11fc6a898b 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 @@ -3931,6 +3931,9 @@ export class _StackClientAppImplIncomplete { await this._redirectTo({ url, ...options }); }, + redirectToHandler: async (handlerName: keyof HandlerUrls, options?: RedirectToOptions) => { + await this._redirectToHandler(handlerName, options); + }, refreshOwnedProjects: async () => { await this._refreshOwnedProjects(await this._getSession()); }, diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 74a65448f6..6d052b53d4 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -127,6 +127,7 @@ export type StackClientApp, getRedirectMethod(): RedirectMethod, redirectToUrl(url: string | URL, options?: { replace?: boolean }): Promise, + redirectToHandler(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise, signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise, }, } diff --git a/packages/template/src/lib/stack-app/url-targets.test.ts b/packages/template/src/lib/stack-app/url-targets.test.ts index 8e167d41fc..de086beafb 100644 --- a/packages/template/src/lib/stack-app/url-targets.test.ts +++ b/packages/template/src/lib/stack-app/url-targets.test.ts @@ -122,16 +122,18 @@ describe("handler URL targets", () => { `); }); - it("does not inherit an absolute default target for the OAuth callback", () => { + it("inherits a hosted default target for the OAuth callback", () => { + vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX", ".example-stack-hosted.test"); + const urls = resolveHandlerUrls({ projectId: "project-id", urls: { - default: "https://app.example.test/handler", + default: { type: "hosted" }, }, }); - expect(urls.signIn).toBe("https://app.example.test/handler"); - expect(urls.oauthCallback).toBe("/handler/oauth-callback"); + expect(urls.signIn).toBe("https://project-id.example-stack-hosted.test/handler/sign-in"); + expect(urls.oauthCallback).toBe("https://project-id.example-stack-hosted.test/handler/oauth-callback"); }); it("supports custom CLI auth confirmation targets", () => { diff --git a/packages/template/src/lib/stack-app/url-targets.ts b/packages/template/src/lib/stack-app/url-targets.ts index 6bbf1f7434..ae6b5c3496 100644 --- a/packages/template/src/lib/stack-app/url-targets.ts +++ b/packages/template/src/lib/stack-app/url-targets.ts @@ -185,9 +185,9 @@ const assertOAuthCallbackTargetIsRelative = (target: HandlerUrlTarget): void => export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefined, projectId: string }): ResolvedHandlerUrls => { const configuredUrls = options.urls; - const defaultTarget: HandlerUrlTarget = configuredUrls?.default ?? { type: "handler-component" }; + const defaultTarget = configuredUrls?.default ?? { type: "handler-component" } as const; const oauthCallbackTarget: HandlerUrlTarget = configuredUrls?.oauthCallback ?? ( - typeof defaultTarget !== "string" && defaultTarget.type === "hosted" + defaultTarget.type === "hosted" ? defaultTarget : { type: "handler-component" } ); @@ -339,10 +339,7 @@ export const resolveUnknownHandlerPathFallbackUrl = (options: { projectId: string, unknownPath: string, }): string | null => { - const defaultTarget = options.defaultTarget ?? { type: "handler-component" } satisfies HandlerUrlTarget; - if (typeof defaultTarget === "string") { - return defaultTarget; - } + const defaultTarget = options.defaultTarget ?? { type: "handler-component" } satisfies DefaultHandlerUrlTarget; switch (defaultTarget.type) { case "handler-component": {