Aggressively deprecate app.urls.xyz & update docs to avoid it

Closes #1587
This commit is contained in:
Konstantin Wohlwend 2026-06-17 09:56:41 -07:00
parent c50f1e64ed
commit ec0008d515
29 changed files with 180 additions and 81 deletions

View File

@ -111,7 +111,7 @@ it("adds secure cross-domain handoff parameters when redirecting to hosted sign-
});
});
it("returns static app.urls.signIn for hosted flows", async ({ expect }) => {
it("throws when app.urls.signIn is read for hosted flows", async ({ expect }) => {
await withHostedDomainSuffix(async () => {
const projectId = "44444444-4444-4444-8444-444444444444";
const currentHref = `${localRedirectUrl}/private-page?foo=bar`;
@ -124,17 +124,13 @@ it("returns static app.urls.signIn for hosted flows", async ({ expect }) => {
href: currentHref,
assign: () => { throw new Error("INTENTIONAL_TEST_ABORT"); },
},
addEventListener: () => {},
removeEventListener: () => {},
} as any;
try {
const clientApp = createClientApp(projectId);
const signInUrl = new URL(clientApp.urls.signIn);
expect(signInUrl.origin).toBe(`https://${projectId}.example-stack-hosted.test`);
expect(signInUrl.pathname).toBe("/handler/sign-in");
expect(signInUrl.searchParams.get("after_auth_return_to")).toBeNull();
expect(signInUrl.searchParams.get("hexclave_cross_domain_state")).toBeNull();
expect(signInUrl.searchParams.get("hexclave_cross_domain_code_challenge")).toBeNull();
expect(signInUrl.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBeNull();
expect(() => clientApp.urls.signIn).toThrowError(/app\.urls\.signIn cannot be used when this app is configured to use hosted components.*Use app\.redirectToSignIn\(\) instead/s);
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
@ -142,7 +138,7 @@ it("returns static app.urls.signIn for hosted flows", async ({ expect }) => {
});
});
it("returns static app.urls.signOut for hosted flows", async ({ expect }) => {
it("throws when app.urls.signOut is read for hosted flows", async ({ expect }) => {
await withHostedDomainSuffix(async () => {
const projectId = "55555555-5555-4555-8555-555555555555";
const currentHref = `${localRedirectUrl}/signed-in-page?foo=bar`;
@ -155,14 +151,13 @@ it("returns static app.urls.signOut for hosted flows", async ({ expect }) => {
href: currentHref,
assign: () => { throw new Error("INTENTIONAL_TEST_ABORT"); },
},
addEventListener: () => {},
removeEventListener: () => {},
} as any;
try {
const clientApp = createClientApp(projectId);
const signOutUrl = new URL(clientApp.urls.signOut);
expect(signOutUrl.origin).toBe(`https://${projectId}.example-stack-hosted.test`);
expect(signOutUrl.pathname).toBe("/handler/sign-out");
expect(signOutUrl.searchParams.get("after_auth_return_to")).toBeNull();
expect(() => clientApp.urls.signOut).toThrowError(/app\.urls\.signOut cannot be used when this app is configured to use hosted components.*Use app\.redirectToSignOut\(\) instead/s);
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;

View File

@ -185,7 +185,7 @@ function HostedAuthPageInner(props: {
<p className="text-muted-foreground">
Don't have an account?{" "}
<a
href={app.urls.signUp}
href="#"
className={authFooterLinkClassName}
onClick={(event) => {
event.preventDefault();
@ -200,7 +200,7 @@ function HostedAuthPageInner(props: {
<p className="text-muted-foreground">
Already have an account?{" "}
<a
href={app.urls.signIn}
href="#"
className={authFooterLinkClassName}
onClick={(event) => {
event.preventDefault();

View File

@ -107,7 +107,7 @@ export function HostedForgotPassword(props: {
<p className="text-muted-foreground">
Remembered your password?{" "}
<a
href={app.urls.signIn}
href="#"
className={authFooterLinkClassName}
onClick={(event) => {
event.preventDefault();

View File

@ -64,7 +64,7 @@ export function CredentialSignIn() {
<div className="mb-1.5 mt-4 flex items-center justify-between">
<Label htmlFor="password" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Password</Label>
<a
href={app.urls.forgotPassword}
href="#"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.preventDefault();

View File

@ -214,7 +214,7 @@ export function HostedPasswordReset(props: {
<p className="text-muted-foreground">
Remembered your password?{" "}
<a
href={app.urls.signIn}
href="#"
className={authFooterLinkClassName}
onClick={(event) => {
event.preventDefault();

File diff suppressed because one or more lines are too long

View File

@ -123,7 +123,6 @@ Let's create a sample table and some RLS policies to demonstrate how to integrat
import { createSupabaseClient } from "@/utils/supabase-client";
import { useHexclaveApp, useUser } from "@hexclave/next";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function Page() {
@ -149,9 +148,9 @@ Let's create a sample table and some RLS policies to demonstrate how to integrat
<>
<p>You are signed in</p>
<p>User ID: {user.id}</p>
<Link href={app.urls.signOut}>Sign Out</Link>
<button onClick={async () => await app.redirectToSignOut()}>Sign Out</button>
</> :
<Link href={app.urls.signIn}>Sign In</Link>
<button onClick={async () => await app.redirectToSignIn()}>Sign In</button>
}
<h3>Supabase data</h3>
<ul>{listContent}</ul>

View File

@ -114,12 +114,11 @@ After setup, open the hosted auth UI (for example `/handler/sign-up`), create a
### Marketing header: sign in / sign out
Use `useHexclaveApp()` so you do not hard-code handler URLs (they can be customized in the project):
Use `useHexclaveApp()` so navigation goes through Hexclave's redirect helpers:
```tsx title="components/auth-header.tsx"
"use client";
import Link from "next/link";
import { useHexclaveApp, useUser } from "@hexclave/next";
export function AuthHeader() {
@ -131,13 +130,13 @@ export function AuthHeader() {
{user ? (
<>
<span>{user.displayName ?? user.primaryEmail ?? user.id}</span>
<Link href={app.urls.accountSettings}>Account</Link>
<Link href={app.urls.signOut}>Sign out</Link>
<button onClick={async () => await app.redirectToAccountSettings()}>Account</button>
<button onClick={async () => await app.redirectToSignOut()}>Sign out</button>
</>
) : (
<>
<Link href={app.urls.signIn}>Sign in</Link>
<Link href={app.urls.signUp}>Sign up</Link>
<button onClick={async () => await app.redirectToSignIn()}>Sign in</button>
<button onClick={async () => await app.redirectToSignUp()}>Sign up</button>
</>
)}
</header>

View File

@ -44,7 +44,7 @@ You typically combine **one or more** of:
Match **only** prefixes that should be gated. Do **not** blanket-match `/` or you can block static assets and Stacks **`/handler`** routes (sign-in, sign-up, callbacks).
If your project uses **custom handler base paths**, use the sign-in URL from `hexclaveServerApp.urls.signIn` as the redirect target instead of hard-coding `/handler/sign-in` (it may be an absolute URL depending on configuration—`NextResponse.redirect` accepts that).
If your project uses hosted components or cross-domain auth, prefer protecting pages with `hexclaveServerApp.getUser({ or: "redirect" })` in a Server Component. Middleware cannot run the runtime `redirectToSignIn()` helper, so only hard-code `/handler/sign-in` here when your app owns a same-domain handler route.
</Tab>
<Tab title="Server Component">
```tsx title="app/app/dashboard/page.tsx"

View File

@ -731,7 +731,6 @@ Follow these instructions to integrate Hexclave with Convex.
import { createSupabaseClient } from "@/utils/supabase-client";
import { useHexclaveApp, useUser } from "@hexclave/next";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function Page() {
@ -756,10 +755,10 @@ Follow these instructions to integrate Hexclave with Convex.
<>
<p>You are signed in</p>
<p>User ID: {user.id}</p>
<Link href={app.urls.signOut}>Sign Out</Link>
<button onClick={async () => await app.redirectToSignOut()}>Sign Out</button>
</>
) : (
<Link href={app.urls.signIn}>Sign In</Link>
<button onClick={async () => await app.redirectToSignIn()}>Sign In</button>
)}
<h3>Supabase data</h3>
<ul>{listContent}</ul>

View File

@ -15,7 +15,7 @@ import { useHexclaveApp } from "@hexclave/next"; // replace `next` with the cor
function MyComponent() {
const hexclaveApp = useHexclaveApp();
return <div>Sign In URL: {hexclaveApp.urls.signIn}</div>;
return <button onClick={async () => await hexclaveApp.redirectToSignIn()}>Sign in</button>;
}
```

File diff suppressed because one or more lines are too long

View File

@ -66,7 +66,7 @@ export default function CustomCredentialSignIn() {
setError('Please enter your password');
return;
}
// This will redirect to app.urls.afterSignIn if successful.
// This will redirect to the configured afterSignIn URL if successful.
// You can customize the redirect URL in the StackServerApp constructor.
const result = await app.signInWithCredential({ email, password });
// It's better to handle each error code separately, but for simplicity,
@ -180,7 +180,7 @@ export default function CustomCredentialSignUp() {
setError('Please enter your password');
return;
}
// This will redirect to app.urls.afterSignUp if successful.
// This will redirect to the configured afterSignUp URL if successful.
// You can customize the redirect URL in the StackServerApp constructor.
const result = await app.signUpWithCredential({ email, password });
// It's better to handle each error code separately, but for simplicity,

View File

@ -142,7 +142,7 @@ You can also store custom user data in the `clientMetadata`, `serverMetadata`, o
## Signing out
You can sign out the user by redirecting them to `/handler/sign-out` or simply by calling `user.signOut()`. They will be redirected to the URL [configured as `afterSignOut` in the `StackServerApp`](../sdk/objects/stack-app).
You can sign out the user by calling `user.signOut()` or by using the app's redirect helper. They will be redirected to the URL [configured as `afterSignOut` in the `StackServerApp`](../sdk/objects/stack-app).
<Tabs defaultValue="signout">
<TabsList>
@ -163,12 +163,13 @@ You can sign out the user by redirecting them to `/handler/sign-out` or simply b
</TabsContent>
<TabsContent value="redirect">
```tsx title="sign-out-link.tsx"
import { stackServerApp } from "@/stack/server";
```tsx title="sign-out-button.tsx"
"use client";
import { useStackApp } from "@stackframe/stack";
export default async function SignOutLink() {
// stackServerApp.urls.signOut is equal to /handler/sign-out
return <a href={stackServerApp.urls.signOut}>Sign Out</a>;
export default function SignOutButton() {
const app = useStackApp();
return <button onClick={async () => await app.redirectToSignOut()}>Sign Out</button>;
}
```
</TabsContent>
@ -228,13 +229,12 @@ Stack automatically creates a user profile on sign-up. Let's build a page that d
<UserButton />
<p>Welcome, {user.displayName ?? "unnamed user"}</p>
<p>Your e-mail: {user.primaryEmail}</p>
<p><a href={stackServerApp.urls.signOut}>Sign Out</a></p>
<UserButton />
</div>
) : (
<div>
<p>You are not logged in</p>
<p><a href={stackServerApp.urls.signIn}>Sign in</a></p>
<p><a href={stackServerApp.urls.signUp}>Sign up</a></p>
<p>Render a client component that calls <code>stackApp.redirectToSignIn()</code> or <code>stackApp.redirectToSignUp()</code>.</p>
</div>
)}
</div>

View File

@ -120,7 +120,6 @@ Let's create an example page that fetches data from Supabase and displays it.
import { createSupabaseClient } from "@/utils/supabase-client";
import { useStackApp, useUser } from "@stackframe/stack";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function Page() {
@ -146,9 +145,9 @@ export default function Page() {
<>
<p>You are signed in</p>
<p>User ID: {user.id}</p>
<Link href={app.urls.signOut}>Sign Out</Link>
<button onClick={async () => await app.redirectToSignOut()}>Sign Out</button>
</> :
<Link href={app.urls.signIn}>Sign In</Link>
<button onClick={async () => await app.redirectToSignIn()}>Sign In</button>
}
<h3>Supabase data</h3>
<ul>{listContent}</ul>

View File

@ -11,6 +11,6 @@ import { useStackApp } from "@stackframe/stack";
function MyComponent() {
const stackApp = useStackApp();
return <div>Sign In URL: {stackApp.urls.signIn}</div>;
return <button onClick={async () => await stackApp.redirectToSignIn()}>Sign in</button>;
}
```

View File

@ -9,7 +9,7 @@ export async function middleware(request: NextRequest) {
const user = await hexclaveServerApp.getUser();
if (!user) {
console.log('User in middleware is not logged in. Redirecting to sign-in page');
return NextResponse.redirect(hexclaveServerApp.urls.signIn);
return NextResponse.redirect(new URL('/handler/sign-in', request.url));
}
console.log('User in middleware is logged in. ID: ', user.id);

View File

@ -216,7 +216,6 @@ export const supabaseSetupPrompt = deindent`
import { createSupabaseClient } from "@/utils/supabase-client";
import { useHexclaveApp, useUser } from "@hexclave/next";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function Page() {
@ -241,10 +240,10 @@ export const supabaseSetupPrompt = deindent`
<>
<p>You are signed in</p>
<p>User ID: {user.id}</p>
<Link href={app.urls.signOut}>Sign Out</Link>
<button onClick={async () => await app.redirectToSignOut()}>Sign Out</button>
</>
) : (
<Link href={app.urls.signIn}>Sign In</Link>
<button onClick={async () => await app.redirectToSignIn()}>Sign In</button>
)}
<h3>Supabase data</h3>
<ul>{listContent}</ul>

View File

@ -218,7 +218,7 @@ function createAuthPagePrompt(type: AuthPagePromptType): CustomPagePrompt {
<Typography>
{"Don't have an account? "}
<a
href={hexclaveApp.urls.signUp}
href="#"
onClick={async (e) => {
e.preventDefault();
await hexclaveApp.redirectToSignUp();
@ -231,7 +231,7 @@ function createAuthPagePrompt(type: AuthPagePromptType): CustomPagePrompt {
}` : `<Typography>
{"Already have an account? "}
<a
href={hexclaveApp.urls.signIn}
href="#"
onClick={async (e) => {
e.preventDefault();
await hexclaveApp.redirectToSignIn();
@ -552,7 +552,15 @@ export function getCustomPagePrompts(): Record<PageComponentKey, CustomPagePromp
<Typography type="h2">Reset Your Password</Typography>
<Typography>
{"Don't need to reset? "}
<a href={hexclaveApp.urls.signIn}>Sign in</a>
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await hexclaveApp.redirectToSignIn();
}}
>
Sign in
</a>
</Typography>
<form onSubmit={async (e) => {
@ -618,7 +626,15 @@ export function getCustomPagePrompts(): Record<PageComponentKey, CustomPagePromp
{showRedirectLink ? (
<Typography>
{"If you are not redirected automatically, "}
<a href={hexclaveApp.urls.home}>click here</a>
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await hexclaveApp.redirectToHome();
}}
>
click here
</a>
</Typography>
) : null}
{error ? <pre>{JSON.stringify(error, null, 2)}</pre> : null}

View File

@ -143,7 +143,7 @@ function Inner(props: Props) {
project.config.signUpEnabled && (
<Typography>
{t("Don't have an account?")}{" "}
<StyledLink href={hexclaveApp.urls.signUp} onClick={(e) => {
<StyledLink href="#" onClick={(e) => {
runAsynchronously(hexclaveApp.redirectToSignUp());
e.preventDefault();
}}>{t("Sign up")}</StyledLink>
@ -152,7 +152,7 @@ function Inner(props: Props) {
) : (
<Typography>
{t("Already have an account?")}{" "}
<StyledLink href={hexclaveApp.urls.signIn} onClick={(e) => {
<StyledLink href="#" onClick={(e) => {
runAsynchronously(hexclaveApp.redirectToSignIn());
e.preventDefault();
}}>{t("Sign in")}</StyledLink>

View File

@ -85,7 +85,13 @@ export function ForgotPassword(props: { fullPage?: boolean }) {
<Typography type='h2'>{t("Reset Your Password")}</Typography>
<Typography>
{t("Don't need to reset?")}{" "}
<StyledLink href={hexclaveApp.urls['signIn']}>
<StyledLink
href="#"
onClick={(e) => {
e.preventDefault();
runAsynchronouslyWithAlert(hexclaveApp.redirectToSignIn());
}}
>
{t("Sign in")}
</StyledLink>
</Typography>

View File

@ -66,7 +66,15 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
<div className="flex flex-col justify-center items-center gap-4">
<Spinner size={20} />
</div>
{showRedirectLink || redirectUrl != null ? <p>{t('If you are not redirected automatically, ')}<StyledLink className="whitespace-nowrap" href={redirectUrl ?? app.urls.home}>{t("click here")}</StyledLink></p> : null}
{showRedirectLink || redirectUrl != null ? <p>{t('If you are not redirected automatically, ')}<StyledLink
className="whitespace-nowrap"
href={redirectUrl ?? "#"}
onClick={(e) => {
if (redirectUrl != null) return;
e.preventDefault();
runAsynchronously(app.redirectToHome());
}}
>{t("click here")}</StyledLink></p> : null}
</div>
</MaybeFullPage>
);

View File

@ -26,7 +26,6 @@ export function TeamCreation(props: { fullPage?: boolean }) {
const project = app.useProject();
const user = useUser({ or: 'redirect' });
const [loading, setLoading] = useState(false);
const navigate = app.useNavigate();
if (!project.config.clientTeamCreationEnabled) {
return <MessageCard title={t('Team creation is not enabled')} />;
@ -36,8 +35,8 @@ export function TeamCreation(props: { fullPage?: boolean }) {
setLoading(true);
try {
const team = await user.createTeam({ displayName: data.displayName });
navigate(`${app.urls.handler}/team-settings/${team.id}`);
await user.createTeam({ displayName: data.displayName });
await app.redirectToAccountSettings();
} finally {
setLoading(false);
}

View File

@ -66,7 +66,14 @@ export function CredentialSignIn() {
/>
<FormWarningText text={errors.password?.message?.toString()} />
<StyledLink href={app.urls.forgotPassword} className="mt-1 text-sm">
<StyledLink
href="#"
className="mt-1 text-sm"
onClick={(e) => {
e.preventDefault();
runAsynchronouslyWithAlert(app.redirectToForgotPassword());
}}
>
{t('Forgot password?')}
</StyledLink>

View File

@ -65,8 +65,7 @@ function Inner<AllowNull extends boolean>(props: TeamSwitcherProps<AllowNull>) {
// Use mock data if provided, otherwise use real data
const app = props.mockUser ? {
useProject: () => props.mockProject || { config: { clientTeamCreationEnabled: false } },
useNavigate: () => () => {}, // Mock navigate function
urls: { accountSettings: '/account-settings' },
redirectToAccountSettings: async () => {},
} : appFromHook;
const user = props.mockUser ? {
@ -75,7 +74,6 @@ function Inner<AllowNull extends boolean>(props: TeamSwitcherProps<AllowNull>) {
setSelectedTeam: async () => {}, // Mock function
} : userFromHook;
const navigate = app.useNavigate();
const project = app.useProject();
const rawTeams = props.teams ?? user?.useTeams();
const selectedTeam = props.team || rawTeams?.find(team => team.id === props.teamId);
@ -120,7 +118,7 @@ function Inner<AllowNull extends boolean>(props: TeamSwitcherProps<AllowNull>) {
className="h-6 w-6"
onClick={() => {
if (!props.mockUser) {
navigate(`${app.urls.accountSettings}#team-${selectedTeam.id}`);
runAsynchronouslyWithAlert(app.redirectToAccountSettings());
}
}}
>
@ -170,7 +168,7 @@ function Inner<AllowNull extends boolean>(props: TeamSwitcherProps<AllowNull>) {
<Button
onClick={() => {
if (!props.mockUser) {
navigate(`${app.urls.accountSettings}#team-creation`);
runAsynchronouslyWithAlert(app.redirectToAccountSettings());
}
}}
className="w-full"

View File

@ -269,6 +269,8 @@ describe("StackClientApp cross-domain auth", () => {
protocol: "https:",
hostname: "demo.stack-auth.com",
},
addEventListener: () => {},
removeEventListener: () => {},
} as any;
const clientApp = new StackClientApp({
@ -456,6 +458,40 @@ describe("StackClientApp cross-domain auth", () => {
}
});
it("throws when public app.urls reads would return hosted component URLs", () => {
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId: "00000000-0000-4000-8000-000000000003",
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "window",
urls: {
default: { type: "hosted" },
},
noAutomaticPrefetch: true,
});
expect(() => clientApp.urls.signIn).toThrowError(/app\.urls\.signIn cannot be used when this app is configured to use hosted components.*Use app\.redirectToSignIn\(\) instead/s);
expect(() => clientApp.urls.signOut).toThrowError(/app\.urls\.signOut cannot be used when this app is configured to use hosted components.*Use app\.redirectToSignOut\(\) instead/s);
expect(clientApp.urls.afterSignIn).toBe("/");
});
it("keeps public app.urls reads available for non-hosted targets", () => {
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId: "00000000-0000-4000-8000-000000000003",
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "window",
urls: {
handler: "/custom-handler",
},
noAutomaticPrefetch: true,
});
expect(clientApp.urls.signIn).toBe("/custom-handler/sign-in");
});
it("keeps default hosted signOut() on the source domain when afterSignOut is not configured", async () => {
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",

View File

@ -97,6 +97,38 @@ const nestedCrossDomainAuthQueryParams = {
afterCallbackRedirectUrl: "after_callback_redirect_url",
} as const;
function getRedirectHelperInstruction(handlerName: string): string {
if (handlerName === "handler") {
return "Use a page-specific redirect helper such as app.redirectToSignIn() instead.";
}
const redirectMethodName = `redirectTo${handlerName.slice(0, 1).toUpperCase()}${handlerName.slice(1)}`;
return `Use app.${redirectMethodName}() instead.`;
}
function createUrlsForPublicAccess(options: {
urls: ResolvedHandlerUrls,
projectId: string,
}): Readonly<ResolvedHandlerUrls> {
const hostedUrlNames = new Set(
Object.entries(options.urls)
.filter(([, url]) => isHostedHandlerUrlForProject({ url, projectId: options.projectId }))
.map(([handlerName]) => handlerName),
);
return new Proxy(options.urls, {
get(target, property, receiver) {
if (typeof property === "string" && hostedUrlNames.has(property)) {
throw new Error(
`app.urls.${property} cannot be used when this app is configured to use hosted components. ` +
"`app.urls` is static and does not include the runtime redirect-back, cross-domain auth, or sign-out state required by hosted components. " +
getRedirectHelperInstruction(property),
);
}
return Reflect.get(target, property, receiver);
},
});
}
const oauthCallbackResponseQueryParams = ["code", "state", "error", "error_description", "errorCode", "message", "details"] as const;
const allClientApps = new Map<string, [checkString: string | undefined, app: StackClientApp<any, any>]>();
@ -1734,7 +1766,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
teamId: crud.id,
email: options.email,
session,
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation, "callbackUrl"),
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app._getUrls().teamInvitation, "callbackUrl"),
});
await app._teamInvitationsCache.refresh([session, crud.id]);
},
@ -1804,7 +1836,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
async sendVerificationEmail(options?: { callbackUrl?: string }) {
await app._interface.sendCurrentUserContactChannelVerificationEmail(
crud.id,
options?.callbackUrl || constructRedirectUrl(app.urls.emailVerification, "callbackUrl"),
options?.callbackUrl || constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"),
session
);
},
@ -2174,7 +2206,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
{
provider,
redirectUrl: app._getOAuthCallbackRedirectUri(),
errorRedirectUrl: app.urls.error,
errorRedirectUrl: app._getUrls().error,
providerScope: mergeScopeStrings(scopeString, (app._oauthScopesOnSignIn[provider as ProviderType] ?? []).join(" ")),
},
session,
@ -2315,7 +2347,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
}
return await app._interface.sendVerificationEmail(
crud.primary_email,
options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification, "callbackUrl"),
options?.callbackUrl ?? constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"),
session
);
},
@ -2798,6 +2830,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
}
get urls(): Readonly<ResolvedHandlerUrls> {
return createUrlsForPublicAccess({
urls: this._getUrls(),
projectId: this.projectId,
});
}
protected _getUrls(): Readonly<ResolvedHandlerUrls> {
return getUrls(this._urlOptions, { projectId: this.projectId });
}

View File

@ -362,7 +362,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
isPrimary: crud.is_primary,
usedForAuth: crud.used_for_auth,
async sendVerificationEmail(options?: { callbackUrl?: string }) {
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification, "callbackUrl"));
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"));
},
async update(data: ServerContactChannelUpdateOptions) {
await app._interface.updateServerContactChannel(userId, crud.id, serverContactChannelUpdateOptionsToCrud(data));
@ -1074,7 +1074,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
await app._interface.sendServerTeamInvitation({
teamId: crud.id,
email: options.email,
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation, "callbackUrl"),
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app._getUrls().teamInvitation, "callbackUrl"),
});
await app._serverTeamInvitationsCache.refresh([crud.id]);
},

View File

@ -64,8 +64,9 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
readonly version: string,
/**
* @deprecated `app.urls` is static and does not include runtime redirect-back parameters.
* For navigation, prefer `redirectToXyz()` methods (for example `redirectToSignIn()`).
* @deprecated Do not use `app.urls` for navigation. It is static and does not include runtime redirect-back,
* cross-domain auth, or sign-out state. Use the matching `redirectToXyz()` method instead, for example
* `redirectToSignIn()`, `redirectToSignUp()`, `redirectToSignOut()`, or `redirectToAccountSettings()`.
*/
readonly urls: Readonly<ResolvedHandlerUrls>,