mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Introduces an internationalization layer so wacrm can ship in multiple
languages — initially en (canonical), es, lt (untranslated mirrors of
en, ready for translators).
Foundation
- next-intl ^4.13 (Next 16 + React 19 compatible)
- src/i18n/{routing,navigation,request}.ts — locale config, locale-aware
Link/router/redirect, server-side message loader
- next.config.ts wired with `createNextIntlPlugin`
- src/app/* moved under src/app/[locale]/ (excluding /api and globals.css)
- [locale]/layout.tsx is now the root layout: <html lang={locale}>,
hasLocale guard, setRequestLocale, NextIntlClientProvider,
generateStaticParams for build-time prerendering of all locales
- middleware.ts composes next-intl's createMiddleware with the existing
Supabase auth check: intl middleware runs first to handle locale
prefix; auth redirects retain the active locale (e.g. signed-out user
on /es/dashboard → /es/login); intl cookies (NEXT_LOCALE) are carried
onto auth-issued responses so the sticky preference survives
Strings extracted (~80 keys across en/es/lt.json)
- common, nav (sidebar + header), roles (Owner/Admin/Agent/Viewer chips)
- auth.login, auth.signup, auth.forgotPassword (all forms + success states)
- join (all four UI states: loading, fail, accept, sign-up CTA + conflict modal)
- localeSwitcher
UI
- New `<LocaleSwitcher />` in the header — globe icon + locale dropdown
using next-intl's useLocale + locale-aware useRouter
Out of scope for this PR (intentional — keeps diff reviewable)
- Dashboard feature pages (dashboard, inbox, contacts, pipelines,
broadcasts, automations, flows, settings) and their components/
namespaces still have hardcoded English; they continue to render
fine because next/link redirects through the locale middleware
- Shared UI a11y strings (dialog close, etc.)
- Translating es.json / lt.json — they currently mirror en.json so the
app runs in any locale without crashes (English fallback)
- Renaming middleware.ts → proxy.ts per Next 16 deprecation
Verification
- npm run build → 62 static pages prerendered across en/es/lt
- npm run typecheck → clean
- npm run test → 366 passing
- npm run lint → 0 errors (18 pre-existing warnings untouched)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
import createNextIntlPlugin from "next-intl/plugin";
|
|
import type { NextConfig } from "next";
|
|
|
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
|
|
|
/**
|
|
* Baseline security headers applied to every response.
|
|
*
|
|
* CSP ships as `Content-Security-Policy-Report-Only` so the browser
|
|
* surfaces violations in the console without blocking anything — once
|
|
* we have confidence nothing legit trips it (two deploys, a pass on
|
|
* every route), flip the key to `Content-Security-Policy` to enforce.
|
|
*
|
|
* The rest of the headers are straight blocks, safe to enforce today:
|
|
* - HSTS: only meaningful on HTTPS (no-op on http://localhost).
|
|
* - X-Content-Type-Options / X-Frame-Options / Referrer-Policy:
|
|
* baseline OWASP hardening, no behavioural cost.
|
|
* - Permissions-Policy: we don't use camera / microphone / etc, so
|
|
* deny them. A supply-chain compromise or a forgotten plugin
|
|
* can't silently opt back in.
|
|
*/
|
|
const SECURITY_HEADERS = [
|
|
{
|
|
key: "Strict-Transport-Security",
|
|
value: "max-age=63072000; includeSubDomains; preload",
|
|
},
|
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
{ key: "X-Frame-Options", value: "DENY" },
|
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
|
{
|
|
key: "Permissions-Policy",
|
|
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
|
},
|
|
{
|
|
key: "Content-Security-Policy-Report-Only",
|
|
value: [
|
|
"default-src 'self'",
|
|
// Next.js needs 'unsafe-inline' for its inline hydration script
|
|
// and 'unsafe-eval' in dev + some production optimisations.
|
|
// Nonce-based CSP is a later project.
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
// Tailwind + inline style attributes on lots of components.
|
|
"style-src 'self' 'unsafe-inline'",
|
|
// Supabase public-bucket avatars, contact avatars (arbitrary
|
|
// https URLs paste-able from the UI), OG images, data URLs for
|
|
// tiny inline assets.
|
|
"img-src 'self' data: blob: https:",
|
|
"font-src 'self' data:",
|
|
// Supabase REST + realtime (WSS). All Meta API calls happen
|
|
// server-side, so graph.facebook.com does not belong here.
|
|
"connect-src 'self' https://*.supabase.co wss://*.supabase.co",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join("; "),
|
|
},
|
|
] as const;
|
|
|
|
const nextConfig: NextConfig = {
|
|
/**
|
|
* Cache-Control policy.
|
|
*
|
|
* Why this exists:
|
|
* Hostinger's CDN was applying `s-maxage=31536000` (1 year) to
|
|
* prerendered HTML pages by default. When a new deploy shipped
|
|
* fresh Turbopack chunk hashes, the edge kept serving year-old
|
|
* HTML referencing chunk filenames that no longer existed on
|
|
* disk — result: HTML 200, every /_next/static/*.js and .css
|
|
* came back 404, the page rendered unstyled. Private/incognito
|
|
* did nothing because the cache is server-side.
|
|
*
|
|
* Strategy:
|
|
* - /_next/static/* — immutable for a year. Filenames are
|
|
* content-hashed, so a new build produces new filenames; the
|
|
* old ones are safe to keep indefinitely in caches.
|
|
* - /api/* — no-store. API responses are per-user and
|
|
* must never be shared across requests at the edge.
|
|
* - Everything else — public, brief s-maxage + generous
|
|
* stale-while-revalidate. The edge serves instantly from cache
|
|
* for the first 5 min, then returns cached content while
|
|
* refreshing in the background for up to 24 h. A deploy's
|
|
* chunk-hash drift self-heals within ~5 min with no user-
|
|
* visible latency.
|
|
*
|
|
* Note: dynamic dashboard routes (/inbox, /contacts, /pipelines,
|
|
* /broadcasts, etc.) are server-rendered per request — Next.js
|
|
* and Supabase auth already prevent them from being served
|
|
* from a shared cache. The s-maxage here is a ceiling; Next.js
|
|
* and auth middleware still set `private` / `no-store` for
|
|
* per-user responses.
|
|
*
|
|
* Security headers are appended via a separate catch-all rule
|
|
* below — Next.js merges headers from every matching rule, so
|
|
* they apply to every response regardless of which cache rule
|
|
* matched.
|
|
*/
|
|
async headers() {
|
|
return [
|
|
{
|
|
source: "/_next/static/:path*",
|
|
headers: [
|
|
{
|
|
key: "Cache-Control",
|
|
value: "public, max-age=31536000, immutable",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
source: "/api/:path*",
|
|
headers: [{ key: "Cache-Control", value: "no-store" }],
|
|
},
|
|
{
|
|
source: "/:path*",
|
|
headers: [
|
|
{
|
|
key: "Cache-Control",
|
|
value:
|
|
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
// Security headers on every response, including /_next/static
|
|
// assets (nosniff matters there) and /api/* (HSTS + referrer-
|
|
// policy don't hurt).
|
|
source: "/:path*",
|
|
headers: [...SECURITY_HEADERS],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
export default withNextIntl(nextConfig);
|