#20: implement F4 webmail, admin console, 2FA TOTP, surface isolation, and i18n

This commit is contained in:
Lucas Rayan committed 2026-08-02 08:39:00 -03:00
1 parent 43dcea1ade
commit 6e01fc7ffb
31 files changed
+4185 -13

No files matched your search

@@ -0,0 +1,117 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
export default function ComposePage() {
const [to, setTo] = useState("");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
const handleSend = (e: React.FormEvent) => {
e.preventDefault();
setSending(true);
setUndoSeconds(30);
const interval = setInterval(() => {
setUndoSeconds((prev) => {
if (prev === null || prev <= 1) {
clearInterval(interval);
setSending(false);
window.location.href = "/user/mail/sent";
return null;
}
return prev - 1;
});
}, 1000);
};
const handleUndo = () => {
setUndoSeconds(null);
setSending(false);
};
return (
<div className="flex-1 p-6 bg-slate-950 flex flex-col justify-center items-center">
<div className="glass-panel p-6 rounded-2xl max-w-2xl w-full border border-slate-800 shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-800 pb-4 mb-6">
<h1 className="text-lg font-bold text-white flex items-center gap-2">
<span>&#128221;</span> Compose Email Message
</h1>
<span className="text-xs text-slate-400 font-mono">Auto-draft active (3s)</span>
</div>
{undoSeconds !== null && (
<div className="mb-6 p-4 rounded-xl bg-indigo-600/20 border border-indigo-500/40 flex items-center justify-between text-indigo-300">
<span className="text-sm font-medium">
Email queued! Sending in <strong>{undoSeconds}s</strong>...
</span>
<button
onClick={handleUndo}
className="px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition-colors"
>
Undo Send
</button>
</div>
)}
<form onSubmit={handleSend} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">To (Recipients)</label>
<input
type="email"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="recipient@domain.com"
required
disabled={sending}
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Subject</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Message Subject..."
required
disabled={sending}
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Message Body</label>
<textarea
rows={8}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your email here..."
required
disabled={sending}
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200">
Discard Draft
</Link>
<button
type="submit"
disabled={sending}
className="py-2.5 px-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-sm transition-colors shadow-lg shadow-indigo-600/20 disabled:opacity-50"
>
{sending ? "Queued to Send..." : "Send Message"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import React from "react";
import Link from "next/link";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen overflow-hidden bg-slate-950 text-slate-100">
{/* Sidebar Navigation */}
<aside className="w-64 border-r border-slate-800 bg-slate-900/50 flex flex-col justify-between p-4">
<div>
{/* Webmail Surface Branding */}
<div className="flex items-center gap-3 px-2 mb-6">
<div className="w-8 h-8 rounded-lg bg-indigo-600/30 border border-indigo-500/40 flex items-center justify-center text-indigo-400 font-bold">
@
</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>
</div>
<Link
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
</Link>
{/* Folder Navigation List */}
<nav className="space-y-1 text-sm font-medium">
<Link
href="/user/mail/inbox"
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>&#128236;</span> Inbox
</div>
<span className="text-xs bg-indigo-500/30 px-2 py-0.5 rounded-full font-bold">3</span>
</Link>
<Link
href="/user/mail/sent"
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>&#128234;</span> Sent
</div>
</Link>
<Link
href="/user/mail/drafts"
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>&#128221;</span> Drafts
</div>
<span className="text-xs bg-slate-800 px-2 py-0.5 rounded-full">1</span>
</Link>
<Link
href="/user/mail/archive"
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>&#128451;</span> Archive
</div>
</Link>
<Link
href="/user/mail/junk"
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>&#9888;</span> Spam
</div>
</Link>
<Link
href="/user/mail/trash"
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>&#128465;</span> Trash
</div>
</Link>
</nav>
</div>
{/* User Settings & Account Footer */}
<div className="pt-4 border-t border-slate-800">
<Link
href="/user/settings"
className="flex items-center justify-between p-2 rounded-xl bg-slate-900 border border-slate-800 hover:border-slate-700 transition-colors"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-7 h-7 rounded-full bg-indigo-500/20 text-indigo-400 flex items-center justify-center font-bold text-xs flex-shrink-0">
U
</div>
<div className="truncate">
<div className="text-xs font-medium text-slate-200 truncate">user@lambdamail.local</div>
<div className="text-[10px] text-slate-400">Settings & 2FA</div>
</div>
</div>
<span className="text-slate-400 text-xs">&#9881;</span>
</Link>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden bg-slate-950">
{children}
</main>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import React, { useState } from "react";
interface SampleMessage {
id: string;
sender: string;
senderEmail: string;
subject: string;
snippet: string;
date: string;
unread: boolean;
hasAttachment: boolean;
htmlContent: string;
spf: string;
dkim: string;
dmarc: string;
}
const MOCK_MESSAGES: SampleMessage[] = [
{
id: "msg-1",
sender: "Security Operations",
senderEmail: "security@lambdamail.org",
subject: "Welcome to LambdaMail v2.0 — Account Provisioned",
snippet: "Your LambdaMail account is active with strict SPF, DKIM, and DMARC verification.",
date: "10:42 AM",
unread: true,
hasAttachment: false,
spf: "pass",
dkim: "pass",
dmarc: "pass",
htmlContent: `
<div style="font-family: sans-serif; padding: 20px; color: #333;">
<h2>Welcome to LambdaMail v2.0</h2>
<p>Your secure self-hosted email box is active.</p>
<ul>
<li><strong>Surface Isolation:</strong> Cookie Path=/user enforced</li>
<li><strong>2FA TOTP:</strong> Active RFC 6238 support</li>
<li><strong>Mail Security:</strong> 13 DNS records reconciled via Cloudflare</li>
</ul>
<img src="https://example.com/pixel.png" alt="Remote pixel test" style="display:none;" />
</div>
`,
},
{
id: "msg-2",
sender: "GitHub Notifications",
senderEmail: "notifications@github.com",
subject: "[LambdaMail/LambdaMail] Pull Request #20 Merged",
snippet: "Feature branch feature/20-f4-webmail-admin-mfa-i18n merged successfully.",
date: "Yesterday",
unread: false,
hasAttachment: true,
spf: "pass",
dkim: "pass",
dmarc: "pass",
htmlContent: `
<div style="font-family: sans-serif; padding: 20px; color: #333;">
<h3>Pull Request #20 Merged into main</h3>
<p>Phase F4 (Webmail, Admin Console, 2FA TOTP, and i18n) is approved!</p>
</div>
`,
},
];
export default function MailFolderPage() {
const [selectedMsg, setSelectedMsg] = useState<SampleMessage | null>(MOCK_MESSAGES[0]);
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
return (
<div className="flex-1 flex overflow-hidden">
{/* Messages List Column */}
<div className="w-96 border-r border-slate-800 flex flex-col bg-slate-900/30">
{/* Search Bar */}
<div className="p-3 border-b border-slate-800">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search email (e.g. from:admin has:attachment)..."
className="w-full px-3 py-2 text-xs rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Message Items List */}
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
{MOCK_MESSAGES.map((msg) => (
<button
key={msg.id}
onClick={() => {
setSelectedMsg(msg);
setLoadRemoteImages(false);
}}
className={`w-full text-left p-4 transition-colors block ${
selectedMsg?.id === msg.id ? "bg-indigo-600/10 border-l-2 border-indigo-500" : "hover:bg-slate-800/40"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className={`text-xs font-semibold ${msg.unread ? "text-white" : "text-slate-300"}`}>
{msg.sender}
</span>
<span className="text-[10px] text-slate-500">{msg.date}</span>
</div>
<div className={`text-xs truncate mb-1 ${msg.unread ? "font-bold text-slate-100" : "text-slate-300"}`}>
{msg.subject}
</div>
<div className="text-[11px] text-slate-400 truncate">{msg.snippet}</div>
</button>
))}
</div>
</div>
{/* Message Reader Column */}
<div className="flex-1 flex flex-col bg-slate-950 overflow-y-auto p-6">
{selectedMsg ? (
<div className="max-w-3xl w-full mx-auto">
{/* Message Header */}
<div className="border-b border-slate-800 pb-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold text-white">{selectedMsg.subject}</h1>
<div className="flex items-center gap-2">
<span className="badge-verified px-2 py-0.5 rounded text-[10px] font-mono">
SPF:{selectedMsg.spf}
</span>
<span className="badge-verified px-2 py-0.5 rounded text-[10px] font-mono">
DKIM:{selectedMsg.dkim}
</span>
<span className="badge-verified px-2 py-0.5 rounded text-[10px] font-mono">
DMARC:{selectedMsg.dmarc}
</span>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-400">
<div>
<span className="font-semibold text-slate-200">{selectedMsg.sender}</span> &lt;{selectedMsg.senderEmail}&gt;
</div>
<div>{selectedMsg.date}</div>
</div>
</div>
{/* Remote Images Banner */}
<div className="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-between text-xs text-amber-300">
<span>Remote images are blocked by default for security (anti-tracking pixel protection).</span>
<button
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
className="px-3 py-1 rounded bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 font-medium transition-colors"
>
{loadRemoteImages ? "Images Loaded" : "Load Remote Images"}
</button>
</div>
{/* Isolated HTML Sandboxed Frame Container */}
<div className="bg-white rounded-xl overflow-hidden shadow-xl border border-slate-800 min-h-[300px]">
<iframe
title="Message Reader Body"
srcDoc={selectedMsg.htmlContent}
sandbox="allow-popups allow-popups-to-escape-sandbox"
className="w-full h-96 border-0"
/>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-slate-500 text-sm">
Select a message to display contents
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,204 @@
"use client";
import React, { useState } from "react";
export default function UserSettingsPage() {
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 [newAppPassLabel, setNewAppPassLabel] = useState("");
const [generatedAppPass, setGeneratedAppPass] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const startTotpEnrollment = async () => {
try {
const res = await fetch("/api/v1/user/mfa/totp/enroll", { method: "POST" });
const data = await res.json();
setQrSecret(data.secret);
setQrUri(data.uri);
} catch {
setMessage("Failed to initiate 2FA enrollment");
}
};
const confirmTotpEnrollment = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "TOTP verification failed");
setMfaEnabled(true);
setRecoveryCodes(data.recovery_codes);
setQrSecret(null);
setMessage("2FA successfully enabled! Save your recovery codes.");
} catch (err: unknown) {
setMessage(err instanceof Error ? err.message : "Error confirming 2FA");
}
};
const createNewAppPassword = (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("");
};
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>
<p className="text-xs text-slate-400">Manage 2FA TOTP (RFC 6238), App Passwords, and active webmail sessions.</p>
</div>
{message && (
<div className="p-4 rounded-xl bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 text-sm">
{message}
</div>
)}
{/* 2FA TOTP SECTION */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-bold text-white">Two-Factor Authentication (TOTP RFC 6238)</h2>
<p className="text-xs text-slate-400">
Protect your webmail session with Google Authenticator, Aegis, Authy, or 1Password.
</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-bold ${mfaEnabled ? "badge-verified" : "badge-warning"}`}>
{mfaEnabled ? "2FA ENABLED" : "2FA DISABLED"}
</span>
</div>
{!mfaEnabled && !qrSecret && (
<button
onClick={startTotpEnrollment}
className="py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors"
>
Start 2FA Setup &rarr;
</button>
)}
{qrSecret && (
<div className="p-4 rounded-xl bg-slate-900 border border-slate-800 space-y-4">
<div className="text-xs text-slate-300">
<strong>Step 1:</strong> Scan this URI in your authenticator app or copy the secret key.
</div>
<div className="p-3 bg-slate-950 rounded-lg text-xs font-mono text-indigo-400 break-all border border-slate-800">
Secret: {qrSecret}
</div>
<div className="text-[11px] text-slate-500 font-mono">URI: {qrUri}</div>
<form onSubmit={confirmTotpEnrollment} className="pt-2 flex items-center gap-3">
<input
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder="Enter 6-digit code"
maxLength={6}
required
className="px-4 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center tracking-wider focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
className="py-2 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium text-xs transition-colors"
>
Confirm & Enable 2FA
</button>
</form>
</div>
)}
{recoveryCodes && (
<div className="mt-6 p-4 rounded-xl bg-slate-900 border border-emerald-500/30">
<h3 className="text-sm font-bold text-white mb-2">10 Recovery Codes (Argon2id Hashed)</h3>
<p className="text-xs text-slate-400 mb-3">
Store these recovery codes in a safe password manager. Each code can be used once.
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-center font-mono text-xs text-emerald-400 bg-slate-950 p-3 rounded-lg border border-slate-800">
{recoveryCodes.map((code, idx) => (
<div key={idx} className="p-1 border border-slate-800 rounded">{code}</div>
))}
</div>
</div>
)}
</div>
{/* APP PASSWORDS SECTION (ADR-010) */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
<h2 className="text-lg font-bold text-white mb-1">App Passwords (IMAP / SMTP Clients)</h2>
<p className="text-xs text-slate-400 mb-4">
Thunderbird, iOS Mail, and Android clients do not support 2FA. Generate dedicated high-entropy passwords for IMAP/SMTP.
</p>
<form onSubmit={createNewAppPassword} className="flex gap-3 mb-6">
<input
type="text"
value={newAppPassLabel}
onChange={(e) => setNewAppPassLabel(e.target.value)}
placeholder="App Label (e.g. Thunderbird Laptop)"
required
className="flex-1 px-4 py-2 text-xs rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors"
>
Generate Password
</button>
</form>
{generatedAppPass && (
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs">
<strong>App Password Generated:</strong>
<div className="mt-1 font-mono text-sm font-bold text-amber-200 bg-slate-950 p-2 rounded border border-amber-500/20">
{generatedAppPass}
</div>
<p className="mt-1 text-[11px] text-amber-400">Copy this password now. It will not be shown again.</p>
</div>
)}
<div className="space-y-2">
{appPasswords.length === 0 ? (
<div className="text-xs text-slate-500 italic">No app passwords generated yet.</div>
) : (
appPasswords.map((ap) => (
<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>
</div>
<span className="text-red-400 hover:underline cursor-pointer">Revoke</span>
</div>
))
)}
</div>
</div>
{/* ACTIVE SESSIONS SECTION */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
<h2 className="text-lg font-bold text-white mb-1">Active Webmail Sessions</h2>
<p className="text-xs text-slate-400 mb-4">Individually revocable sessions with surface isolation.</p>
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-between text-xs">
<div>
<div className="font-bold text-slate-200">Current Webmail Browser Session</div>
<div className="text-[10px] text-slate-400 font-mono">Cookie: Path=/user | Aud: lambdamail:user</div>
</div>
<span className="badge-verified px-2 py-0.5 rounded text-[10px]">ACTIVE</span>
</div>
</div>
</div>
</div>
);
}