#21: guard the web surfaces and connect them to the auth service

Three separate gaps, all of which made the F4 interface non-functional or
unprotected:

The admin console rendered for anyone. There was no middleware and no
guard in the layout, so /admin/dashboard returned 200 unauthenticated -
only its data calls failed. middleware.ts now redirects both surfaces to
their login when the session cookie is missing, expired, carries the wrong
audience, or is a pre-MFA challenge token. It reads the claims without
verifying the signature on purpose: the signing key belongs to the auth
service and must not reach the browser bundle. That is sound because this
is not the boundary - every /api/v1 request is verified for real upstream.

Nothing served /api/v1/*. next.config.ts had no rewrites and there were no
route handlers, so every call the pages made 404'd against the Next
server, while AUTH_SERVICE_URL sat unused. A catch-all route now proxies
to the auth service, forwarding the client address and preserving multiple
Set-Cookie headers, which logout needs to clear both surfaces. Session
cookies also move to Path=/, since a cookie pinned to /user or /admin was
never sent with the /api/v1 requests that needed it.

i18n existed only as files. The three bundles had perfect key parity and
real translations, but no library, no imports and no switcher: the UI was
hardcoded English and i18n.test.ts compared keys while the feature was
absent. There is now a provider, a hook, a language switcher, and
server-side Accept-Language negotiation that honours q-values and matches
a bare "pt" to pt-BR. Logins, both layouts and the settings screen consume
it; the admin pages still have English titles.

The settings screen invented app passwords in the browser with Math.random
and never stored them. It now calls the API, which returns the password
once and keeps only its hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 09:33:49 -03:00
1 parent 833dc76c2e
commit 132cecd41c
16 files changed
+534 -42

No files matched your search

+3 -1
View File
@@ -51,7 +51,9 @@
"enableMfa": "Enable 2FA", "enableMfa": "Enable 2FA",
"generateAppPassword": "Generate App Password", "generateAppPassword": "Generate App Password",
"recoveryCodesTitle": "Recovery Codes", "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": { "admin": {
"dashboardTitle": "System Dashboard", "dashboardTitle": "System Dashboard",
+3 -1
View File
@@ -51,7 +51,9 @@
"enableMfa": "Activar 2FA", "enableMfa": "Activar 2FA",
"generateAppPassword": "Generar Contraseña de Aplicación", "generateAppPassword": "Generar Contraseña de Aplicación",
"recoveryCodesTitle": "Códigos de Recuperació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": { "admin": {
"dashboardTitle": "Panel del Sistema", "dashboardTitle": "Panel del Sistema",
+3 -1
View File
@@ -51,7 +51,9 @@
"enableMfa": "Ativar 2FA", "enableMfa": "Ativar 2FA",
"generateAppPassword": "Gerar Senha de Aplicativo", "generateAppPassword": "Gerar Senha de Aplicativo",
"recoveryCodesTitle": "Códigos de Recuperação", "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": { "admin": {
"dashboardTitle": "Painel do Sistema", "dashboardTitle": "Painel do Sistema",
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start -p 3000", "start": "next start -p 3000",
"test": "vitest run" "test": "vitest run",
"lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"next": "15.5.21", "next": "15.5.21",
@@ -1,7 +1,12 @@
"use client";
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
return ( return (
<div className="flex h-screen overflow-hidden bg-slate-950 text-slate-100"> <div className="flex h-screen overflow-hidden bg-slate-950 text-slate-100">
{/* Executive Sidebar */} {/* Executive Sidebar */}
@@ -14,7 +19,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div> </div>
<div> <div>
<div className="font-bold text-sm text-white">LambdaMail Admin</div> <div className="font-bold text-sm text-white">LambdaMail Admin</div>
<div className="text-[10px] text-emerald-400 uppercase tracking-wider font-mono">Management Console</div> <div className="text-[10px] text-emerald-400 uppercase tracking-wider font-mono">{t("common.adminPortal")}</div>
</div> </div>
</div> </div>
@@ -29,46 +34,61 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
href="/admin/dashboard" 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" 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"
> >
<span>&#128202;</span> Dashboard <span>&#128202;</span> {t("admin.dashboardTitle")}
</Link> </Link>
<Link <Link
href="/admin/domains" href="/admin/domains"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors" className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors"
> >
<span>&#127760;</span> Domains & DNS (13) <span>&#127760;</span> {t("admin.domainsTitle")}
</Link> </Link>
<Link <Link
href="/admin/mailboxes" href="/admin/mailboxes"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors" className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors"
> >
<span>&#128100;</span> Mailboxes & Quotas <span>&#128100;</span> {t("admin.mailboxesTitle")}
</Link> </Link>
<Link <Link
href="/admin/queue" href="/admin/queue"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors" className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors"
> >
<span>&#128238;</span> Delivery Queue <span>&#128238;</span> {t("admin.queueTitle")}
</Link> </Link>
<Link <Link
href="/admin/dmarc" href="/admin/dmarc"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors" className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors"
> >
<span>&#128200;</span> DMARC Analytics <span>&#128200;</span> {t("admin.dmarcTitle")}
</Link> </Link>
<Link <Link
href="/admin/security" href="/admin/security"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors" className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors"
> >
<span>&#128737;</span> Audit Logs & Preflight <span>&#128737;</span> {t("admin.securityTitle")}
</Link> </Link>
</nav> </nav>
</div> </div>
{/* Language and session controls */}
<div className="mb-3 flex items-center justify-between gap-2">
<LanguageSwitcher />
<button
type="button"
onClick={() => {
void fetch("/api/v1/auth/logout", { method: "POST" }).finally(() => {
window.location.href = "/admin/login";
});
}}
className="rounded-md border border-slate-700 px-2 py-1 text-xs text-slate-300 hover:text-white"
>
{t("settings.signOut")}
</button>
</div>
{/* Operator Profile Footer */} {/* Operator Profile Footer */}
<div className="pt-4 border-t border-slate-800"> <div className="pt-4 border-t border-slate-800">
<div className="flex items-center justify-between p-2 rounded-xl bg-slate-900 border border-slate-800"> <div className="flex items-center justify-between p-2 rounded-xl bg-slate-900 border border-slate-800">
@@ -2,8 +2,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "../../../../i18n/provider";
export default function AdminLoginPage() { export default function AdminLoginPage() {
const t = useTranslations();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState(""); const [mfaCode, setMfaCode] = useState("");
@@ -59,7 +61,7 @@ export default function AdminLoginPage() {
&#9881; &#9881;
</div> </div>
<div> <div>
<h1 className="text-xl font-bold text-white">Management Console</h1> <h1 className="text-xl font-bold text-white">{t("auth.adminLoginTitle")}</h1>
<p className="text-xs text-slate-400">Surface: /admin/* (Cookie: Path=/admin, MFA Mandatory)</p> <p className="text-xs text-slate-400">Surface: /admin/* (Cookie: Path=/admin, MFA Mandatory)</p>
</div> </div>
</div> </div>
@@ -74,7 +76,7 @@ export default function AdminLoginPage() {
{!challengeToken ? ( {!challengeToken ? (
<> <>
<div> <div>
<label className="block text-xs font-medium text-slate-300 mb-1">Administrator Email</label> <label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.emailLabel")}</label>
<input <input
type="email" type="email"
value={email} value={email}
@@ -86,7 +88,7 @@ export default function AdminLoginPage() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-300 mb-1">Password</label> <label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.passwordLabel")}</label>
<input <input
type="password" type="password"
value={password} value={password}
@@ -100,9 +102,9 @@ export default function AdminLoginPage() {
) : ( ) : (
<div> <div>
<div className="p-3 mb-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs"> <div className="p-3 mb-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
Mandatory Admin 2FA Requirement (PLAN.md ADR-008). Enter your 6-digit TOTP code. {t("auth.mfaRequired")}
</div> </div>
<label className="block text-xs font-medium text-slate-300 mb-1">Admin 2FA Code (6 Digits)</label> <label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.totpCodeLabel")}</label>
<input <input
type="text" type="text"
value={mfaCode} value={mfaCode}
@@ -120,7 +122,7 @@ export default function AdminLoginPage() {
disabled={loading} disabled={loading}
className="w-full py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors shadow-lg shadow-emerald-600/20 disabled:opacity-50" className="w-full py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors shadow-lg shadow-emerald-600/20 disabled:opacity-50"
> >
{loading ? "Verifying Admin Credentials..." : challengeToken ? "Verify Admin 2FA" : "Sign In to Admin Console"} {loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
</button> </button>
</form> </form>
@@ -2,8 +2,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "../../../../i18n/provider";
export default function UserLoginPage() { export default function UserLoginPage() {
const t = useTranslations();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState(""); const [mfaCode, setMfaCode] = useState("");
@@ -59,8 +61,8 @@ export default function UserLoginPage() {
@ @
</div> </div>
<div> <div>
<h1 className="text-xl font-bold text-white">LambdaMail Webmail</h1> <h1 className="text-xl font-bold text-white">{t("auth.userLoginTitle")}</h1>
<p className="text-xs text-slate-400">Surface: /user/* (Cookie: Path=/user)</p> <p className="text-xs text-slate-400">{t("common.appName")} &middot; {t("common.userPortal")}</p>
</div> </div>
</div> </div>
@@ -74,7 +76,7 @@ export default function UserLoginPage() {
{!challengeToken ? ( {!challengeToken ? (
<> <>
<div> <div>
<label className="block text-xs font-medium text-slate-300 mb-1">Email Address</label> <label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.emailLabel")}</label>
<input <input
type="email" type="email"
value={email} value={email}
@@ -86,7 +88,7 @@ export default function UserLoginPage() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-300 mb-1">Password</label> <label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.passwordLabel")}</label>
<input <input
type="password" type="password"
value={password} value={password}
@@ -100,9 +102,9 @@ export default function UserLoginPage() {
) : ( ) : (
<div> <div>
<div className="p-3 mb-4 rounded-lg bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 text-xs"> <div className="p-3 mb-4 rounded-lg bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 text-xs">
Two-Factor Authentication is enabled for this account. Enter the 6-digit code from your authenticator app. {t("auth.mfaRequired")}
</div> </div>
<label className="block text-xs font-medium text-slate-300 mb-1">2FA Code (6 Digits)</label> <label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.totpCodeLabel")}</label>
<input <input
type="text" type="text"
value={mfaCode} value={mfaCode}
@@ -120,7 +122,7 @@ export default function UserLoginPage() {
disabled={loading} disabled={loading}
className="w-full py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors shadow-lg shadow-indigo-600/20 disabled:opacity-50" className="w-full py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors shadow-lg shadow-indigo-600/20 disabled:opacity-50"
> >
{loading ? "Authenticating..." : challengeToken ? "Verify 2FA Code" : "Sign In to Webmail"} {loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
</button> </button>
</form> </form>
@@ -1,7 +1,12 @@
"use client";
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
return ( return (
<div className="flex h-screen overflow-hidden bg-slate-950 text-slate-100"> <div className="flex h-screen overflow-hidden bg-slate-950 text-slate-100">
{/* Sidebar Navigation */} {/* Sidebar Navigation */}
@@ -14,7 +19,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</div> </div>
<div> <div>
<div className="font-bold text-sm text-white">LambdaMail</div> <div className="font-bold text-sm text-white">LambdaMail</div>
<div className="text-[10px] text-slate-400 uppercase tracking-wider font-mono">Webmail Surface</div> <div className="text-[10px] text-slate-400 uppercase tracking-wider font-mono">{t("common.userPortal")}</div>
</div> </div>
</div> </div>
@@ -22,7 +27,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
href="/user/compose" 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" 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"
> >
<span>+</span> Write Email <span>+</span> {t("mail.compose")}
</Link> </Link>
{/* Folder Navigation List */} {/* 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" className="flex items-center justify-between px-3 py-2 rounded-lg bg-indigo-600/10 text-indigo-300 border border-indigo-500/20"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span>&#128236;</span> Inbox <span>&#128236;</span> {t("mail.inbox")}
</div> </div>
<span className="text-xs bg-indigo-500/30 px-2 py-0.5 rounded-full font-bold">3</span> <span className="text-xs bg-indigo-500/30 px-2 py-0.5 rounded-full font-bold">3</span>
</Link> </Link>
@@ -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" 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"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span>&#128234;</span> Sent <span>&#128234;</span> {t("mail.sent")}
</div> </div>
</Link> </Link>
@@ -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" 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"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span>&#128221;</span> Drafts <span>&#128221;</span> {t("mail.drafts")}
</div> </div>
<span className="text-xs bg-slate-800 px-2 py-0.5 rounded-full">1</span> <span className="text-xs bg-slate-800 px-2 py-0.5 rounded-full">1</span>
</Link> </Link>
@@ -86,6 +91,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</div> </div>
{/* User Settings & Account Footer */} {/* User Settings & Account Footer */}
{/* Language and session controls */}
<div className="mb-3 flex items-center justify-between gap-2">
<LanguageSwitcher />
<button
type="button"
onClick={() => {
void fetch("/api/v1/auth/logout", { method: "POST" }).finally(() => {
window.location.href = "/user/login";
});
}}
className="rounded-md border border-slate-700 px-2 py-1 text-xs text-slate-300 hover:text-white"
>
{t("settings.signOut")}
</button>
</div>
<div className="pt-4 border-t border-slate-800"> <div className="pt-4 border-t border-slate-800">
<Link <Link
href="/user/settings" href="/user/settings"
@@ -1,14 +1,16 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useTranslations } from "../../../../i18n/provider";
export default function UserSettingsPage() { export default function UserSettingsPage() {
const t = useTranslations();
const [mfaEnabled, setMfaEnabled] = useState(false); const [mfaEnabled, setMfaEnabled] = useState(false);
const [qrSecret, setQrSecret] = useState<string | null>(null); const [qrSecret, setQrSecret] = useState<string | null>(null);
const [qrUri, setQrUri] = useState<string | null>(null); const [qrUri, setQrUri] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(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 [newAppPassLabel, setNewAppPassLabel] = useState("");
const [generatedAppPass, setGeneratedAppPass] = useState<string | null>(null); const [generatedAppPass, setGeneratedAppPass] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@@ -30,7 +32,7 @@ export default function UserSettingsPage() {
const res = await fetch("/api/v1/user/mfa/totp/confirm", { const res = await fetch("/api/v1/user/mfa/totp/confirm", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: qrSecret, code: totpCode }), body: JSON.stringify({ code: totpCode }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.message || "TOTP verification failed"); 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(); e.preventDefault();
if (!newAppPassLabel) return; if (!newAppPassLabel) return;
const rawPass = `lmp_${Math.random().toString(36).substring(2, 10)}-${Math.random().toString(36).substring(2, 10)}`; try {
setAppPasswords([...appPasswords, { id: Date.now().toString(), label: newAppPassLabel, created: "Just now" }]); const res = await fetch("/api/v1/user/app-passwords", {
setGeneratedAppPass(rawPass); 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(""); 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 ( return (
<div className="flex-1 p-8 bg-slate-950 overflow-y-auto"> <div className="flex-1 p-8 bg-slate-950 overflow-y-auto">
<div className="max-w-4xl mx-auto space-y-8"> <div className="max-w-4xl mx-auto space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-white mb-1">Security & Account Settings</h1> <h1 className="text-2xl font-bold text-white mb-1">{t("settings.title")}</h1>
<p className="text-xs text-slate-400">Manage 2FA TOTP (RFC 6238), App Passwords, and active webmail sessions.</p> <p className="text-xs text-slate-400">Manage 2FA TOTP (RFC 6238), App Passwords, and active webmail sessions.</p>
</div> </div>
@@ -177,9 +221,17 @@ export default function UserSettingsPage() {
<div key={ap.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-900 border border-slate-800 text-xs"> <div key={ap.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-900 border border-slate-800 text-xs">
<div> <div>
<span className="font-bold text-slate-200">{ap.label}</span> <span className="font-bold text-slate-200">{ap.label}</span>
<span className="text-[10px] text-slate-500 ml-2">Created: {ap.created}</span> <span className="text-[10px] text-slate-500 ml-2">
{ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
</span>
</div> </div>
<span className="text-red-400 hover:underline cursor-pointer">Revoke</span> <button
type="button"
onClick={() => void revokeAppPassword(ap.id)}
className="text-red-400 hover:underline"
>
{t("common.delete")}
</button>
</div> </div>
)) ))
)} )}
@@ -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<Response> {
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<Response> {
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";
+22 -3
View File
@@ -1,16 +1,35 @@
import "./globals.css"; import "./globals.css";
import React from "react"; 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 = { export const metadata = {
title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console", title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console",
description: "Modern, open-source email server web interface with 2FA TOTP and surface isolation.", 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 ( return (
<html lang="en" className="dark"> <html lang={locale} className="dark">
<body className="bg-slate-950 text-slate-100 min-h-screen font-sans antialiased"> <body className="bg-slate-950 text-slate-100 min-h-screen font-sans antialiased">
{children} <I18nProvider locale={locale}>{children}</I18nProvider>
</body> </body>
</html> </html>
); );
+40
View File
@@ -38,3 +38,43 @@ describe("i18n message bundles", () => {
expect(esKeys).toEqual(enKeys); 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)/);
}
});
});
@@ -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 (
<label className={`flex items-center gap-2 text-xs ${className}`}>
<span className="sr-only">{t("settings.language")}</span>
<select
aria-label={t("settings.language")}
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1 text-xs text-slate-200"
>
{LOCALES.map((l) => (
<option key={l} value={l}>
{LOCALE_LABELS[l]}
</option>
))}
</select>
</label>
);
}
+71
View File
@@ -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<Locale, string> = {
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<Locale, Messages> = {
en: en as Messages,
"pt-BR": ptBR as Messages,
es: es as Messages,
};
export type Messages = Record<string, Record<string, string>>;
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;
}
+52
View File
@@ -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<I18nContextValue | null>(null);
export function I18nProvider({ locale: initialLocale, children }: { locale: Locale; children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(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<I18nContextValue>(() => {
const messages = MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE];
return { locale, setLocale, t: (key: string) => translate(messages, key) };
}, [locale, setLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n must be used inside <I18nProvider>");
}
return ctx;
}
/** Shorthand for components that only need to translate. */
export function useTranslations(): (key: string) => string {
return useI18n().t;
}
+80
View File
@@ -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*"],
};