Files
flatrender/src/middleware.ts
T
soroush.asadi 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
feat: AI SEO generator, full admin panel, i18n sweep, new logo + auth/RTL fixes
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>
2026-06-02 09:35:14 +03:30

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)$).*)",
],
};