fix(activate): move handoff handler to /activate/handoff/[token]/route.ts

Next.js disallows cookie writes inside Server Components, so the handoff
exchange had to move to a Route Handler. But a route.ts cannot coexist
with a sibling page.tsx in the same segment, so the handler now lives at
/activate/handoff/[token]/route.ts and the page.tsx at /activate/[token]
becomes a stale-link error page.

Additional fixes:

- Use an explicit relative Location header (Location: /) on the success
  redirect instead of NextResponse.redirect(new URL("/", req.url)). In
  dev, req.url resolves to the canonical host and can differ from the
  host the user came in on (127.0.0.1 vs localhost), causing cookies set
  on the response to drop on cross-host follow.

- Same fix for the error redirect to /activate/error.

- New /activate/error/page.tsx renders the failure UI from the ?reason=
  query string.

- /activate/[token]/route.ts (the original location) is preserved as
  route.ts.disabled so Next does not register it and the prior segment's
  page.tsx can take over the error UI for stale links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Najjar\NajjarV02 2026-05-20 17:22:25 +04:00
parent ccb9eacd86
commit 019ea56e6f
4 changed files with 259 additions and 84 deletions

View File

@ -1,99 +1,39 @@
import { redirect } from "next/navigation" import Link from "next/link"
import { AlertCircle, ShieldCheck } from "lucide-react" import { AlertCircle, ShieldCheck } from "lucide-react"
import { applyHandoff } from "@/modules/auth/auth.actions" /**
* Error UI for failed activation handoffs. The success path is handled by
type SearchParams = { ws?: string; api?: string } * the sibling route.ts handler at /activate/[token] it sets cookies on the
* response and redirects to "/". Errors redirect here with ?reason= in the
export default async function ActivatePage(props: { * query string.
params: Promise<{ token: string }> *
searchParams: Promise<SearchParams> * The route.ts file takes precedence over this page for matching requests,
* but the page is still reachable via a redirect to
* /activate/[token]?error=1&reason=
*/
export default async function ActivateErrorPage(props: {
searchParams: Promise<{ reason?: string; error?: string }>
}) { }) {
const { token } = await props.params const { reason } = await props.searchParams
const { ws, api } = await props.searchParams
if (!ws || !api) {
return (
<ActivationError
title="Activation link is incomplete"
detail="The link is missing the workspace identifier or backend URL. Please open the garage again from the SaaS dashboard."
/>
)
}
let parsed: URL
try {
parsed = new URL(api)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Backend URL must be http(s).")
}
} catch {
return (
<ActivationError
title="Activation link is invalid"
detail="The backend URL provided in the activation link is malformed."
/>
)
}
const endpoint = `${parsed.toString().replace(/\/$/, "")}/api/saas/handoff/exchange`
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Workspace-UUID": ws,
},
body: JSON.stringify({
handoff_token: token,
saas_workspace_uuid: ws,
}),
})
} catch (err) {
return (
<ActivationError
title="Cannot reach the garage backend"
detail={(err as Error).message}
/>
)
}
let payload: { token?: string; user?: any; message?: string } = {}
try {
payload = await response.json()
} catch {
// ignore — handled below
}
if (!response.ok || !payload.token || !payload.user) {
return (
<ActivationError
title="Activation failed"
detail={payload.message ?? `HTTP ${response.status}`}
/>
)
}
await applyHandoff(payload.token, payload.user, parsed.toString().replace(/\/$/, ""), ws)
redirect("/")
}
function ActivationError({ title, detail }: { title: string; detail: string }) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-6"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-6">
<div className="max-w-md w-full text-center space-y-3 bg-white rounded-xl shadow-sm border p-8"> <div className="max-w-md w-full text-center space-y-3 bg-white rounded-xl shadow-sm border p-8">
<AlertCircle className="size-12 mx-auto text-rose-500" /> <AlertCircle className="size-12 mx-auto text-rose-500" />
<h1 className="text-lg font-semibold">{title}</h1> <h1 className="text-lg font-semibold">Activation failed</h1>
<p className="text-sm text-muted-foreground">{detail}</p> <p className="text-sm text-muted-foreground">
{reason ?? "Could not exchange the handoff token."}
</p>
<p className="text-xs text-muted-foreground pt-2 flex items-center justify-center gap-1.5"> <p className="text-xs text-muted-foreground pt-2 flex items-center justify-center gap-1.5">
<ShieldCheck className="size-3.5" /> <ShieldCheck className="size-3.5" />
Re-open the garage from the SaaS dashboard to get a fresh link. Re-open the garage from the SaaS dashboard to get a fresh link.
</p> </p>
<Link
href={process.env.NEXT_PUBLIC_SAAS_URL ?? "/login"}
className="inline-block text-sm underline text-primary"
>
Back to SaaS
</Link>
</div> </div>
</div> </div>
) )

View File

