#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:
1 parent
833dc76c2e
commit
132cecd41c
16 files changed
+535
-43
No files matched your search
@@ -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 (
|
||||
<div className="flex h-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||
{/* Sidebar Navigation */}
|
||||
@@ -14,7 +19,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span>+</span> Write Email
|
||||
<span>+</span> {t("mail.compose")}
|
||||
</Link>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span>📬</span> Inbox
|
||||
<span>📬</span> {t("mail.inbox")}
|
||||
</div>
|
||||
<span className="text-xs bg-indigo-500/30 px-2 py-0.5 rounded-full font-bold">3</span>
|
||||
</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"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span>📪</span> Sent
|
||||
<span>📪</span> {t("mail.sent")}
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span>📝</span> Drafts
|
||||
<span>📝</span> {t("mail.drafts")}
|
||||
</div>
|
||||
<span className="text-xs bg-slate-800 px-2 py-0.5 rounded-full">1</span>
|
||||
</Link>
|
||||
@@ -86,6 +91,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Link
|
||||
href="/user/settings"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
|
||||
export default function UserSettingsPage() {
|
||||
const t = useTranslations();
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
||||
const [qrUri, setQrUri] = useState<string | null>(null);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
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 [generatedAppPass, setGeneratedAppPass] = 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", {
|
||||
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 (
|
||||
<div className="flex-1 p-8 bg-slate-950 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user