"use client"; import React, { useEffect, useRef, useState } from "react"; import { Shield, Filter, Palmtree, FileText, KeyRound, Monitor, CheckCircle2, Trash2, ChevronUp, ChevronDown, Plus, Lock, Languages, UserRound, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Badge } from "../../../../components/ui/Badge"; import { Button } from "../../../../components/ui/Button"; import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment"; import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher"; import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature"; import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules"; import { useFolders } from "../../../../lib/useFolders"; import { AvatarEditor } from "../../../../components/AvatarEditor"; import { bumpAvatar } from "../../../../lib/avatarVersion"; import { useAccount } from "../../../../lib/useAccount"; import { useConversationView } from "../../../../lib/conversations"; import { moveTargets } from "../../../../lib/mailCounts"; interface SieveRule { id: string; field: "From" | "Subject" | "Header"; match: "contains" | "equals" | "matches"; value: string; action: "move" | "flag" | "discard" | "redirect"; targetFolder?: string; } interface AppPassword { id: string; label: string; created_at?: string; last_used_at?: string | null; } interface WebSession { id: string; surface: string; ip_address: string | null; user_agent: string | null; created_at: string; current: boolean; } type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language"; export default function UserSettingsPage() { const t = useTranslations(); const [activeTab, setActiveTab] = useState("profile"); // Bumped after a save so the browser refetches instead of showing the photo // it already holds under the same URL. const [avatarVersion, setAvatarVersion] = useState(0); const account = useAccount("user"); // Null until the account loads, so the editor is not told "no photo" before // it is known whether there is one. const avatarSrc = account?.email ? `/api/v1/avatar?address=${encodeURIComponent(account.email)}&v=${avatarVersion}` : null; const [mfaEnabled, setMfaEnabled] = useState(false); const [qrSecret, setQrSecret] = useState(null); const [qrUri, setQrUri] = useState(null); const [totpCode, setTotpCode] = useState(""); const [recoveryCodes, setRecoveryCodes] = useState(null); const [message, setMessage] = useState(null); const [appPasswords, setAppPasswords] = useState([]); const [newAppPassLabel, setNewAppPassLabel] = useState(""); const [generatedAppPass, setGeneratedAppPass] = useState(null); const [sessions, setSessions] = useState([]); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [vacationEnabled, setVacationEnabled] = useState(false); const [vacationSubject, setVacationSubject] = useState(() => t("settings.vacationDefaultSubject")); const [vacationBody, setVacationBody] = useState(() => t("settings.vacationDefaultBody")); const [vacationSaved, setVacationSaved] = useState(false); const [signature, setSignature] = useState(""); const conversationView = useConversationView(); const signatureRef = useRef(null); // execCommand edits the DOM directly, so the mirrored value is re-read here // rather than relying on an input event that may not be dispatched. const applySignatureCommand = (command: string, value?: string) => { signatureRef.current?.focus(); document.execCommand(command, false, value); setSignature(signatureRef.current?.innerHTML ?? ""); }; const [sigSaved, setSigSaved] = useState(false); // Starts empty rather than with a sample rule. A seeded "[Boletim]" filter // used to sit here, and because saving posts the whole list, the first time // anyone added or removed a rule that invented filter was written into their // real Sieve script and began moving their mail. const { folders } = useFolders(); const [rules, setRules] = useState([]); // A script this screen cannot express - hand-written, or written by a // desktop client over ManageSieve. Shown as it is rather than rewritten into // the nearest thing this form can say, which would quietly change what the // user's mail does. const [foreignScript, setForeignScript] = useState(null); const [newField, setNewField] = useState("subject"); const [newCondition, setNewCondition] = useState("contains"); const [newAction, setNewAction] = useState("move"); const [newValue, setNewValue] = useState(""); const [newTarget, setNewTarget] = useState("Archive"); useEffect(() => { fetch("/api/v1/user/preferences") .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data?.signature) { const html = signatureToHtml(data.signature); setSignature(html); if (signatureRef.current) signatureRef.current.innerHTML = html; } }) .catch(() => undefined); fetch("/api/v1/user/sieve") .then((res) => (res.ok ? res.json() : null)) .then((data) => { const script = typeof data?.script === "string" ? data.script : ""; const parsed = parseSieve(script); if (parsed === null) { // Not expressible in this form; shown verbatim instead of being // rewritten into something simpler that means something else. setForeignScript(script); setRules([]); } else { setForeignScript(null); setRules(parsed); } }) .catch(() => undefined); }, []); const saveSignature = async () => { try { await fetch("/api/v1/user/preferences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ signature: sanitizeSignature(signature), auto_save_drafts: true }), }); localStorage.setItem("lm_user_signature", sanitizeSignature(signature)); setSigSaved(true); setTimeout(() => setSigSaved(false), 2000); } catch { setMessage(t("settings.signatureSaveFailed")); } }; const saveVacationSettings = async (enabled: boolean, subject: string, body: string) => { setVacationEnabled(enabled); try { await fetch("/api/v1/user/vacation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled, subject, message: body }), }); setVacationSaved(true); setTimeout(() => setVacationSaved(false), 2000); } catch { setMessage(t("settings.autoReplySaveFailed")); } }; /** Writes the whole rule list to the server as one script. */ const persistRules = async (updated: Rule[]) => { setRules(updated); await fetch("/api/v1/user/sieve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "user-rules", script: buildSieve(updated), is_active: true }), }).catch(() => undefined); }; const addSieveRule = async (e: React.FormEvent) => { e.preventDefault(); const value = newValue.trim(); if (!value) return; await persistRules([ ...rules, { id: Date.now().toString(), field: newField, condition: newCondition, value, action: newAction, target: newTarget, }, ]); setNewValue(""); }; const removeSieveRule = async (id: string) => { await persistRules(rules.filter((r) => r.id !== id)); }; /** Moves a rule, since the first one that files a message decides. */ const moveRule = async (index: number, delta: number) => { const target = index + delta; if (target < 0 || target >= rules.length) return; const reordered = [...rules]; const [moved] = reordered.splice(index, 1); reordered.splice(target, 0, moved); await persistRules(reordered); }; const saveVacationResponder = (e: React.FormEvent) => { e.preventDefault(); void saveVacationSettings(vacationEnabled, vacationSubject, vacationBody); }; const loadAccount = async () => { try { const [me, passwords, activeSessions] = await Promise.all([ fetch("/api/v1/user/me").then((r) => (r.ok ? r.json() : null)), fetch("/api/v1/user/app-passwords").then((r) => (r.ok ? r.json() : [])), fetch("/api/v1/user/sessions").then((r) => (r.ok ? r.json() : [])), ]); if (me) setMfaEnabled(Boolean(me.mfa_enrolled)); setAppPasswords(passwords); setSessions(activeSessions); } catch { // ignore } }; useEffect(() => { void loadAccount(); }, []); const createAppPassword = async (e: React.FormEvent) => { e.preventDefault(); if (!newAppPassLabel.trim()) return; const res = await fetch("/api/v1/user/app-passwords", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label: newAppPassLabel.trim() }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setMessage(data.message ?? t("errors.serverError")); return; } setGeneratedAppPass(data.password); setNewAppPassLabel(""); await loadAccount(); }; const revokeAppPassword = async (id: string) => { await fetch(`/api/v1/user/app-passwords/${id}`, { method: "DELETE" }); await loadAccount(); }; const revokeSession = async (id: string) => { await fetch(`/api/v1/user/sessions/${id}`, { method: "DELETE" }); await loadAccount(); }; const revokeOtherSessions = async () => { await fetch("/api/v1/user/sessions/others", { method: "DELETE" }); await loadAccount(); }; const changePassword = async (e: React.FormEvent) => { e.preventDefault(); const res = await fetch("/api/v1/user/password", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), }); const data = await res.json().catch(() => ({})); setMessage(res.ok ? t("ui.passwordChanged") : (data.message ?? t("errors.serverError"))); if (res.ok) { setCurrentPassword(""); setNewPassword(""); await loadAccount(); } }; /* The screen was one long scroll of eight panels, so the two things people come here to do - turn on a second factor, and write a filter - were separated by everything else. The same panels now sit behind a tab strip, in the order the redesign groups them. */ const tabs = [ { id: "profile" as const, label: t("avatar.title"), icon: UserRound }, { id: "security" as const, label: t("settings.security"), icon: Shield }, { id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter }, { id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree }, { id: "signature" as const, label: t("settings.signatureTitle"), icon: FileText }, { id: "language" as const, label: t("settings.language"), icon: Languages }, ]; const input = "w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"; const fieldLabel = "mb-1.5 block text-xs text-slate-400"; const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-5 shadow-edge"; const panelTitle = "text-[17px] font-medium leading-tight text-slate-100"; const panelIntro = "mt-1 text-[13px] leading-relaxed text-slate-400"; const tile = "flex flex-wrap items-center gap-3 rounded-xl bg-dark-card px-3.5 py-3 text-[13px] shadow-edge"; return (

{t("settings.title")}

{t("settings.subtitle")}

{/* Tabs wrap rather than scroll, so a longer translation never pushes one out of reach. */}
{tabs.map((tab) => { const Icon = tab.icon; return ( ); })}
{message && (
{message}
)} {activeTab === "profile" && (

{t("avatar.title")}

{t("avatar.explainer")}

{ // The query string changes so the browser fetches the new // photo instead of the one it already has under this URL; // bumpAvatar does the same for every on screen. setAvatarVersion((v) => v + 1); bumpAvatar(account?.email); }} />
)} {activeTab === "security" && (
{/* 2FA TOTP SECTION */}

{t("ui.twoFactorSection")}

{t("settings.twoFactorIntro")}

{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
{!mfaEnabled && !qrSecret && ( )} {qrSecret && (
{/* Enrolment resumes rather than restarting, so this code is the same one already scanned. Anyone who removed the entry from their app needs a way to ask for a different secret. */}
{ 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().catch(() => ({})); if (res.ok) { setMfaEnabled(true); setRecoveryCodes(data.recovery_codes ?? []); setQrSecret(null); setTotpCode(""); } else { setMessage(data.message ?? t("auth.verificationFailed")); } }} className="flex flex-wrap items-center gap-3" > setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} placeholder={t("settings.sixDigitCode")} maxLength={7} required className="min-h-[36px] w-[150px] rounded-[10px] bg-dark-rail px-3 py-2 text-center font-mono text-[13.5px] tracking-[0.2em] text-slate-100 shadow-edge focus:outline-none focus-visible:shadow-edge-accent" />
)} {recoveryCodes && ( setRecoveryCodes(null)} continueLabel={t("common.continue")} /> )}
{/* APP PASSWORDS */}

{t("ui.appPasswordsSection")}

{generatedAppPass && (
{t("ui.appPasswordGenerated")}
{generatedAppPass}
{t("ui.copyPasswordNow")}
)}
setNewAppPassLabel(e.target.value)} placeholder={t("settings.appPasswordPlaceholder")} className={`${input} min-w-[220px] flex-1`} />
{appPasswords.length === 0 ? (
{t("ui.noAppPasswords")}
) : ( appPasswords.map((ap) => (
{ap.label}
{ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
)) )}
{/* ACTIVE SESSIONS */}

{t("ui.activeSessions")}

{sessions.map((session) => (
{session.ip_address ?? "-"} {session.surface}
{session.user_agent ?? ""}
{session.current ? ( {t("ui.currentSession")} ) : ( )}
))}
{/* PASSWORD CHANGE */}

{t("ui.changePassword")}

setCurrentPassword(e.target.value)} required className={input} />
setNewPassword(e.target.value)} minLength={12} required className={input} />
)} {/* VISUAL SIEVE RULES BUILDER */} {activeTab === "filters" && (

{t("settings.sieveTitle")}

{t("settings.sieveIntro")}

{/* The rule reads as a sentence: IF THEN . Each control keeps its own label, so the row still makes sense when it wraps on a narrow window. */} {/* The rule reads as a sentence: IF THEN . The vocabulary is deliberately the user's, not Sieve's: the old form offered ":matches (regex)" and pasted whatever was typed into a script, which asked someone who wanted "mail from the bank goes in Faturas" to write a pattern language. Wildcards are now generated from "starts with" and "ends with"; nobody types one. */}
{t("settings.ruleIf")}
setNewValue(e.target.value)} placeholder={t("settings.ruleValuePlaceholder")} required className={input} />
{t("settings.ruleThen")}
{/* Real folders, listed from the mailbox: the old form typed a folder name into the script by hand, so a rule could name a folder that did not exist and simply never fired. */} {newAction === "move" && (
)}
{/* A script this form cannot express is shown as it is. Rewriting somebody's rules into the nearest thing this vocabulary can say would quietly change what their mail does. */} {foreignScript !== null && (

{t("settings.foreignScriptTitle")}

{t("settings.foreignScriptIntro")}

                  {foreignScript}
                
)} {rules.length > 0 && (

{t("settings.ruleOrderNote")}

{rules.map((r, index) => (
{index + 1} {/* Read back as the sentence it was written as, not as the Sieve it became. */} {t("settings.ruleIf")}{" "} {t(`settings.field${r.field.charAt(0).toUpperCase()}${r.field.slice(1)}`)}{" "} {t(`settings.cond${r.condition.charAt(0).toUpperCase()}${r.condition.slice(1)}`).toLowerCase()}{" "} "{r.value}" {" \u2192 "} {r.action === "move" ? `${t("mail.moveTo")} ${r.target}` : t(`settings.action${r.action === "delete" ? "Discard" : r.action.charAt(0).toUpperCase() + r.action.slice(1)}`)} {/* Order matters: the first rule that files a message decides where it lands, so the list has to be rearrangeable. */}
))}
)}
)} {/* VACATION AUTO-RESPONDER */} {activeTab === "vacation" && (

{t("settings.vacationTitle")}

{t("settings.vacationIntro")}

{/* A segmented pair rather than an unlabelled switch: a toggle with nothing written on it says neither which state it is in nor what turning it on would do. */}
{[ { on: true, label: t("ui.active") }, { on: false, label: t("settings.mfaDisabled") }, ].map((option) => ( ))}
{vacationSaved && (
{t("settings.settingsSaved")}
)} {vacationEnabled && (
setVacationSubject(e.target.value)} className={input} />