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>
41 lines
1.8 KiB
TypeScript
41 lines
1.8 KiB
TypeScript
import Link from "next/link"
|
|
import { AlertCircle, ShieldCheck } from "lucide-react"
|
|
|
|
/**
|
|
* 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 { 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>
|
|
)
|
|
}
|