+ Open the full link you received (it includes a transfer code). If the link expired, go back to the partner or integrations screen and start the transfer again.
+
- >;
+ return ;
}
- return (
- <>
-
- >
- );
+ return ;
}
diff --git a/apps/dashboard/src/app/(main)/integrations/neon-transfer-confirm-page.tsx b/apps/dashboard/src/app/(main)/integrations/neon-transfer-confirm-page.tsx
new file mode 100644
index 0000000000..3795b6bf4b
--- /dev/null
+++ b/apps/dashboard/src/app/(main)/integrations/neon-transfer-confirm-page.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { Logo } from "@/components/logo";
+import { useRouter } from "@/components/router";
+import { Button, Card, CardContent, CardFooter, CardHeader, Input, Typography } from "@/components/ui";
+import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
+import { useStackApp, useUser } from "@stackframe/stack";
+import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
+import { runAsynchronously, runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises";
+import Image from "next/image";
+import { useSearchParams } from "next/navigation";
+import { useEffect, useState } from "react";
+import NeonLogo from "../../../../public/neon.png";
+
+type NeonTransferState = "loading" | "success" | { type: "error", message: string };
+
+function buildSignUpUrl(): string {
+ const currentUrl = new URL(window.location.href);
+ const signUpSearchParams = new URLSearchParams();
+ signUpSearchParams.set("after_auth_return_to", currentUrl.pathname + currentUrl.search + currentUrl.hash);
+ return `/handler/signup?${signUpSearchParams.toString()}`;
+}
+
+/**
+ * Neon project transfer confirmation — legacy UI and copy (unchanged from pre–custom-redesign behavior).
+ */
+export default function NeonIntegrationProjectTransferConfirmPageClient() {
+ const app = useStackApp();
+ const user = useUser({ projectIdMustMatch: "internal" });
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [state, setState] = useState("loading");
+
+ useEffect(() => {
+ runAsynchronously(async () => {
+ try {
+ await (app as any)[stackAppInternalsSymbol].sendRequest("/integrations/neon/projects/transfer/confirm/check", {
+ method: "POST",
+ body: JSON.stringify({
+ code: searchParams.get("code"),
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ setState("success");
+ } catch (err: unknown) {
+ console.error("Neon project transfer confirm check failed:", err);
+ setState({
+ type: "error",
+ message: "This transfer link is invalid, has expired, or has already been used. Return to your Neon dashboard and start the transfer again.",
+ });
+ }
+ });
+ }, [app, searchParams]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Project transfer
+
+ {state === "success" && <>
+
+ {"Neon would like to transfer a Stack Auth project and link it to your own account. This will let you access the project from Stack Auth's dashboard."}
+
+ {user ? (
+ <>
+
+ {"Which Stack Auth account would you like to transfer the project to? (You'll still be able to access your project from Neon's dashboard.)"}
+
+ } value={`Signed in as ${user.primaryEmail || user.displayName || "Unnamed user"}`} />
+
+ >
+ ) : (
+
+ To continue, please sign in or create a Stack Auth account.
+
+ )}
+ >}
+
+ {typeof state !== "string" && <>
+
+ {state.message}
+
+ >}
+
+
+ {state === "success" &&
+
+
+
+
+ }
+
+ );
+}
diff --git a/apps/dashboard/src/app/(main)/integrations/neon/projects/transfer/confirm/page.tsx b/apps/dashboard/src/app/(main)/integrations/neon/projects/transfer/confirm/page.tsx
index f4cae6b4d2..27401cbf02 100644
--- a/apps/dashboard/src/app/(main)/integrations/neon/projects/transfer/confirm/page.tsx
+++ b/apps/dashboard/src/app/(main)/integrations/neon/projects/transfer/confirm/page.tsx
@@ -1,4 +1,4 @@
-import IntegrationProjectTransferConfirmPageClient from "@/app/(main)/integrations/transfer-confirm-page";
+import NeonIntegrationProjectTransferConfirmPageClient from "@/app/(main)/integrations/neon-transfer-confirm-page";
export const metadata = {
title: "Project transfer",
@@ -14,7 +14,7 @@ export default async function Page(props: { searchParams: Promise<{ code?: strin
return (
<>
-
+
>
);
}
diff --git a/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx b/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx
index 6b19aa3dd7..c86e0f5cdf 100644
--- a/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx
+++ b/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx
@@ -1,28 +1,34 @@
"use client";
-import { Logo } from "@/components/logo";
+import { ProjectTransferConfirmView, type ProjectTransferConfirmUiState } from "@/components/project-transfer-confirm-view";
import { useRouter } from "@/components/router";
-import { Button, Card, CardContent, CardFooter, CardHeader, Input, Typography } from "@/components/ui";
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
import { useStackApp, useUser } from "@stackframe/stack";
+import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
-import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
-import NeonLogo from "../../../../public/neon.png";
-export default function IntegrationProjectTransferConfirmPageClient(props: { type: "neon" | "custom" }) {
+function buildSignUpUrl(): string {
+ const currentUrl = new URL(window.location.href);
+ const signUpSearchParams = new URLSearchParams();
+ signUpSearchParams.set("after_auth_return_to", currentUrl.pathname + currentUrl.search + currentUrl.hash);
+ return `/handler/signup?${signUpSearchParams.toString()}`;
+}
+
+/** Custom integration project transfer — design-components UI. Neon uses `neon-transfer-confirm-page`. */
+export default function CustomIntegrationProjectTransferConfirmPageClient() {
const app = useStackApp();
const user = useUser({ projectIdMustMatch: "internal" });
const router = useRouter();
const searchParams = useSearchParams();
- const [state, setState] = useState<'loading'|'success'|{type: 'error', message: string}>('loading');
+ const [state, setState] = useState("loading");
useEffect(() => {
runAsynchronously(async () => {
try {
- await (app as any)[stackAppInternalsSymbol].sendRequest(`/integrations/${props.type}/projects/transfer/confirm/check`, {
+ await (app as any)[stackAppInternalsSymbol].sendRequest("/integrations/custom/projects/transfer/confirm/check", {
method: "POST",
body: JSON.stringify({
code: searchParams.get("code"),
@@ -31,119 +37,58 @@ export default function IntegrationProjectTransferConfirmPageClient(props: { typ
"Content-Type": "application/json",
},
});
- setState('success');
- } catch (err: any) {
- setState({ type: 'error', message: err.message });
+ setState("success");
+ } catch (err: unknown) {
+ console.error("Project transfer confirm check failed:", err);
+ setState({
+ type: "error",
+ message: "This transfer link is invalid, has expired, or has already been used. Open the original link from the partner or integrations dashboard, or start the transfer again.",
+ });
}
});
+ }, [app, searchParams]);
- }, [app, searchParams, props.type]);
-
- const currentUrl = new URL(window.location.href);
- const signUpSearchParams = new URLSearchParams();
- signUpSearchParams.set("after_auth_return_to", currentUrl.pathname + currentUrl.search + currentUrl.hash);
- const signUpUrl = `/handler/signup?${signUpSearchParams.toString()}`;
+ const signedIn = user != null;
+ const accountLabel = user
+ ? `Signed in as ${user.primaryEmail ?? user.displayName ?? "Unnamed user"}`
+ : undefined;
return (
-
-
- {props.type === "neon" && (<>
-
-
-
-
-
-
-
-
-
- >)}
-
-
-
-
- Project transfer
-
- {state === 'success' && <>
-
- {props.type === "neon" ? "Neon" : "A third party"} would like to transfer a Stack Auth project and link it to your own account. This will let you access the project from Stack Auth's dashboard.
-
- {user ? (
- <>
-
- Which Stack Auth account would you like to transfer the project to? (You'll still be able to access your project from {props.type === "neon" ? "Neon" : "the third party"}'s dashboard.)
-
- } value={`Signed in as ${user.primaryEmail || user.displayName || "Unnamed user"}`} />
-
- >
- ) : (
-
- To continue, please sign in or create a Stack Auth account.
-
- )}
- >}
-
- {typeof state !== 'string' && <>
-
- {state.message}
-
- >}
-
-
- {state === 'success' &&
-
-
-
-
- }
-
+ {
+ window.close();
+ }}
+ onPrimary={async () => {
+ if (user) {
+ const confirmRes = await (app as any)[stackAppInternalsSymbol].sendRequest("/integrations/custom/projects/transfer/confirm", {
+ method: "POST",
+ body: JSON.stringify({
+ code: searchParams.get("code"),
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ const confirmResJson = await confirmRes.json();
+ if (typeof confirmResJson?.project_id !== "string") {
+ throw new StackAssertionError("Project transfer confirm response is missing `project_id`", { confirmResJson });
+ }
+ router.push(`/projects/${confirmResJson.project_id}`);
+ await wait(3000);
+ } else {
+ router.push(buildSignUpUrl());
+ await wait(3000);
+ }
+ }}
+ onSwitchAccount={async () => {
+ if (user == null) {
+ return;
+ }
+ await user.signOut({ redirectUrl: buildSignUpUrl() });
+ }}
+ />
);
}
diff --git a/apps/dashboard/src/components/project-transfer-confirm-view.tsx b/apps/dashboard/src/components/project-transfer-confirm-view.tsx
new file mode 100644
index 0000000000..024b090c6f
--- /dev/null
+++ b/apps/dashboard/src/components/project-transfer-confirm-view.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { DesignAlert } from "@/components/design-components/alert";
+import { DesignButton } from "@/components/design-components/button";
+import { DesignCard } from "@/components/design-components/card";
+import { DesignInput } from "@/components/design-components/input";
+import { Logo } from "@/components/logo";
+import { Spinner } from "@/components/ui";
+import { ArrowsLeftRightIcon } from "@phosphor-icons/react";
+import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
+
+export type ProjectTransferConfirmUiState = "loading" | "success" | { type: "error", message: string };
+
+export type ProjectTransferConfirmViewProps = {
+ state: ProjectTransferConfirmUiState,
+ /** When `state === "success"`, whether the “signed in” branch is shown. */
+ signedIn: boolean,
+ /** Label for the disabled “Receiving account” field. Required when `state === "success"` and `signedIn === true`. */
+ signedInAsLabel?: string,
+ onCancel?: () => void | Promise,
+ onPrimary?: () => void | Promise,
+ onSwitchAccount?: () => void | Promise,
+};
+
+/** Presentational shell for the custom integration project transfer confirmation screen. */
+export function ProjectTransferConfirmView(props: ProjectTransferConfirmViewProps) {
+ const {
+ state,
+ signedIn,
+ signedInAsLabel,
+ onCancel,
+ onPrimary,
+ onSwitchAccount,
+ } = props;
+
+ if (state === "success") {
+ if (onCancel == null || onPrimary == null) {
+ throw new StackAssertionError("ProjectTransferConfirmView requires `onCancel` and `onPrimary` in the success state");
+ }
+ if (signedIn && (signedInAsLabel == null || onSwitchAccount == null)) {
+ throw new StackAssertionError("ProjectTransferConfirmView requires `signedInAsLabel` and `onSwitchAccount` when `signedIn` is true in the success state");
+ }
+ }
+
+ const primaryLabel = signedIn ? "Accept transfer" : "Sign in";
+
+ return (
+
+
+
+
+ )}
+ >
+ {state === "loading" && (
+
+
+
Verifying this transfer link…
+
+ )}
+
+ {state === "success" && (
+
+ {signedIn ? (
+ <>
+
+ {"You'll still be able to open this project from the third party's dashboard after you accept."}
+