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:
+108
-14
@@ -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)$).*)",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user