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>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user