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>
108 lines
3.5 KiB
TypeScript
108 lines
3.5 KiB
TypeScript
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 },
|
|
})
|
|
}
|