3fc7bf2b97
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s
AI SEO content generator - content-svc: per-tenant OpenAI config (ai_settings) + /v1/ai endpoints (settings GET/PUT, seo-post) with SEO-expert prompt → structured article - admin UI to configure token/base-url/model and generate + save as blog - configurable base URL for restricted networks Full data-driven admin panel - generic /api/admin/resource proxy + reusable AdminResource component - categories/tags/fonts/blogs (CRUD), users (list + ban), plans/slides - AI content section; nav + i18n i18n localization sweep - localized 116 user-facing + studio/editor components to next-intl (fa+en) under the auto.* namespace; merge tooling in scripts/merge-i18n.js Branding + assets - Monoline F logo (LogoMark + favicon) - offline SVG placeholder generator (/api/placeholder), dropped picsum.photos Fixes - JWT issuer mismatch on content/studio (flatrender → flatrender-identity) - missing role claim → [Authorize(Roles="Admin")] now works (RBAC) - Secure cookies broke HTTP sessions → gated behind AUTH_COOKIE_SECURE - Radix RTL via DirectionProvider (right-aligned menus in fa) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server";
|
|
import createIntlMiddleware from "next-intl/middleware";
|
|
|
|
import { routing } from "@/i18n/routing";
|
|
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.
|
|
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.AUTH_COOKIE_SECURE === "true";
|
|
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")) {
|
|
return i18nResponse;
|
|
}
|
|
|
|
const { pathname } = request.nextUrl;
|
|
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/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 = {
|
|
matcher: [
|
|
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
],
|
|
};
|