diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx new file mode 100644 index 0000000..3681a9f --- /dev/null +++ b/src/app/[locale]/admin/layout.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getCurrentUser(); + if (!user || !user.is_admin) { + redirect("/dashboard"); + } + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/[locale]/admin/nodes/page.tsx b/src/app/[locale]/admin/nodes/page.tsx new file mode 100644 index 0000000..c58944a --- /dev/null +++ b/src/app/[locale]/admin/nodes/page.tsx @@ -0,0 +1,41 @@ +import { adminGet } from "@/lib/api/admin-gateway"; +import { NodesTable } from "@/components/admin/NodesTable"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +interface V2Node { + id: string; + name: string; + status: "Online" | "Busy" | "Offline" | "Draining"; + last_heartbeat: string; + active_job_id: string | null; + slots_total: number; + slots_used: number; + version: string | null; + tags: string[] | null; +} + +interface V2NodeList { + items: V2Node[]; + total: number; +} + +export default async function AdminNodesPage() { + const data = await adminGet("/v1/nodes?pageSize=100"); + const nodes = data?.items ?? []; + + return ( +
+
+
+

Render Nodes

+

+ {nodes.length} node{nodes.length !== 1 ? "s" : ""} registered +

+
+
+ +
+ ); +} diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..edb4649 --- /dev/null +++ b/src/app/[locale]/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminRootPage() { + redirect("/admin/nodes"); +} diff --git a/src/app/[locale]/admin/renders/page.tsx b/src/app/[locale]/admin/renders/page.tsx new file mode 100644 index 0000000..67a2885 --- /dev/null +++ b/src/app/[locale]/admin/renders/page.tsx @@ -0,0 +1,80 @@ +import { adminGet } from "@/lib/api/admin-gateway"; +import { RenderQueueTable } from "@/components/admin/RenderQueueTable"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export type V2RenderJob = { + id: string; + saved_project_id: string; + user_id: string; + status: string; + step: string; + progress: number; + quality: string; + resolution: string; + frame_rate: number; + node_id: string | null; + error_message: string | null; + created_at: string; + updated_at: string; +}; + +interface V2RenderList { + items: V2RenderJob[]; + total: number; +} + +export default async function AdminRendersPage({ + searchParams, +}: { + searchParams: { step?: string }; +}) { + const step = searchParams.step ?? ""; + const qs = step ? `?step=${step}&pageSize=50` : "?pageSize=50"; + const data = await adminGet(`/v1/renders${qs}`); + const jobs = data?.items ?? []; + const total = data?.total ?? 0; + + const steps = ["Queued", "Preparing", "Rendering", "Uploading", "Done", "Failed", "Cancelled"]; + + return ( +
+
+
+

Render Queue

+

{total} total jobs

+
+
+ + {/* Step filter tabs */} +
+ + All + + {steps.map((s) => ( + + {s} + + ))} +
+ + +
+ ); +} diff --git a/src/app/api/admin/_adminProxy.ts b/src/app/api/admin/_adminProxy.ts new file mode 100644 index 0000000..4075214 --- /dev/null +++ b/src/app/api/admin/_adminProxy.ts @@ -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 { + 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 }); +} diff --git a/src/app/api/admin/nodes/[nodeId]/drain/route.ts b/src/app/api/admin/nodes/[nodeId]/drain/route.ts new file mode 100644 index 0000000..c9bb467 --- /dev/null +++ b/src/app/api/admin/nodes/[nodeId]/drain/route.ts @@ -0,0 +1,33 @@ +// "Drain" sets node status to Draining via PATCH so it finishes its current +// job but won't accept new ones. +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 const runtime = "nodejs"; +interface Ctx { params: { nodeId: string } } + +export async function POST(_req: NextRequest, { params }: Ctx) { + const token = await getAccessToken(); + if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const claims = decodeJwt(token); + const isAdmin = String(claims?.is_admin) === "true" || claims?.is_admin === true; + if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const res = await fetch(gatewayUrl(`/v1/nodes/${params.nodeId}`), { + method: "PATCH", + cache: "no-store", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ status: "Draining" }), + }); + 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 }); +} diff --git a/src/app/api/admin/nodes/[nodeId]/release/route.ts b/src/app/api/admin/nodes/[nodeId]/release/route.ts new file mode 100644 index 0000000..41600a3 --- /dev/null +++ b/src/app/api/admin/nodes/[nodeId]/release/route.ts @@ -0,0 +1,9 @@ +import { type NextRequest } from "next/server"; +import { adminProxy } from "@/app/api/admin/_adminProxy"; + +export const runtime = "nodejs"; +interface Ctx { params: { nodeId: string } } + +export async function POST(req: NextRequest, { params }: Ctx) { + return adminProxy(req, `/v1/nodes/${params.nodeId}/release`); +} diff --git a/src/app/api/admin/renders/[jobId]/cancel/route.ts b/src/app/api/admin/renders/[jobId]/cancel/route.ts new file mode 100644 index 0000000..b04191b --- /dev/null +++ b/src/app/api/admin/renders/[jobId]/cancel/route.ts @@ -0,0 +1,9 @@ +import { type NextRequest } from "next/server"; +import { adminProxy } from "@/app/api/admin/_adminProxy"; + +export const runtime = "nodejs"; +interface Ctx { params: { jobId: string } } + +export async function POST(req: NextRequest, { params }: Ctx) { + return adminProxy(req, `/v1/renders/${params.jobId}/cancel`); +} diff --git a/src/app/api/admin/renders/[jobId]/retry/route.ts b/src/app/api/admin/renders/[jobId]/retry/route.ts new file mode 100644 index 0000000..4ec1046 --- /dev/null +++ b/src/app/api/admin/renders/[jobId]/retry/route.ts @@ -0,0 +1,9 @@ +import { type NextRequest } from "next/server"; +import { adminProxy } from "@/app/api/admin/_adminProxy"; + +export const runtime = "nodejs"; +interface Ctx { params: { jobId: string } } + +export async function POST(req: NextRequest, { params }: Ctx) { + return adminProxy(req, `/v1/renders/${params.jobId}/retry`); +} diff --git a/src/components/admin/NodesTable.tsx b/src/components/admin/NodesTable.tsx new file mode 100644 index 0000000..8bc3f4b --- /dev/null +++ b/src/components/admin/NodesTable.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { apiFetch } from "@/lib/api/fetch"; +import { useRouter } from "next/navigation"; + +interface V2Node { + id: string; + name: string; + status: "Online" | "Busy" | "Offline" | "Draining"; + last_heartbeat: string; + active_job_id: string | null; + slots_total: number; + slots_used: number; + version: string | null; + tags: string[] | null; +} + +const STATUS_COLORS: Record = { + Online: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", + Busy: "bg-blue-500/20 text-blue-300 border-blue-500/30", + Offline: "bg-gray-500/20 text-gray-400 border-gray-500/30", + Draining: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30", +}; + +function heartbeatAge(iso: string): string { + const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + return `${Math.floor(diff / 3600)}h ago`; +} + +export function NodesTable({ nodes }: { nodes: V2Node[] }) { + const router = useRouter(); + const [loading, setLoading] = useState>({}); + + const action = async (nodeId: string, endpoint: string) => { + setLoading((p) => ({ ...p, [nodeId]: true })); + try { + await apiFetch(`/api/admin/nodes/${nodeId}/${endpoint}`, { method: "POST" }); + router.refresh(); + } finally { + setLoading((p) => ({ ...p, [nodeId]: false })); + } + }; + + if (nodes.length === 0) { + return ( +
+ No nodes registered. Start the node agent on a render machine to see it here. +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + + + ))} + +
NodeStatusSlotsHeartbeatActive JobTagsActions
+
{node.name}
+
{node.id.slice(0, 8)}…
+
+ + {node.status} + + + {node.slots_used} / {node.slots_total} + + {heartbeatAge(node.last_heartbeat)} + + {node.active_job_id ? node.active_job_id.slice(0, 12) + "…" : "—"} + +
+ {(node.tags ?? []).map((t) => ( + + {t} + + ))} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/components/admin/RenderQueueTable.tsx b/src/components/admin/RenderQueueTable.tsx new file mode 100644 index 0000000..e69df50 --- /dev/null +++ b/src/components/admin/RenderQueueTable.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { apiFetch } from "@/lib/api/fetch"; +import type { V2RenderJob } from "@/app/[locale]/admin/renders/page"; + +const STEP_COLORS: Record = { + Queued: "bg-gray-500/20 text-gray-400 border-gray-500/30", + Preparing: "bg-blue-500/20 text-blue-300 border-blue-500/30", + TemplateCache:"bg-blue-500/20 text-blue-300 border-blue-500/30", + JsxGen: "bg-blue-500/20 text-blue-300 border-blue-500/30", + Music: "bg-purple-500/20 text-purple-300 border-purple-500/30", + Rendering: "bg-indigo-500/20 text-indigo-300 border-indigo-500/30", + Validating: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", + Repairing: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30", + Optimisation: "bg-teal-500/20 text-teal-300 border-teal-500/30", + Video: "bg-indigo-500/20 text-indigo-300 border-indigo-500/30", + Mixing: "bg-purple-500/20 text-purple-300 border-purple-500/30", + Final: "bg-teal-500/20 text-teal-300 border-teal-500/30", + Uploading: "bg-sky-500/20 text-sky-300 border-sky-500/30", + Done: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", + Failed: "bg-red-500/20 text-red-300 border-red-500/30", + Cancelled: "bg-gray-500/20 text-gray-500 border-gray-500/20", +}; + +function relativeTime(iso: string): string { + const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) { + const router = useRouter(); + const [loading, setLoading] = useState>({}); + + const retryJob = async (jobId: string) => { + setLoading((p) => ({ ...p, [jobId]: true })); + try { + await apiFetch(`/api/admin/renders/${jobId}/retry`, { method: "POST" }); + router.refresh(); + } finally { + setLoading((p) => ({ ...p, [jobId]: false })); + } + }; + + const cancelJob = async (jobId: string) => { + setLoading((p) => ({ ...p, [jobId]: true })); + try { + await apiFetch(`/api/admin/renders/${jobId}/cancel`, { method: "POST" }); + router.refresh(); + } finally { + setLoading((p) => ({ ...p, [jobId]: false })); + } + }; + + if (jobs.length === 0) { + return ( +
+ No render jobs found for the selected filter. +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + {jobs.map((job) => { + const stepColor = STEP_COLORS[job.step] ?? STEP_COLORS.Queued; + const canRetry = job.step === "Failed" || job.step === "Cancelled"; + const canCancel = !["Done", "Failed", "Cancelled"].includes(job.step); + + return ( + + + + + + + + + + + ); + })} + +
Job IDProjectStepProgressQualityNodeCreatedActions
+ {job.id.slice(0, 12)}… + + {job.saved_project_id.slice(0, 12)}… + + + {job.step} + + +
+
+
+
+ + {job.progress}% + +
+ {job.error_message && ( +

+ {job.error_message} +

+ )} +
+ {job.quality} / {job.resolution} + + {job.node_id ? job.node_id.slice(0, 8) + "…" : "—"} + + {relativeTime(job.created_at)} + +
+ {canRetry && ( + + )} + {canCancel && ( + + )} +
+
+
+ ); +} diff --git a/src/components/studio/RenderModal.tsx b/src/components/studio/RenderModal.tsx index a9ae21c..687e7f0 100644 --- a/src/components/studio/RenderModal.tsx +++ b/src/components/studio/RenderModal.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { Download, Link2, Loader2, RefreshCw } from "lucide-react"; +import { apiFetch } from "@/lib/api/fetch"; + import { Button } from "@/components/ui/button"; import { Dialog, @@ -86,7 +88,7 @@ export function RenderModal({ const poll = async () => { try { - const response = await fetch(`/api/render/${jobId}/status`); + const response = await apiFetch(`/api/render/${jobId}/status`); const data = (await response.json()) as StatusResponse; if (!response.ok) { @@ -128,12 +130,11 @@ export function RenderModal({ setErrorMessage(null); try { - const response = await fetch("/api/render", { + const response = await apiFetch("/api/render", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, - scenes, settings: { resolution, format: "mp4" as const, diff --git a/src/lib/api/admin-gateway.ts b/src/lib/api/admin-gateway.ts new file mode 100644 index 0000000..ac3df42 --- /dev/null +++ b/src/lib/api/admin-gateway.ts @@ -0,0 +1,26 @@ +/** + * Server-side helpers for admin API calls that go through the V2 gateway. + * These use the current user's access token (is_admin check is done in layout). + */ +import { getAccessToken } from "@/lib/auth/session"; +import { gatewayUrl } from "@/lib/api/gateway"; + +export async function adminFetch(path: string, init?: RequestInit): Promise { + const token = await getAccessToken(); + return fetch(gatewayUrl(path), { + ...init, + cache: "no-store", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers ?? {}), + }, + }); +} + +export async function adminGet(path: string): Promise { + const res = await adminFetch(path); + if (!res.ok) return null; + return res.json().catch(() => null) as Promise; +} diff --git a/src/lib/api/fetch.ts b/src/lib/api/fetch.ts new file mode 100644 index 0000000..eaea9b7 --- /dev/null +++ b/src/lib/api/fetch.ts @@ -0,0 +1,55 @@ +/** + * Client-side fetch wrapper that transparently handles 401 responses by + * attempting one token refresh, then retrying the original request. + * + * Usage: replace `fetch(...)` with `apiFetch(...)` everywhere in client + * components that call internal Next.js API routes (/api/*). + * + * If the refresh also fails the user is redirected to /auth. + */ + +let refreshing: Promise | null = null; + +async function doRefresh(): Promise { + try { + const res = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "same-origin", + }); + return res.ok; + } catch { + return false; + } +} + +/** Deduplicated refresh — at most one in-flight at a time. */ +async function refreshOnce(): Promise { + if (!refreshing) { + refreshing = doRefresh().finally(() => { + refreshing = null; + }); + } + return refreshing; +} + +export async function apiFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const res = await fetch(input, { credentials: "same-origin", ...init }); + + if (res.status !== 401) return res; + + // Try refreshing the token once + const refreshed = await refreshOnce(); + if (!refreshed) { + // Session is truly dead — redirect to login + const next = encodeURIComponent(window.location.pathname + window.location.search); + window.location.href = `/auth?next=${next}`; + // Return the original 401 so callers don't hang + return res; + } + + // Retry the original request with the new cookie + return fetch(input, { credentials: "same-origin", ...init }); +} diff --git a/src/lib/render-schemas.ts b/src/lib/render-schemas.ts index f443f8d..ff06738 100644 --- a/src/lib/render-schemas.ts +++ b/src/lib/render-schemas.ts @@ -29,7 +29,9 @@ export const renderSettingsSchema = z.object({ export const renderRequestSchema = z.object({ projectId: z.string().min(1), - scenes: z.array(sceneSchema).min(1), + // scenes is no longer sent to the server — V2 render service fetches the + // project directly from the Studio service via saved_project_id. + scenes: z.array(sceneSchema).optional(), settings: renderSettingsSchema, }); diff --git a/src/middleware.ts b/src/middleware.ts index 79560ac..f3b2e1f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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> = 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)$).*)", ],