diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 65e292e..2313671 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -51,7 +51,9 @@ "enableMfa": "Enable 2FA", "generateAppPassword": "Generate App Password", "recoveryCodesTitle": "Recovery Codes", - "saveRecoveryCodes": "Save these recovery codes in a safe place. They will not be shown again." + "saveRecoveryCodes": "Save these recovery codes in a safe place. They will not be shown again.", + "language": "Language", + "signOut": "Sign out" }, "admin": { "dashboardTitle": "System Dashboard", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 2969640..33bf425 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -51,7 +51,9 @@ "enableMfa": "Activar 2FA", "generateAppPassword": "Generar Contraseña de Aplicación", "recoveryCodesTitle": "Códigos de Recuperación", - "saveRecoveryCodes": "Guarde estos códigos de recuperación en un lugar seguro. No se volverán a mostrar." + "saveRecoveryCodes": "Guarde estos códigos de recuperación en un lugar seguro. No se volverán a mostrar.", + "language": "Idioma", + "signOut": "Cerrar sesión" }, "admin": { "dashboardTitle": "Panel del Sistema", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 4512217..00573da 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -51,7 +51,9 @@ "enableMfa": "Ativar 2FA", "generateAppPassword": "Gerar Senha de Aplicativo", "recoveryCodesTitle": "Códigos de Recuperação", - "saveRecoveryCodes": "Guarde estes códigos de recuperação em local seguro. Eles não serão exibidos novamente." + "saveRecoveryCodes": "Guarde estes códigos de recuperação em local seguro. Eles não serão exibidos novamente.", + "language": "Idioma", + "signOut": "Sair" }, "admin": { "dashboardTitle": "Painel do Sistema", diff --git a/services/webmail/package.json b/services/webmail/package.json index a6c7ec8..5c84532 100644 --- a/services/webmail/package.json +++ b/services/webmail/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start -p 3000", - "test": "vitest run" + "test": "vitest run", + "lint": "tsc --noEmit" }, "dependencies": { "next": "15.5.21", diff --git a/services/webmail/src/app/(admin)/admin/layout.tsx b/services/webmail/src/app/(admin)/admin/layout.tsx index 20b9330..593ffcf 100644 --- a/services/webmail/src/app/(admin)/admin/layout.tsx +++ b/services/webmail/src/app/(admin)/admin/layout.tsx @@ -1,7 +1,12 @@ +"use client"; + import React from "react"; import Link from "next/link"; +import { useTranslations } from "../../../i18n/provider"; +import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; export default function AdminLayout({ children }: { children: React.ReactNode }) { + const t = useTranslations(); return (
{/* Executive Sidebar */} @@ -14,7 +19,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
LambdaMail Admin
-
Management Console
+
{t("common.adminPortal")}
@@ -29,46 +34,61 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) href="/admin/dashboard" className="flex items-center gap-2.5 px-3 py-2 rounded-lg bg-emerald-600/10 text-emerald-300 border border-emerald-500/20" > - 📊 Dashboard + 📊 {t("admin.dashboardTitle")} - 🌐 Domains & DNS (13) + 🌐 {t("admin.domainsTitle")} - 👤 Mailboxes & Quotas + 👤 {t("admin.mailboxesTitle")} - 📮 Delivery Queue + 📮 {t("admin.queueTitle")} - 📈 DMARC Analytics + 📈 {t("admin.dmarcTitle")} - 🛡 Audit Logs & Preflight + 🛡 {t("admin.securityTitle")} + {/* Language and session controls */} +
+ + +
{/* Operator Profile Footer */}
diff --git a/services/webmail/src/app/(auth)/admin/login/page.tsx b/services/webmail/src/app/(auth)/admin/login/page.tsx index 9d7f3b5..d4ac59c 100644 --- a/services/webmail/src/app/(auth)/admin/login/page.tsx +++ b/services/webmail/src/app/(auth)/admin/login/page.tsx @@ -2,8 +2,10 @@ import React, { useState } from "react"; import Link from "next/link"; +import { useTranslations } from "../../../../i18n/provider"; export default function AdminLoginPage() { + const t = useTranslations(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [mfaCode, setMfaCode] = useState(""); @@ -59,7 +61,7 @@ export default function AdminLoginPage() { ⚙
-

Management Console

+

{t("auth.adminLoginTitle")}

Surface: /admin/* (Cookie: Path=/admin, MFA Mandatory)

@@ -74,7 +76,7 @@ export default function AdminLoginPage() { {!challengeToken ? ( <>
- +
- +
- Mandatory Admin 2FA Requirement (PLAN.md ADR-008). Enter your 6-digit TOTP code. + {t("auth.mfaRequired")}
- + - {loading ? "Verifying Admin Credentials..." : challengeToken ? "Verify Admin 2FA" : "Sign In to Admin Console"} + {loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")} diff --git a/services/webmail/src/app/(auth)/user/login/page.tsx b/services/webmail/src/app/(auth)/user/login/page.tsx index 69a9e13..825c258 100644 --- a/services/webmail/src/app/(auth)/user/login/page.tsx +++ b/services/webmail/src/app/(auth)/user/login/page.tsx @@ -2,8 +2,10 @@ import React, { useState } from "react"; import Link from "next/link"; +import { useTranslations } from "../../../../i18n/provider"; export default function UserLoginPage() { + const t = useTranslations(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [mfaCode, setMfaCode] = useState(""); @@ -59,8 +61,8 @@ export default function UserLoginPage() { @
-

LambdaMail Webmail

-

Surface: /user/* (Cookie: Path=/user)

+

{t("auth.userLoginTitle")}

+

{t("common.appName")} · {t("common.userPortal")}

@@ -74,7 +76,7 @@ export default function UserLoginPage() { {!challengeToken ? ( <>
- +
- +
- Two-Factor Authentication is enabled for this account. Enter the 6-digit code from your authenticator app. + {t("auth.mfaRequired")}
- + - {loading ? "Authenticating..." : challengeToken ? "Verify 2FA Code" : "Sign In to Webmail"} + {loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")} diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index e90d31a..d15d45d 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -1,7 +1,12 @@ +"use client"; + import React from "react"; import Link from "next/link"; +import { useTranslations } from "../../../i18n/provider"; +import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { + const t = useTranslations(); return (
{/* Sidebar Navigation */} @@ -14,7 +19,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
LambdaMail
-
Webmail Surface
+
{t("common.userPortal")}
@@ -22,7 +27,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN href="/user/compose" className="flex items-center justify-center gap-2 w-full py-2.5 px-4 mb-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-sm transition-colors shadow-lg shadow-indigo-600/20" > - + Write Email + + {t("mail.compose")} {/* Folder Navigation List */} @@ -32,7 +37,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN className="flex items-center justify-between px-3 py-2 rounded-lg bg-indigo-600/10 text-indigo-300 border border-indigo-500/20" >
- 📬 Inbox + 📬 {t("mail.inbox")}
3 @@ -42,7 +47,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors" >
- 📪 Sent + 📪 {t("mail.sent")}
@@ -51,7 +56,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors" >
- 📝 Drafts + 📝 {t("mail.drafts")}
1 @@ -86,6 +91,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{/* User Settings & Account Footer */} + {/* Language and session controls */} +
+ + +
(null); const [qrUri, setQrUri] = useState(null); const [totpCode, setTotpCode] = useState(""); const [recoveryCodes, setRecoveryCodes] = useState(null); - const [appPasswords, setAppPasswords] = useState<{ id: string; label: string; created: string }[]>([]); + const [appPasswords, setAppPasswords] = useState<{ id: string; label: string; created_at?: string }[]>([]); const [newAppPassLabel, setNewAppPassLabel] = useState(""); const [generatedAppPass, setGeneratedAppPass] = useState(null); const [message, setMessage] = useState(null); @@ -30,7 +32,7 @@ export default function UserSettingsPage() { const res = await fetch("/api/v1/user/mfa/totp/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ secret: qrSecret, code: totpCode }), + body: JSON.stringify({ code: totpCode }), }); const data = await res.json(); if (!res.ok) throw new Error(data.message || "TOTP verification failed"); @@ -44,20 +46,62 @@ export default function UserSettingsPage() { } }; - const createNewAppPassword = (e: React.FormEvent) => { + const loadAppPasswords = useCallback(async () => { + try { + const res = await fetch("/api/v1/user/app-passwords"); + if (res.ok) setAppPasswords(await res.json()); + } catch { + // Leaving the list as it is beats replacing it with an empty one. + } + }, []); + + const loadProfile = useCallback(async () => { + try { + const res = await fetch("/api/v1/user/me"); + if (res.ok) { + const data = await res.json(); + setMfaEnabled(Boolean(data.mfa_enrolled)); + } + } catch { + // Non-fatal: the enrollment panel simply starts collapsed. + } + }, []); + + useEffect(() => { + void loadProfile(); + void loadAppPasswords(); + }, [loadProfile, loadAppPasswords]); + + const createNewAppPassword = async (e: React.FormEvent) => { e.preventDefault(); if (!newAppPassLabel) return; - const rawPass = `lmp_${Math.random().toString(36).substring(2, 10)}-${Math.random().toString(36).substring(2, 10)}`; - setAppPasswords([...appPasswords, { id: Date.now().toString(), label: newAppPassLabel, created: "Just now" }]); - setGeneratedAppPass(rawPass); - setNewAppPassLabel(""); + try { + const res = await fetch("/api/v1/user/app-passwords", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: newAppPassLabel }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || "Could not create app password"); + // Shown once; the server keeps only an Argon2id hash of it. + setGeneratedAppPass(data.password); + setNewAppPassLabel(""); + await loadAppPasswords(); + } catch (err: unknown) { + setMessage(err instanceof Error ? err.message : "Error creating app password"); + } + }; + + const revokeAppPassword = async (id: string) => { + await fetch(`/api/v1/user/app-passwords/${id}`, { method: "DELETE" }); + await loadAppPasswords(); }; return (
-

Security & Account Settings

+

{t("settings.title")}

Manage 2FA TOTP (RFC 6238), App Passwords, and active webmail sessions.

@@ -177,9 +221,17 @@ export default function UserSettingsPage() {
{ap.label} - Created: {ap.created} + + {ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""} +
- Revoke +
)) )} diff --git a/services/webmail/src/app/api/v1/[...path]/route.ts b/services/webmail/src/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..bcb3838 --- /dev/null +++ b/services/webmail/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; + +/** + * Proxies /api/v1/* to the auth service. + * + * The browser cannot reach http://auth:3001 - that name only resolves inside + * the compose network - so every call the pages make used to 404 against the + * Next server. Routing them through here keeps the session cookie first-party + * (same origin, so SameSite=Strict still applies) and keeps the auth service + * off the public network. + */ + +const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? "http://auth:3001"; + +// Hop-by-hop headers must not be forwarded (RFC 9110 section 7.6.1), and the +// upstream sets its own content-length. +const STRIPPED = new Set([ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "upgrade", + "proxy-authorization", + "proxy-authenticate", + "te", + "trailer", + "content-length", +]); + +function filterHeaders(source: Headers): Headers { + const out = new Headers(); + source.forEach((value, key) => { + if (!STRIPPED.has(key.toLowerCase())) out.append(key, value); + }); + return out; +} + +async function forward(req: NextRequest, path: string[]): Promise { + const target = `${AUTH_SERVICE_URL}/api/v1/${path.join("/")}${req.nextUrl.search}`; + const headers = filterHeaders(req.headers); + + // The client's address is what the auth service records on sessions and + // recovery-code use; without this every login looks like it came from the + // web container. + const forwardedFor = req.headers.get("x-forwarded-for"); + if (forwardedFor) headers.set("x-forwarded-for", forwardedFor); + + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + + let upstream: Response; + try { + upstream = await fetch(target, { + method: req.method, + headers, + body: hasBody ? await req.arrayBuffer() : undefined, + redirect: "manual", + cache: "no-store", + }); + } catch { + // A dead upstream is a gateway problem, not a 404 - the difference decides + // whether an operator looks at the auth service or at this route. + return NextResponse.json( + { error: "UPSTREAM_UNAVAILABLE", message: "Authentication service is unreachable" }, + { status: 502 }, + ); + } + + const responseHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (!STRIPPED.has(key.toLowerCase())) responseHeaders.append(key, value); + }); + // Set-Cookie can legitimately appear more than once (logout clears both + // surfaces); getSetCookie preserves them where forEach would fold them into + // one malformed header. + responseHeaders.delete("set-cookie"); + for (const cookie of upstream.headers.getSetCookie?.() ?? []) { + responseHeaders.append("set-cookie", cookie); + } + + return new NextResponse(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); +} + +type Context = { params: Promise<{ path: string[] }> }; + +async function handler(req: NextRequest, ctx: Context): Promise { + const { path } = await ctx.params; + return forward(req, path ?? []); +} + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; + +// Sessions are per-request; nothing here may be cached or prerendered. +export const dynamic = "force-dynamic"; diff --git a/services/webmail/src/app/layout.tsx b/services/webmail/src/app/layout.tsx index c0e743e..b064d0d 100644 --- a/services/webmail/src/app/layout.tsx +++ b/services/webmail/src/app/layout.tsx @@ -1,16 +1,35 @@ import "./globals.css"; import React from "react"; +import { cookies, headers } from "next/headers"; +import { I18nProvider } from "../i18n/provider"; +import { LOCALE_COOKIE, isLocale, negotiateLocale } from "../i18n/config"; export const metadata = { title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console", description: "Modern, open-source email server web interface with 2FA TOTP and surface isolation.", }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +/** + * The locale is resolved on the server so the first paint is already in the + * right language: an explicit choice wins, otherwise the browser's + * Accept-Language decides. + */ +async function resolveLocale() { + const cookieStore = await cookies(); + const chosen = cookieStore.get(LOCALE_COOKIE)?.value; + if (isLocale(chosen)) return chosen; + + const headerStore = await headers(); + return negotiateLocale(headerStore.get("accept-language")); +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const locale = await resolveLocale(); + return ( - + - {children} + {children} ); diff --git a/services/webmail/src/i18n.test.ts b/services/webmail/src/i18n.test.ts index 4d59442..6cb63bb 100644 --- a/services/webmail/src/i18n.test.ts +++ b/services/webmail/src/i18n.test.ts @@ -38,3 +38,43 @@ describe("i18n message bundles", () => { expect(esKeys).toEqual(enKeys); }); }); + +// Key parity alone passed while the interface was hardcoded English and the +// bundles were never imported. These tests check the wiring instead. +describe("i18n is actually wired into the app", () => { + it("resolves a key through the same helper the components use", async () => { + const { MESSAGES, translate } = await import("./i18n/config.js"); + expect(translate(MESSAGES["pt-BR"], "auth.signInButton")).not.toBe("auth.signInButton"); + expect(translate(MESSAGES["pt-BR"], "auth.signInButton")).not.toBe( + translate(MESSAGES.en, "auth.signInButton"), + ); + }); + + it("falls back to the key rather than throwing on an unknown lookup", async () => { + const { MESSAGES, translate } = await import("./i18n/config.js"); + expect(translate(MESSAGES.en, "nope.missing")).toBe("nope.missing"); + }); + + it("negotiates a locale from Accept-Language, honouring q-values and bare tags", async () => { + const { negotiateLocale } = await import("./i18n/config.js"); + expect(negotiateLocale("pt-BR,pt;q=0.9,en;q=0.8")).toBe("pt-BR"); + expect(negotiateLocale("pt")).toBe("pt-BR"); + expect(negotiateLocale("es-AR,es;q=0.9")).toBe("es"); + expect(negotiateLocale("en-US")).toBe("en"); + expect(negotiateLocale("de-DE")).toBe("en"); + expect(negotiateLocale(null)).toBe("en"); + // A low-quality preferred tag must not beat a higher-quality supported one. + expect(negotiateLocale("de;q=1.0,es;q=0.9")).toBe("es"); + }); + + it("keeps every UI string reachable: the layouts import the provider", async () => { + const { readFileSync } = await import("node:fs"); + for (const file of [ + "src/app/layout.tsx", + "src/app/(user)/user/layout.tsx", + "src/app/(admin)/admin/layout.tsx", + ]) { + expect(readFileSync(file, "utf8")).toMatch(/i18n\/(provider|config)/); + } + }); +}); diff --git a/services/webmail/src/i18n/LanguageSwitcher.tsx b/services/webmail/src/i18n/LanguageSwitcher.tsx new file mode 100644 index 0000000..aca9693 --- /dev/null +++ b/services/webmail/src/i18n/LanguageSwitcher.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { LOCALES, LOCALE_LABELS, type Locale } from "./config"; +import { useI18n } from "./provider"; + +/** Language selector, rendered in both surface layouts (PLAN.md section 21.2). */ +export function LanguageSwitcher({ className = "" }: { className?: string }) { + const { locale, setLocale, t } = useI18n(); + + return ( + + ); +} diff --git a/services/webmail/src/i18n/config.ts b/services/webmail/src/i18n/config.ts new file mode 100644 index 0000000..3fbc24a --- /dev/null +++ b/services/webmail/src/i18n/config.ts @@ -0,0 +1,71 @@ +import en from "../../messages/en.json"; +import ptBR from "../../messages/pt-BR.json"; +import es from "../../messages/es.json"; + +export const LOCALES = ["en", "pt-BR", "es"] as const; +export type Locale = (typeof LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = "en"; +export const LOCALE_COOKIE = "lm_locale"; + +export const LOCALE_LABELS: Record = { + en: "English", + "pt-BR": "Português (Brasil)", + es: "Español", +}; + +// Bundled at build time rather than fetched: these are small, needed on first +// paint, and a network round trip would flash untranslated text. +export const MESSAGES: Record = { + en: en as Messages, + "pt-BR": ptBR as Messages, + es: es as Messages, +}; + +export type Messages = Record>; + +export function isLocale(value: string | undefined | null): value is Locale { + return !!value && (LOCALES as readonly string[]).includes(value); +} + +/** + * Picks the best supported locale from an Accept-Language header. + * Quality values are honoured, and a bare "pt" matches "pt-BR" so a Brazilian + * browser that only advertises "pt" is not silently served English. + */ +export function negotiateLocale(acceptLanguage: string | null): Locale { + if (!acceptLanguage) return DEFAULT_LOCALE; + + const ranked = acceptLanguage + .split(",") + .map((part) => { + const [tag, ...params] = part.trim().split(";"); + const q = params.find((p) => p.trim().startsWith("q=")); + return { tag: tag.trim().toLowerCase(), q: q ? Number(q.split("=")[1]) || 0 : 1 }; + }) + .sort((a, b) => b.q - a.q); + + for (const { tag } of ranked) { + const exact = LOCALES.find((l) => l.toLowerCase() === tag); + if (exact) return exact; + const base = tag.split("-")[0]; + const prefixed = LOCALES.find((l) => l.toLowerCase().split("-")[0] === base); + if (prefixed) return prefixed; + } + return DEFAULT_LOCALE; +} + +/** + * Resolves a dotted key such as "auth.signInButton". + * + * A missing key falls back to English and, failing that, returns the key + * itself: a screen showing "settings.title" is a visible bug report, whereas + * throwing would blank the page over a typo in a label. + */ +export function translate(messages: Messages, key: string): string { + const [namespace, name] = key.split("."); + const value = messages?.[namespace]?.[name]; + if (typeof value === "string") return value; + const fallback = MESSAGES[DEFAULT_LOCALE]?.[namespace]?.[name]; + return typeof fallback === "string" ? fallback : key; +} diff --git a/services/webmail/src/i18n/provider.tsx b/services/webmail/src/i18n/provider.tsx new file mode 100644 index 0000000..e3473c0 --- /dev/null +++ b/services/webmail/src/i18n/provider.tsx @@ -0,0 +1,52 @@ +"use client"; + +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import { LOCALE_COOKIE, MESSAGES, DEFAULT_LOCALE, translate, type Locale } from "./config"; + +interface I18nContextValue { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: string) => string; +} + +const I18nContext = createContext(null); + +export function I18nProvider({ locale: initialLocale, children }: { locale: Locale; children: React.ReactNode }) { + const [locale, setLocaleState] = useState(initialLocale); + + const setLocale = useCallback((next: Locale) => { + setLocaleState(next); + // Persisted for the next server render, so the page does not come back in + // the previous language on reload. Not HttpOnly: this is a display + // preference the client itself sets, carrying nothing sensitive. + document.cookie = `${LOCALE_COOKIE}=${next}; Path=/; Max-Age=31536000; SameSite=Lax`; + // Best effort: keep the account's stored locale in step for anything the + // server sends by mail. A failure here must not break the UI switch. + void fetch("/api/v1/user/locale", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locale: next }), + }).catch(() => undefined); + document.documentElement.lang = next; + }, []); + + const value = useMemo(() => { + const messages = MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE]; + return { locale, setLocale, t: (key: string) => translate(messages, key) }; + }, [locale, setLocale]); + + return {children}; +} + +export function useI18n(): I18nContextValue { + const ctx = useContext(I18nContext); + if (!ctx) { + throw new Error("useI18n must be used inside "); + } + return ctx; +} + +/** Shorthand for components that only need to translate. */ +export function useTranslations(): (key: string) => string { + return useI18n().t; +} diff --git a/services/webmail/src/middleware.ts b/services/webmail/src/middleware.ts new file mode 100644 index 0000000..8cdab4a --- /dev/null +++ b/services/webmail/src/middleware.ts @@ -0,0 +1,80 @@ +import { NextResponse, type NextRequest } from "next/server"; + +/** + * Gate for the two web surfaces. + * + * Without this, /admin/* rendered for anyone who typed the URL: the console + * was reachable unauthenticated and only its data calls failed. The check here + * is deliberately shallow - it reads the session cookie and its audience, and + * does not verify the signature, because the signing key belongs to the auth + * service and must not be copied into the browser-facing bundle. + * + * That is sound because this is not the security boundary: every /api/v1/* + * request is verified for real by the auth service, which owns the key. This + * only stops an unauthenticated visitor from loading a shell that cannot work, + * and sends them somewhere useful instead. + */ + +const USER_COOKIE = "lm_user_session"; +const ADMIN_COOKIE = "lm_admin_session"; + +interface JwtClaims { + aud?: string; + surface?: string; + purpose?: string; + exp?: number; +} + +/** Reads the claims without verifying the signature. See the note above. */ +function readClaims(token: string | undefined): JwtClaims | null { + if (!token) return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const json = atob(payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=")); + return JSON.parse(json) as JwtClaims; + } catch { + return null; + } +} + +function isUsable(claims: JwtClaims | null, expectedAud: string): boolean { + if (!claims) return false; + // A challenge token is issued between the password and the second factor; + // it must not open either surface. + if (claims.purpose !== "session") return false; + if (claims.aud !== expectedAud) return false; + if (typeof claims.exp === "number" && claims.exp * 1000 <= Date.now()) return false; + return true; +} + +export function middleware(req: NextRequest) { + const { pathname, search } = req.nextUrl; + + const isAdminArea = pathname.startsWith("/admin") && !pathname.startsWith("/admin/login"); + const isUserArea = pathname.startsWith("/user") && !pathname.startsWith("/user/login"); + if (!isAdminArea && !isUserArea) { + return NextResponse.next(); + } + + const surface = isAdminArea ? "admin" : "user"; + const cookieName = isAdminArea ? ADMIN_COOKIE : USER_COOKIE; + const claims = readClaims(req.cookies.get(cookieName)?.value); + + if (isUsable(claims, `lambdamail:${surface}`)) { + return NextResponse.next(); + } + + // The original destination travels along so the login can return there, + // rather than always dropping the user on a default page. + const login = req.nextUrl.clone(); + login.pathname = `/${surface}/login`; + login.search = ""; + login.searchParams.set("next", `${pathname}${search}`); + return NextResponse.redirect(login); +} + +export const config = { + matcher: ["/admin/:path*", "/user/:path*"], +};