#35: replace both web surfaces with the Nocturne redesign

The webmail was indigo over glass and the console was green over glass, so the
two halves of one product did not look related, and neither followed a system
anything could be checked against. This replaces the interface with the
Nocturne design system from Claude Design: a near-neutral blue-grey ground,
Inter at weight 500, solid surfaces with a 1px inset edge instead of blur, and
a single blurple accent used as a line, a mark and a glow rather than a flood.

The palette is remapped at the source rather than renamed at every call site.
tailwind.config.js points slate, indigo, emerald, teal and the rest at the
Nocturne ramps, and globals.css carries the same values as variables, so a
utility class and a token cannot drift apart and no colour can enter the
interface from outside the system. The console's green is gone because it
resolves to the one accent; that was the point.

Structurally, both surfaces now share one rail - the same brand mark, the same
3px accent mark on the active item, the same account tile - with a segmented
control for crossing between them. Crossing into the console still goes to the
step-up rather than the admin sign-in, and leaving it is still a plain link.
Settings moves from eight stacked panels to a tab strip, so enabling a second
factor and writing a filter are no longer separated by everything else.

Nothing is sized by the length of its text any more. Labels wrap, tab strips
wrap instead of scrolling, subjects and queue diagnostics wrap instead of
truncating, and the message list's snippet is clamped to two lines rather than
one. That is what broke when the interface was read in Portuguese or Spanish.

Defects found while doing it:

  - The queue and DMARC screens styled their status pills with "badge-danger",
    "badge-warning" and "badge-verified" - three class names no stylesheet in
    this project defines - so every verdict rendered as identical unstyled
    text. They are Badge variants now, which exist.
  - The DMARC screen printed the literal string "quarantine" as the
    enforcement level whatever the domains publish. The report endpoint returns
    no policy at all, so it was asserting a configuration nobody had made. It
    shows the DMARC pass ratio it genuinely has.
  - Half the mailboxes screen and two lines of the audit trace were hardcoded
    strings, some Portuguese and some English, so they stayed in one language
    whatever the interface was set to. They come from the bundle now, with the
    new keys added to all three locales.
  - Avatars printed email[0], so every account at one domain collapsed onto the
    same letter. initialsFor takes two letters from the local part's own word
    boundaries, behind tests.

Trivy reported one HIGH: nanoid 3.3.16, pulled in transitively by postcss,
vulnerable to a denial of service via an infinite loop in ID generation
(CVE-2026-67213). Pinned to ^3.3.18 through the overrides block the package
already uses for sharp and postcss.

Verified: 98 webmail tests green, tsc clean, production build clean, and the
compiled stylesheet serves the Nocturne values with no stock Tailwind indigo,
emerald or slate hex left in it. Trivy fs with the CI scanners, severities and
skip-dirs exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-18 20:25:24 -03:00
1 parent 8b69521c76
commit b33fc12f08
29 files changed
+2596 -1893

No files matched your search

