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:
soroush.asadi
2026-06-01 13:42:30 +03:30
parent d7743a6fbe
commit 12773e125a
16 changed files with 757 additions and 18 deletions
+108 -14
View File
@@ -2,41 +2,135 @@ import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants";
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@/lib/auth/constants";
import { decodeJwt, isJwtExpired } from "@/lib/auth/jwt";
const handleI18n = createIntlMiddleware(routing);
// Routes that require an authenticated Identity session (optionally /en/-prefixed).
const PROTECTED = /^\/(?:en\/)?(?:dashboard|studio)(?:\/|$)/;
// Routes that require an authenticated Identity session.
const PROTECTED = /^\/(?:en\/)?(?:dashboard|studio|admin)(?:\/|$)/;
// Admin-only routes.
const ADMIN_ONLY = /^\/(?:en\/)?admin(?:\/|$)/;
// Proactively refresh the access token when fewer than 120 s remain.
const REFRESH_BEFORE_EXPIRY_S = 120;
async function tryRefreshToken(
request: NextRequest
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> {
const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
if (!refreshToken) return null;
const gatewayUrl = (
process.env.API_GATEWAY_URL ?? "http://localhost:8088"
).replace(/\/$/, "");
try {
const res = await fetch(`${gatewayUrl}/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
// Never cache refresh calls.
cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
if (!data?.access_token || !data?.refresh_token) return null;
return {
accessToken: data.access_token as string,
refreshToken: data.refresh_token as string,
expiresIn: (data.expires_in as number) ?? 900,
};
} catch {
return null;
}
}
function applyNewTokens(
response: NextResponse,
accessToken: string,
refreshToken: string,
expiresIn: number
): NextResponse {
const secure = process.env.NODE_ENV === "production";
const base = { httpOnly: true, sameSite: "lax" as const, secure, path: "/" };
response.cookies.set(ACCESS_TOKEN_COOKIE, accessToken, {
...base,
maxAge: expiresIn,
});
response.cookies.set(REFRESH_TOKEN_COOKIE, refreshToken, {
...base,
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
export async function middleware(request: NextRequest) {
// 1. Locale detection / redirect (next-intl)
const i18nResponse = handleI18n(request);
if (
i18nResponse.status !== 200 ||
i18nResponse.headers.has("location")
) {
if (i18nResponse.status !== 200 || i18nResponse.headers.has("location")) {
return i18nResponse;
}
// 2. Auth guard for protected sections
const { pathname } = request.nextUrl;
if (PROTECTED.test(pathname)) {
const token = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
if (!token || isJwtExpired(decodeJwt(token))) {
if (!PROTECTED.test(pathname)) return i18nResponse;
// 2. Read the current access token
let accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value ?? null;
let claims = decodeJwt(accessToken ?? "");
let newTokens: Awaited<ReturnType<typeof tryRefreshToken>> = null;
// 3. Proactively refresh when token is about to expire (< 120 s left)
if (
accessToken &&
claims?.exp &&
claims.exp - Date.now() / 1000 < REFRESH_BEFORE_EXPIRY_S
) {
newTokens = await tryRefreshToken(request);
if (newTokens) {
accessToken = newTokens.accessToken;
claims = decodeJwt(accessToken);
}
}
// 4. If token is missing or expired (and refresh failed), redirect to login
if (!accessToken || isJwtExpired(claims)) {
const url = request.nextUrl.clone();
url.pathname = pathname.startsWith("/en") ? "/en/auth" : "/auth";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
// 5. Admin guard — is_admin must be truthy
if (ADMIN_ONLY.test(pathname)) {
const isAdmin =
String(claims?.is_admin) === "true" ||
claims?.is_admin === true ||
String(claims?.is_tenant_admin) === "true";
if (!isAdmin) {
const url = request.nextUrl.clone();
url.pathname = pathname.startsWith("/en") ? "/en/auth" : "/auth";
url.searchParams.set("next", pathname);
url.pathname = pathname.startsWith("/en") ? "/en/dashboard" : "/dashboard";
return NextResponse.redirect(url);
}
}
// 6. Stamp fresh cookies onto the response if we refreshed
if (newTokens) {
return applyNewTokens(
i18nResponse,
newTokens.accessToken,
newTokens.refreshToken,
newTokens.expiresIn
);
}
return i18nResponse;
}
export const config = {
// Match all routes except api, _next, static assets
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],