The console took pasted SVG text and a URL. It now: - takes a PNG or SVG and makes the logo BIMI accepts (SVG Tiny PS, square, at most 32 KB). An SVG has what the format forbids removed and is told what; a PNG is traced into vectors in the browser (imagetracerjs, public domain), walking down quality presets until it fits. The person positions it as in the photo editor: drag, zoom, background colour, preview in a circle or rounded square, and downloads the exact SVG to give a certificate authority - holds the .pem the authority returns, validated against the logo and domain, and serves it at /.well-known/bimi/vmc.pem (routed by the webmail) - shows the DNS record and runs the setup check (logo, DMARC, record, public URLs, certificate) - a golden-file contract: the SVGs the studio produces are validated by the server's own validator (Go) and pinned by the studio's tests (TS) - found by those tests: an element inside a link kept its onclick when the link was unwrapped; links are now unwrapped before cleaning - the BIMI and photo texts are in Brazilian Portuguese with accents (they had been written without), and in Spanish with accents A VMC cannot be generated from an image: it is issued by a certificate authority. The README and the panel say so. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
525 lines
21 KiB
TypeScript
525 lines
21 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";
|
|
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<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 [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.
|
|
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 && <BimiPanel domain={selected.name} onNotice={setNotice} />}
|
|
|
|
{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 "{dkimRecord.value}"
|
|
</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>
|
|
);
|
|
}
|