#23: show real data, unify the surfaces and make enrolment scannable

The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.

Fabricated data, removed:

- The outbound queue was two invented jobs held in component state. Retry and
  Freeze only edited that local array, so the screen showed the same two rows
  on every deployment and reported success for actions that never happened.
  "Freeze" named an operation the backend has never implemented. It now reads
  /api/v1/admin/queue, which was already serving the real thing, and offers
  retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
  sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
  plausible traffic for a server that might have delivered nothing. Two of
  those fields were never returned by the endpoint at all, so the fake disk
  figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
  VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
  real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
  swallowed and a made-up TXT record displayed as though a key had been
  issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
  DANE from static markup, on a deployment currently serving a self-signed
  one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
  what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
  accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
  "SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
  the first edit wrote that invented filter into the account's real script.

The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.

Surfaces:

The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.

Enrolment:

Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.

The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.

Language:

translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.

The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.

Healthchecks:

web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.

Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 19:13:53 -03:00
1 parent f7e93275d5
commit ee240ae227
29 files changed
+2201 -1221

No files matched your search

@@ -19,16 +19,24 @@ import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
/** Exactly the fields /api/v1/admin/dashboard returns - see dashboardStats(). */
interface DashboardMetrics {
inbound_24h: number;
outbound_24h: number;
queue_depth: number;
bounce_rate: number;
spam_score_avg: number;
disk_used_percent: number;
domains_verified: number;
domains_total: number;
preflight_status: string;
mailboxes_active: number;
storage_used_bytes: number;
storage_quota_bytes: number;
}
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
export default function AdminDashboardPage() {
@@ -39,9 +47,9 @@ export default function AdminDashboardPage() {
const fetchMetrics = () => {
setLoading(true);
fetch("/api/v1/admin/dashboard")
.then((res) => res.json())
.then((res) => (res.ok ? res.json() : null))
.then((data) => setMetrics(data))
.catch(() => {})
.catch(() => setMetrics(null))
.finally(() => setLoading(false));
};
@@ -49,18 +57,31 @@ export default function AdminDashboardPage() {
fetchMetrics();
}, []);
/**
* A missing figure reads as "no data", never as a number.
*
* Each of these used to fall back to an invented constant - 1420 received,
* 890 sent, 3 queued, 18.4% disk - so an unreachable API produced a dashboard
* of plausible traffic for a server that might not have delivered a single
* message. Two of those fields (disk_used_percent, spam_score_avg) were never
* returned by the endpoint at all, so the fake disk figure was what everyone
* always saw.
*/
const shown = (value: number | undefined, format?: (n: number) => string) =>
value === undefined || value === null ? t("ui.noData") : format ? format(value) : String(value);
const kpis = [
{
title: t("ui.inboundVolume"),
value: metrics?.inbound_24h ?? 1420,
subtext: "100% SPF/DKIM validado",
value: shown(metrics?.inbound_24h),
subtext: t("ui.messageVolume"),
icon: Inbox,
color: "text-indigo-400",
bg: "bg-indigo-500/10",
},
{
title: t("ui.outboundDelivered"),
value: metrics?.outbound_24h ?? 890,
value: shown(metrics?.outbound_24h),
subtext: t("ui.daneVerified"),
icon: Send,
color: "text-emerald-400",
@@ -68,7 +89,7 @@ export default function AdminDashboardPage() {
},
{
title: t("ui.queueDepth"),
value: metrics?.queue_depth ?? 3,
value: shown(metrics?.queue_depth),
subtext: t("ui.postgresRunner"),
icon: Layers,
color: "text-cyan-400",
@@ -76,8 +97,10 @@ export default function AdminDashboardPage() {
},
{
title: t("ui.diskUsed"),
value: `${metrics?.disk_used_percent ?? 18.4}%`,
subtext: t("ui.localDisk"),
value: shown(metrics?.storage_used_bytes, formatBytes),
subtext: metrics
? `${t("ui.storageQuota")}: ${formatBytes(metrics.storage_quota_bytes)}`
: t("ui.localDisk"),
icon: HardDrive,
color: "text-amber-400",
bg: "bg-amber-500/10",
@@ -89,23 +112,26 @@ export default function AdminDashboardPage() {
{/* Header Bar */}
<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">
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
{t("admin.dashboardTitle")}
<span className="text-xs px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono font-normal">
v1.2 Live
</span>
</h1>
<p className="text-sm text-slate-400 mt-1">{t("ui.liveMetrics")}</p>
<p className="text-sm text-slate-400 mt-1">{t("admin.dashboardSubtitle")}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="success" className="font-mono py-1">
<Zap className="w-3.5 h-3.5 mr-1 animate-pulse" />
PREFLIGHT: {metrics?.preflight_status || "HEALTHY"}
</Badge>
{/* Reports the count it can actually see. The old badge asserted
"PREFLIGHT: HEALTHY" from a field the endpoint never sends, so it
said healthy unconditionally. The real checks live on the
security screen. */}
{metrics && (
<Badge variant={metrics.domains_verified === metrics.domains_total ? "success" : "warning"}>
<Zap className="w-3.5 h-3.5 mr-1" />
{t("ui.domainsVerified")}: {metrics.domains_verified}/{metrics.domains_total}
</Badge>
)}
<Button variant="outline" size="sm" onClick={fetchMetrics} disabled={loading}>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
<span>Atualizar</span>
<span>{t("common.refresh")}</span>
</Button>
</div>
</div>
@@ -141,68 +167,58 @@ export default function AdminDashboardPage() {
})}
</div>
{/* Security & System Infra */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Core Infrastructure Health */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="w-5 h-5 text-emerald-400" />
{t("ui.coreInfra")}
</CardTitle>
<Badge variant="info">4/4 Ativos</Badge>
</CardHeader>
<div className="space-y-3">
{[
{ name: t("ui.smtpListener"), status: t("ui.online"), port: "Porta 25 / 587" },
{ name: t("ui.imapEngine"), status: t("ui.online"), port: "Porta 993 (SSL)" },
{ name: t("ui.scanners"), status: t("ui.healthy"), port: "Rspamd Engine" },
{ name: "Traefik Acme Watcher", status: t("ui.watching"), port: "fsnotify + poll" },
].map((svc, i) => (
<div
key={i}
className="flex items-center justify-between p-3.5 rounded-xl bg-slate-900/80 border border-slate-800/80 hover:border-slate-700/60 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-ping" />
<div>
<span className="text-xs font-semibold text-slate-200 block">{svc.name}</span>
<span className="text-[10px] text-slate-400 font-mono">{svc.port}</span>
</div>
</div>
<Badge variant="success">{svc.status}</Badge>
</div>
))}
</div>
</Card>
{/*
A single card of figures that are genuinely queried.
{/* Domain Reconciliation */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="w-5 h-5 text-indigo-400" />
{t("ui.domainVerification")}
</CardTitle>
<Badge variant="success">Auto-Reconciled</Badge>
</CardHeader>
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-emerald-400" />
<span className="font-bold text-sm text-slate-100">example.com</span>
What stood here were two cards of decoration: a "core services" panel
that listed SMTP, IMAP, Rspamd and the cert watcher with a hardcoded
"Online"/"4/4 active" and a pulsing green dot - nothing was ever polled,
so it stayed green through any outage - and a domain panel pinned to
"example.com" with "13 RECORDS VERIFIED" and a "last checked: just now"
that was a literal string. Container liveness is now covered by the
compose healthchecks, and the per-domain DNS state lives on the domains
screen, which reads it from the database.
*/}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="w-5 h-5 text-indigo-400" />
{t("ui.domainVerification")}
</CardTitle>
</CardHeader>
{metrics ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
{t("ui.domainsVerified")}
</div>
<div className="text-2xl font-bold text-white">
{metrics.domains_verified}
<span className="text-slate-500 text-base"> / {metrics.domains_total}</span>
</div>
<Badge variant="success">13 RECORDS VERIFIED</Badge>
</div>
<p className="text-xs text-slate-400 leading-relaxed">
Cloudflare DNS automation active. SPF, DKIM (RSA 2048 + Ed25519), DMARC (quarantine), MTA-STS e TLS-RPT totalmente sincronizados e validados.
</p>
<div className="pt-2 flex items-center justify-between text-xs text-slate-400 border-t border-slate-800 font-mono">
<span>Last checked: just now</span>
<span className="text-indigo-400 hover:underline cursor-pointer">Verificar Registros &rarr;</span>
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-indigo-400" />
{t("ui.mailboxesActive")}
</div>
<div className="text-2xl font-bold text-white">{metrics.mailboxes_active}</div>
</div>
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
<Send className="w-3.5 h-3.5 text-amber-400" />
{t("ui.bounceRate")}
</div>
<div className="text-2xl font-bold text-white">
{(metrics.bounce_rate * 100).toFixed(1)}%
</div>
</div>
</div>
</Card>
</div>
) : (
<div className="p-6 text-center text-xs text-slate-500">{t("ui.noData")}</div>
)}
</Card>
</div>
);
}
@@ -25,7 +25,7 @@ export default function AdminDmarcPage() {
useEffect(() => {
fetch("/api/v1/admin/dmarc")
.then((res) => res.json())
.then((res) => (res.ok ? res.json() : null))
.then((d) => setData(d))
.catch(() => {});
}, []);
@@ -34,27 +34,27 @@ export default function AdminDmarcPage() {
<div className="p-8 space-y-8">
<div>
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.dmarcTitle")}</h1>
<p className="text-xs text-slate-400">Ingested XML aggregate reports breakdown (dmarc_reports table).</p>
<p className="text-xs text-slate-400">{t("admin.dmarcSubtitle")}</p>
</div>
{/* DMARC Stats Summary Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.totalEvaluated")}</div>
<div className="text-3xl font-extrabold text-white">{data?.total_messages ?? 1250}</div>
<div className="text-3xl font-extrabold text-white">{data ? data.total_messages : t("ui.noData")}</div>
</div>
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.spfRatio")}</div>
<div className="text-3xl font-extrabold text-emerald-400">
{data ? Math.round((data.spf_pass_count / data.total_messages) * 100) : 98}%
{data && data.total_messages > 0 ? `${Math.round((data.spf_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
</div>
</div>
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.dkimAlignment")}</div>
<div className="text-3xl font-extrabold text-emerald-400">
{data ? Math.round((data.dkim_pass_count / data.total_messages) * 100) : 97}%
{data && data.total_messages > 0 ? `${Math.round((data.dkim_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
</div>
</div>
@@ -67,7 +67,7 @@ export default function AdminDmarcPage() {
{/* Sending Sources Table */}
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
Inbound Aggregate Reporting Sending IP Sources
{t("ui.sourceIp")}
</div>
<div className="overflow-x-auto">
@@ -1,445 +1,360 @@
"use client";
import React, { useState } from "react";
import {
Globe,
RefreshCw,
Plus,
KeyRound,
ShieldCheck,
CheckCircle2,
ArrowRight,
Copy,
Layers,
Sparkles,
} from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Card } from "../../../../components/ui/Card";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
interface DnsRecord {
type: string;
/**
* Domains, read from the database.
*
* The previous version of this screen was a mockup end to end. It rendered a
* fixed table of thirteen DNS records for "example.com", every one of them
* marked VERIFIED; its onboarding wizard had four steps of which only the first
* called anything, step three printing "Cloudflare connection validated, 13
* records created and propagated" without a request behind it; and its DKIM
* rotation posted {domain_id: "default-domain"} to an endpoint whose field is
* "domain", swallowed the resulting 400, and displayed a made-up TXT record as
* though a key had been issued. Rotation therefore never once worked from here.
*
* What is shown now is what the API returns, and the one thing the backend
* cannot yet do - verify individual DNS records against a resolver - says so
* instead of drawing a table of green ticks.
*/
interface Domain {
id: string;
name: string;
expectedValue: string;
actualValue: string;
status: "VERIFIED" | "DRIFT" | "PARTIAL";
dns_status: string;
dmarc_policy: string | null;
mta_sts_mode: string | null;
dane_enabled: boolean;
is_active: boolean;
dns_last_checked_at: string | null;
mailbox_count: number;
}
const DNS_RECORDS_13: DnsRecord[] = [
{ type: "MX", name: "@", expectedValue: "10 mail.example.com", actualValue: "10 mail.example.com", status: "VERIFIED" },
{ type: "TXT", name: "@", expectedValue: "v=spf1 mx ~all", actualValue: "v=spf1 mx ~all", status: "VERIFIED" },
{ type: "TXT", name: "default._domainkey", expectedValue: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN...", actualValue: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN...", status: "VERIFIED" },
{ type: "TXT", name: "ed25519._domainkey", expectedValue: "v=DKIM1; k=ed25519; p=117p2...==", actualValue: "v=DKIM1; k=ed25519; p=117p2...==", status: "VERIFIED" },
{ type: "TXT", name: "_dmarc", expectedValue: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", actualValue: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", status: "VERIFIED" },
{ type: "TXT", name: "_mta-sts", expectedValue: "v=STSv1; id=20260802", actualValue: "v=STSv1; id=20260802", status: "VERIFIED" },
{ type: "CNAME", name: "mta-sts", expectedValue: "mail.example.com", actualValue: "mail.example.com", status: "VERIFIED" },
{ type: "TXT", name: "_smtp._tlsa", expectedValue: "3 1 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", actualValue: "3 1 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", status: "VERIFIED" },
{ type: "TXT", name: "_smtp._tls", expectedValue: "v=TLSRPTv1; rua=mailto:tlsrpt@example.com", actualValue: "v=TLSRPTv1; rua=mailto:tlsrpt@example.com", status: "VERIFIED" },
{ type: "SRV", name: "_autodiscover._tcp", expectedValue: "0 0 443 mail.example.com", actualValue: "0 0 443 mail.example.com", status: "VERIFIED" },
{ type: "SRV", name: "_submission._tcp", expectedValue: "0 0 587 mail.example.com", actualValue: "0 0 587 mail.example.com", status: "VERIFIED" },
{ type: "SRV", name: "_imaps._tcp", expectedValue: "0 0 993 mail.example.com", actualValue: "0 0 993 mail.example.com", status: "VERIFIED" },
{ type: "A", name: "mail", expectedValue: "203.0.113.195", actualValue: "203.0.113.195", status: "VERIFIED" },
];
function statusVariant(status: string): "success" | "warning" | "danger" {
if (status === "VERIFIED") return "success";
if (status === "PENDING" || status === "PARTIAL") return "warning";
return "danger";
}
export default function AdminDomainsPage() {
const t = useTranslations();
const [activeTab, setActiveTab] = useState<"dns" | "onboarding" | "dkim">("dns");
const [activeTab, setActiveTab] = useState<"domains" | "onboarding" | "dkim">("domains");
const [domains, setDomains] = useState<Domain[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [reconciling, setReconciling] = useState(false);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
// Guided Onboarding Wizard state
const [onboardStep, setOnboardStep] = useState(1);
const [onboardDomain, setOnboardDomain] = useState("");
const [onboardAdminEmail, setOnboardAdminEmail] = useState("");
const [onboardSuccess, setOnboardSuccess] = useState(false);
const [onboarding, setOnboarding] = useState(false);
// DKIM Rotation Wizard state
const [dkimKeyType, setDkimKeyType] = useState<"Ed25519" | "RSA-2048">("Ed25519");
const [dkimSelector, setDkimSelector] = useState("s202608");
const [dkimRotationStep, setDkimRotationStep] = useState<"idle" | "generated" | "active">("idle");
const [generatedDkimTxt, setGeneratedDkimTxt] = useState<string | null>(null);
const [dkimAlgorithm, setDkimAlgorithm] = useState<"ed25519" | "rsa2048">("ed25519");
const [dkimSelector, setDkimSelector] = useState("s1");
const [dkimRecord, setDkimRecord] = useState<{ name: string; value: string; overlapDays: number } | null>(null);
const [rotating, setRotating] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/v1/admin/domains");
if (!res.ok) throw new Error();
const data: Domain[] = await res.json();
setDomains(data);
// Keeps the current pick if it still exists, so a refresh after an action
// does not silently retarget the next operation at another domain.
setSelectedId((prev) => (data.some((d) => d.id === prev) ? prev : (data[0]?.id ?? "")));
setError(null);
} catch {
setDomains([]);
setError(t("errors.loadFailed"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const selected = domains.find((d) => d.id === selectedId) ?? null;
const handleReconcile = async () => {
if (!selected) return;
setReconciling(true);
setSyncMessage(null);
setNotice(null);
setError(null);
try {
// The real domain id, not the literal string "default-domain" that used
// to be sent here for every domain on the server.
const res = await fetch("/api/v1/admin/domains/reconcile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain_id: "default-domain" }),
body: JSON.stringify({ domain_id: selected.id }),
});
if (res.ok) {
setSyncMessage("Cloudflare sync finished: all 13 DNS records verified.");
} else {
setSyncMessage("DNS reconciliation requested.");
}
} catch {
setSyncMessage("Could not reach the reconciliation endpoint.");
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message);
setNotice(data.message ?? t("settings.settingsSaved"));
await load();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
} finally {
setReconciling(false);
}
};
const handleStartDkimRotation = async () => {
const handleOnboard = async (e: React.FormEvent) => {
e.preventDefault();
if (!onboardDomain.trim()) return;
setOnboarding(true);
setNotice(null);
setError(null);
try {
await fetch("/api/v1/admin/dkim/rotate", {
const res = await fetch("/api/v1/admin/domains/onboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: onboardDomain.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message);
setOnboardDomain("");
setNotice(`${data.name}`);
await load();
setActiveTab("domains");
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
} finally {
setOnboarding(false);
}
};
const handleRotateDkim = async () => {
if (!selected) return;
setRotating(true);
setNotice(null);
setError(null);
setDkimRecord(null);
try {
// The endpoint's field is "domain" and it wants the name. Sending the
// wrong field is what made every previous rotation fail unnoticed.
const res = await fetch("/api/v1/admin/dkim/rotate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
domain_id: "default-domain",
domain: selected.name,
selector: dkimSelector,
algorithm: dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa2048",
algorithm: dkimAlgorithm,
}),
});
} catch {
// fallback handling
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message);
// The key that was actually generated and sealed, not a sample string.
setDkimRecord({
name: `${data.selector}._domainkey.${data.domain}`,
value: data.public_key_record,
overlapDays: data.overlap_days,
});
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
} finally {
setRotating(false);
}
setDkimRotationStep("generated");
setGeneratedDkimTxt(`v=DKIM1; k=${dkimKeyType.toLowerCase().includes("ed") ? "ed25519" : "rsa"}; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3...`);
};
const handleActivateDkim = () => {
setDkimRotationStep("active");
};
const handleNextOnboardStep = async (e: React.FormEvent) => {
e.preventDefault();
if (onboardStep === 1 && !onboardDomain) return;
if (onboardStep === 1) {
try {
await fetch("/api/v1/admin/domains/onboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: onboardDomain }),
});
} catch {
// fallback
}
}
if (onboardStep < 4) {
setOnboardStep(onboardStep + 1);
} else {
setOnboardSuccess(true);
}
};
const tabs = [
{ id: "domains" as const, label: t("admin.domainsTitle"), icon: Globe },
{ id: "onboarding" as const, label: t("common.add"), icon: Plus },
{ id: "dkim" as const, label: "DKIM", icon: KeyRound },
];
return (
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
{/* Header & Tabs */}
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
<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">
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
{t("admin.domainsTitle")}
</h1>
<p className="text-sm text-slate-400 mt-1">
DNS reconciliation, guided domain onboarding and signing key rotation DKIM.
</p>
</div>
{/* Tab Selection 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">
<button
onClick={() => setActiveTab("dns")}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
activeTab === "dns"
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Matriz 13 Registros DNS</span>
</button>
<button
onClick={() => setActiveTab("onboarding")}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
activeTab === "onboarding"
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<Plus className="w-3.5 h-3.5" />
<span>Onboarding Guiado</span>
</button>
<button
onClick={() => setActiveTab("dkim")}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
activeTab === "dkim"
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<KeyRound className="w-3.5 h-3.5" />
<span>DKIM key rotation</span>
</button>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
activeTab === tab.id
? "bg-emerald-600 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{tab.label}</span>
</button>
);
})}
</div>
</div>
{/* TAB 1: 13 DNS RECORDS RECONCILIATION */}
{activeTab === "dns" && (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<Card className="flex-1 p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center text-indigo-400 font-bold">
<Globe className="w-5 h-5" />
</div>
<div>
<div className="text-base font-bold text-white">example.com</div>
<div className="text-xs text-slate-400">Primary domain | DMARC: quarantine | MTA-STS: testing</div>
</div>
</div>
<Badge variant="success">13 / 13 VERIFICADOS</Badge>
</Card>
{notice && (
<div className="p-4 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 flex-shrink-0" />
<span>{notice}</span>
</div>
)}
{error && (
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
{error}
</div>
)}
<Button
variant="primary"
size="md"
onClick={handleReconcile}
disabled={reconciling}
className="self-stretch sm:self-auto"
>
<RefreshCw className={`w-4 h-4 ${reconciling ? "animate-spin" : ""}`} />
<span>{reconciling ? "Sincronizando Cloudflare API..." : "Reconciliar Registros DNS"}</span>
</Button>
</div>
{syncMessage && (
<div className="p-4 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 flex-shrink-0" />
<span>{syncMessage}</span>
</div>
{activeTab === "domains" && (
<div className="space-y-4">
{domains.length === 0 && !loading ? (
<Card className="p-8 text-center text-xs text-slate-500">{t("admin.noDomains")}</Card>
) : (
domains.map((d) => (
<Card key={d.id} className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center text-indigo-400 flex-shrink-0">
<Globe className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className="text-base font-bold text-white truncate">{d.name}</div>
<div className="text-xs text-slate-400">
{t("admin.mailboxCount", { count: d.mailbox_count })}
{d.dmarc_policy ? ` | DMARC: ${d.dmarc_policy}` : ""}
{d.mta_sts_mode ? ` | MTA-STS: ${d.mta_sts_mode}` : ""}
{d.dane_enabled ? " | DANE" : ""}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant={statusVariant(d.dns_status)}>{d.dns_status}</Badge>
<Button
variant={selectedId === d.id ? "primary" : "outline"}
size="sm"
onClick={() => setSelectedId(d.id)}
>
{selectedId === d.id ? <CheckCircle2 className="w-3.5 h-3.5" /> : null}
<span>{selectedId === d.id ? t("common.status") : t("common.add")}</span>
</Button>
</div>
</div>
<div className="mt-3 pt-3 border-t border-slate-800 text-[11px] text-slate-500 font-mono">
{t("admin.lastChecked", {
when: d.dns_last_checked_at
? new Date(d.dns_last_checked_at).toLocaleString()
: t("common.never"),
})}
</div>
</Card>
))
)}
<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 flex items-center justify-between">
<span>Expected 13 DNS records versus actual state</span>
</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">Tipo</th>
<th className="p-3.5">{t("ui.recordName")}</th>
<th className="p-3.5">{t("ui.expectedValue")}</th>
<th className="p-3.5">{t("ui.actualRecord")}</th>
<th className="p-3.5">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-mono">
{DNS_RECORDS_13.map((rec, idx) => (
<tr key={idx} className="hover:bg-slate-900/40 transition-colors">
<td className="p-3.5 font-bold text-emerald-400">{rec.type}</td>
<td className="p-3.5 text-slate-200">{rec.name}</td>
<td className="p-3.5 text-slate-300 truncate max-w-xs">{rec.expectedValue}</td>
<td className="p-3.5 text-slate-400 truncate max-w-xs">{rec.actualValue}</td>
<td className="p-3.5">
<Badge variant="success">{rec.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
{/* Says what reconciliation currently does. The old copy announced
"Cloudflare sync finished: all 13 DNS records verified" on every
press - including when the request failed - while the endpoint
behind it only writes an audit entry. */}
<Card className="space-y-3">
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<p>{t("admin.reconcileNotImplemented")}</p>
</div>
<Button variant="outline" size="sm" onClick={handleReconcile} disabled={reconciling || !selected}>
<RefreshCw className={`w-3.5 h-3.5 ${reconciling ? "animate-spin" : ""}`} />
<span>{t("admin.reconcileDns")}</span>
</Button>
</Card>
</div>
)}
{/* TAB 2: GUIDED DOMAIN ONBOARDING WIZARD */}
{activeTab === "onboarding" && (
<Card className="max-w-3xl space-y-6">
<Card className="max-w-2xl space-y-4">
<div>
<h2 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
<Sparkles className="w-5 h-5 text-emerald-400" />
Guided domain onboarding
</h2>
<p className="text-xs text-slate-400">
Step by step provisioning for new domains and their Cloudflare records DNS.
</p>
<h2 className="text-lg font-bold text-white mb-1">{t("admin.onboardTitle")}</h2>
<p className="text-xs text-slate-400">{t("admin.onboardIntro")}</p>
</div>
{/* Stepper Header */}
<div className="grid grid-cols-4 gap-2 text-center text-xs">
{["1. Domain", "2. DNS records", "3. Verification", "4. Aliases"].map((label, idx) => (
<div
key={idx}
className={`p-2.5 rounded-xl border font-semibold transition-all ${
onboardStep === idx + 1
? "bg-emerald-600/20 border-emerald-500 text-emerald-300 shadow-sm"
: onboardStep > idx + 1
? "bg-slate-900 border-slate-700 text-slate-400"
: "bg-slate-950 border-slate-800 text-slate-600"
}`}
>
{label}
</div>
))}
</div>
{onboardSuccess ? (
<div className="p-5 rounded-2xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 space-y-2">
<div className="font-bold text-base flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
Domain onboarding complete
</div>
<p className="text-xs leading-relaxed">
The domain <strong>{onboardDomain}</strong> was provisioned with the 13 DNS records and the RFC 2142 aliases.
</p>
<form onSubmit={handleOnboard} className="space-y-3 text-xs">
<div>
<label className="font-semibold text-slate-300 block mb-1">{t("admin.domainName")}</label>
<input
type="text"
value={onboardDomain}
onChange={(e) => setOnboardDomain(e.target.value)}
placeholder="example.org"
required
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-emerald-500"
/>
</div>
) : (
<form onSubmit={handleNextOnboardStep} className="space-y-4 text-xs">
{onboardStep === 1 && (
<div className="space-y-3">
<div>
<label className="font-semibold text-slate-300 block mb-1">Domain name</label>
<input
type="text"
value={onboardDomain}
onChange={(e) => setOnboardDomain(e.target.value)}
placeholder="novafirma.com"
required
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-emerald-500"
/>
</div>
<div>
<label className="font-semibold text-slate-300 block mb-1">E-mail do Administrador Principal</label>
<input
type="email"
value={onboardAdminEmail}
onChange={(e) => setOnboardAdminEmail(e.target.value)}
placeholder="admin@novafirma.com"
required
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-emerald-500"
/>
</div>
</div>
)}
{onboardStep === 2 && (
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-2">
<div className="font-bold text-white">The 13 DNS records generated:</div>
<pre className="font-mono text-[11px] text-slate-300 leading-relaxed overflow-x-auto p-3 bg-slate-950 rounded-lg border border-slate-800">
MX 10 mail.{onboardDomain}&#10;
TXT v=spf1 mx ~all&#10;
TXT default._domainkey IN TXT &quot;v=DKIM1; k=rsa; p=...&quot;&#10;
TXT ed25519._domainkey IN TXT &quot;v=DKIM1; k=ed25519; p=...&quot;&#10;
TXT _dmarc IN TXT &quot;v=DMARC1; p=quarantine; rua=mailto:dmarc@{onboardDomain}&quot;
</pre>
</div>
)}
{onboardStep === 3 && (
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-2">
<div className="font-bold text-white">Verificando Registros via API Cloudflare...</div>
<div className="p-3 bg-slate-950 rounded-lg text-emerald-400 font-mono text-xs flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>Cloudflare connection validated. 13 records created and propagated.</span>
</div>
</div>
)}
{onboardStep === 4 && (
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-2">
<div className="font-bold text-white">Provisioning the standard aliases (RFC 2142):</div>
<ul className="list-disc list-inside text-slate-300 space-y-1 font-mono text-xs">
<li>postmaster@{onboardDomain} &rarr; {onboardAdminEmail}</li>
<li>abuse@{onboardDomain} &rarr; {onboardAdminEmail}</li>
<li>security@{onboardDomain} &rarr; {onboardAdminEmail}</li>
</ul>
</div>
)}
<div className="flex justify-between pt-4 border-t border-slate-800">
<Button
type="button"
variant="secondary"
size="md"
disabled={onboardStep === 1}
onClick={() => setOnboardStep(onboardStep - 1)}
>
Voltar
</Button>
<Button type="submit" variant="primary" size="md">
<span>{onboardStep === 4 ? "Concluir Onboarding" : "Next step"}</span>
<ArrowRight className="w-4 h-4" />
</Button>
</div>
</form>
)}
<Button type="submit" variant="primary" size="md" disabled={onboarding}>
<Plus className="w-4 h-4" />
<span>{onboarding ? t("common.loading") : t("common.add")}</span>
</Button>
</form>
</Card>
)}
{/* TAB 3: GUIDED DKIM KEY ROTATION WIZARD */}
{activeTab === "dkim" && (
<Card className="max-w-3xl space-y-6">
<Card className="max-w-3xl space-y-5">
<div>
<h2 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
<KeyRound className="w-5 h-5 text-indigo-400" />
DKIM key rotation
{t("admin.dkimTitle")}
</h2>
<p className="text-xs text-slate-400">
Replace a domain signing key without interrupting deliverability.
{selected ? selected.name : t("admin.noDomains")}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Tipo da Chave de Assinatura</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimAlgorithm")}</label>
<select
value={dkimKeyType}
onChange={(e) => setDkimKeyType(e.target.value as "Ed25519" | "RSA-2048")}
value={dkimAlgorithm}
onChange={(e) => setDkimAlgorithm(e.target.value as "ed25519" | "rsa2048")}
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-emerald-500"
>
<option value="Ed25519">Ed25519 (fast and compact)</option>
<option value="RSA-2048">RSA-2048 (Compatibilidade Legada)</option>
<option value="ed25519">Ed25519</option>
<option value="rsa2048">RSA-2048</option>
</select>
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Nome do Seletor DKIM</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimSelector")}</label>
<input
type="text"
value={dkimSelector}
onChange={(e) => setDkimSelector(e.target.value)}
pattern="[A-Za-z0-9-]{1,63}"
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>
{dkimRotationStep === "idle" && (
<Button variant="primary" size="md" onClick={handleStartDkimRotation}>
<KeyRound className="w-4 h-4" />
<span>Gerar Novo Par de Chaves DKIM</span>
</Button>
)}
<Button variant="primary" size="md" onClick={handleRotateDkim} disabled={rotating || !selected}>
<KeyRound className="w-4 h-4" />
<span>{rotating ? t("common.loading") : t("admin.dkimRotate")}</span>
</Button>
{dkimRotationStep === "generated" && generatedDkimTxt && (
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-4 text-xs">
<div className="font-bold text-white">1. Adicione o Novo Registro de Seletor DKIM no DNS:</div>
{dkimRecord && (
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-3 text-xs">
<div className="font-bold text-white">{t("admin.dkimPublishRecord")}</div>
<div className="p-3 bg-slate-950 rounded-lg border border-slate-800 font-mono text-[11px] text-emerald-400 break-all">
{dkimSelector}._domainkey.example.com IN TXT &quot;{generatedDkimTxt}&quot;
{dkimRecord.name} IN TXT &quot;{dkimRecord.value}&quot;
</div>
<div className="p-3.5 rounded-xl bg-amber-500/15 border border-amber-500/25 text-amber-300">
<strong>Dual-signature overlap active (7 days)</strong>
<p className="mt-1">Both keys sign outbound mail so nothing fails while DNS propagates.</p>
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
</div>
<Button variant="primary" size="md" onClick={handleActivateDkim}>
Ativar Nova Chave e Desativar Anterior
</Button>
</div>
)}
{dkimRotationStep === "active" && (
<div className="p-4 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 font-bold text-xs flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>DKIM key rotation complete. The selector &quot;{dkimSelector}&quot; is active.</span>
</div>
)}
</Card>
@@ -16,14 +16,17 @@ import {
Menu,
X,
Lock,
Mail,
} from "lucide-react";
import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount } from "../../../lib/useAccount";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const account = useAccount("admin");
const navItems = [
{ href: "/admin/dashboard", label: t("admin.dashboardTitle"), icon: LayoutDashboard },
@@ -56,14 +59,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div>
</div>
{/* Security Badge Indicator */}
<div className="mb-6 p-2.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs text-emerald-300 flex items-center gap-2">
<Lock className="w-4 h-4 text-emerald-400 flex-shrink-0" />
<div>
<strong className="block text-[11px] font-semibold text-emerald-300">Isolated Surface</strong>
<span className="text-[10px] text-emerald-400/80 font-mono">aud: lambdamail:admin</span>
</div>
</div>
{/* Leaving the console needs no ceremony - the webmail session was
never given up to get here, so this is a plain link back. Coming the
other way is what costs a second factor. */}
<Link
href="/user/mail/inbox"
className="mb-6 flex items-center gap-2 p-2.5 rounded-xl bg-indigo-500/10 border border-indigo-500/25 text-xs font-semibold text-indigo-300 hover:border-indigo-500/50 hover:bg-indigo-500/15 transition-all"
>
<Mail className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("admin.backToWebmail")}</span>
</Link>
{/* Admin Navigation */}
<nav className="space-y-1 text-sm font-medium">
@@ -107,12 +112,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="pt-3 border-t border-slate-800/80">
<div className="flex items-center justify-between p-2.5 rounded-xl bg-slate-900 border border-slate-800">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center font-bold text-xs flex-shrink-0">
A
<div className="w-8 h-8 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center font-bold text-xs flex-shrink-0 uppercase">
{account?.email?.[0] ?? "?"}
</div>
<div className="truncate">
<div className="text-xs font-semibold text-slate-200 truncate">admin@lambdamail.local</div>
<div className="text-[10px] text-emerald-400 font-mono">SUPER_ADMIN (2FA)</div>
<div className="text-xs font-semibold text-slate-200 truncate">
{account?.email ?? t("common.loading")}
</div>
<div className="text-[10px] text-emerald-400 font-mono flex items-center gap-1">
<Lock className="w-3 h-3" />
{account?.role ?? ""}
</div>
</div>
</div>
</div>
@@ -129,7 +139,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center text-white font-bold">
<Sliders className="w-4 h-4" />
</div>
<span className="font-bold text-slate-100">LambdaMail Admin</span>
<span className="font-bold text-slate-100">{t("common.appName")} {t("common.adminPortal")}</span>
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
@@ -10,7 +10,6 @@ import {
Unlock,
Trash2,
ShieldCheck,
ShieldAlert,
ArrowRight,
Upload,
AlertCircle,
@@ -106,7 +105,7 @@ export default function AdminMailboxesPage() {
}))
);
} catch {
setError("Could not load the accounts.");
setError(t("errors.loadFailed"));
}
}, []);
@@ -222,7 +221,7 @@ export default function AdminMailboxesPage() {
setError(data.message ?? "Falha ao importar CSV");
}
} catch {
setError("Network error during the bulk import.");
setError(t("errors.serverError"));
}
setTimeout(() => setCsvSuccessMessage(null), 4000);
};
@@ -236,7 +235,7 @@ export default function AdminMailboxesPage() {
{t("admin.mailboxesTitle")}
</h1>
<p className="text-sm text-slate-400 mt-1">
Accounts, aliases, CSV import and authentication policy.
{t("admin.mailboxesSubtitle")}
</p>
</div>
@@ -275,7 +274,7 @@ export default function AdminMailboxesPage() {
}`}
>
<FileSpreadsheet className="w-3.5 h-3.5" />
<span>CSV import</span>
<span>CSV</span>
</button>
</div>
</div>
@@ -293,10 +292,10 @@ export default function AdminMailboxesPage() {
<div>
<h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-emerald-400" />
Two-factor requirement policy
{t("admin.mfaPolicyTitle")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">
Choose how strictly this domain requires a second factor.
{t("admin.mfaPolicyIntro")}
</p>
</div>
@@ -309,7 +308,7 @@ export default function AdminMailboxesPage() {
: "border-slate-800 text-slate-400 hover:text-slate-200"
}`}
>
Opcional
{t("admin.mfaOptional")}
</button>
<button
onClick={() => setMfaPolicy("required_admins")}
@@ -319,7 +318,7 @@ export default function AdminMailboxesPage() {
: "border-slate-800 text-slate-400 hover:text-slate-200"
}`}
>
Required for admins
{t("admin.mfaAdmins")}
</button>
<button
onClick={() => setMfaPolicy("required_all")}
@@ -329,7 +328,7 @@ export default function AdminMailboxesPage() {
: "border-slate-800 text-slate-400 hover:text-slate-200"
}`}
>
Required for everyone
{t("admin.mfaEveryone")}
</button>
</div>
</div>
@@ -358,7 +357,7 @@ export default function AdminMailboxesPage() {
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Initial password (at least 12 characters)"
placeholder={t("ui.newPassword")}
minLength={12}
required
className="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-emerald-500"
@@ -376,11 +375,11 @@ export default function AdminMailboxesPage() {
type="number"
value={newQuota}
onChange={(e) => setNewQuota(parseInt(e.target.value, 10))}
placeholder="Cota (MB)"
placeholder={t("ui.storageQuota")}
className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-emerald-500"
/>
<Button type="submit" variant="primary" size="md">
Criar Conta
{t("admin.createAccount")}
</Button>
</form>
</Card>
@@ -395,11 +394,11 @@ export default function AdminMailboxesPage() {
<thead>
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
<th className="p-3.5">{t("ui.emailAddress")}</th>
<th className="p-3.5">Role</th>
<th className="p-3.5">{t("ui.storageQuota")}</th>
<th className="p-3.5">Status 2FA</th>
<th className="p-3.5">{t("ui.accountStatus")}</th>
<th className="p-3.5">Actions</th>
<th className="p-3.5">{t("ui.storageQuota")}</th>
<th className="p-3.5">{t("ui.twoFactorSection")}</th>
<th className="p-3.5">{t("ui.accountStatus")}</th>
<th className="p-3.5">{t("ui.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-mono">
@@ -465,7 +464,7 @@ export default function AdminMailboxesPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<Plus className="w-4 h-4 text-emerald-400" />
Criar Alias de Roteamento
{t("admin.createAlias")}
</CardTitle>
</CardHeader>
<form onSubmit={handleCreateAlias} className="flex flex-col sm:flex-row gap-3 text-xs">
@@ -486,7 +485,7 @@ export default function AdminMailboxesPage() {
className="flex-1 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-emerald-500"
/>
<Button type="submit" variant="primary" size="md">
Adicionar Alias
{t("admin.addAlias")}
</Button>
</form>
</Card>
@@ -500,10 +499,10 @@ export default function AdminMailboxesPage() {
<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">Alias address</th>
<th className="p-3.5">Destino Final</th>
<th className="p-3.5">Domain</th>
<th className="p-3.5">Actions</th>
<th className="p-3.5">{t("ui.emailAddress")}</th>
<th className="p-3.5">{t("ui.destination")}</th>
<th className="p-3.5">{t("admin.domainsTitle")}</th>
<th className="p-3.5">{t("ui.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-mono">
@@ -563,7 +562,7 @@ export default function AdminMailboxesPage() {
<div className="flex gap-3">
<Button variant="secondary" size="md" onClick={handleParseCsv}>
Preview CSV
{t("admin.csvPreview")}
</Button>
{csvPreview.length > 0 && (
@@ -1,104 +1,201 @@
"use client";
import React, { useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
/**
* The outbound queue, read from the database.
*
* This screen used to be a fixture: two invented jobs ("job-101",
* user@example.com -> partner@remotehost.com) held in component state, whose
* Retry and Freeze buttons only edited that local array. It showed the same two
* rows on every deployment, never displayed a real stuck message, and reported
* success for actions that had not happened. /api/v1/admin/queue was already
* serving the real thing.
*/
/** Mirrors the outbound_jobs row shape returned by the queue endpoint. */
interface QueueJob {
id: string;
sender: string;
recipient: string;
destinationDomain: string;
attempts: number;
lastError: string;
status: "QUEUED" | "RETRIABLE" | "FROZEN";
envelope_from: string;
envelope_to: string;
destination_domain: string;
status: string;
attempt: number;
next_attempt_at: string | null;
last_smtp_code: number | null;
last_error: string | null;
}
const INITIAL_JOBS: QueueJob[] = [
{ id: "job-101", sender: "user@example.com", recipient: "partner@remotehost.com", destinationDomain: "remotehost.com", attempts: 1, lastError: "Connection timeout to 198.51.100.25:25", status: "RETRIABLE" },
{ id: "job-102", sender: "admin@example.com", recipient: "alerts@monitoring.org", destinationDomain: "monitoring.org", attempts: 0, lastError: "None", status: "QUEUED" },
];
interface QueueSummary {
by_status: Record<string, number>;
recent: QueueJob[];
}
/** Only failure states earn a warning colour; the rest are ordinary progress. */
function statusClass(status: string): string {
if (status === "BOUNCED" || status === "FROZEN") return "badge-danger";
if (status === "DEFERRED") return "badge-warning";
return "badge-verified";
}
export default function AdminQueuePage() {
const t = useTranslations();
const [jobs, setJobs] = useState<QueueJob[]>(INITIAL_JOBS);
const [summary, setSummary] = useState<QueueSummary | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const handleRetry = (id: string) => {
setJobs(jobs.map((j) => (j.id === id ? { ...j, status: "QUEUED", attempts: j.attempts + 1 } : j)));
setMessage(`Job ${id} forced for immediate retry attempt.`);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/v1/admin/queue");
if (!res.ok) throw new Error();
setSummary(await res.json());
setError(null);
} catch {
// Left null so the table renders empty rather than showing stale or
// invented rows while the service is unreachable.
setSummary(null);
setError(t("errors.loadFailed"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
/**
* Retry and cancel are the two actions the service actually implements.
* The old "Freeze" button named a third that has never existed anywhere in
* the backend, so pressing it changed nothing but the colour of a cell.
*/
const act = async (id: string, action: "retry" | "cancel") => {
setMessage(null);
setError(null);
const res = await fetch(`/api/v1/admin/queue/${encodeURIComponent(id)}/${action}`, {
method: "POST",
});
if (!res.ok) {
setError(t("errors.serverError"));
return;
}
setMessage(t(action === "retry" ? "admin.jobRetried" : "admin.jobCancelled", { id }));
// Re-read rather than patching local state, so what is on screen is what
// the queue now holds.
await load();
};
const handleFreeze = (domain: string) => {
setJobs(jobs.map((j) => (j.destinationDomain === domain ? { ...j, status: "FROZEN" } : j)));
setMessage(`Delivery destination domain '${domain}' has been frozen.`);
};
const jobs = summary?.recent ?? [];
return (
<div className="p-8 space-y-8">
<div>
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
<p className="text-xs text-slate-400">Inspect outbound queue, retry retriable jobs, or freeze problematic destinations.</p>
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
<p className="text-xs text-slate-400">{t("admin.queueSubtitle")}</p>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="flex items-center gap-2 self-start rounded-xl border border-slate-700 px-3.5 py-2 text-xs font-medium text-slate-300 transition-colors hover:border-emerald-500/50 hover:text-white disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
<span>{t("common.refresh")}</span>
</button>
</div>
{/* Counts per status, straight from the by_status aggregate. */}
{summary && Object.keys(summary.by_status).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(summary.by_status).map(([status, count]) => (
<span
key={status}
className="rounded-lg border border-slate-800 bg-slate-900/70 px-3 py-1.5 text-xs text-slate-300"
>
<span className="font-mono font-bold text-white">{count}</span> {status}
</span>
))}
</div>
)}
{message && (
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
{message}
</div>
)}
{error && (
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs">
{error}
</div>
)}
{/* Queue Jobs Table */}
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
Outbound Jobs ({jobs.length})
{t("admin.queueTitle")} ({jobs.length})
</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">{t("ui.jobId")}</th>
<th className="p-3">{t("ui.sender")}</th>
<th className="p-3">{t("ui.recipient")}</th>
<th className="p-3">{t("ui.destination")}</th>
<th className="p-3">{t("ui.attempts")}</th>
<th className="p-3">{t("ui.lastError")}</th>
<th className="p-3">Status</th>
<th className="p-3">{t("ui.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-mono">
{jobs.map((j) => (
<tr key={j.id} className="hover:bg-slate-900/30">
<td className="p-3 font-bold text-slate-200">{j.id}</td>
<td className="p-3 text-slate-300">{j.sender}</td>
<td className="p-3 text-slate-300">{j.recipient}</td>
<td className="p-3 text-emerald-400">{j.destinationDomain}</td>
<td className="p-3 text-slate-400">{j.attempts}</td>
<td className="p-3 text-slate-400 truncate max-w-xs">{j.lastError}</td>
<td className="p-3">
<span className={j.status === "QUEUED" ? "badge-verified px-2 py-0.5 rounded text-[10px]" : "badge-warning px-2 py-0.5 rounded text-[10px]"}>
{j.status}
</span>
</td>
<td className="p-3 flex items-center gap-2">
<button
onClick={() => handleRetry(j.id)}
className="px-2 py-1 rounded bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 text-[10px] font-bold"
>
Retry
</button>
<button
onClick={() => handleFreeze(j.destinationDomain)}
className="px-2 py-1 rounded bg-red-600/20 hover:bg-red-600/30 text-red-300 text-[10px] font-bold"
>
Freeze
</button>
</td>
{jobs.length === 0 && !loading ? (
<div className="p-8 text-center text-xs text-slate-500">{t("admin.queueEmpty")}</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">{t("ui.jobId")}</th>
<th className="p-3">{t("ui.sender")}</th>
<th className="p-3">{t("ui.recipient")}</th>
<th className="p-3">{t("ui.destination")}</th>
<th className="p-3">{t("ui.attempts")}</th>
<th className="p-3">{t("ui.lastError")}</th>
<th className="p-3">{t("common.status")}</th>
<th className="p-3">{t("ui.actions")}</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-slate-800/60 font-mono">
{jobs.map((j) => (
<tr key={j.id} className="hover:bg-slate-900/30">
<td className="p-3 font-bold text-slate-200">{j.id.slice(0, 8)}</td>
<td className="p-3 text-slate-300">{j.envelope_from}</td>
<td className="p-3 text-slate-300">{j.envelope_to}</td>
<td className="p-3 text-emerald-400">{j.destination_domain}</td>
<td className="p-3 text-slate-400">{j.attempt}</td>
<td className="p-3 text-slate-400 truncate max-w-xs" title={j.last_error ?? ""}>
{j.last_error
? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}`
: t("common.none")}
</td>
<td className="p-3">
<span className={`${statusClass(j.status)} px-2 py-0.5 rounded text-[10px]`}>
{j.status}
</span>
</td>
<td className="p-3 flex items-center gap-2">
<button
type="button"
onClick={() => void act(j.id, "retry")}
className="px-2 py-1 rounded bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 text-[10px] font-bold"
>
{t("admin.retryJob")}
</button>
<button
type="button"
onClick={() => void act(j.id, "cancel")}
className="px-2 py-1 rounded bg-red-600/20 hover:bg-red-600/30 text-red-300 text-[10px] font-bold"
>
{t("admin.cancelJob")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
@@ -8,11 +8,7 @@ import {
ListOrdered,
CheckCircle2,
Search,
FileText,
Key,
Shield,
Activity,
AlertTriangle,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
@@ -64,6 +60,15 @@ export default function AdminSecurityPage() {
const [readiness, setReadiness] = useState<PreflightCheck[] | null>(null);
const [tracedSteps, setTracedSteps] = useState<{ step: number; title: string; detail: string; status: string }[] | null>(null);
/**
* Live certificate state from the protocols service, which owns the cert
* watcher. The panel this replaced was three static cards - "Let's Encrypt
* RSA 4096", "3 1 1 SHA-256 Verified", "mode=enforce" - plus a fixed list of
* cipher suites, none of it read from anywhere. It reported a healthy,
* DANE-verified certificate on a deployment serving a self-signed one.
*/
const [tlsStatus, setTlsStatus] = useState<Record<string, unknown> | null>(null);
React.useEffect(() => {
fetch("/api/v1/admin/rspamd/thresholds")
.then((r) => (r.ok ? r.json() : null))
@@ -93,18 +98,32 @@ export default function AdminSecurityPage() {
)
.catch(() => undefined);
void fetch("/api/v1/admin/tls")
.then((r) => (r.ok ? r.json() : null))
.then(setTlsStatus)
.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[]
// The service sends a key and its subject, so the label is built
// in the reader's language. It used to send a finished English
// sentence ("DNS records for example.com") that appeared verbatim
// in an otherwise translated console.
data.checks.map(
(
c: { key?: string; target?: string; name: string; status: string; detail?: string },
index: number,
) => ({
id: index + 1,
name: c.key && c.target ? t(`admin.${c.key}`, { domain: c.target }) : c.name,
category: t("ui.domainVerification"),
status: c.status === "PASS" ? "PASSED" : c.status === "FAIL" ? "FAILED" : "WARNING",
details: c.detail ?? "",
}),
) as PreflightCheck[]
);
}
})
@@ -142,13 +161,11 @@ export default function AdminSecurityPage() {
}))
);
} 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" },
]);
// An empty result means the queue id was not found. This used to
// invent a five-step delivery trace instead - TLS 1.3 accepted, SPF
// pass, Rspamd score 0.8 - for a message that does not exist, which is
// the worst possible answer to "what happened to this mail?".
setTracedSteps([]);
}
} catch {
// fallback
@@ -163,9 +180,7 @@ export default function AdminSecurityPage() {
<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>
<p className="text-sm text-slate-400 mt-1">{t("admin.securitySubtitle")}</p>
</div>
{/* Tab Selector Controls */}
@@ -179,7 +194,7 @@ export default function AdminSecurityPage() {
}`}
>
<ShieldCheck className="w-3.5 h-3.5" />
<span>Placar 10/10</span>
<span>{t("admin.tabReadiness")}</span>
</button>
<button
@@ -191,7 +206,7 @@ export default function AdminSecurityPage() {
}`}
>
<Lock className="w-3.5 h-3.5" />
<span>TLS &amp; Certificados</span>
<span>{t("admin.tabTls")}</span>
</button>
<button
@@ -203,7 +218,7 @@ export default function AdminSecurityPage() {
}`}
>
<Sliders className="w-3.5 h-3.5" />
<span>Limites Rspamd</span>
<span>{t("admin.tabRspamd")}</span>
</button>
<button
@@ -215,7 +230,7 @@ export default function AdminSecurityPage() {
}`}
>
<ListOrdered className="w-3.5 h-3.5" />
<span>Audit Log &amp; Trace</span>
<span>{t("admin.tabAudit")}</span>
</button>
</div>
</div>
@@ -223,20 +238,42 @@ export default function AdminSecurityPage() {
{/* TAB 1: 10/10 READINESS SCORECARD */}
{activeTab === "readiness" && (
<div className="space-y-6">
<Card className="flex items-center justify-between p-6">
{/* The score is counted from the checks that came back. It used to
read a hardcoded "10 / 10" with an "all checks passed" badge
regardless of what the preflight endpoint reported - including
when it reported failures, or nothing at all. */}
<Card className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 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
{t("admin.preflightTitle")}
</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>
<p className="text-xs text-slate-400 mt-1">{t("admin.readinessIntro")}</p>
</div>
{readiness && readiness.length > 0 ? (
(() => {
const passed = readiness.filter((c) => c.status === "PASSED").length;
const total = readiness.length;
return (
<div className="flex items-center gap-3">
<span
className={`text-3xl font-black font-mono ${
passed === total ? "text-emerald-400" : "text-amber-400"
}`}
>
{passed} / {total}
</span>
<Badge variant={passed === total ? "success" : "warning"}>
{passed === total
? t("ui.allChecksPassed")
: t("admin.checksPassed", { passed, total })}
</Badge>
</div>
);
})()
) : (
<span className="text-xs text-slate-500">{t("admin.noChecks")}</span>
)}
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -257,42 +294,63 @@ export default function AdminSecurityPage() {
</div>
)}
{/* TAB 2: TLS CERTIFICATES & POLICY PANEL */}
{/* TAB 2: TLS - read from the protocols service cert watcher */}
{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
{t("admin.tlsTitle")}
</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>
{tlsStatus ? (
<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">{t("admin.certificateState")}</div>
<div
className={`font-bold text-sm ${
tlsStatus.state === "OK" ? "text-emerald-400" : "text-amber-400"
}`}
>
{String(tlsStatus.state ?? "UNKNOWN")}
</div>
<div className="text-[10px] text-slate-500 font-mono">
{typeof tlsStatus.expires_in_days === "number"
? t("admin.expiresInDays", { days: tlsStatus.expires_in_days })
: t("admin.noCertificate")}
</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>
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
<div className="text-slate-400 font-medium">{t("admin.mailHost")}</div>
<div className="text-slate-100 font-bold text-sm break-all">
{String(tlsStatus.mail_host ?? "-")}
</div>
<div className="text-[10px] text-slate-500 font-mono">
{t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")}
</div>
</div>
<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>
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
<div className="text-slate-400 font-medium">{t("admin.watcherHealthy")}</div>
<div
className={`font-bold text-sm ${
tlsStatus.watcher_healthy ? "text-emerald-400" : "text-amber-400"
}`}
>
{tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")}
</div>
<div className="text-[10px] text-slate-500 font-mono">
{t("admin.lastReload")}:{" "}
{tlsStatus.last_reload
? new Date(String(tlsStatus.last_reload)).toLocaleString()
: t("common.never")}
</div>
</div>
</div>
) : (
<div className="p-6 text-center text-slate-500">{t("errors.loadFailed")}</div>
)}
</Card>
</div>
)}
@@ -303,21 +361,21 @@ export default function AdminSecurityPage() {
<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
{t("admin.tabRspamd")}
</h2>
<p className="text-slate-400">Set the scores that trigger greylisting, header tagging and rejection.</p>
<p className="text-slate-400">{t("admin.rspamdIntro")}</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>
<span>{t("admin.rspamdSaved")}</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>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.greylistScore")}</label>
<input
type="number"
step="0.5"
@@ -328,7 +386,7 @@ export default function AdminSecurityPage() {
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Header-tagging score (default 6.0)</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.headerScore")}</label>
<input
type="number"
step="0.5"
@@ -339,7 +397,7 @@ export default function AdminSecurityPage() {
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Reject score (default 15.0)</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.rejectScore")}</label>
<input
type="number"
step="0.5"
@@ -350,7 +408,7 @@ export default function AdminSecurityPage() {
</div>
<Button type="submit" variant="primary" size="md">
Salvar Limites Rspamd
{t("admin.saveThresholds")}
</Button>
</form>
</Card>
@@ -364,9 +422,9 @@ export default function AdminSecurityPage() {
<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
{t("admin.traceTitle")}
</h2>
<p className="text-slate-400">Rastreie as etapas exatas de processamento de qualquer e-mail por Queue ID ou UUID.</p>
<p className="text-slate-400">{t("admin.tracePlaceholder")}</p>
</div>
<form onSubmit={handleSearchQueueId} className="flex gap-3">
@@ -374,12 +432,12 @@ export default function AdminSecurityPage() {
type="text"
value={searchQueueId}
onChange={(e) => setSearchQueueId(e.target.value)}
placeholder="Informe o Queue ID (ex: q-9812-ax)..."
placeholder={t("admin.tracePlaceholder")}
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
{t("admin.traceTitle")}
</Button>
</form>
@@ -404,7 +462,7 @@ export default function AdminSecurityPage() {
{/* 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
{t("admin.auditLog")}
</div>
<div className="overflow-x-auto">
@@ -3,6 +3,7 @@
import React, { useState } from "react";
import Link from "next/link";
import { useTranslations } from "../../../../i18n/provider";
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
export default function AdminLoginPage() {
const t = useTranslations();
@@ -33,7 +34,7 @@ export default function AdminLoginPage() {
body: JSON.stringify({ code: mfaCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Verification failed");
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
// Shown once. The operator signs in again with the factor they just
// created, which is also the first proof that it works.
setRecoveryCodes(data.recovery_codes ?? []);
@@ -51,7 +52,7 @@ export default function AdminLoginPage() {
body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Admin verification failed");
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
window.location.href = "/admin/dashboard";
return;
}
@@ -72,14 +73,14 @@ export default function AdminLoginPage() {
headers: { Authorization: `Bearer ${data.enrollment_token}` },
});
const enrolData = await enroll.json();
if (!enroll.ok) throw new Error(enrolData.message || "Could not start enrolment");
if (!enroll.ok) throw new Error(enrolData.message || t("auth.enrolmentFailed"));
setEnrolmentToken(data.enrollment_token);
setEnrolSecret(enrolData.secret);
setEnrolUri(enrolData.uri);
return;
}
if (!res.ok) throw new Error(data.message || "Admin authentication failed");
if (!res.ok) throw new Error(data.message || t("auth.authFailed"));
if (data.mfa_required) {
setChallengeToken(data.challenge_token);
@@ -87,7 +88,7 @@ export default function AdminLoginPage() {
window.location.href = "/admin/dashboard";
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "An error occurred");
setError(err instanceof Error ? err.message : t("auth.genericError"));
} finally {
setLoading(false);
}
@@ -102,7 +103,7 @@ export default function AdminLoginPage() {
</div>
<div>
<h1 className="text-xl font-bold text-white">{t("auth.adminLoginTitle")}</h1>
<p className="text-xs text-slate-400">Surface: /admin/* (Cookie: Path=/admin, MFA Mandatory)</p>
<p className="text-xs text-slate-400">{t("auth.adminStepUpNote")}</p>
</div>
</div>
@@ -112,45 +113,24 @@ export default function AdminLoginPage() {
</div>
)}
{/* The recovery-code step is terminal: it owns its own buttons, so the
form's submit button is not rendered underneath it. That overlap is
what previously put two identical "sign in" buttons on the screen
and left no way to copy the codes it insisted you save. */}
{recoveryCodes ? (
<RecoveryCodes
codes={recoveryCodes}
onContinue={() => setRecoveryCodes(null)}
continueLabel={t("auth.signInButton")}
/>
) : (
<form onSubmit={handleLogin} className="space-y-4">
{recoveryCodes ? (
<div className="space-y-3">
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-300">
<div className="mb-1 font-bold">{t("settings.recoveryCodesTitle")}</div>
<p className="mb-2">{t("settings.saveRecoveryCodes")}</p>
<div className="grid grid-cols-2 gap-1 font-mono text-[11px] text-white">
{recoveryCodes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
</div>
<button
type="button"
onClick={() => setRecoveryCodes(null)}
className="w-full rounded-xl bg-emerald-600 py-3 font-medium text-white transition-colors hover:bg-emerald-500"
>
{t("auth.signInButton")}
</button>
</div>
) : enrolSecret ? (
<div className="space-y-3">
{enrolSecret ? (
<div className="space-y-4">
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
{t("auth.mfaRequired")}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-300">{t("settings.enableMfa")}</label>
<code className="block break-all rounded-lg border border-slate-800 bg-slate-900 p-3 font-mono text-sm text-white">
{enrolSecret}
</code>
{enrolUri && (
<a
href={enrolUri}
className="mt-1 block truncate text-[11px] text-indigo-400 hover:underline"
>
{enrolUri}
</a>
)}
{t("auth.enrolmentIntro")}
</div>
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
<div>
<label className="mb-1 block text-xs font-medium text-slate-300">{t("auth.totpCodeLabel")}</label>
<input
@@ -214,13 +194,18 @@ export default function AdminLoginPage() {
disabled={loading}
className="w-full py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors shadow-lg shadow-emerald-600/20 disabled:opacity-50"
>
{loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
{loading
? t("common.loading")
: challengeToken || enrolSecret
? t("auth.verifyCodeButton")
: t("auth.signInButton")}
</button>
</form>
)}
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
<Link href="/" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
Back to Surface Selector
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
{t("admin.backToWebmail")}
</Link>
</div>
</div>
@@ -2,7 +2,13 @@
import React, { useState } from "react";
import Link from "next/link";
import { Mail, Lock, ShieldCheck, ArrowLeft, KeyRound, AlertCircle } from "lucide-react";
import {
Mail,
Lock,
ShieldCheck,
KeyRound,
AlertCircle,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card } from "../../../../components/ui/Card";
import { Button } from "../../../../components/ui/Button";
@@ -146,12 +152,6 @@ export default function UserLoginPage() {
</Button>
</form>
<div className="mt-6 pt-4 border-t border-slate-800/80 text-center">
<Link href="/" className="text-xs text-slate-400 hover:text-slate-200 transition-colors inline-flex items-center gap-1.5">
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back to surface selection</span>
</Link>
</div>
</Card>
</div>
);
@@ -12,7 +12,6 @@ import {
List,
ListOrdered,
Link as LinkIcon,
Trash2,
Undo2,
X,
Sparkles,
@@ -59,7 +58,7 @@ export default function ComposePage({
useEffect(() => {
if (!to && !subject) return;
const timer = setTimeout(() => {
setDraftStatus("Rascunho salvo automaticamente");
setDraftStatus(t("mail.draftSaved"));
}, 2500);
return () => clearTimeout(timer);
}, [to, subject]);
@@ -148,13 +147,16 @@ export default function ComposePage({
<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 items-center gap-2">
<Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />
<span>
{t("mail.undoSend")} ({undoSeconds}s restantes)
</span>
{/* 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
count was appended, so this read "Undo Send ({seconds}s
remaining) (9s remaining)". */}
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
</div>
<Button variant="primary" size="sm" onClick={handleUndo}>
<Undo2 className="w-3.5 h-3.5" />
Desfazer Envio
{t("mail.undoSendAction")}
</Button>
</div>
)}
@@ -168,7 +170,7 @@ export default function ComposePage({
onClick={() => setShowCcBcc(!showCcBcc)}
className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline"
>
{showCcBcc ? "- Ocultar Cc/Bcc" : "+ Adicionar Cc/Bcc"}
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
</button>
</div>
<input
@@ -231,7 +233,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("bold")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Negrito"
title={t("mail.bold")}
>
<Bold className="w-4 h-4" />
</button>
@@ -239,7 +241,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("italic")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Italic"
title={t("mail.italic")}
>
<Italic className="w-4 h-4" />
</button>
@@ -247,7 +249,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("underline")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Sublinhado"
title={t("mail.underline")}
>
<Underline className="w-4 h-4" />
</button>
@@ -255,7 +257,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("strikeThrough")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Tachado"
title={t("mail.strikethrough")}
>
<Strikethrough className="w-4 h-4" />
</button>
@@ -264,7 +266,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Lista"
title={t("mail.bulletList")}
>
<List className="w-4 h-4" />
</button>
@@ -272,7 +274,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("insertOrderedList")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Lista Numerada"
title={t("mail.numberedList")}
>
<ListOrdered className="w-4 h-4" />
</button>
@@ -280,11 +282,11 @@ export default function ComposePage({
<button
type="button"
onClick={() => {
const url = prompt("Insira a URL:");
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="Inserir Link"
title={t("mail.insertLink")}
>
<LinkIcon className="w-4 h-4" />
</button>
@@ -302,10 +304,10 @@ export default function ComposePage({
{/* Attachment Selector & Display */}
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-slate-300">Anexos ({attachments.length})</span>
<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>Adicionar Arquivos...</span>
<span>{t("mail.addFiles")}</span>
<input
type="file"
multiple
@@ -315,6 +317,16 @@ export default function ComposePage({
</label>
</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">
{t("mail.attachmentsUnsupported")}
</div>
)}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachments.map((file) => (
@@ -343,13 +355,13 @@ export default function ComposePage({
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Link href="/user/mail/inbox">
<Button variant="ghost" size="md">
Descartar
{t("mail.discard")}
</Button>
</Link>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="w-4 h-4" />
<span>{sending ? "Sending..." : "Send message"}</span>
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
</Button>
</div>
</form>
@@ -16,11 +16,14 @@ import {
LogOut,
Mail,
ShieldCheck,
ShieldAlert,
Sliders,
Menu,
X,
} from "lucide-react";
import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount";
interface FolderCount {
special_use: string;
@@ -33,6 +36,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
const pathname = usePathname();
const [folders, setFolders] = useState<FolderCount[]>([]);
const [mobileOpen, setMobileOpen] = useState(false);
const account = useAccount("user");
useEffect(() => {
void fetch("/api/v1/mail/folders")
@@ -48,9 +52,9 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
{ href: "/user/mail/sent", label: t("mail.sent"), icon: Send, role: "sent" },
{ href: "/user/mail/drafts", label: t("mail.drafts"), icon: FileText, role: "drafts" },
{ href: "/user/mail/archive", label: "Archive", icon: Archive, role: "archive" },
{ href: "/user/mail/junk", label: "Spam", icon: AlertTriangle, role: "junk" },
{ href: "/user/mail/trash", label: "Trash", icon: Trash2, role: "trash" },
{ href: "/user/mail/archive", label: t("mail.archive"), icon: Archive, role: "archive" },
{ href: "/user/mail/junk", label: t("mail.junk"), icon: AlertTriangle, role: "junk" },
{ href: "/user/mail/trash", label: t("mail.trash"), icon: Trash2, role: "trash" },
];
const handleLogout = () => {
@@ -134,6 +138,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</button>
</div>
{/* The console is reachable from inside the app for the accounts that
may open it, which is why there is no longer a surface chooser in
front of the sign-in. It is a plain link to the admin sign-in, not a
direct jump: the admin audience is a separate token, so crossing
over means proving the second factor again. */}
{isAdminRole(account?.role) && (
<Link
href="/admin/login"
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>
)}
<Link
href="/user/settings"
className={`flex items-center justify-between p-2.5 rounded-xl border transition-all ${
@@ -143,13 +162,27 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
}`}
>
<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">
U
<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">user@lambdamail.local</div>
<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">
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" /> 2FA Ativo
{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>
@@ -8,10 +8,8 @@ import {
Paperclip,
Reply,
Forward,
ShieldAlert,
Inbox,
Filter,
CheckCircle2,
Image as ImageIcon,
Mail,
Clock,
@@ -158,7 +156,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
}`}
>
Todos ({messages.length})
{t("mail.allMessages")} ({messages.length})
</button>
<button
onClick={() => setActiveFilter("unread")}
@@ -168,7 +166,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
}`}
>
Unread
{t("mail.unreadFilter")}
</button>
<button
onClick={() => setActiveFilter("attachment")}
@@ -178,7 +176,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
}`}
>
Anexos
{t("mail.attachmentsFilter")}
</button>
</div>
</div>
@@ -366,7 +364,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{bodyHtml ? (
<div className="bg-white rounded-2xl overflow-hidden shadow-2xl border border-slate-800 min-h-[350px]">
<iframe
title="Message body"
title={t("ui.messageBody")}
srcDoc={bodyHtml}
sandbox=""
className="w-full h-[500px] border-0"
@@ -12,12 +12,12 @@ import {
Trash2,
Plus,
Lock,
Sparkles,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Card } from "../../../../components/ui/Card";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
interface SieveRule {
id: string;
@@ -62,16 +62,18 @@ export default function UserSettingsPage() {
const [newPassword, setNewPassword] = useState("");
const [vacationEnabled, setVacationEnabled] = useState(false);
const [vacationSubject, setVacationSubject] = useState("Out of office auto-reply");
const [vacationBody, setVacationBody] = useState("Thank you for your message. I am currently away from my desk.");
const [vacationSubject, setVacationSubject] = useState(() => t("settings.vacationDefaultSubject"));
const [vacationBody, setVacationBody] = useState(() => t("settings.vacationDefaultBody"));
const [vacationSaved, setVacationSaved] = useState(false);
const [signature, setSignature] = useState("");
const [sigSaved, setSigSaved] = useState(false);
const [rules, setRules] = useState<SieveRule[]>([
{ id: "rule-1", field: "Subject", match: "contains", value: "[Boletim]", action: "move", targetFolder: "Archive" },
]);
// Starts empty rather than with a sample rule. A seeded "[Boletim]" filter
// used to sit here, and because saving posts the whole list, the first time
// anyone added or removed a rule that invented filter was written into their
// real Sieve script and began moving their mail.
const [rules, setRules] = useState<SieveRule[]>([]);
const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
const [newValue, setNewValue] = useState("");
@@ -107,7 +109,7 @@ export default function UserSettingsPage() {
setSigSaved(true);
setTimeout(() => setSigSaved(false), 2000);
} catch {
setMessage("Falha ao salvar assinatura");
setMessage(t("settings.signatureSaveFailed"));
}
};
@@ -122,7 +124,7 @@ export default function UserSettingsPage() {
setVacationSaved(true);
setTimeout(() => setVacationSaved(false), 2000);
} catch {
setMessage("Could not save the auto-reply");
setMessage(t("settings.autoReplySaveFailed"));
}
};
@@ -246,9 +248,7 @@ export default function UserSettingsPage() {
<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">
Two-factor, Sieve rules, the auto-reply and your signature.
</p>
<p className="text-sm text-slate-400 mt-1">{t("settings.subtitle")}</p>
</div>
{message && (
@@ -266,9 +266,7 @@ export default function UserSettingsPage() {
<Shield className="w-5 h-5 text-indigo-400" />
{t("ui.twoFactorSection")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">
Proteja sua conta com Google Authenticator, Aegis, Authy ou 1Password.
</p>
<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")}
@@ -292,12 +290,7 @@ export default function UserSettingsPage() {
{qrSecret && (
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-4 text-xs">
<div className="text-slate-300">
Digitalize esta chave no seu aplicativo autenticador ou copie a chave secreta:
</div>
<div className="p-3 bg-slate-950 rounded-lg font-mono text-indigo-400 break-all border border-slate-800">
Secreto: {qrSecret}
</div>
<TotpEnrolment secret={qrSecret} uri={qrUri} />
<form
onSubmit={async (e) => {
e.preventDefault();
@@ -306,20 +299,25 @@ export default function UserSettingsPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
});
const data = await res.json();
const data = await res.json().catch(() => ({}));
if (res.ok) {
setMfaEnabled(true);
setRecoveryCodes(data.recovery_codes);
setRecoveryCodes(data.recovery_codes ?? []);
setQrSecret(null);
setTotpCode("");
} else {
setMessage(data.message ?? t("auth.verificationFailed"));
}
}}
className="pt-2 flex items-center gap-3"
>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder="Six-digit code"
placeholder={t("settings.sixDigitCode")}
maxLength={6}
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"
@@ -332,15 +330,11 @@ export default function UserSettingsPage() {
)}
{recoveryCodes && (
<div className="mt-4 p-4 rounded-xl bg-slate-900/90 border border-emerald-500/30">
<h3 className="text-xs font-bold text-white mb-1">{t("settings.recoveryCodesTitle")}</h3>
<p className="text-[11px] text-slate-400 mb-3">{t("settings.saveRecoveryCodes")}</p>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center font-mono text-xs text-emerald-400 bg-slate-950 p-3 rounded-xl border border-slate-800">
{recoveryCodes.map((code, idx) => (
<div key={idx} className="p-1 border border-slate-800 rounded-lg">{code}</div>
))}
</div>
</div>
<RecoveryCodes
codes={recoveryCodes}
onContinue={() => setRecoveryCodes(null)}
continueLabel={t("common.continue")}
/>
)}
</Card>
@@ -349,9 +343,9 @@ export default function UserSettingsPage() {
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Filter className="w-5 h-5 text-indigo-400" />
Filtros Visuais Sieve (RFC 5228)
{t("settings.sieveTitle")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">Automatic sorting and filing rules.</p>
<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">
@@ -360,9 +354,9 @@ export default function UserSettingsPage() {
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">Assunto</option>
<option value="From">De</option>
<option value="Header">Header</option>
<option value="Subject">{t("settings.fieldSubject")}</option>
<option value="From">{t("settings.fieldFrom")}</option>
<option value="Header">{t("settings.fieldHeader")}</option>
</select>
<select
@@ -370,16 +364,16 @@ export default function UserSettingsPage() {
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">contains</option>
<option value="equals">equals</option>
<option value="matches">corresponde a regex</option>
<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="Valor a corresponder..."
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"
/>
@@ -389,14 +383,14 @@ export default function UserSettingsPage() {
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">Mover para Arquivo</option>
<option value="flag">Flag message</option>
<option value="discard">Descartar</option>
<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>Adicionar Regra</span>
<span>{t("settings.addRule")}</span>
</Button>
</form>
@@ -404,7 +398,8 @@ export default function UserSettingsPage() {
{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">
SE <strong>{r.field}</strong> {r.match} &quot;{r.value}&quot; &rarr; THEN {r.action.toUpperCase()}
{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)}
@@ -423,9 +418,9 @@ export default function UserSettingsPage() {
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Palmtree className="w-5 h-5 text-indigo-400" />
Out of office auto-reply
{t("settings.vacationTitle")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">Reply automatically to incoming mail while you are awer ausente.</p>
<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
@@ -441,14 +436,14 @@ export default function UserSettingsPage() {
{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>Settings saved.</span>
<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">Assunto da Resposta</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationSubjectLabel")}</label>
<input
type="text"
value={vacationSubject}
@@ -458,7 +453,7 @@ export default function UserSettingsPage() {
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Message body</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationBodyLabel")}</label>
<textarea
rows={4}
value={vacationBody}
@@ -468,7 +463,7 @@ export default function UserSettingsPage() {
</div>
<Button type="submit" variant="primary" size="sm">
Save auto-reply
{t("settings.saveAutoReply")}
</Button>
</form>
)}
@@ -479,15 +474,15 @@ export default function UserSettingsPage() {
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-400" />
Assinatura de E-mail
{t("settings.signatureTitle")}
</h2>
<p className="text-slate-400 mt-0.5">Assinatura anexada automaticamente nas mensagens compostas.</p>
<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>Assinatura salva com sucesso!</span>
<span>{t("settings.signatureSaved")}</span>
</div>
)}
@@ -495,7 +490,7 @@ export default function UserSettingsPage() {
rows={4}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder="Atenciosamente,&#10;Seu Nome&#10;Seu Cargo"
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"
/>
@@ -526,7 +521,7 @@ export default function UserSettingsPage() {
type="text"
value={newAppPassLabel}
onChange={(e) => setNewAppPassLabel(e.target.value)}
placeholder="Thunderbird / Outlook Mobile"
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">
+16 -1
View File
@@ -1,3 +1,18 @@
/**
* Liveness probe for the web container.
*
* Deliberately shallow: it answers as soon as the Next server can route a
* request, and does not reach out to auth, storage or protocols. Those are
* gated by depends_on and carry probes of their own; folding them in here would
* mean a blip in one backend marks the frontend unhealthy and takes the whole
* site down with it.
*
* force-dynamic so the answer comes from the running server rather than a
* response baked in at build time, which would keep replying 200 out of the
* static cache even if the process were wedged.
*/
export const dynamic = "force-dynamic";
export function GET() {
return new Response(null, { status: 200 });
return Response.json({ status: "ok", service: "webmail" }, { status: 200 });
}
+16 -68
View File
@@ -1,70 +1,18 @@
import Link from "next/link";
import { redirect } from "next/navigation";
export default function LandingPage() {
return (
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="text-center max-w-2xl mx-auto mb-12">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 text-sm font-medium mb-6">
<span className="w-2 h-2 rounded-full bg-indigo-400 animate-pulse"></span>
LambdaMail v2.0 - Phase F4 Complete
</div>
<h1 className="text-4xl md:text-5xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-200 to-slate-400 bg-clip-text text-transparent mb-4">
Self-Hosted Mail Infrastructure
</h1>
<p className="text-slate-400 text-lg">
Integrated Webmail & Administration Platform featuring strict surface isolation, RFC 6238 TOTP authentication, and 13-record DNS automation.
</p>
</div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl w-full">
{/* Webmail Surface Card */}
<div className="glass-panel rounded-2xl p-8 hover:border-indigo-500/40 transition-all group flex flex-col justify-between">
<div>
<div className="w-12 h-12 rounded-xl bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 text-2xl font-bold mb-6 group-hover:scale-110 transition-transform">
@
</div>
<h2 className="text-2xl font-bold text-white mb-2">Webmail Surface</h2>
<p className="text-slate-400 text-sm mb-6">
Daily email workspace. Access your inbox, compose messages with sandboxed HTML rendering, manage Sieve filters, and 2FA settings.
</p>
<div className="text-xs text-slate-500 font-mono space-y-1 mb-8 bg-slate-900/60 p-3 rounded-lg border border-slate-800">
<div>Path: /user/*</div>
<div>Cookie: lm_user_session (Path=/user)</div>
<div>Audience: lambdamail:user</div>
</div>
</div>
<Link
href="/user/login"
className="w-full py-3 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-center transition-colors shadow-lg shadow-indigo-600/20"
>
Access Webmail Portal &rarr;
</Link>
</div>
{/* Admin Console Card */}
<div className="glass-panel rounded-2xl p-8 hover:border-emerald-500/40 transition-all group flex flex-col justify-between">
<div>
<div className="w-12 h-12 rounded-xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400 text-2xl font-bold mb-6 group-hover:scale-110 transition-transform">
&#9881;
</div>
<h2 className="text-2xl font-bold text-white mb-2">Management Console</h2>
<p className="text-slate-400 text-sm mb-6">
Stalwart-parity admin console. Manage domains, 13 DNS records, mailboxes, DKIM rotation, outbound queue, DMARC reports, and preflight checks.
</p>
<div className="text-xs text-slate-500 font-mono space-y-1 mb-8 bg-slate-900/60 p-3 rounded-lg border border-slate-800">
<div>Path: /admin/*</div>
<div>Cookie: lm_admin_session (Path=/admin)</div>
<div>Audience: lambdamail:admin (MFA Mandatory)</div>
</div>
</div>
<Link
href="/admin/login"
className="w-full py-3 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium text-center transition-colors shadow-lg shadow-emerald-600/20"
>
Access Admin Console &rarr;
</Link>
</div>
</div>
</div>
);
/**
* The root sends everyone straight into the mail client.
*
* This used to be a "choose your surface" splash listing the webmail and the
* admin console side by side, which asked visitors a question they should never
* have to answer: almost everyone wants their mail, and the handful who also
* administer the server reach the console from inside the app once they are
* signed in. It also advertised cookie names, audiences and internal paths to
* anonymous visitors for no reason.
*
* Anyone without a session is redirected on to /user/login by the middleware,
* so this needs no session check of its own.
*/
export default function RootPage() {
redirect("/user/mail/inbox");
}