"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"; import { BimiPanel } from "../../../../components/BimiPanel"; /** * 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([]); const [selectedId, setSelectedId] = useState(""); const [loading, setLoading] = useState(true); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const [reconciling, setReconciling] = useState(false); const [verification, setVerification] = useState(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(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. 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 (

{t("admin.domainsTitle")}

{t("admin.verifyIntro")}

{tabs.map((tab) => { const Icon = tab.icon; return ( ); })}
{notice && (
{notice}
)} {error && (
{error}
)} {activeTab === "domains" && (
{domains.length === 0 && !loading ? (
{t("admin.noDomains")}
) : ( domains.map((d) => (
{d.name}
{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" : ""}
{d.dns_status}
{t("admin.lastChecked", { when: d.dns_last_checked_at ? new Date(d.dns_last_checked_at).toLocaleString() : t("common.never"), })}
)) )}

{selected ? t("admin.dnsRecordsFor", { domain: selected.name }) : t("ui.domainVerification")}

{/* 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 && (
{t("admin.reconcileSummary", { created: reconcileResult.created, updated: reconcileResult.updated, unchanged: reconcileResult.unchanged, })} {(reconcileResult.conflicts ?? []).length > 0 && (
{/* Left untouched on purpose: a record of the right type and name already holding another value may be something the zone still needs. */} {t("admin.reconcileConflicts")} {(reconcileResult.conflicts ?? []).map((c) => ( {c} ))}
)} {(reconcileResult.errors ?? []).length > 0 && (
{t("admin.reconcileErrors")} {(reconcileResult.errors ?? []).map((e) => ( {e} ))}
)}
)} {verification && ( <>
{verification.verified} / {verification.total} {verification.status}
{verification.records.map((rec, i) => ( ))}
{t("ui.recordName")} {t("ui.expectedValue")} {t("common.status")}
{rec.type} {rec.name} {rec.expected} {rec.verified ? ( {rec.proxied ? t("admin.presentProxied") : t("admin.dnsStatusVerified")} ) : ( {t("admin.notPublished")} )}
)}
)} {activeTab === "onboarding" && (

{t("admin.onboardTitle")}

{t("admin.onboardIntro")}

setOnboardDomain(e.target.value)} placeholder="example.org" required className={input} />
)} {/* 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 && } {activeTab === "dkim" && (

{t("admin.dkimTitle")}

{selected ? selected.name : t("admin.noDomains")}

setDkimSelector(e.target.value)} pattern="[A-Za-z0-9-]{1,63}" className={`${input} font-mono`} />
{dkimRecord && (
{t("admin.dkimPublishRecord")}
{dkimRecord.name} IN TXT "{dkimRecord.value}"
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
)}
)}
); }