@@ -18,7 +18,6 @@ import {
CheckCircle2,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Button } from "../../../../components/ui/Button";
/** Splits a comma or semicolon separated field, dropping empty entries. */
@@ -178,32 +177,50 @@ export default function ComposePage({
setSending(false);
};
/** One row of the compose sheet: a fixed-width label beside the control. */
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
const bareInput =
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
const toolButton =
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
return (
<div className="flex-1 p-6 md:p-8 bg-dark-bg flex flex-col items-center overflow-y-auto">
<Card className="max-w-3xl w-full border border-slate-800 shadow-2xl space-y-5">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h1 className="text-xl font-bold text-white flex items-center gap-2 tracking-tight">
<Send className="w-5 h-5 text-indigo-400" />
<span>{t("mail.compose")}</span>
</h1>
{draftStatus && (
<span className="text-xs text-emerald-400 font-mono flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5" />
{draftStatus}
</span>
)}
<div className="flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8">
<form onSubmit={handleSend} className="mx-auto flex w-full max-w-[860px] flex-col gap-[18px]">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("mail.compose")}</h1>
{draftStatus && (
<div className="mt-1 flex items-center gap-1.5 text-[12.5px] text-slate-400">
<CheckCircle2 className="h-3.5 w-3.5 flex-none text-indigo-500" />
{draftStatus}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Link href="/user/mail/inbox">
<Button variant="secondary" size="md">
{t("mail.discard")}
</Button>
</Link>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="h-4 w-4" />
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
</Button>
</div>
</div>
{sendError && (
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-xs leading-relaxed text-rose-200 shadow-edge">
{sendError}
</div>
)}
{undoSeconds !== null && (
<div className="p-4 rounded-xl bg-indigo-600/20 border border-indigo-500/40 flex items-center justify-between text-indigo-300 text-xs">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-dark-card px-4 py-3 text-xs text-slate-200 shadow-edge">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />
<Sparkles className="h-4 w-4 flex-none animate-spin text-indigo-500" />
{/* The countdown is a placeholder inside the message, not text
bolted on after it. It used to be both: the bundle's own
"{seconds}" was never substituted and a second, hardcoded
@@ -212,217 +229,191 @@ export default function ComposePage({
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
</div>
<Button variant="primary" size="sm" onClick={handleUndo}>
<Undo2 className="w-3.5 h-3.5" />
<Undo2 className="h-3.5 w-3.5" />
{t("mail.undoSendAction")}
</Button>
</div>
)}
<form onSubmit={handleSend} className="space-y-4 text-xs">
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="font-semibold text-slate-300">{t("ui.toRecipients")}</label>
<button
type="button"
onClick={() => setShowCcBcc(!showCcBcc)}
className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline"
>
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
</button>
</div>
{/* One sheet, the fields stacked as rows inside it, rather than a stack
of separately outlined inputs: the message is one object. */}
<div className="overflow-hidden rounded-2xl bg-dark-panel shadow-edge">
<div className={fieldRow}>
<label htmlFor="compose-to" className={fieldLabel}>
{t("ui.toRecipients")}
</label>
<input
id="compose-to"
type="text"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="recipient@example.com"
required
disabled={sending}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
className={bareInput}
/>
<button
type="button"
onClick={() => setShowCcBcc(!showCcBcc)}
className="flex-none rounded-lg px-2 py-1 text-[12.5px] text-indigo-500 transition-colors hover:bg-indigo-500/10"
>
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
</button>
</div>
{showCcBcc && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="font-semibold text-slate-300 mb-1 block">Cc</label>
<>
<div className={fieldRow}>
<label htmlFor="compose-cc" className={fieldLabel}>
Cc
</label>
<input
id="compose-cc"
type="text"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc@domain.com"
disabled={sending}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
className={bareInput}
/>
</div>
<div>
<label className="font-semibold text-slate-300 mb-1 block">Bcc</label>
<div className={fieldRow}>
<label htmlFor="compose-bcc" className={fieldLabel}>
Bcc
</label>
<input
id="compose-bcc"
type="text"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="bcc@domain.com"
disabled={sending}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
className={bareInput}
/>
</div>
</div>
</>
)}
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("ui.subject")}</label>
<div className={fieldRow}>
<label htmlFor="compose-subject" className={fieldLabel}>
{t("ui.subject")}
</label>
<input
id="compose-subject"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("ui.subject")}
required
disabled={sending}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
className={bareInput}
/>
</div>
{/* Formatting Toolbar */}
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("ui.messageBody")}</label>
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-900/90">
<div className="flex items-center gap-1 p-2 bg-slate-950/80 border-b border-slate-800/80 flex-wrap text-slate-300">
<button
type="button"
onClick={() => executeFormat("bold")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title={t("mail.bold")}
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("italic")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title={t("mail.italic")}
>
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("underline")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title={t("mail.underline")}
>
<Underline className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("strikeThrough")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title={t("mail.strikethrough")}
>
<Strikethrough className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title={t("mail.bulletList")}
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("insertOrderedList")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title={t("mail.numberedList")}
>
<ListOrdered className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onClick={() => {
const url = prompt(t("mail.insertUrl"));
if (url) executeFormat("createLink", url);
}}
className="p-1.5 hover:bg-slate-800 rounded-lg text-indigo-400 hover:text-indigo-300"
title={t("mail.insertLink")}
>
<LinkIcon className="w-4 h-4" />
</button>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="p-4 min-h-[220px] focus:outline-none text-white font-sans text-xs leading-relaxed"
/>
</div>
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
<Bold className="h-4 w-4" />
</button>
<button type="button" onClick={() => executeFormat("italic")} className={toolButton} title={t("mail.italic")}>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("underline")}
className={toolButton}
title={t("mail.underline")}
>
<Underline className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("strikeThrough")}
className={toolButton}
title={t("mail.strikethrough")}
>
<Strikethrough className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<button
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className={toolButton}
title={t("mail.bulletList")}
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("insertOrderedList")}
className={toolButton}
title={t("mail.numberedList")}
>
<ListOrdered className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<button
type="button"
onClick={() => {
const url = prompt(t("mail.insertUrl"));
if (url) executeFormat("createLink", url);
}}
className={`${toolButton} text-indigo-500`}
title={t("mail.insertLink")}
>
<LinkIcon className="h-4 w-4" />
</button>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
aria-label={t("ui.messageBody")}
role="textbox"
aria-multiline="true"
className="min-h-[280px] px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none"
/>
{/* Attachment Selector & Display */}
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-slate-300">{t("mail.attachments", { count: attachments.length })}</span>
<label className="cursor-pointer text-indigo-400 hover:text-indigo-300 flex items-center gap-1.5 font-medium">
<Paperclip className="w-3.5 h-3.5" />
<span>{t("mail.addFiles")}</span>
<input
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
<Paperclip className="h-[15px] w-[15px]" />
<span>{t("mail.addFiles")}</span>
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
</label>
{attachments.map((file) => (
<div
key={file.id}
className="flex items-center gap-2 rounded-[10px] bg-dark-card px-3 py-2 text-[12.5px] text-slate-200 shadow-edge"
>
<Paperclip className="h-[15px] w-[15px] flex-none text-indigo-400" />
<span className="max-w-[160px] truncate">{file.name}</span>
<span className="text-[11px] text-slate-500">{(file.size / 1024).toFixed(1)} KB</span>
<button
type="button"
onClick={() => removeAttachment(file.id)}
className="text-slate-400 transition-colors hover:text-rose-400"
aria-label={t("common.delete")}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
{/* Says so plainly rather than letting someone attach a contract
and watch it silently not arrive: /mail/send is handed the file
names only, because there is no upload endpoint behind this
picker yet. */}
{attachments.length > 0 && (
<div className="mb-2 rounded-xl border border-amber-500/30 bg-amber-500/10 p-2.5 text-[11px] text-amber-300">
<span className="ml-auto text-[11.5px] leading-relaxed text-slate-400">
{t("mail.attachmentsUnsupported")}
</div>
)}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachments.map((file) => (
<div
key={file.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200"
>
<Paperclip className="w-3 h-3 text-indigo-400" />
<span className="font-medium max-w-[140px] truncate">{file.name}</span>
<span className="text-[10px] text-slate-500">
({(file.size / 1024).toFixed(1)} KB)
</span>
<button
type="button"
onClick={() => removeAttachment(file.id)}
className="text-slate-400 hover:text-rose-400 ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</span>
)}
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Link href="/user/mail/inbox">
<Button variant="ghost" size="md">
{t("mail.discard")}
</Button>
</Link>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="w-4 h-4" />
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
</Button>
</div>
</form>
</Card>
</div>
</form>
</div>
);
}
+154 -137
View File
@@ -11,13 +11,12 @@ import {
Archive,
AlertTriangle,
Trash2,
Settings,
PenSquare,
LogOut,
Mail,
ShieldCheck,
ShieldAlert,
Sliders,
SlidersHorizontal,
Menu,
X,
} from "lucide-react";
@@ -26,6 +25,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount";
import { useFolders } from "../../../lib/useFolders";
import { badgeCount, folderMetrics } from "../../../lib/mailCounts";
import { initialsFor } from "../../../lib/initials";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
@@ -51,143 +51,160 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
});
};
const settingsActive = pathname === "/user/settings";
const SidebarContent = (
<div className="flex flex-col justify-between h-full p-4">
<div>
{/* Webmail Surface Branding */}
<div className="flex items-center gap-3 px-2 mb-6">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-indigo-600 to-cyan-500 border border-indigo-400/30 flex items-center justify-center text-white font-bold shadow-lg shadow-indigo-500/20">
<Mail className="w-5 h-5 text-white" />
</div>
<div>
<div className="font-bold text-base text-white tracking-tight">LambdaMail</div>
<div className="text-[10px] text-indigo-400 uppercase tracking-widest font-mono font-semibold">
{t("common.userPortal")}
</div>
<div className="flex h-full flex-col gap-4 p-3">
{/* The brand mark. One mark for both surfaces - the line underneath is
the only thing that says which one you are in. */}
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
λ
</div>
<div className="min-w-0">
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
{t("common.userPortal")}
</div>
</div>
{/* Compose Floating Button */}
<Link
href="/user/compose"
className="group relative flex items-center justify-center gap-2 w-full py-3 px-4 mb-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-sm transition-all duration-200 shadow-lg shadow-indigo-600/30 active:scale-[0.98]"
>
<PenSquare className="w-4 h-4 transition-transform group-hover:rotate-12" />
<span>{t("mail.compose")}</span>
</Link>
{/* Folder Navigation List */}
<nav className="space-y-1 text-sm font-medium">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
// Drafts counts what is waiting rather than what is unread; see
// badgeCount, which is where that rule is tested.
const badge = badgeCount(folders, item.role);
const { total } = folderMetrics(folders, item.role);
return (
<Link
key={item.href}
href={item.href}
title={total > 0 ? t("mail.messagesInFolder", { count: total }) : item.label}
className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all duration-150 ${
isActive
? "bg-indigo-600/20 text-indigo-300 border border-indigo-500/30 shadow-sm"
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<Icon className={`w-4 h-4 flex-shrink-0 ${isActive ? "text-indigo-400" : "text-slate-400"}`} />
<span className="truncate">{item.label}</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* The folder's size, shown quietly beside the badge: the
sidebar reported unread only, so there was nowhere in the
interface that said how much mail a folder holds. */}
{total > 0 && (
<span className="text-[10px] font-mono text-slate-500 tabular-nums">{total}</span>
)}
{badge > 0 && (
<span
className={`text-xs px-2 py-0.5 rounded-full font-semibold tabular-nums ${
isActive ? "bg-indigo-500/40 text-indigo-200" : "bg-indigo-500/20 text-indigo-300"
}`}
>
{badge}
</span>
)}
</div>
</Link>
);
})}
</nav>
</div>
{/* Footer Controls & User Settings */}
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 p-2 rounded-xl bg-slate-900/60 border border-slate-800/80">
<LanguageSwitcher />
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-rose-400 hover:border-rose-500/40 hover:bg-rose-500/10 transition-all"
>
<LogOut className="w-3.5 h-3.5" />
<span>{t("settings.signOut")}</span>
</button>
</div>
<Link
href="/user/compose"
onClick={() => setMobileOpen(false)}
className="flex w-full items-center justify-center gap-2 rounded-[10px] border border-indigo-500 px-3 py-2.5 text-sm font-medium text-indigo-500 transition-colors hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]"
>
<PenSquare className="h-4 w-4 flex-none" />
<span className="min-w-0">{t("mail.compose")}</span>
</Link>
{/* The console is reachable from inside the app for the accounts that
may open it. It goes to the step-up rather than to the admin sign-in:
the admin audience is a separate token, so crossing over costs a
second factor - but not the password, which the session in hand
proved already. */}
{/* Folder navigation. Every row is a mark, an icon, a label that may wrap,
and its numbers - nothing is sized by the length of the label, which
is what clipped the Portuguese and Spanish names before. */}
<nav className="flex flex-col gap-0.5">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
// Drafts counts what is waiting rather than what is unread; see
// badgeCount, which is where that rule is tested.
const badge = badgeCount(folders, item.role);
const { total } = folderMetrics(folders, item.role);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
data-active={isActive}
title={total > 0 ? t("mail.messagesInFolder", { count: total }) : item.label}
className="lm-nav"
>
<span className="lm-nav-mark" />
<Icon className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`} />
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{item.label}</span>
<span className="flex flex-none items-center gap-1.5">
{/* The folder's size, shown quietly beside the badge: the
sidebar reported unread only, so there was nowhere in the
interface that said how much mail a folder holds. */}
{total > 0 && badge > 0 && (
<span className="text-[10.5px] tabular-nums text-slate-500">{total}</span>
)}
{badge > 0 ? (
<span
className={`text-[11.5px] tabular-nums ${isActive ? "text-indigo-300" : "text-slate-400"}`}
>
{badge}
</span>
) : (
total > 0 && (
<span className="text-[11.5px] tabular-nums text-slate-500">{total}</span>
)
)}
</span>
</Link>
);
})}
<div className="lm-rule my-2.5" />
<Link
href="/user/settings"
onClick={() => setMobileOpen(false)}
data-active={settingsActive}
className="lm-nav"
>
<span className="lm-nav-mark" />
<SlidersHorizontal
className={`h-[17px] w-[17px] flex-none ${settingsActive ? "text-indigo-500" : "text-slate-400"}`}
/>
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{t("settings.title")}</span>
</Link>
</nav>
<div className="mt-auto flex flex-col gap-2.5">
{/* The two surfaces as one control, so where you are and where else you
could be are the same question. The console goes to the step-up
rather than to the admin sign-in: the admin audience is a separate
token, so crossing over costs a second factor - but not the
password, which the session in hand proved already. */}
{isAdminRole(account?.role) && (
<Link
href="/admin/step-up"
className="flex items-center gap-2.5 p-2.5 rounded-xl border border-emerald-500/25 bg-emerald-500/10 text-emerald-300 hover:border-emerald-500/50 hover:bg-emerald-500/15 transition-all"
>
<Sliders className="w-4 h-4 flex-shrink-0" />
<span className="text-xs font-semibold truncate">{t("admin.openAdmin")}</span>
</Link>
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
<span className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] bg-dark-card px-2 py-1.5 text-[12px] leading-snug text-indigo-500 shadow-edge-accent">
<Mail className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("common.userPortal")}</span>
</span>
<Link
href="/admin/step-up"
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
>
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("admin.openAdmin")}</span>
</Link>
</div>
)}
<Link
href="/user/settings"
className={`flex items-center justify-between p-2.5 rounded-xl border transition-all ${
pathname === "/user/settings"
? "bg-indigo-600/15 border-indigo-500/30 text-slate-100"
: "bg-slate-900/80 border-slate-800 hover:border-slate-700 text-slate-300"
}`}
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-indigo-500 to-cyan-500 text-white flex items-center justify-center font-bold text-xs flex-shrink-0 shadow-sm uppercase">
{account?.email?.[0] ?? "?"}
</div>
<div className="truncate">
<div className="text-xs font-semibold text-slate-200 truncate">
{account?.email ?? t("common.loading")}
</div>
{/* Reports what is actually configured. The old fixed "2FA
active" reassured accounts that had no second factor at all. */}
<div className="text-[10px] text-slate-400 flex items-center gap-1">
{account?.mfa_enrolled ? (
<>
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" />
{t("settings.mfaEnabled")}
</>
) : (
<>
<ShieldAlert className="w-3 h-3 text-amber-400 inline" />
{t("settings.mfaDisabled")}
</>
)}
</div>
</div>
</div>
<Settings className="w-4 h-4 text-slate-400 hover:text-slate-200 transition-colors" />
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
{initialsFor(account?.email)}
</span>
<span className="min-w-0 flex-1">
<span className="block break-words text-[12.5px] leading-tight">
{account?.email ?? t("common.loading")}
</span>
{/* Reports what is actually configured. The old fixed "2FA
active" reassured accounts that had no second factor at all. */}
<span className="mt-0.5 flex items-center gap-1 text-[10.5px] text-slate-400">
{account?.mfa_enrolled ? (
<>
<ShieldCheck className="h-3 w-3 flex-none text-indigo-500" />
{t("settings.mfaEnabled")}
</>
) : (
<>
<ShieldAlert className="h-3 w-3 flex-none text-amber-400" />
{t("settings.mfaDisabled")}
</>
)}
</span>
</span>
</Link>
<div className="flex items-center gap-1.5">
<LanguageSwitcher />
<button
type="button"
onClick={handleLogout}
title={t("settings.signOut")}
aria-label={t("settings.signOut")}
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
>
<LogOut className="h-[15px] w-[15px]" />
</button>
</div>
</div>
</div>
);
@@ -195,25 +212,25 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
return (
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
{/* Mobile Top Header */}
<div className="md:hidden flex items-center justify-between p-4 border-b border-slate-800 bg-slate-900/90 z-20 w-full fixed top-0 left-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white font-bold">
<Mail className="w-4 h-4" />
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
λ
</div>
<span className="font-bold text-slate-100">LambdaMail</span>
<span className="font-medium text-slate-100">LambdaMail</span>
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={t("ui.toggleMenu")}
aria-expanded={mobileOpen}
className="p-2 rounded-lg bg-slate-800 text-slate-300 hover:text-white"
className="flex h-9 w-9 items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{/* Desktop Sidebar */}
<aside className="hidden md:flex w-64 border-r border-slate-800/80 bg-slate-900/60 backdrop-blur-xl flex-col flex-shrink-0">
{/* Desktop rail */}
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
{SidebarContent}
</aside>
@@ -225,7 +242,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed inset-0 z-30 bg-slate-950 w-72 border-r border-slate-800 pt-16 md:hidden"
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
>
{SidebarContent}
</motion.aside>
@@ -233,7 +250,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</AnimatePresence>
{/* Main Content Area */}
<main className="flex-1 flex flex-col overflow-hidden pt-16 md:pt-0 bg-dark-bg relative">
<main className="relative flex flex-1 flex-col overflow-hidden bg-dark-bg pt-14 md:pt-0">
{children}
</main>
</div>
@@ -14,6 +14,7 @@ import {
Clock,
MailOpen,
Download,
RefreshCw,
X,
} from "lucide-react";
import { useI18n } from "../../../../../i18n/provider";
@@ -261,76 +262,97 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{ key: "attachment", label: t("mail.attachmentsFilter"), count: counts.withAttachments },
];
// The folder's own name, translated. The heading printed the URL segment
// capitalised, so an interface running in Portuguese still said "Inbox".
const FOLDER_LABELS: Record<string, string> = {
inbox: t("mail.inbox"),
sent: t("mail.sent"),
drafts: t("mail.drafts"),
archive: t("mail.archive"),
junk: t("mail.junk"),
trash: t("mail.trash"),
};
const folderTitle = FOLDER_LABELS[folder.toLowerCase()] ?? folder;
return (
<div className="flex-1 flex overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* Messages List Column */}
<div
className={`${selected ? "hidden md:flex" : "flex"} w-full md:w-[22rem] lg:w-96 border-r border-slate-800/80 flex-col bg-slate-900/40 backdrop-blur-md md:flex-shrink-0`}
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[404px] md:flex-none`}
>
{/* Folder heading, with the counts that belong to the folder rather
than to the page of it that happens to be loaded. */}
<div className="px-3.5 pt-3.5 flex items-baseline justify-between gap-2">
<h2 className="text-sm font-bold text-slate-100 capitalize truncate">{folder}</h2>
<span className="text-[11px] text-slate-400 font-mono tabular-nums flex-shrink-0">
{header.isFolderTotal
? t("mail.messagesInFolder", { count: header.count })
: t("mail.searchMatches", { count: header.count })}
{metrics.unread > 0 && (
<span className="text-indigo-300"> &middot; {t("mail.unreadCount", { count: metrics.unread })}</span>
)}
</span>
</div>
<div className="flex flex-col gap-3 px-4 pb-3 pt-4">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<h2 className="text-xl font-medium leading-tight text-slate-100">{folderTitle}</h2>
<div className="mt-1 text-xs tabular-nums text-slate-400">
{header.isFolderTotal
? t("mail.messagesInFolder", { count: header.count })
: t("mail.searchMatches", { count: header.count })}
{metrics.unread > 0 && (
<span className="text-indigo-300"> &middot; {t("mail.unreadCount", { count: metrics.unread })}</span>
)}
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void loadMessages(searchQuery)}
className="flex-none rounded-[9px]"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
<span>{t("common.refresh")}</span>
</Button>
</div>
{/* Search Bar & Filter Chips */}
<div className="p-3.5 border-b border-slate-800 space-y-2.5">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-3 text-slate-400" />
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("mail.searchPlaceholder")}
aria-label={t("common.search")}
className="w-full pl-9 pr-3 py-2 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
className="min-h-[38px] w-full rounded-xl bg-dark-panel py-2 pl-[34px] pr-3 text-[13px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"
/>
</div>
<div className="flex items-center gap-1.5 text-[11px] overflow-x-auto pb-0.5">
{/* Every chip carries its own number: two of the three used to filter
to a count nothing on screen disclosed. Chips wrap rather than
scroll, so a longer translation is never pushed out of reach. */}
<div className="flex flex-wrap gap-1.5">
{chips.map((chip) => (
<button
key={chip.label}
type="button"
aria-pressed={activeFilter === chip.key}
data-active={activeFilter === chip.key}
onClick={() => setActiveFilter(chip.key)}
className={`px-2.5 py-1 rounded-lg font-medium transition-all whitespace-nowrap flex items-center gap-1.5 ${
activeFilter === chip.key
? "bg-indigo-600 text-white shadow-sm"
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
}`}
className="lm-chip"
>
<span>{chip.label}</span>
{/* Every chip carries its own number: two of the three used to
filter to a count nothing on screen disclosed. */}
<span className="tabular-nums opacity-80">{chip.count}</span>
<span className="tabular-nums opacity-70">{chip.count}</span>
</button>
))}
</div>
</div>
{/* Message Items List */}
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/50">
<div className="flex flex-1 flex-col gap-[3px] overflow-y-auto px-2 pb-3">
{loading ? (
<div className="p-6 text-center text-xs text-slate-500 animate-pulse flex items-center justify-center gap-2">
<Clock className="w-4 h-4 text-indigo-400 animate-spin" />
<div className="flex items-center justify-center gap-2 p-6 text-center text-xs text-slate-400">
<Clock className="h-4 w-4 animate-spin text-indigo-500" />
<span>{t("common.loading")}</span>
</div>
) : error ? (
<div className="p-4 text-xs text-rose-400 bg-rose-500/10 m-3 rounded-xl border border-rose-500/20">
<div className="m-2 rounded-xl bg-rose-900/60 p-3.5 text-xs leading-relaxed text-rose-200 shadow-edge">
{error}
</div>
) : filteredMessages.length === 0 ? (
<div className="p-8 text-center text-xs text-slate-500 flex flex-col items-center gap-2">
<Inbox className="w-8 h-8 text-slate-600" />
<div className="flex flex-col items-center gap-2 p-8 text-center text-xs text-slate-400">
<Inbox className="h-8 w-8 text-slate-600" strokeWidth={1.25} />
<span>{searchQuery ? t("mail.noMatches") : t("mail.noMessages")}</span>
</div>
) : (
@@ -340,53 +362,59 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
<button
key={msg.uid}
onClick={() => void openMessage(msg.uid)}
className={`w-full text-left p-3.5 transition-all duration-150 flex gap-3 relative ${
isSelected ? "bg-indigo-600/15" : "hover:bg-slate-800/30"
}`}
data-active={isSelected}
className="lm-row w-full text-left"
>
{isSelected && <span className="absolute left-0 top-0 bottom-0 w-[3px] bg-indigo-500" />}
{/* An unread row is marked by a dot in a reserved column, so
the subject does not shift sideways when it is read. */}
<span className="mt-1.5 w-2 flex-shrink-0">
{!msg.seen && <span className="block w-2 h-2 rounded-full bg-indigo-400" />}
</span>
<span
className={`mt-0.5 w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-[10px] font-bold ${
className={`flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-xs ${
msg.seen
? "bg-slate-800 text-slate-400"
: "bg-indigo-500/25 text-indigo-200 border border-indigo-500/30"
? "bg-dark-card text-slate-400"
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
}`}
aria-hidden="true"
>
{initials(msg.from_display_name, msg.sender_address)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2 mb-0.5">
<span className="flex min-w-0 flex-1 flex-col gap-[3px]">
<span className="flex items-baseline gap-2">
<span
className={`text-xs truncate ${
msg.seen ? "text-slate-300 font-medium" : "text-white font-bold"
className={`min-w-0 truncate text-[13.5px] ${
msg.seen ? "text-slate-300" : "text-slate-100"
}`}
>
{msg.from_display_name || msg.sender_address}
</span>
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0 tabular-nums">
<span className="ml-auto flex-none text-[11px] tabular-nums text-slate-500">
{listTimestamp(msg.received_at, locale)}
</span>
</span>
{/* The subject wraps instead of being clipped - a truncated
subject is the one thing a mail list must not do. */}
<span
className={`text-xs truncate flex items-center gap-1.5 mb-0.5 ${
msg.seen ? "text-slate-300" : "font-semibold text-slate-100"
className={`text-[13.5px] leading-snug ${
msg.seen ? "text-slate-300" : "text-slate-100"
}`}
>
<span className="truncate">{msg.subject || t("mail.noSubject")}</span>
{msg.has_attachments && (
<Paperclip className="w-3 h-3 text-slate-400 flex-shrink-0" />
)}
{msg.subject || t("mail.noSubject")}
</span>
<span className="block text-[11px] text-slate-500 truncate leading-relaxed">
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400">
{msg.snippet}
</span>
{msg.has_attachments && (
<span className="mt-px flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-md bg-slate-800 px-2 py-0.5 text-[10.5px] text-slate-300">
<Paperclip className="h-2.5 w-2.5" />
{t("mail.attachments")}
</span>
</span>
)}
</span>
{/* An unread row is marked by a dot in a reserved column, so
the subject does not shift sideways when it is read. */}
<span className="mt-3 w-[7px] flex-none">
{!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />}
</span>
</button>
);
@@ -396,9 +424,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</div>
{/* Message Reader Column */}
<div
className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col bg-dark-bg overflow-y-auto`}
>
<div className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col overflow-y-auto bg-dark-bg`}>
<AnimatePresence mode="wait">
{selected ? (
<motion.article
@@ -407,137 +433,140 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-5 space-y-5"
className="mx-auto flex w-full max-w-[760px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7"
>
{/* Toolbar: closing, replying and read state all in one row, so
the reader is not the only pane with no way out of itself. */}
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setSelected(null)}
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-xs text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
>
<X className="w-3.5 h-3.5" />
<span>{t("mail.backToList")}</span>
</button>
<div className="flex items-center gap-2 text-xs">
<button
type="button"
onClick={() => {
const nowSeen = !(selectedRow?.seen ?? true);
void setSeen(selected.uid, nowSeen, { persist: true });
}}
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
>
<MailOpen className="w-3.5 h-3.5" />
<span className="hidden sm:inline">
{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
</span>
</button>
<Link
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
>
<Button variant="primary" size="sm">
<Reply className="w-3.5 h-3.5" />
<span>{t("mail.reply")}</span>
</Button>
</Link>
{/* Forward carries the subject but no recipient: it used to
prefill the original sender, which sends the message
straight back to whoever wrote it. */}
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
<Button variant="secondary" size="sm">
<Forward className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t("mail.forward")}</span>
</Button>
</Link>
</div>
<Button variant="primary" size="sm">
<Reply className="h-3.5 w-3.5" />
<span>{t("mail.reply")}</span>
</Button>
</Link>
{/* Forward carries the subject but no recipient: it used to
prefill the original sender, which sends the message
straight back to whoever wrote it. */}
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
<Button variant="secondary" size="sm">
<Forward className="h-3.5 w-3.5" />
<span>{t("mail.forward")}</span>
</Button>
</Link>
<Button
variant="secondary"
size="sm"
onClick={() => {
const nowSeen = !(selectedRow?.seen ?? true);
void setSeen(selected.uid, nowSeen, { persist: true });
}}
>
<MailOpen className="h-3.5 w-3.5" />
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setSelected(null)}
className="ml-auto h-9 w-9 flex-none p-0"
title={t("mail.backToList")}
aria-label={t("mail.backToList")}
>
<X className="h-4 w-4" />
</Button>
</div>
<header className="space-y-4">
<h1 className="text-lg md:text-xl font-bold text-white tracking-tight leading-snug break-words">
<header className="flex flex-col gap-3.5">
<h1 className="text-[25px] font-medium leading-tight tracking-[-0.015em] text-slate-100 [text-wrap:pretty]">
{selected.subject || t("mail.noSubject")}
</h1>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-indigo-500/30 to-cyan-500/20 border border-indigo-500/30 text-indigo-200 flex items-center justify-center text-[11px] font-bold flex-shrink-0">
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[13px] text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
{initials(selectedRow?.from_display_name ?? "", selected.from)}
</div>
<div className="min-w-0 flex-1 text-xs">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="font-semibold text-slate-100 break-all">{selected.from}</span>
<span className="text-[11px] text-slate-500 font-mono">
{selected.date
? new Date(selected.date).toLocaleString(locale)
: selectedRow
? new Date(selectedRow.received_at).toLocaleString(locale)
: ""}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<span className="break-all text-sm text-slate-100">{selected.from}</span>
{selectedRow && selectedRow.size_bytes > 0 && (
<span className="text-[11px] tabular-nums text-slate-500">
{formatBytes(selectedRow.size_bytes)}
</span>
)}
</div>
{/* Recipients are labelled with translated headings; they
used to be introduced by a Portuguese word regardless of
the interface language. */}
{selected.to.length > 0 && (
<div className="text-[11px] text-slate-400 break-all">
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
<span className="text-slate-500">{t("ui.toRecipients")} </span>
{selected.to.join(", ")}
</div>
)}
{selected.cc.length > 0 && (
<div className="text-[11px] text-slate-400 break-all">
<span className="text-slate-500">{t("ui.ccRecipients")}: </span>
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
<span className="text-slate-500">{t("ui.ccRecipients")} </span>
{selected.cc.join(", ")}
</div>
)}
{selectedRow && selectedRow.size_bytes > 0 && (
<div className="text-[11px] text-slate-500 font-mono">
{formatBytes(selectedRow.size_bytes)}
</div>
)}
</div>
<span className="flex-none text-xs tabular-nums text-slate-400">
{selected.date
? new Date(selected.date).toLocaleString(locale)
: selectedRow
? new Date(selectedRow.received_at).toLocaleString(locale)
: ""}
</span>
</div>
</header>
{/* Attachment Download Chips */}
{selected.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 text-xs">
{/* Remote content banner, shown only when something was actually
held back rather than over every message. The wording is the
reassurance, not the colour: this is not an error. */}
{hasBlockedContent && (
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-dark-card p-3 shadow-edge">
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" />
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
{t("mail.remoteContentBlocked")}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
>
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
</Button>
</div>
)}
{/* Attachment Download Chips */}
{selected.attachments.length > 0 && (
<div className="flex flex-col gap-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-slate-400">
{t("mail.attachments")}
</div>
<div className="flex flex-wrap gap-2">
{selected.attachments.map((filename, idx) => (
<a
key={`${filename}-${idx}`}
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
download={filename}
className="group max-w-full px-3 py-2 rounded-xl bg-slate-900/80 border border-slate-800 hover:border-indigo-500/60 hover:bg-slate-900 text-slate-200 flex items-center gap-2 transition-all"
className="group flex max-w-full items-center gap-2.5 rounded-xl bg-dark-card px-3 py-2.5 text-slate-200 shadow-edge transition-shadow hover:shadow-edge-accent"
>
<Paperclip className="w-3.5 h-3.5 text-indigo-400 flex-shrink-0" />
<span className="truncate font-mono text-[11px]">{filename}</span>
<Download className="w-3.5 h-3.5 text-slate-500 group-hover:text-indigo-300 flex-shrink-0" />
<Paperclip className="h-[18px] w-[18px] flex-none text-indigo-400" />
<span className="min-w-0 break-all text-[13px]">{filename}</span>
<Download className="h-4 w-4 flex-none text-slate-500 group-hover:text-indigo-300" />
</a>
))}
</div>
)}
</header>
{/* Remote content banner, shown only when something was actually
held back rather than over every message. */}
{hasBlockedContent && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/25 flex flex-wrap items-center justify-between gap-2 text-xs text-amber-200">
<span className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-amber-400 flex-shrink-0" />
<span>{t("mail.remoteContentBlocked")}</span>
</span>
<button
type="button"
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
className="px-3 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-100 font-medium transition-colors"
>
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
</button>
</div>
)}
{/* Message Body */}
{readerDocument ? (
<div className="bg-white rounded-2xl overflow-hidden border border-slate-800 shadow-xl">
<div className="overflow-hidden rounded-2xl bg-white shadow-edge">
<iframe
title={t("ui.messageBody")}
srcDoc={readerDocument}
@@ -549,12 +578,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
style={{ height: `${readerHeight}px` }}
className="w-full border-0 block"
className="block w-full border-0"
/>
</div>
) : (
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-5">
<pre className="whitespace-pre-wrap break-words text-sm text-slate-200 font-sans leading-relaxed">
<div className="rounded-2xl bg-dark-panel px-6 py-6 shadow-edge">
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
{selected.text || t("mail.emptyBody")}
</pre>
</div>
@@ -564,9 +593,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex-1 flex flex-col items-center justify-center text-slate-500 text-sm gap-3 py-20 px-6 text-center"
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-20 text-center text-sm text-slate-400"
>
<Mail className="w-12 h-12 text-slate-700 stroke-1" />
<Mail className="h-12 w-12 text-slate-600" strokeWidth={1} />
{/* This pane used to say "No messages" even with a full list
beside it: the empty reader is not an empty folder. */}
<span>{t("mail.selectAMessage")}</span>
@@ -12,12 +12,13 @@ import {
Trash2,
Plus,
Lock,
Languages,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card } from "../../../../components/ui/Card";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
interface SieveRule {
id: string;
@@ -44,8 +45,11 @@ interface WebSession {
current: boolean;
}
type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
export default function UserSettingsPage() {
const t = useTranslations();
const [activeTab, setActiveTab] = useState<SettingsTab>("security");
const [mfaEnabled, setMfaEnabled] = useState(false);
const [qrSecret, setQrSecret] = useState<string | null>(null);
@@ -241,396 +245,551 @@ export default function UserSettingsPage() {
}
};
/* 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: "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 (
<div className="flex-1 p-6 md:p-8 bg-dark-bg overflow-y-auto">
<div className="max-w-4xl mx-auto space-y-8">
<div className="flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8">
<div className="mx-auto flex w-full max-w-[900px] flex-col gap-5">
<div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
{t("settings.title")}
</h1>
<p className="text-sm text-slate-400 mt-1">{t("settings.subtitle")}</p>
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("settings.title")}</h1>
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("settings.subtitle")}</p>
</div>
{/* Tabs wrap rather than scroll, so a longer translation never pushes
one out of reach. */}
<div className="lm-tabstrip self-start">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
data-active={activeTab === tab.id}
aria-pressed={activeTab === tab.id}
className="lm-tab"
>
<Icon className="h-[15px] w-[15px] flex-none" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{message && (
<div className="p-4 rounded-xl bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 text-xs flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-indigo-400" />
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
<span>{message}</span>
</div>
)}
{/* 2FA TOTP SECTION */}
<Card className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Shield className="w-5 h-5 text-indigo-400" />
{t("ui.twoFactorSection")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">{t("settings.twoFactorIntro")}</p>
</div>
<Badge variant={mfaEnabled ? "success" : "warning"}>
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
</Badge>
</div>
{activeTab === "security" && (
<div className="flex flex-col gap-4">
{/* 2FA TOTP SECTION */}
<section className={panel}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-[260px] flex-1">
<h2 className={panelTitle}>{t("ui.twoFactorSection")}</h2>
<p className={panelIntro}>{t("settings.twoFactorIntro")}</p>
</div>
<Badge variant={mfaEnabled ? "success" : "warning"} className="flex-none">
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
</Badge>
</div>
{!mfaEnabled && !qrSecret && (
<Button
variant="primary"
size="sm"
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);
}}
>
<span>{t("settings.enableMfa")}</span>
</Button>
)}
{qrSecret && (
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-4 text-xs">
<TotpEnrolment secret={qrSecret} uri={qrUri} />
{/* 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. */}
<button
type="button"
onClick={async () => {
const res = await fetch("/api/v1/user/mfa/totp/enroll?reset=1", { method: "POST" });
const data = await res.json().catch(() => ({}));
if (res.ok) {
{!mfaEnabled && !qrSecret && (
<Button
variant="primary"
size="sm"
className="self-start"
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);
setTotpCode("");
}
}}
className="text-[11px] text-slate-400 underline hover:text-slate-200"
>
{t("settings.startOverMfa")}
</button>
<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().catch(() => ({}));
if (res.ok) {
setMfaEnabled(true);
setRecoveryCodes(data.recovery_codes ?? []);
setQrSecret(null);
setTotpCode("");
} else {
setMessage(data.message ?? t("auth.verificationFailed"));
}
}}
className="pt-2 flex items-center gap-3"
>
}}
>
<span>{t("settings.enableMfa")}</span>
</Button>
)}
{qrSecret && (
<div className="flex flex-col gap-4 rounded-xl bg-dark-card p-4 text-xs shadow-edge">
<TotpEnrolment secret={qrSecret} uri={qrUri} />
{/* 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. */}
<button
type="button"
onClick={async () => {
const res = await fetch("/api/v1/user/mfa/totp/enroll?reset=1", { method: "POST" });
const data = await res.json().catch(() => ({}));
if (res.ok) {
setQrSecret(data.secret);
setQrUri(data.uri);
setTotpCode("");
}
}}
className="self-start text-[11.5px] text-slate-400 underline underline-offset-4 hover:text-slate-200"
>
{t("settings.startOverMfa")}
</button>
<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().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"
>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={(e) => 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"
/>
<Button type="submit" variant="primary" size="sm">
{t("common.confirm")}
</Button>
</form>
</div>
)}
{recoveryCodes && (
<RecoveryCodes
codes={recoveryCodes}
onContinue={() => setRecoveryCodes(null)}
continueLabel={t("common.continue")}
/>
)}
</section>
{/* APP PASSWORDS */}
<section className={panel}>
<div>
<h2 className={panelTitle}>{t("ui.appPasswordsSection")}</h2>
</div>
{generatedAppPass && (
<div className="flex flex-col gap-2 rounded-xl bg-dark-card p-4 text-xs shadow-edge">
<div className="text-slate-100">{t("ui.appPasswordGenerated")}</div>
<code className="lm-code block break-all p-2.5 text-sm">{generatedAppPass}</code>
<div className="text-[11px] text-amber-400">{t("ui.copyPasswordNow")}</div>
</div>
)}
<form onSubmit={createAppPassword} className="flex flex-wrap gap-2">
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder={t("settings.sixDigitCode")}
maxLength={7}
required
className="px-4 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center focus:outline-none focus:border-indigo-500"
value={newAppPassLabel}
onChange={(e) => setNewAppPassLabel(e.target.value)}
placeholder={t("settings.appPasswordPlaceholder")}
className={`${input} min-w-[220px] flex-1`}
/>
<Button type="submit" variant="primary" size="sm">
{t("common.confirm")}
<Button type="submit" variant="primary" size="md" className="flex-none">
{t("settings.generateAppPassword")}
</Button>
</form>
</div>
)}
{recoveryCodes && (
<RecoveryCodes
codes={recoveryCodes}
onContinue={() => setRecoveryCodes(null)}
continueLabel={t("common.continue")}
/>
)}
</Card>
{/* VISUAL SIEVE RULES BUILDER */}
<Card className="space-y-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Filter className="w-5 h-5 text-indigo-400" />
{t("settings.sieveTitle")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">{t("settings.sieveIntro")}</p>
</div>
<form onSubmit={addSieveRule} className="grid grid-cols-1 sm:grid-cols-2 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-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="Subject">{t("settings.fieldSubject")}</option>
<option value="From">{t("settings.fieldFrom")}</option>
<option value="Header">{t("settings.fieldHeader")}</option>
</select>
<select
value={newMatch}
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="contains">{t("settings.matchContains")}</option>
<option value="equals">{t("settings.matchEquals")}</option>
<option value="matches">{t("settings.matchRegex")}</option>
</select>
<input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder={t("settings.valuePlaceholder")}
required
className="px-3 py-2 rounded-xl 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-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="move">{t("settings.actionMove")}</option>
<option value="flag">{t("settings.actionFlag")}</option>
<option value="discard">{t("settings.actionDiscard")}</option>
</select>
<Button type="submit" variant="primary" size="sm" className="w-full">
<Plus className="w-4 h-4" />
<span>{t("settings.addRule")}</span>
</Button>
</form>
<div className="space-y-2 text-xs">
{rules.map((r) => (
<div key={r.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/80 border border-slate-800">
<span className="font-mono text-slate-200">
{t("settings.ruleIf")} <strong>{r.field}</strong> {r.match} &quot;{r.value}&quot; &rarr;{" "}
{t("settings.ruleThen")} {r.action.toUpperCase()}
</span>
<button
onClick={() => removeSieveRule(r.id)}
className="text-rose-400 hover:text-rose-300 transition-colors p-1"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</Card>
{/* VACATION AUTO-RESPONDER */}
<Card className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Palmtree className="w-5 h-5 text-indigo-400" />
{t("settings.vacationTitle")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">{t("settings.vacationIntro")}</p>
</div>
<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.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>{t("settings.settingsSaved")}</span>
</div>
)}
{vacationEnabled && (
<form onSubmit={saveVacationResponder} className="space-y-3 text-xs">
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationSubjectLabel")}</label>
<input
type="text"
value={vacationSubject}
onChange={(e) => setVacationSubject(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationBodyLabel")}</label>
<textarea
rows={4}
value={vacationBody}
onChange={(e) => setVacationBody(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<Button type="submit" variant="primary" size="sm">
{t("settings.saveAutoReply")}
</Button>
</form>
)}
</Card>
{/* EMAIL SIGNATURE MANAGER */}
<Card className="space-y-4 text-xs">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-400" />
{t("settings.signatureTitle")}
</h2>
<p className="text-slate-400 mt-0.5">{t("settings.signatureIntro")}</p>
</div>
{sigSaved && (
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>{t("settings.signatureSaved")}</span>
</div>
)}
<textarea
rows={4}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder={t("settings.signaturePlaceholder")}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<Button onClick={saveSignature} variant="primary" size="sm">
{t("common.save")}
</Button>
</Card>
{/* APP PASSWORDS */}
<Card className="space-y-4">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<KeyRound className="w-5 h-5 text-indigo-400" />
{t("ui.appPasswordsSection")}
</h2>
{generatedAppPass && (
<div className="p-4 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs space-y-1">
<div className="font-bold">{t("ui.appPasswordGenerated")}</div>
<code className="block font-mono text-sm text-white break-all bg-slate-950 p-2 rounded-lg border border-slate-800">
{generatedAppPass}
</code>
<div className="text-[10px] text-emerald-400">{t("ui.copyPasswordNow")}</div>
</div>
)}
<form onSubmit={createAppPassword} className="flex gap-2">
<input
type="text"
value={newAppPassLabel}
onChange={(e) => setNewAppPassLabel(e.target.value)}
placeholder={t("settings.appPasswordPlaceholder")}
className="flex-1 px-3.5 py-2 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
<Button type="submit" variant="primary" size="sm">
{t("settings.generateAppPassword")}
</Button>
</form>
<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-xl bg-slate-900/80 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-rose-400 hover:text-rose-300 p-1">
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</Card>
{/* ACTIVE SESSIONS */}
<Card className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Monitor className="w-5 h-5 text-indigo-400" />
{t("ui.activeSessions")}
</h2>
<Button variant="ghost" size="sm" onClick={() => void revokeOtherSessions()}>
{t("ui.signOutOthers")}
</Button>
</div>
<div className="space-y-2">
{sessions.map((session) => (
<div key={session.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/80 border border-slate-800 text-xs">
<div className="min-w-0">
<div className="text-slate-200 truncate font-mono">
{session.ip_address ?? "-"}
<span className="text-[10px] text-slate-500 ml-2 uppercase font-sans font-bold">{session.surface}</span>
</div>
<div className="text-[10px] text-slate-500 truncate">{session.user_agent ?? ""}</div>
</div>
{session.current ? (
<Badge variant="success">{t("ui.currentSession")}</Badge>
<div className="flex flex-col gap-2">
{appPasswords.length === 0 ? (
<div className="text-[13px] text-slate-400">{t("ui.noAppPasswords")}</div>
) : (
<button type="button" onClick={() => void revokeSession(session.id)} className="text-rose-400 hover:text-rose-300 p-1">
<Trash2 className="w-4 h-4" />
</button>
appPasswords.map((ap) => (
<div key={ap.id} className={tile}>
<div className="min-w-0 flex-1">
<div className="break-words text-[13.5px] text-slate-100">{ap.label}</div>
<div className="mt-0.5 text-[11.5px] text-slate-400">
{ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
</div>
</div>
<button
type="button"
onClick={() => void revokeAppPassword(ap.id)}
aria-label={t("common.delete")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
>
<Trash2 className="h-[15px] w-[15px]" />
</button>
</div>
))
)}
</div>
))}
</div>
</Card>
</section>
{/* PASSWORD CHANGE */}
<Card className="space-y-4">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Lock className="w-5 h-5 text-indigo-400" />
{t("ui.changePassword")}
</h2>
<form onSubmit={changePassword} className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder={t("ui.currentPassword")}
required
className="w-full px-3.5 py-2.5 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("ui.newPassword")}
minLength={12}
required
className="w-full px-3.5 py-2.5 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
<Button type="submit" variant="primary" size="sm">
{t("common.save")}
</Button>
</form>
</Card>
{/* ACTIVE SESSIONS */}
<section className={panel}>
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className={panelTitle}>{t("ui.activeSessions")}</h2>
<Button variant="secondary" size="sm" onClick={() => void revokeOtherSessions()}>
{t("ui.signOutOthers")}
</Button>
</div>
<div className="flex flex-col gap-2">
{sessions.map((session) => (
<div key={session.id} className={tile}>
<Monitor className="h-5 w-5 flex-none text-slate-400" />
<div className="min-w-0 flex-1">
<div className="break-all font-mono text-[13px] text-slate-100">
{session.ip_address ?? "-"}
<span className="ml-2 font-sans text-[10.5px] uppercase tracking-[0.08em] text-slate-400">
{session.surface}
</span>
</div>
<div className="mt-0.5 break-all text-[11.5px] text-slate-400">{session.user_agent ?? ""}</div>
</div>
{session.current ? (
<Badge variant="info" className="flex-none">
{t("ui.currentSession")}
</Badge>
) : (
<button
type="button"
onClick={() => void revokeSession(session.id)}
aria-label={t("common.delete")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
>
<Trash2 className="h-[15px] w-[15px]" />
</button>
)}
</div>
))}
</div>
</section>
{/* PASSWORD CHANGE */}
<section className={panel}>
<div className="flex items-center gap-2">
<Lock className="h-[17px] w-[17px] flex-none text-indigo-500" />
<h2 className={panelTitle}>{t("ui.changePassword")}</h2>
</div>
<form onSubmit={changePassword} className="flex flex-col gap-3">
<div>
<label htmlFor="current-password" className={fieldLabel}>
{t("ui.currentPassword")}
</label>
<input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className={input}
/>
</div>
<div>
<label htmlFor="new-password" className={fieldLabel}>
{t("ui.newPassword")}
</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={12}
required
className={input}
/>
</div>
<Button type="submit" variant="primary" size="md" className="self-start">
{t("common.save")}
</Button>
</form>
</section>
</div>
)}
{/* VISUAL SIEVE RULES BUILDER */}
{activeTab === "filters" && (
<div className="flex flex-col gap-4">
<section className={panel}>
<div>
<h2 className={panelTitle}>{t("settings.sieveTitle")}</h2>
<p className={panelIntro}>{t("settings.sieveIntro")}</p>
</div>
{/* The rule reads as a sentence: IF <field> <match> <value> THEN
<action>. Each control keeps its own label, so the row still
makes sense when it wraps on a narrow window. */}
<form onSubmit={addSieveRule} className="flex flex-wrap items-end gap-2.5">
<div className="pb-2.5 text-xs uppercase tracking-[0.08em] text-slate-400">
{t("settings.ruleIf")}
</div>
<div className="min-w-[150px] flex-1">
<label htmlFor="rule-field" className={fieldLabel}>
{t("ui.eventAction")}
</label>
<select
id="rule-field"
value={newField}
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
className={input}
>
<option value="Subject">{t("settings.fieldSubject")}</option>
<option value="From">{t("settings.fieldFrom")}</option>
<option value="Header">{t("settings.fieldHeader")}</option>
</select>
</div>
<div className="min-w-[150px] flex-1">
<label htmlFor="rule-match" className={fieldLabel}>
{t("common.status")}
</label>
<select
id="rule-match"
value={newMatch}
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
className={input}
>
<option value="contains">{t("settings.matchContains")}</option>
<option value="equals">{t("settings.matchEquals")}</option>
<option value="matches">{t("settings.matchRegex")}</option>
</select>
</div>
<div className="min-w-[200px] flex-[2]">
<label htmlFor="rule-value" className={fieldLabel}>
{t("settings.valuePlaceholder")}
</label>
<input
id="rule-value"
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder={t("settings.valuePlaceholder")}
required
className={input}
/>
</div>
<div className="pb-2.5 text-xs uppercase tracking-[0.08em] text-slate-400">
{t("settings.ruleThen")}
</div>
<div className="min-w-[170px] flex-1">
<label htmlFor="rule-action" className={fieldLabel}>
{t("ui.actions")}
</label>
<select
id="rule-action"
value={newAction}
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
className={input}
>
<option value="move">{t("settings.actionMove")}</option>
<option value="flag">{t("settings.actionFlag")}</option>
<option value="discard">{t("settings.actionDiscard")}</option>
</select>
</div>
<Button type="submit" variant="primary" size="md" className="flex-none">
<Plus className="h-[15px] w-[15px]" />
<span>{t("settings.addRule")}</span>
</Button>
</form>
</section>
{rules.length > 0 && (
<section className={panel}>
<div className="flex flex-col gap-2">
{rules.map((r, index) => (
<div key={r.id} className={tile}>
<span className="flex h-[22px] w-[22px] flex-none items-center justify-center rounded-[7px] bg-indigo-900 text-[11px] text-indigo-300">
{index + 1}
</span>
<span className="min-w-[200px] flex-1 break-words font-mono text-[13px] leading-relaxed text-slate-200">
{t("settings.ruleIf")} <strong className="font-medium">{r.field}</strong> {r.match} &quot;
{r.value}&quot; &rarr; {t("settings.ruleThen")} {r.action.toUpperCase()}
</span>
<button
type="button"
onClick={() => removeSieveRule(r.id)}
aria-label={t("common.delete")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
>
<Trash2 className="h-[15px] w-[15px]" />
</button>
</div>
))}
</div>
</section>
)}
</div>
)}
{/* VACATION AUTO-RESPONDER */}
{activeTab === "vacation" && (
<section className={panel}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-[240px] flex-1">
<h2 className={panelTitle}>{t("settings.vacationTitle")}</h2>
<p className={panelIntro}>{t("settings.vacationIntro")}</p>
</div>
{/* 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. */}
<div className="flex flex-none gap-[3px] rounded-[10px] bg-dark-card p-[3px] shadow-edge">
{[
{ on: true, label: t("ui.active") },
{ on: false, label: t("settings.mfaDisabled") },
].map((option) => (
<button
key={String(option.on)}
type="button"
onClick={() => setVacationEnabled(option.on)}
aria-pressed={vacationEnabled === option.on}
className={`rounded-lg px-3 py-1.5 text-[12.5px] leading-snug transition-colors ${
vacationEnabled === option.on
? "text-indigo-500 shadow-edge-accent"
: "text-slate-400 hover:text-slate-100"
}`}
>
{option.label}
</button>
))}
</div>
</div>
{vacationSaved && (
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
<span>{t("settings.settingsSaved")}</span>
</div>
)}
{vacationEnabled && (
<form onSubmit={saveVacationResponder} className="flex flex-col gap-3">
<div>
<label htmlFor="vacation-subject" className={fieldLabel}>
{t("settings.vacationSubjectLabel")}
</label>
<input
id="vacation-subject"
type="text"
value={vacationSubject}
onChange={(e) => setVacationSubject(e.target.value)}
className={input}
/>
</div>
<div>
<label htmlFor="vacation-body" className={fieldLabel}>
{t("settings.vacationBodyLabel")}
</label>
<textarea
id="vacation-body"
rows={5}
value={vacationBody}
onChange={(e) => setVacationBody(e.target.value)}
className={`${input} leading-relaxed`}
/>
</div>
<Button type="submit" variant="primary" size="md" className="self-start">
{t("settings.saveAutoReply")}
</Button>
</form>
)}
</section>
)}
{/* EMAIL SIGNATURE MANAGER */}
{activeTab === "signature" && (
<div className="flex flex-wrap gap-4">
<section className={`${panel} min-w-[320px] flex-1`}>
<div>
<h2 className={panelTitle}>{t("settings.signatureTitle")}</h2>
<p className={panelIntro}>{t("settings.signatureIntro")}</p>
</div>
{sigSaved && (
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
<span>{t("settings.signatureSaved")}</span>
</div>
)}
<div>
<label htmlFor="signature-text" className={fieldLabel}>
{t("ui.messageBody")}
</label>
<textarea
id="signature-text"
rows={7}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder={t("settings.signaturePlaceholder")}
className={`${input} leading-relaxed`}
/>
</div>
<Button onClick={saveSignature} variant="primary" size="md" className="self-start">
{t("common.save")}
</Button>
</section>
{/* What the signature will actually look like under a reply, so it
is not written blind into a textarea. */}
<section className={`${panel} min-w-[280px] flex-1`}>
<div className="text-[11px] uppercase tracking-[0.08em] text-slate-400">
{t("admin.csvPreview")}
</div>
<div className="rounded-xl bg-dark-card p-4 text-[13.5px] leading-relaxed text-slate-300">
<div className="lm-rule my-2.5" />
<pre className="whitespace-pre-wrap break-words font-sans">
{signature || t("settings.signaturePlaceholder")}
</pre>
</div>
</section>
</div>
)}
{activeTab === "language" && (
<section className={panel}>
<div>
<h2 className={panelTitle}>{t("settings.language")}</h2>
<p className={panelIntro}>{t("settings.subtitle")}</p>
</div>
<div className="max-w-[280px]">
<LanguageSwitcher />
</div>
</section>
)}
</div>
</div>
);