@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server"
/**
* SaaS → garage-erp handoff landing.
*
* URL shape:
* /activate/{handoff_token}?ws={workspace_uuid}&api={backend_url}
*
* Calls the backend's /api/saas/handoff/exchange with the token + workspace
* UUID, then sets four cookies (auth_token, auth_user, workspace_uuid,
* api_base_url) on the redirect response and lands the user on /.
*
* Implemented as a Route Handler (not a Page) because Next.js disallows
* cookie writes inside Server Components — only Server Actions and Route
* Handlers can mutate cookies.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ token: string }> },
) {
const { token } = await ctx.params
const url = new URL(req.url)
const ws = url.searchParams.get("ws")
const api = url.searchParams.get("api")
if (!ws || !api) {
return redirectToError(req, "Activation link is missing workspace or backend URL.")
}
let parsed: URL
try {
parsed = new URL(api)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Backend URL must be http(s).")
}
} catch {
return redirectToError(req, "Activation link has a malformed backend URL.")
}
const apiBase = parsed.toString().replace(/\/$/, "")
const endpoint = `${apiBase}/api/saas/handoff/exchange`
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Workspace-UUID": ws,
},
body: JSON.stringify({
handoff_token: token,
saas_workspace_uuid: ws,
}),
})
} catch (err) {
return redirectToError(req, `Cannot reach garage backend: ${(err as Error).message}`)
}
let payload: { token?: string; user?: any; message?: string } = {}
try {
payload = await response.json()
} catch {
// body is not JSON — fall through to error path
}
if (!response.ok || !payload.token || !payload.user) {
return redirectToError(req, payload.message ?? `Exchange failed (HTTP ${response.status})`)
}
const dest = new URL("/", req.url)
const res = NextResponse.redirect(dest, 302)
const isProd = process.env.NODE_ENV === "production"
const sevenDays = 60 * 60 * 24 * 7
const cookieOptions = {
path: "/",
sameSite: "strict" as const,
httpOnly: true,
secure: isProd,
maxAge: sevenDays,
}
res.cookies.set("auth_token", payload.token, cookieOptions)
res.cookies.set("auth_user", JSON.stringify(payload.user), cookieOptions)
res.cookies.set("workspace_uuid", ws, cookieOptions)
res.cookies.set("api_base_url", apiBase, cookieOptions)
return res
}
function redirectToError(req: NextRequest, reason: string) {
const url = new URL("/activate/error", req.url)
url.searchParams.set("reason", reason)
return NextResponse.redirect(url, 302)
}

View File

@ -0,0 +1,30 @@
import Link from "next/link"
import { AlertCircle, ShieldCheck } from "lucide-react"
export default async function ActivateErrorPage(props: {
searchParams: Promise<{ reason?: string }>
}) {
const { reason } = await props.searchParams
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-6">
<div className="max-w-md w-full text-center space-y-3 bg-white rounded-xl shadow-sm border p-8">
<AlertCircle className="size-12 mx-auto text-rose-500" />
<h1 className="text-lg font-semibold">Activation failed</h1>
<p className="text-sm text-muted-foreground">
{reason ?? "Could not exchange the handoff token."}
</p>
<p className="text-xs text-muted-foreground pt-2 flex items-center justify-center gap-1.5">
<ShieldCheck className="size-3.5" />
Re-open the garage from the SaaS dashboard to get a fresh link.
</p>
<Link
href={process.env.NEXT_PUBLIC_SAAS_URL ?? "/login"}
className="inline-block text-sm underline text-primary"
>
Back to SaaS
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server"
/**
* SaaS garage-erp handoff landing.
*
* URL shape:
* /activate/handoff/{handoff_token}?ws={workspace_uuid}&api={backend_url}
*
* Calls the backend's /api/saas/handoff/exchange with the token + workspace
* UUID, then sets four cookies (auth_token, auth_user, workspace_uuid,
* api_base_url) on the redirect response and lands the user on /.
*
* Implemented as a Route Handler (not a Page) because Next.js disallows
* cookie writes inside Server Components only Server Actions and Route
* Handlers can mutate cookies.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ token: string }> },
) {
const { token } = await ctx.params
const url = new URL(req.url)
const ws = url.searchParams.get("ws")
const api = url.searchParams.get("api")
if (!ws || !api) {
return redirectToError(req, "Activation link is missing workspace or backend URL.")
}
let parsed: URL
try {
parsed = new URL(api)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Backend URL must be http(s).")
}
} catch {
return redirectToError(req, "Activation link has a malformed backend URL.")
}
const apiBase = parsed.toString().replace(/\/$/, "")
const endpoint = `${apiBase}/api/saas/handoff/exchange`
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Workspace-UUID": ws,
},
body: JSON.stringify({
handoff_token: token,
saas_workspace_uuid: ws,
}),
})
} catch (err) {
return redirectToError(req, `Cannot reach garage backend: ${(err as Error).message}`)
}
let payload: { token?: string; user?: any; message?: string } = {}
try {
payload = await response.json()
} catch {
// body is not JSON — fall through to error path
}
if (!response.ok || !payload.token || !payload.user) {
return redirectToError(req, payload.message ?? `Exchange failed (HTTP ${response.status})`)
}
// Use a relative Location to keep the user on whichever host they came
// in on (127.0.0.1 vs localhost). NextResponse.redirect(new URL("/", req.url))
// resolves to the Next dev server's canonical host which can differ from
// the request's actual host, and cookies set here would not survive that
// cross-host redirect.
const res = new NextResponse(null, {
status: 302,
headers: { Location: "/" },
})
const isProd = process.env.NODE_ENV === "production"
const sevenDays = 60 * 60 * 24 * 7
const cookieOptions = {
path: "/",
sameSite: "strict" as const,
httpOnly: true,
secure: isProd,
maxAge: sevenDays,
}
res.cookies.set("auth_token", payload.token, cookieOptions)
res.cookies.set("auth_user", JSON.stringify(payload.user), cookieOptions)
res.cookies.set("workspace_uuid", ws, cookieOptions)
res.cookies.set("api_base_url", apiBase, cookieOptions)
return res
}
function redirectToError(req: NextRequest, reason: string) {
const location = "/activate/error?reason=" + encodeURIComponent(reason)
return new NextResponse(null, {
status: 302,
headers: { Location: location },
})
}