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:
parent
ccb9eacd86
commit
019ea56e6f
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
98
apps/dashboard/app/activate/[token]/route.ts.disabled
Normal file
98
apps/dashboard/app/activate/[token]/route.ts.disabled
Normal 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)
|
||||||
|
}
|
||||||
30
apps/dashboard/app/activate/error/page.tsx
Normal file
30
apps/dashboard/app/activate/error/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
apps/dashboard/app/activate/handoff/[token]/route.ts
Normal file
107
apps/dashboard/app/activate/handoff/[token]/route.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user