diff --git a/apps/dashboard/app/activate/[token]/page.tsx b/apps/dashboard/app/activate/[token]/page.tsx index 895cee0..8db62a6 100644 --- a/apps/dashboard/app/activate/[token]/page.tsx +++ b/apps/dashboard/app/activate/[token]/page.tsx @@ -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 +/** + * 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 ( - - ) - } - - 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 ( - - ) - } - - 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 ( - - ) - } - - 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 ( - - ) - } - - await applyHandoff(payload.token, payload.user, parsed.toString().replace(/\/$/, ""), ws) - - redirect("/") -} - -function ActivationError({ title, detail }: { title: string; detail: string }) { return (
-

{title}

-

{detail}

+

Activation failed

+

+ {reason ?? "Could not exchange the handoff token."} +

Re-open the garage from the SaaS dashboard to get a fresh link.

+ + Back to SaaS +
) diff --git a/apps/dashboard/app/activate/[token]/route.ts.disabled b/apps/dashboard/app/activate/[token]/route.ts.disabled new file mode 100644 index 0000000..e876461 --- /dev/null +++ b/apps/dashboard/app/activate/[token]/route.ts.disabled @@ -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) +} diff --git a/apps/dashboard/app/activate/error/page.tsx b/apps/dashboard/app/activate/error/page.tsx new file mode 100644 index 0000000..bebb312 --- /dev/null +++ b/apps/dashboard/app/activate/error/page.tsx @@ -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 ( +
+
+ +

Activation failed

+

+ {reason ?? "Could not exchange the handoff token."} +

+

+ + Re-open the garage from the SaaS dashboard to get a fresh link. +

+ + Back to SaaS + +
+
+ ) +} diff --git a/apps/dashboard/app/activate/handoff/[token]/route.ts b/apps/dashboard/app/activate/handoff/[token]/route.ts new file mode 100644 index 0000000..e96c1a9 --- /dev/null +++ b/apps/dashboard/app/activate/handoff/[token]/route.ts @@ -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 }, + }) +}