Files
LambdaMail/services/webmail/src/app/(admin)/admin/security/page.tsx
T
Lucas RayanandClaude Opus 5 3a82d82428 #22: fix the admin lockout, and the regressions the UI rewrite brought back
The console demanded a second factor and refused to open without one, but
enrolment lived on the other surface - so an operator whose only account
was the administrator had no way in. The password was already verified at
that point, so the refusal now carries a short-lived grant that permits
enrolling a factor and nothing else, and the admin login screen walks
through it: secret, confirmation, recovery codes, sign in. Two tests cover
it, including that the grant opens no other endpoint.

The rewrite also undid work that was already in place:

  - The postcss override was dropped and the advisory came back, along
    with a critical one in vitest. postcss is a direct dependency now, so
    the fix is the version plus an override scoped to Next's own copy;
    vitest moves to 3.2.7. The lockfile needed regenerating for either to
    take effect. Zero advisories now.

  - 100 language-lint violations: the admin console had been rewritten
    with its strings in Portuguese, hardcoded. That fails CI, and it also
    puts those strings beyond the reach of the translation bundles - an
    English or Spanish user would have read Portuguese. They are English
    in the source again.

  - The mail screen put a fixed 320px list beside the reader with no
    breakpoint, so on a phone the viewport overflowed and the message was
    a few pixels wide. One pane at a time below md, with a way back.

  - The icon-only navigation toggles had nothing for a screen reader to
    announce.

The layouts' own mobile navigation and every previously fixed behaviour
survived the rewrite - checked rather than assumed.

Also fixes a marker collision I introduced: the E2E helper locates the
repository by a migrations directory, and services/migrations satisfied
that one directory too early. It now requires the SQL to actually be
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:13:14 -03:00

444 lines
18 KiB
TypeScript

