Files
flatrender/src/app/api/admin/_adminProxy.ts
T
soroush.asadi 12773e125a feat: token auto-refresh, studio→render wiring, admin panel (nodes + render queue)
Token auto-refresh (middleware):
- Proactively refresh fr_access when < 120s remain — no more silent 15-min kick
- Inlines /v1/auth/refresh call in middleware, stamps new cookies on response
- /admin/* protected: is_admin JWT claim required, else redirect /dashboard
- apiFetch() (src/lib/api/fetch.ts): client-side 401 → auto-refresh → retry;
  de-duplicates concurrent refresh calls; redirects to /auth on failure

Studio → Render V2 wiring:
- scenes[] no longer sent to POST /api/render (V2 render-svc fetches project
  from Studio service via saved_project_id directly)
- renderRequestSchema.scenes is now optional
- RenderModal uses apiFetch for auto-refresh on 401 during polling

Admin panel (/admin/*):
- Admin layout: server-side is_admin guard + top nav (Nodes, Render Queue)
- /admin/nodes: lists all nodes from GET /v1/nodes with status badges,
  heartbeat age, slot usage, tags; Drain (PATCH status=Draining) + Release actions
- /admin/renders: render job table with step filter tabs; progress bars,
  error messages, Retry + Cancel per-row actions; polls GET /v1/renders
- API proxy routes: /api/admin/nodes/:id/drain|release,
  /api/admin/renders/:id/retry|cancel — all validate is_admin in JWT before proxying

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:42:30 +03:30

52 lines
1.4 KiB
TypeScript

/**
* Shared helper for admin action proxy routes.
* Validates the caller is an admin (checks is_admin in the JWT), then
* proxies the action to the V2 gateway.
*/
import { type NextRequest, NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
import { decodeJwt } from "@/lib/auth/jwt";
export async function adminProxy(
_req: NextRequest,
gatewayPath: string,
method: string = "POST"
): Promise<NextResponse> {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Quick admin check on the server side before forwarding
const claims = decodeJwt(token);
const isAdmin =
String(claims?.is_admin) === "true" ||
claims?.is_admin === true ||
String(claims?.is_tenant_admin) === "true";
if (!isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const res = await fetch(gatewayUrl(gatewayPath), {
method,
cache: "no-store",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const err = await res.json().catch(() => null) as { message?: string } | null;
return NextResponse.json(
{ error: err?.message ?? "Gateway error" },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
}