Files
LambdaMail/services/webmail/src/app/(admin)/admin/domains/page.tsx
T
Lucas RayanandClaude Opus 5 c59548e311 Revert "revert: foto de perfil e BIMI, para restaurar o servico"
Repoe as duas funcionalidades. A reversao foi uma precaucao enquanto o site
estava em baixo, e acabou por ser ela a provar que a culpa nao era delas: o
502 continuou depois de reverter.

A avaria era o servico web nao declarar porta nenhuma, o que levava o Coolify
a gerar um servico Traefik apontado ao porto 80. Fica corrigida no commit
anterior, com expose 3000.

As migracoes 0012 e 0013 nunca sairam do repositorio nem da base de dados, por
isso o codigo reposto encontra as tabelas que espera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:29:12 -03:00

658 lines
26 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
/**
* 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 DnsRecordCheck {
type: string;
name: string;
expected: string;
verified: boolean;
detail: string;
proxied: boolean;
}
interface ReconcileResult {
domain: string;
created: number;
updated: number;
unchanged: number;
conflicts?: string[];
errors?: string[];
}
interface DnsVerification {
domain: string;
status: string;
verified: number;
total: number;
records: DnsRecordCheck[];
}
interface Domain {
id: string;
name: string;
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;
}
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<"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);
// The BIMI mark for the selected domain: the one thing an external mail
// client will ever render for this sender.
const [bimi, setBimi] = useState<{ configured: boolean; vmc_url?: string; bytes?: number } | null>(null);
const [bimiSvg, setBimiSvg] = useState("");
const [bimiVmc, setBimiVmc] = useState("");
const [bimiBusy, setBimiBusy] = useState(false);
const [bimiProblems, setBimiProblems] = useState<string[]>([]);
const [verification, setVerification] = useState<DnsVerification | null>(null);
// What the last reconciliation actually did, so the panel can say it rather
// than leaving the operator to infer it from the verification that follows.
const [reconcileResult, setReconcileResult] = useState<ReconcileResult | null>(null);
const [onboardDomain, setOnboardDomain] = useState("");
const [onboarding, setOnboarding] = useState(false);
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;
// Asks the protocols service to resolve every expected record and report
// each one. It used to post to the auth service, which has no resolver and
// could only re-read the status already in the database.
// Reloaded whenever the selected domain changes: the mark belongs to the
// domain, not to the console.
useEffect(() => {
if (!selected) {
setBimi(null);
return;
}
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`);
const data = res.ok ? await res.json() : { configured: false };
if (cancelled) return;
setBimi(data);
setBimiVmc(data.vmc_url ?? "");
setBimiSvg("");
setBimiProblems([]);
} catch {
if (!cancelled) setBimi({ configured: false });
}
})();
return () => {
cancelled = true;
};
}, [selected?.name]);
const saveBimi = async () => {
if (!selected || !bimiSvg.trim()) return;
setBimiBusy(true);
setBimiProblems([]);
setError(null);
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: bimiSvg, vmc_url: bimiVmc.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Every rule that failed, not the first: a receiver that rejects the
// mark shows nothing at all, so one round trip per rule is the worst
// possible way to learn this format.
setBimiProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setBimi({ configured: true, vmc_url: bimiVmc.trim(), bytes: bimiSvg.length });
setBimiSvg("");
setNotice(t("admin.bimiSaved"));
} catch {
setError(t("errors.serverError"));
} finally {
setBimiBusy(false);
}
};
const removeBimi = async () => {
if (!selected) return;
setBimiBusy(true);
try {
await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, { method: "DELETE" });
setBimi({ configured: false });
setBimiVmc("");
} finally {
setBimiBusy(false);
}
};
const handleReconcile = async () => {
if (!selected) return;
setReconciling(true);
setNotice(null);
setError(null);
setVerification(null);
setReconcileResult(null);
try {
// Publish first, then look. The button used to verify and then call an
// endpoint that only wrote an audit row, so it reported what was missing
// and never created any of it - which is what made the panel feel like
// it did nothing.
const applied = await fetch(
`/api/v1/admin/dns/reconcile?domain=${encodeURIComponent(selected.name)}`,
{ method: "POST" },
);
const result = await applied.json().catch(() => ({}));
if (!applied.ok) throw new Error(result.message);
setReconcileResult(result);
// Then verify, so what is shown is the state after publishing rather
// than the state that prompted it.
const res = await fetch(`/api/v1/admin/dns/verify?domain=${encodeURIComponent(selected.name)}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message);
setVerification(data);
// The audit trail still records who asked for it.
await fetch("/api/v1/admin/domains/reconcile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain_id: selected.id }),
}).catch(() => undefined);
await load();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
} finally {
setReconciling(false);
}
};
const handleOnboard = async (e: React.FormEvent) => {
e.preventDefault();
if (!onboardDomain.trim()) return;
setOnboarding(true);
setNotice(null);
setError(null);
try {
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: selected.name,
selector: dkimSelector,
algorithm: dkimAlgorithm,
}),
});
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);
}
};
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 },
];
const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
const input =
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
return (
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
<div>
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.domainsTitle")}</h1>
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.verifyIntro")}</p>
</div>
<div className="lm-tabstrip self-start">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
data-active={activeTab === tab.id}
aria-pressed={activeTab === tab.id}
className="lm-tab"
>
<Icon className="h-[15px] w-[15px] flex-none" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{notice && (
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
<span className="break-words">{notice}</span>
</div>
)}
{error && (
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] leading-relaxed text-rose-200 shadow-edge">
{error}
</div>
)}
{activeTab === "domains" && (
<div className="flex flex-col gap-3">
{domains.length === 0 && !loading ? (
<div className="rounded-2xl bg-dark-panel p-8 text-center text-[13px] text-slate-400 shadow-edge">
{t("admin.noDomains")}
</div>
) : (
domains.map((d) => (
<div
key={d.id}
className="flex flex-col gap-3.5 rounded-2xl bg-dark-panel px-[18px] py-4 shadow-edge"
>
<div className="flex flex-wrap items-center gap-3.5">
<div className="min-w-[200px] flex-1">
<div className="break-words text-lg font-medium leading-tight text-slate-100">{d.name}</div>
<div className="mt-1 text-[12.5px] leading-relaxed text-slate-400">
{t("admin.mailboxCount", { count: d.mailbox_count })}
{d.dmarc_policy ? ` \u00b7 DMARC: ${d.dmarc_policy}` : ""}
{d.mta_sts_mode ? ` \u00b7 MTA-STS: ${d.mta_sts_mode}` : ""}
{d.dane_enabled ? " \u00b7 DANE" : ""}
</div>
</div>
<div className="flex flex-none flex-wrap items-center gap-2.5">
<Badge variant={statusVariant(d.dns_status)}>{d.dns_status}</Badge>
<Button
variant={selectedId === d.id ? "primary" : "secondary"}
size="sm"
onClick={() => setSelectedId(d.id)}
>
{selectedId === d.id ? <CheckCircle2 className="h-3.5 w-3.5" /> : null}
{/* It said "Add" for a domain already on the list, and
"Status" once picked. It does neither: it chooses
which domain the actions below act on. */}
<span>{selectedId === d.id ? t("admin.domainSelected") : t("admin.selectDomain")}</span>
</Button>
</div>
</div>
<div className="text-[11.5px] text-slate-500">
{t("admin.lastChecked", {
when: d.dns_last_checked_at
? new Date(d.dns_last_checked_at).toLocaleString()
: t("common.never"),
})}
</div>
</div>
))
)}
<section className={panel}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
{selected ? t("admin.dnsRecordsFor", { domain: selected.name }) : t("ui.domainVerification")}
</h2>
<Button variant="secondary" size="sm" onClick={handleReconcile} disabled={reconciling || !selected}>
<RefreshCw className={`h-3.5 w-3.5 ${reconciling ? "animate-spin" : ""}`} />
<span>{t("admin.reconcileDns")}</span>
</Button>
</div>
{/* What the reconciliation did. Without this the panel showed only
the verification that followed, so an operator could not tell
whether anything had been published or the records had simply
been there all along. */}
{reconcileResult && (
<div className="flex flex-col gap-2 rounded-xl bg-dark-card p-3.5 shadow-edge">
<span className="text-[13px] text-slate-200">
{t("admin.reconcileSummary", {
created: reconcileResult.created,
updated: reconcileResult.updated,
unchanged: reconcileResult.unchanged,
})}
</span>
{(reconcileResult.conflicts ?? []).length > 0 && (
<div className="flex flex-col gap-1">
{/* Left untouched on purpose: a record of the right type
and name already holding another value may be something
the zone still needs. */}
<span className="text-[12px] text-amber-300">{t("admin.reconcileConflicts")}</span>
{(reconcileResult.conflicts ?? []).map((c) => (
<span key={c} className="font-mono text-[11.5px] text-slate-400">{c}</span>
))}
</div>
)}
{(reconcileResult.errors ?? []).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[12px] text-rose-300">{t("admin.reconcileErrors")}</span>
{(reconcileResult.errors ?? []).map((e) => (
<span key={e} className="font-mono text-[11.5px] text-slate-400">{e}</span>
))}
</div>
)}
</div>
)}
{verification && (
<>
<div className="flex flex-wrap items-center gap-3">
<span className="font-mono text-2xl font-medium tabular-nums text-slate-100">
{verification.verified} / {verification.total}
</span>
<Badge variant={statusVariant(verification.status)}>{verification.status}</Badge>
</div>
<div className="overflow-x-auto">
<table className="lm-table">
<thead>
<tr>
<th className="pl-0">{t("ui.recordName")}</th>
<th>{t("ui.expectedValue")}</th>
<th className="pr-0 text-right">{t("common.status")}</th>
</tr>
</thead>
<tbody>
{verification.records.map((rec, i) => (
<tr key={i} className="align-top">
<td className="whitespace-nowrap pl-0 text-[13px]">
<Badge variant="neutral" className="mr-2 font-mono">
{rec.type}
</Badge>
<span className="font-mono text-[12.5px] text-slate-200">{rec.name}</span>
</td>
<td className="max-w-[420px] break-all font-mono text-xs text-slate-400">
{rec.expected}
</td>
<td className="whitespace-nowrap pr-0 text-right">
{rec.verified ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px] text-indigo-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{rec.proxied ? t("admin.presentProxied") : t("admin.dnsStatusVerified")}
</span>
) : (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] text-amber-400"
title={rec.detail}
>
<AlertTriangle className="h-3.5 w-3.5" />
{t("admin.notPublished")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</section>
</div>
)}
{activeTab === "onboarding" && (
<section className={`${panel} max-w-[620px]`}>
<div>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("admin.onboardTitle")}</h2>
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.onboardIntro")}</p>
</div>
<form onSubmit={handleOnboard} className="flex flex-col gap-4">
<div>
<label htmlFor="onboard-domain" className={fieldLabel}>
{t("admin.domainName")}
</label>
<input
id="onboard-domain"
type="text"
value={onboardDomain}
onChange={(e) => setOnboardDomain(e.target.value)}
placeholder="example.org"
required
className={input}
/>
</div>
<Button type="submit" variant="primary" size="md" className="self-start" disabled={onboarding}>
<Plus className="h-4 w-4" />
<span>{onboarding ? t("common.loading") : t("common.add")}</span>
</Button>
</form>
</section>
)}
{/* BIMI: the only mark an external mail client ever renders for this
sender, and the reason a profile photo does not travel. */}
{activeTab === "domains" && selected && (
<section className={panel}>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
{t("admin.bimiTitle", { domain: selected.name })}
</h2>
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant={bimi?.configured ? "success" : "neutral"}>
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
</Badge>
{bimi?.configured && !bimi.vmc_url && (
<span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>
)}
</div>
<div>
<label htmlFor="bimi-svg" className={fieldLabel}>{t("admin.bimiSvg")}</label>
<textarea
id="bimi-svg"
value={bimiSvg}
onChange={(e) => setBimiSvg(e.target.value)}
rows={7}
spellCheck={false}
className={`${input} font-mono text-[12px]`}
/>
</div>
<div>
<label htmlFor="bimi-vmc" className={fieldLabel}>{t("admin.bimiVmc")}</label>
<input
id="bimi-vmc"
type="url"
value={bimiVmc}
onChange={(e) => setBimiVmc(e.target.value)}
placeholder="https://.../vmc.pem"
className={input}
/>
</div>
{bimiProblems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
{bimiProblems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void saveBimi()} disabled={bimiBusy || !bimiSvg.trim()}>
{bimiBusy ? t("common.loading") : t("common.save")}
</Button>
{bimi?.configured && (
<Button variant="ghost" size="sm" onClick={() => void removeBimi()} disabled={bimiBusy}>
{t("common.delete")}
</Button>
)}
</div>
</section>
)}
{activeTab === "dkim" && (
<section className={`${panel} max-w-[720px]`}>
<div>
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
<KeyRound className="h-[17px] w-[17px] flex-none text-indigo-500" />
{t("admin.dkimTitle")}
</h2>
<p className="mt-1 text-[13px] text-slate-400">{selected ? selected.name : t("admin.noDomains")}</p>
</div>
<div className="flex flex-wrap gap-3">
<div className="min-w-[200px] flex-1">
<label htmlFor="dkim-algorithm" className={fieldLabel}>
{t("admin.dkimAlgorithm")}
</label>
<select
id="dkim-algorithm"
value={dkimAlgorithm}
onChange={(e) => setDkimAlgorithm(e.target.value as "ed25519" | "rsa2048")}
className={input}
>
<option value="ed25519">Ed25519</option>
<option value="rsa2048">RSA-2048</option>
</select>
</div>
<div className="min-w-[200px] flex-1">
<label htmlFor="dkim-selector" className={fieldLabel}>
{t("admin.dkimSelector")}
</label>
<input
id="dkim-selector"
type="text"
value={dkimSelector}
onChange={(e) => setDkimSelector(e.target.value)}
pattern="[A-Za-z0-9-]{1,63}"
className={`${input} font-mono`}
/>
</div>
</div>
<Button
variant="primary"
size="md"
className="self-start"
onClick={handleRotateDkim}
disabled={rotating || !selected}
>
<KeyRound className="h-4 w-4" />
<span>{rotating ? t("common.loading") : t("admin.dkimRotate")}</span>
</Button>
{dkimRecord && (
<div className="flex flex-col gap-2">
<div className="text-xs text-slate-400">{t("admin.dkimPublishRecord")}</div>
<div className="lm-code break-all px-3.5 py-3">
{dkimRecord.name} IN TXT &quot;{dkimRecord.value}&quot;
</div>
<div className="flex items-start gap-2.5 rounded-xl bg-dark-card px-3.5 py-3 shadow-edge">
<AlertTriangle className="mt-px h-4 w-4 flex-none text-amber-400" />
<span className="text-[12.5px] leading-relaxed text-slate-300">
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
</span>
</div>
</div>
)}
</section>
)}
</div>
);
}