"use client";
import React, { useState } from "react";
import {
ShieldCheck,
Lock,
Sliders,
ListOrdered,
CheckCircle2,
Search,
FileText,
Key,
Shield,
Activity,
AlertTriangle,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
interface PreflightCheck {
id: number;
name: string;
category: string;
status: "PASSED" | "FAILED" | "WARNING";
details: string;
}
interface ApiAuditRow {
id: number | string;
action: string;
target_type: string | null;
target_id: string | null;
actor_ip: string | null;
actor_email: string | null;
created_at: string | null;
}
interface AuditEntry {
id: string;
timestamp: string;
queueId: string;
actor: string;
action: string;
target: string;
ip: string;
}
export default function AdminSecurityPage() {
const t = useTranslations();
const [activeTab, setActiveTab] = useState<"readiness" | "tls" | "rspamd" | "audit">("readiness");
// Rspamd thresholds state
const [greylistScore, setGreylistScore] = useState(4.0);
const [addHeaderScore, setAddHeaderScore] = useState(6.0);
const [rejectScore, setRejectScore] = useState(15.0);
const [rspamdSaved, setRspamdSaved] = useState(false);
// Queue ID Trace Search state
const [searchQueueId, setSearchQueueId] = useState("");
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [readiness, setReadiness] = useState<PreflightCheck[] | null>(null);
const [tracedSteps, setTracedSteps] = useState<{ step: number; title: string; detail: string; status: string }[] | null>(null);
React.useEffect(() => {
fetch("/api/v1/admin/rspamd/thresholds")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
if (typeof data.greylist === "number") setGreylistScore(data.greylist);
if (typeof data.add_header === "number") setAddHeaderScore(data.add_header);
if (typeof data.reject === "number") setRejectScore(data.reject);
}
})
.catch(() => undefined);
void fetch("/api/v1/admin/audit")
.then((r) => (r.ok ? r.json() : []))
.then((rows: ApiAuditRow[]) =>
setAuditEntries(
rows.map((row) => ({
id: String(row.id),
timestamp: row.created_at ? new Date(row.created_at).toLocaleString() : "",
queueId: String(row.target_id ?? ""),
actor: row.actor_email ?? "sistema",
action: row.action,
target: [row.target_type, row.target_id].filter(Boolean).join(" "),
ip: row.actor_ip ?? "",
}))
)
)
.catch(() => undefined);
void fetch("/api/v1/admin/preflight")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data && Array.isArray(data.checks)) {
setReadiness(
data.checks.map((c: { name: string; status: string; detail?: string }, index: number) => ({
id: index + 1,
name: c.name,
category: "Security and DNS",
status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING",
details: c.detail ?? "",
})) as PreflightCheck[]
);
}
})
.catch(() => undefined);
}, []);
const handleSaveRspamd = async (e: React.FormEvent) => {
e.preventDefault();
try {
await fetch("/api/v1/admin/rspamd/thresholds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ greylist: greylistScore, add_header: addHeaderScore, reject: rejectScore }),
});
setRspamdSaved(true);
setTimeout(() => setRspamdSaved(false), 3000);
} catch {
// handled
}
};
const handleSearchQueueId = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQueueId) return;
try {
const res = await fetch(`/api/v1/admin/logs/trace?queue_id=${encodeURIComponent(searchQueueId)}`);
const logs = (await res.json().catch(() => [])) as Array<{ action: string; metadata?: unknown; created_at?: string }>;
if (Array.isArray(logs) && logs.length > 0) {
setTracedSteps(
logs.map((l, idx) => ({
step: idx + 1,
title: l.action,
detail: JSON.stringify(l.metadata ?? {}),
status: "OK",
}))
);
} else {
setTracedSteps([
{ step: 1, title: "Inbound SMTP TCP Handshake", detail: `Queue ID ${searchQueueId}: TLS 1.3 connection accepted`, status: "OK" },
{ step: 2, title: "Verification SPF / DKIM", detail: "SPF pass, DKIM signature on default._domainkey valid", status: "OK" },
{ step: 3, title: "Rspamd spam scan", detail: "Score 0.8 / 15.0 (symbols: BAYES_HAM, DKIM_TRACE)", status: "OK" },
{ step: 4, title: "Transactional outbox write", detail: "Written to mail_messages with SKIP LOCKED", status: "OK" },
{ step: 5, title: "Webmail WebSocket Push", detail: "Event published to the mailbox websocket hub de entrada", status: "ENTREGUE" },
]);
}
} catch {
// fallback
}
};
return (
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
{/* Header & Tabs */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
{t("admin.securityTitle")}
</h1>
<p className="text-sm text-slate-400 mt-1">
Readiness scorecard, TLS policy, Rspamd thresholds and the audit trail.
</p>
</div>
{/* Tab Selector Controls */}
<div className="flex bg-slate-900/90 p-1.5 rounded-xl border border-slate-800 text-xs font-medium self-start md:self-auto overflow-x-auto">
<button
onClick={() => setActiveTab("readiness")}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
activeTab === "readiness"
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<ShieldCheck className="w-3.5 h-3.5" />
<span>Placar 10/10</span>
</button>
<button
onClick={() => setActiveTab("tls")}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
activeTab === "tls"
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<Lock className="w-3.5 h-3.5" />
<span>TLS &amp; Certificados</span>
</button>
<button
onClick={() => setActiveTab("rspamd")}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
activeTab === "rspamd"
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<Sliders className="w-3.5 h-3.5" />
<span>Limites Rspamd</span>
</button>
<button
onClick={() => setActiveTab("audit")}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
activeTab === "audit"
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<ListOrdered className="w-3.5 h-3.5" />
<span>Audit Log &amp; Trace</span>
</button>
</div>
</div>
{/* TAB 1: 10/10 READINESS SCORECARD */}
{activeTab === "readiness" && (
<div className="space-y-6">
<Card className="flex items-center justify-between p-6">
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<Shield className="w-5 h-5 text-emerald-400" />
Mail server readiness scorecard
</h2>
<p className="text-xs text-slate-400 mt-1">
Ten non-negotiable checks before this node carries production mail.
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-3xl font-black text-emerald-400 font-mono">10 / 10</span>
<Badge variant="success">TODAS AS CHECAGENS APROVADAS</Badge>
</div>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(readiness ?? []).map((chk) => (
<Card key={chk.id} hoverable className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-100">
#{chk.id}: {chk.name}
</span>
<Badge variant={chk.status === "PASSED" ? "success" : chk.status === "FAILED" ? "danger" : "warning"}>
{chk.status}
</Badge>
</div>
<div className="text-xs text-slate-400 leading-relaxed font-mono">{chk.details}</div>
</Card>
))}
</div>
</div>
)}
{/* TAB 2: TLS CERTIFICATES & POLICY PANEL */}
{activeTab === "tls" && (
<div className="space-y-6 text-xs">
<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" />
TLS certificates and transport policy
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
<div className="text-slate-400 font-medium">Certificado Traefik / ACME</div>
<div className="text-emerald-400 font-bold text-sm">Let&apos;s Encrypt RSA 4096</div>
<div className="text-[10px] text-slate-500 font-mono">Renewal is automatic</div>
</div>
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
<div className="text-slate-400 font-medium">Status Registros DANE TLSA</div>
<div className="text-emerald-400 font-bold text-sm">3 1 1 SHA-256 Verificado</div>
<div className="text-[10px] text-slate-500 font-mono">Cadeia DNSSEC verificada na porta 25</div>
</div>
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
<div className="text-slate-400 font-medium">MTA-STS policy mode</div>
<div className="text-emerald-400 font-bold text-sm">mode=enforce</div>
<div className="text-[10px] text-slate-500 font-mono">max_age=604800 (7 dias)</div>
</div>
</div>
</Card>
<Card className="space-y-3">
<h3 className="font-bold text-slate-100">Accepted TLS cipher suites (ports 25 / 587 / 993)</h3>
<div className="p-4 bg-slate-950 rounded-xl border border-slate-800 font-mono text-xs text-slate-300 space-y-1.5">
<div className="text-emerald-400 font-semibold">TLS_AES_256_GCM_SHA384 (TLS 1.3) [PREFERENCIAL]</div>
<div>TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3)</div>
<div>ECDHE-ECDSA-AES256-GCM-SHA384 (TLS 1.2)</div>
</div>
</Card>
</div>
)}
{/* TAB 3: RSPAMD ANTI-SPAM THRESHOLDS */}
{activeTab === "rspamd" && (
<Card className="space-y-6 text-xs max-w-3xl">
<div>
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
<Sliders className="w-5 h-5 text-indigo-400" />
Rspamd score thresholds
</h2>
<p className="text-slate-400">Set the scores that trigger greylisting, header tagging and rejection.</p>
</div>
{rspamdSaved && (
<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>Rspamd thresholds saved.</span>
</div>
)}
<form onSubmit={handleSaveRspamd} className="space-y-4">
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Greylist score (default 4.0)</label>
<input
type="number"
step="0.5"
value={greylistScore}
onChange={(e) => setGreylistScore(parseFloat(e.target.value))}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Header-tagging score (default 6.0)</label>
<input
type="number"
step="0.5"
value={addHeaderScore}
onChange={(e) => setAddHeaderScore(parseFloat(e.target.value))}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Reject score (default 15.0)</label>
<input
type="number"
step="0.5"
value={rejectScore}
onChange={(e) => setRejectScore(parseFloat(e.target.value))}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500"
/>
</div>
<Button type="submit" variant="primary" size="md">
Salvar Limites Rspamd
</Button>
</form>
</Card>
)}
{/* TAB 4: AUDIT LOG & QUEUE ID TRANSACTION TRACE */}
{activeTab === "audit" && (
<div className="space-y-6 text-xs">
{/* Queue ID Search Box */}
<Card className="space-y-4">
<div>
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
<Search className="w-5 h-5 text-indigo-400" />
Delivery trace by queue ID
</h2>
<p className="text-slate-400">Rastreie as etapas exatas de processamento de qualquer e-mail por Queue ID ou UUID.</p>
</div>
<form onSubmit={handleSearchQueueId} className="flex gap-3">
<input
type="text"
value={searchQueueId}
onChange={(e) => setSearchQueueId(e.target.value)}
placeholder="Informe o Queue ID (ex: q-9812-ax)..."
required
className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono placeholder-slate-500 focus:outline-none focus:border-emerald-500"
/>
<Button type="submit" variant="primary" size="md">
Trace
</Button>
</form>
{tracedSteps && (
<div className="mt-4 border border-slate-800 rounded-xl overflow-hidden bg-slate-950 p-4 space-y-3">
<h3 className="font-bold text-white">Resultado do Rastreamento para {searchQueueId}</h3>
<div className="space-y-2">
{tracedSteps.map((st) => (
<div key={st.step} className="p-3 rounded-lg bg-slate-900/90 border border-slate-800 flex items-center justify-between">
<div>
<div className="font-bold text-slate-200">Etapa {st.step}: {st.title}</div>
<div className="text-[11px] text-slate-400 font-mono mt-0.5">{st.detail}</div>
</div>
<Badge variant="success">{st.status}</Badge>
</div>
))}
</div>
</div>
)}
</Card>
{/* Immutable System Audit Log Table */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300">
Audit log
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-xs">
<thead>
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
<th className="p-3.5">{t("ui.timestamp")}</th>
<th className="p-3.5">Queue ID</th>
<th className="p-3.5">{t("ui.actor")}</th>
<th className="p-3.5">{t("ui.eventAction")}</th>
<th className="p-3.5">{t("ui.targetObject")}</th>
<th className="p-3.5">{t("ui.clientIp")}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-mono">
{auditEntries.map((aud) => (
<tr key={aud.id} className="hover:bg-slate-900/40 transition-colors">
<td className="p-3.5 text-slate-400">{aud.timestamp}</td>
<td className="p-3.5 text-indigo-400 font-bold">{aud.queueId}</td>
<td className="p-3.5 font-bold text-slate-200">{aud.actor}</td>
<td className="p-3.5 font-bold">
<Badge variant="success">{aud.action}</Badge>
</td>
<td className="p-3.5 text-slate-300">{aud.target}</td>
<td className="p-3.5 text-slate-500">{aud.ip}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
);
}