diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx index bbedefbd7d..a457aa04bb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx @@ -1631,7 +1631,8 @@ function Draggable(props: { A runtime error occured while rendering this widget.



{errorToNiceString(props.error)} diff --git a/apps/dashboard/src/app/(main)/integrations/custom/projects/transfer/confirm/page.tsx b/apps/dashboard/src/app/(main)/integrations/custom/projects/transfer/confirm/page.tsx index f8b73e9a78..9fdf9bdc1a 100644 --- a/apps/dashboard/src/app/(main)/integrations/custom/projects/transfer/confirm/page.tsx +++ b/apps/dashboard/src/app/(main)/integrations/custom/projects/transfer/confirm/page.tsx @@ -1,20 +1,42 @@ -import IntegrationProjectTransferConfirmPageClient from "@/app/(main)/integrations/transfer-confirm-page"; +import CustomIntegrationProjectTransferConfirmPageClient from "@/app/(main)/integrations/transfer-confirm-page"; export const metadata = { title: "Project transfer", }; +function MissingCodeView() { + return ( +
+
+ +
+
+ This transfer link is incomplete +
+

+ 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. +

+
+
+
+ ); +} + export default async function Page(props: { searchParams: Promise<{ code?: string }> }) { const transferCode = (await props.searchParams).code; if (!transferCode) { - return <> -
Error: No transfer code provided.
- ; + 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 ( + + + Neon +
+
+
+
+
+
+
+
+ + + +

+ 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" && (<> - 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."} +

+
+ + Receiving account + + } + value={signedInAsLabel} + /> +
+ { + runAsynchronouslyWithAlert(async () => { + await onSwitchAccount?.(); + }); + }} + > + Use a different account + + + ) : ( + + )} +
+ )} + + {typeof state !== "string" && ( + + )} + + {state === "success" && ( +
+ { + runAsynchronouslyWithAlert(async () => { + await onCancel?.(); + }); + }} + > + Cancel + + { + runAsynchronouslyWithAlert(async () => { + await onPrimary?.(); + }); + }} + > + {primaryLabel} + +
+ )} + +
+ ); +}