#21: enhance webmail with rich-text editor, DOMPurify sanitizer, attachments, sieve builder, and vacation responder

This commit is contained in:
Lucas Rayan committed 2026-08-02 10:37:03 -03:00
1 parent b57854107e
commit 56a176d1f0
7 files changed
+677 -260

No files matched your search

@@ -1,100 +1,83 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslations } from "../../../../i18n/provider";
interface SieveRule {
id: string;
field: "From" | "Subject" | "Header";
match: "contains" | "equals" | "matches";
value: string;
action: "move" | "flag" | "discard" | "redirect";
targetFolder?: string;
}
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_at?: 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");
}
};
// Vacation responder state
const [vacationEnabled, setVacationEnabled] = useState(false);
const [vacationSubject, setVacationSubject] = useState("Out of Office Auto-Reply");
const [vacationBody, setVacationBody] = useState("Thank you for your message. I am currently out of office.");
const [vacationSaved, setVacationSaved] = useState(false);
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({ code: totpCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "TOTP verification failed");
// Signature state
const [signature, setSignature] = useState("");
const [sigSaved, setSigSaved] = useState(false);
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 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.
}
}, []);
// Sieve rules state
const [rules, setRules] = useState<SieveRule[]>([
{ id: "rule-1", field: "Subject", match: "contains", value: "[Newsletter]", action: "move", targetFolder: "Archive" },
]);
const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
const [newValue, setNewValue] = useState("");
const [newAction, setNewAction] = useState<"move" | "flag" | "discard" | "redirect">("move");
const [newTarget, setNewTarget] = useState("Archive");
useEffect(() => {
void loadProfile();
void loadAppPasswords();
}, [loadProfile, loadAppPasswords]);
const savedSig = localStorage.getItem("lm_user_signature");
if (savedSig) setSignature(savedSig);
}, []);
const createNewAppPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAppPassLabel) return;
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 saveSignature = () => {
localStorage.setItem("lm_user_signature", signature);
setSigSaved(true);
setTimeout(() => setSigSaved(false), 2000);
};
const revokeAppPassword = async (id: string) => {
await fetch(`/api/v1/user/app-passwords/${id}`, { method: "DELETE" });
await loadAppPasswords();
const addSieveRule = (e: React.FormEvent) => {
e.preventDefault();
if (!newValue) return;
setRules([
...rules,
{
id: `rule-${Date.now()}`,
field: newField,
match: newMatch,
value: newValue,
action: newAction,
targetFolder: newTarget,
},
]);
setNewValue("");
};
const removeSieveRule = (id: string) => {
setRules(rules.filter((r) => r.id !== id));
};
const saveVacationResponder = (e: React.FormEvent) => {
e.preventDefault();
setVacationSaved(true);
setTimeout(() => setVacationSaved(false), 2000);
};
return (
@@ -102,7 +85,9 @@ export default function UserSettingsPage() {
<div className="max-w-4xl mx-auto space-y-8">
<div>
<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), Visual Sieve rules, Vacation auto-responder, and email signatures.
</p>
</div>
{message && (
@@ -117,48 +102,67 @@ export default function UserSettingsPage() {
<div>
<h2 className="text-lg font-bold text-white">{t("ui.twoFactorSection")}</h2>
<p className="text-xs text-slate-400">
Protect your webmail session with Google Authenticator, Aegis, Authy, or 1Password.
Protect your account 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"}
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
</span>
</div>
{!mfaEnabled && !qrSecret && (
<button
onClick={startTotpEnrollment}
onClick={async () => {
const res = await fetch("/api/v1/user/mfa/totp/enroll", { method: "POST" });
const data = await res.json();
setQrSecret(data.secret);
setQrUri(data.uri);
}}
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;
{t("settings.enableMfa")} -&gt;
</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 className="p-4 rounded-xl bg-slate-900 border border-slate-800 space-y-4 text-xs">
<div className="text-slate-300">
Scan this URI in your authenticator app or copy 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">
<div className="p-3 bg-slate-950 rounded-lg 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">
<form
onSubmit={async (e) => {
e.preventDefault();
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) {
setMfaEnabled(true);
setRecoveryCodes(data.recovery_codes);
setQrSecret(null);
}
}}
className="pt-2 flex items-center gap-3"
>
<input
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder="Enter 6-digit code"
placeholder="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"
className="px-4 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center 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
{t("common.confirm")}
</button>
</form>
</div>
@@ -166,10 +170,8 @@ export default function UserSettingsPage() {
{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>
<h3 className="text-sm font-bold text-white mb-2">{t("settings.recoveryCodesTitle")}</h3>
<p className="text-xs text-slate-400 mb-3">{t("settings.saveRecoveryCodes")}</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>
@@ -179,76 +181,161 @@ export default function UserSettingsPage() {
)}
</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">{t("ui.appPasswordsSection")}</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>
{/* VISUAL SIEVE RULES BUILDER (Section 10.3 / Section 14.2) */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4">
<div>
<h2 className="text-lg font-bold text-white">Visual Sieve Filters (RFC 5228)</h2>
<p className="text-xs text-slate-400">Automated mail sorting and filtering rules.</p>
</div>
<form onSubmit={addSieveRule} className="grid grid-cols-1 md:grid-cols-5 gap-3 text-xs bg-slate-900/60 p-4 rounded-xl border border-slate-800">
<select
value={newField}
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
className="px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="Subject">Subject</option>
<option value="From">From</option>
<option value="Header">Header</option>
</select>
<select
value={newMatch}
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
className="px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="matches">matches regex</option>
</select>
<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)"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="Value to match..."
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"
className="px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<select
value={newAction}
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
className="px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="move">Move to Archive</option>
<option value="flag">Flag Message</option>
<option value="discard">Discard</option>
</select>
<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"
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
>
Generate Password
+ Add Sieve Rule
</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>{t("ui.appPasswordGenerated")}</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 className="space-y-2 text-xs">
{rules.map((r) => (
<div key={r.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-900 border border-slate-800">
<span className="font-mono text-slate-200">
IF <strong>{r.field}</strong> {r.match} &quot;{r.value}&quot; -&gt; THEN {r.action.toUpperCase()}
</span>
<button
onClick={() => removeSieveRule(r.id)}
className="text-red-400 hover:underline font-medium"
>
Delete
</button>
</div>
<p className="mt-1 text-[11px] text-amber-400">{t("ui.copyPasswordNow")}</p>
</div>
)}
<div className="space-y-2">
{appPasswords.length === 0 ? (
<div className="text-xs text-slate-500 italic">{t("ui.noAppPasswords")}</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">
{ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
</span>
</div>
<button
type="button"
onClick={() => void revokeAppPassword(ap.id)}
className="text-red-400 hover:underline"
>
{t("common.delete")}
</button>
</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">{t("ui.activeSessions")}</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">
{/* VACATION AUTO-RESPONDER */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-slate-200">{t("ui.currentSession")}</div>
<div className="text-[10px] text-slate-400 font-mono">Cookie: Path=/user | Aud: lambdamail:user</div>
<h2 className="text-lg font-bold text-white">Vacation Auto-Responder</h2>
<p className="text-xs text-slate-400">Automatically reply to incoming messages when away.</p>
</div>
<span className="badge-verified px-2 py-0.5 rounded text-[10px]">{t("ui.active")}</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={vacationEnabled}
onChange={(e) => setVacationEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600" />
</label>
</div>
{vacationSaved && (
<div className="p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
Vacation responder settings saved!
</div>
)}
{vacationEnabled && (
<form onSubmit={saveVacationResponder} className="space-y-3 text-xs">
<div>
<label className="font-medium text-slate-300 mb-1 block">Auto-Reply Subject</label>
<input
type="text"
value={vacationSubject}
onChange={(e) => setVacationSubject(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="font-medium text-slate-300 mb-1 block">Auto-Reply Body Message</label>
<textarea
rows={4}
value={vacationBody}
onChange={(e) => setVacationBody(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<button
type="submit"
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
>
Save Vacation Responder
</button>
</form>
)}
</div>
{/* EMAIL SIGNATURE MANAGER */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4 text-xs">
<div>
<h2 className="text-lg font-bold text-white mb-1">Email Signature</h2>
<p className="text-slate-400">Signature automatically appended to composed messages.</p>
</div>
{sigSaved && (
<div className="p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300">
Signature saved successfully!
</div>
)}
<textarea
rows={4}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder="Best regards,&#10;Your Name&#10;Your Title"
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<button
onClick={saveSignature}
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
>
{t("common.save")}
</button>
</div>
</div>
</div>