diff --git a/apps/admin-ui/next.config.js b/apps/admin-ui/next.config.js index e5169a6..bf4df47 100644 --- a/apps/admin-ui/next.config.js +++ b/apps/admin-ui/next.config.js @@ -1,16 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - // Required for Docker image (Dokploy). Local Windows may skip via unset DOCKER_BUILD. output: process.env.DOCKER_BUILD === "1" ? "standalone" : undefined, - async rewrites() { - const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; - return [ - { - source: "/api/:path*", - destination: `${apiUrl}/api/:path*`, - }, - ]; - }, + // API calls go through src/app/api/[...path] runtime proxy (not build-time rewrites) }; module.exports = nextConfig; diff --git a/apps/admin-ui/src/app/api/[...path]/route.ts b/apps/admin-ui/src/app/api/[...path]/route.ts new file mode 100644 index 0000000..ba8776a --- /dev/null +++ b/apps/admin-ui/src/app/api/[...path]/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function masterBaseUrl(): string { + return ( + process.env.MASTER_API_URL || + process.env.NEXT_PUBLIC_API_URL || + "http://master-api:3000" + ).replace(/\/$/, ""); +} + +async function proxy(request: NextRequest, pathSegments: string[]) { + const targetPath = pathSegments.join("/"); + const url = new URL(request.url); + const target = `${masterBaseUrl()}/api/${targetPath}${url.search}`; + + const headers = new Headers(); + const contentType = request.headers.get("content-type"); + if (contentType) headers.set("content-type", contentType); + const cookie = request.headers.get("cookie"); + if (cookie) headers.set("cookie", cookie); + headers.set("accept", "application/json"); + + const init: RequestInit = { + method: request.method, + headers, + redirect: "manual", + }; + + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = await request.arrayBuffer(); + } + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch (err) { + const message = err instanceof Error ? err.message : "Upstream unreachable"; + return NextResponse.json( + { error: { code: "UPSTREAM_ERROR", message: `Master API unreachable: ${message}` } }, + { status: 502 } + ); + } + + const body = await upstream.arrayBuffer(); + const response = new NextResponse(body, { status: upstream.status }); + + const upstreamType = upstream.headers.get("content-type"); + if (upstreamType) { + response.headers.set("content-type", upstreamType); + } else { + response.headers.set("content-type", "application/json"); + } + + // Forward Set-Cookie so login session works on admin domain + const anyHeaders = upstream.headers as Headers & { getSetCookie?: () => string[] }; + const setCookies = + typeof anyHeaders.getSetCookie === "function" + ? anyHeaders.getSetCookie() + : upstream.headers.get("set-cookie") + ? [upstream.headers.get("set-cookie") as string] + : []; + + for (const c of setCookies) { + // Master may set Secure; admin is HTTPS via NPM so keep as-is + response.headers.append("set-cookie", c); + } + + return response; +} + +type Ctx = { params: Promise<{ path: string[] }> }; + +export async function GET(request: NextRequest, ctx: Ctx) { + const { path } = await ctx.params; + return proxy(request, path); +} + +export async function POST(request: NextRequest, ctx: Ctx) { + const { path } = await ctx.params; + return proxy(request, path); +} + +export async function PUT(request: NextRequest, ctx: Ctx) { + const { path } = await ctx.params; + return proxy(request, path); +} + +export async function PATCH(request: NextRequest, ctx: Ctx) { + const { path } = await ctx.params; + return proxy(request, path); +} + +export async function DELETE(request: NextRequest, ctx: Ctx) { + const { path } = await ctx.params; + return proxy(request, path); +} diff --git a/apps/admin-ui/src/app/page.tsx b/apps/admin-ui/src/app/page.tsx index db52906..7ecd7c1 100644 --- a/apps/admin-ui/src/app/page.tsx +++ b/apps/admin-ui/src/app/page.tsx @@ -28,8 +28,15 @@ export default function LoginPage() { body: JSON.stringify({ email, password }), }); if (!res.ok) { - const data = await res.json(); - throw new Error(data.error?.message ?? "Login failed"); + const text = await res.text(); + let message = "Login failed"; + try { + const data = JSON.parse(text); + message = data.error?.message ?? message; + } catch { + message = text.slice(0, 120) || `Login failed (HTTP ${res.status})`; + } + throw new Error(message); } router.push("/dashboard"); } catch (err) { diff --git a/deploy/docker/docker-compose.dokploy.yml b/deploy/docker/docker-compose.dokploy.yml index 7e53d5a..98c31d2 100644 --- a/deploy/docker/docker-compose.dokploy.yml +++ b/deploy/docker/docker-compose.dokploy.yml @@ -73,7 +73,8 @@ services: environment: HOSTNAME: 0.0.0.0 PORT: "3000" - # Server-side rewrite target (Docker network) + # Runtime proxy target (server-side, Docker network) + MASTER_API_URL: http://master-api:3000 NEXT_PUBLIC_API_URL: http://master-api:3000 depends_on: - master-api