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 { applyHandoff } from "@/modules/auth/auth.actions"
|
||||
|
||||
type SearchParams = { ws?: string; api?: string }
|
||||
|
||||
export default async function ActivatePage(props: {
|
||||
params: Promise<{ token: string }>
|
||||
searchParams: Promise<SearchParams>
|
||||
/**
|
||||
* Error UI for failed activation handoffs. The success path is handled by
|
||||
* the sibling route.ts handler at /activate/[token] — it sets cookies on the
|
||||
* response and redirects to "/". Errors redirect here with ?reason=… in the
|
||||
* query string.
|
||||
*
|
||||
* 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 { ws, api } = await props.searchParams
|
||||
const { reason } = 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 (
|
||||
<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">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{detail}</p>
|
||||
<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>
|
||||
)
|
||||
|
||||
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