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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/stack-shared/src/interface/handler-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +47 to +49
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we will probably change the default here with the next breaking version so make that clear

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated — both JSDoc blocks now note "current default, may change in the next breaking version".

*
* **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<HandlerUrls> & { default?: DefaultHandlerUrlTarget };
export type ResolvedHandlerUrls = {
[K in keyof HandlerUrls]: string;
Expand Down
72 changes: 36 additions & 36 deletions packages/template/src/components-page/stack-handler-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -89,77 +88,77 @@ function renderComponent(props: {
searchParams: Record<string, string>,
fullPage: boolean,
componentProps?: BaseHandlerProps['componentProps'],
redirectIfNotHandler?: (name: keyof HandlerUrls) => void,
shouldRedirectToPage?: (name: keyof HandlerUrls) => boolean,
getDefaultUnknownPathUrl?: (path: string) => string | null,
onNotFound: () => any,
app: StackClientApp<any> | StackServerApp<any>,
}) {
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 <SignIn
fullPage={fullPage}
automaticRedirect
{...filterUndefinedINU(componentProps?.SignIn)}
/>;
}
case availablePaths.signUp: {
redirectIfNotHandler?.('signUp');
if (shouldRedirectToPage?.('signUp')) return { redirectToPage: 'signUp' as const };
return <SignUp
fullPage={fullPage}
automaticRedirect
{...filterUndefinedINU(componentProps?.SignUp)}
/>;
}
case availablePaths.emailVerification: {
redirectIfNotHandler?.('emailVerification');
if (shouldRedirectToPage?.('emailVerification')) return { redirectToPage: 'emailVerification' as const };
return <EmailVerification
searchParams={searchParams}
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.EmailVerification)}
/>;
}
case availablePaths.passwordReset: {
redirectIfNotHandler?.('passwordReset');
if (shouldRedirectToPage?.('passwordReset')) return { redirectToPage: 'passwordReset' as const };
return <PasswordReset
searchParams={searchParams}
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.PasswordReset)}
/>;
}
case availablePaths.forgotPassword: {
redirectIfNotHandler?.('forgotPassword');
if (shouldRedirectToPage?.('forgotPassword')) return { redirectToPage: 'forgotPassword' as const };
return <ForgotPassword
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.ForgotPassword)}
/>;
}
case availablePaths.signOut: {
redirectIfNotHandler?.('signOut');
if (shouldRedirectToPage?.('signOut')) return { redirectToPage: 'signOut' as const };
return <SignOut
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.SignOut)}
/>;
}
case availablePaths.oauthCallback: {
redirectIfNotHandler?.('oauthCallback');
if (shouldRedirectToPage?.('oauthCallback')) return { redirectToPage: 'oauthCallback' as const };
return <OAuthCallback
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.OAuthCallback)}
/>;
}
case availablePaths.magicLinkCallback: {
redirectIfNotHandler?.('magicLinkCallback');
if (shouldRedirectToPage?.('magicLinkCallback')) return { redirectToPage: 'magicLinkCallback' as const };
return <MagicLinkCallback
searchParams={searchParams}
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.MagicLinkCallback)}
/>;
}
case availablePaths.teamInvitation: {
redirectIfNotHandler?.('teamInvitation');
if (shouldRedirectToPage?.('teamInvitation')) return { redirectToPage: 'teamInvitation' as const };
return <TeamInvitation
searchParams={searchParams}
fullPage={fullPage}
Expand All @@ -180,21 +179,21 @@ function renderComponent(props: {
/>;
}
case availablePaths.cliAuthConfirm: {
redirectIfNotHandler?.('cliAuthConfirm');
if (shouldRedirectToPage?.('cliAuthConfirm')) return { redirectToPage: 'cliAuthConfirm' as const };
return <CliAuthConfirmation
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.CliAuthConfirmation)}
/>;
}
case availablePaths.mfa: {
redirectIfNotHandler?.('mfa');
if (shouldRedirectToPage?.('mfa')) return { redirectToPage: 'mfa' as const };
return <MFA
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.MFA)}
/>;
}
case availablePaths.onboarding: {
redirectIfNotHandler?.('onboarding');
if (shouldRedirectToPage?.('onboarding')) return { redirectToPage: 'onboarding' as const };
return <Onboarding
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.Onboarding)}
Expand Down Expand Up @@ -262,39 +261,25 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
});
};

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({
path,
searchParams,
fullPage: props.fullPage,
componentProps: props.componentProps,
redirectIfNotHandler,
shouldRedirectToPage,
getDefaultUnknownPathUrl,
onNotFound: () =>
// IF_PLATFORM next
Expand All @@ -315,6 +300,21 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
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 (
<MessageCard title="Redirecting..." fullPage={props.fullPage} />
);
}

if (result && 'redirect' in result) {
// IF_PLATFORM next
redirect(result.redirect, RedirectType.replace);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3931,6 +3931,9 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
redirectToUrl: async (url: string | URL, options?: { replace?: boolean }) => {
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());
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
sendRequest(path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin"): Promise<Response>,
getRedirectMethod(): RedirectMethod,
redirectToUrl(url: string | URL, options?: { replace?: boolean }): Promise<void>,
redirectToHandler(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise<void>,
signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise<void>,
},
}
Expand Down
10 changes: 6 additions & 4 deletions packages/template/src/lib/stack-app/url-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
9 changes: 3 additions & 6 deletions packages/template/src/lib/stack-app/url-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
);
Expand Down Expand Up @@ -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": {
Expand Down
Loading