revert: foto de perfil e BIMI, para restaurar o servico
O site ficou a responder 502 em todos os hostnames depois do deploy destas duas funcionalidades. A reversao vem primeiro; o diagnostico vem a seguir, com o servico de pe. As migracoes 0012 e 0013 ficam. Sao aditivas, nada no codigo revertido as usa, e apaga-las deixaria a base de dados numa versao sem ficheiro correspondente, o que parte o proprio migrate no arranque seguinte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e06162fa0
commit
e30d000e43
26 files changed
+21
-1899
No files matched your search
@@ -78,14 +78,6 @@ export default function AdminDomainsPage() {
|
||||
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.
|
||||
@@ -126,73 +118,6 @@ export default function AdminDomainsPage() {
|
||||
// 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);
|
||||
@@ -521,70 +446,6 @@ export default function AdminDomainsPage() {
|
||||
</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>
|
||||
|
||||
@@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||
import { useFolders } from "../../../lib/useFolders";
|
||||
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
||||
import { Avatar } from "../../../components/Avatar";
|
||||
import { initialsFor } from "../../../lib/initials";
|
||||
|
||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations();
|
||||
@@ -279,7 +279,9 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
|
||||
>
|
||||
<Avatar address={account?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||
{initialsFor(account?.email)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate text-[12.5px] leading-tight"
|
||||
|
||||
@@ -38,7 +38,6 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../..
|
||||
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
||||
import { Button } from "../../../../../components/ui/Button";
|
||||
import { Avatar } from "../../../../../components/Avatar";
|
||||
|
||||
interface MessageSummary {
|
||||
uid: number;
|
||||
@@ -458,16 +457,16 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
data-active={isSelected}
|
||||
className="lm-row w-full text-left"
|
||||
>
|
||||
<Avatar
|
||||
address={msg.sender_address}
|
||||
initials={initials(msg.from_display_name, msg.sender_address)}
|
||||
size={38}
|
||||
className={
|
||||
<span
|
||||
className={`flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-xs ${
|
||||
msg.seen
|
||||
? "bg-dark-card text-slate-400"
|
||||
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
}
|
||||
/>
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials(msg.from_display_name, msg.sender_address)}
|
||||
</span>
|
||||
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||
<span className="flex items-baseline gap-2">
|
||||
@@ -653,12 +652,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
</h1>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar
|
||||
address={selectedRow?.sender_address ?? selected.from}
|
||||
initials={initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||
size={38}
|
||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
/>
|
||||
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[13px] text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||
{initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
<span className="break-all text-sm text-slate-100">{selected.from}</span>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Plus,
|
||||
Lock,
|
||||
Languages,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
@@ -25,8 +24,6 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
||||
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
||||
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
|
||||
import { useFolders } from "../../../../lib/useFolders";
|
||||
import { AvatarEditor } from "../../../../components/AvatarEditor";
|
||||
import { useAccount } from "../../../../lib/useAccount";
|
||||
import { moveTargets } from "../../../../lib/mailCounts";
|
||||
|
||||
interface SieveRule {
|
||||
@@ -54,20 +51,11 @@ interface WebSession {
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language";
|
||||
type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
|
||||
|
||||
export default function UserSettingsPage() {
|
||||
const t = useTranslations();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
|
||||
// Bumped after a save so the browser refetches instead of showing the photo
|
||||
// it already holds under the same URL.
|
||||
const [avatarVersion, setAvatarVersion] = useState(0);
|
||||
const account = useAccount("user");
|
||||
// Null until the account loads, so the editor is not told "no photo" before
|
||||
// it is known whether there is one.
|
||||
const avatarSrc = account?.email
|
||||
? `/api/v1/avatar?address=${encodeURIComponent(account.email)}&v=${avatarVersion}`
|
||||
: null;
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("security");
|
||||
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
||||
@@ -297,7 +285,6 @@ export default function UserSettingsPage() {
|
||||
separated by everything else. The same panels now sit behind a tab strip,
|
||||
in the order the redesign groups them. */
|
||||
const tabs = [
|
||||
{ id: "profile" as const, label: t("avatar.title"), icon: UserRound },
|
||||
{ id: "security" as const, label: t("settings.security"), icon: Shield },
|
||||
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
|
||||
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
|
||||
@@ -349,21 +336,6 @@ export default function UserSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "profile" && (
|
||||
<section className={panel}>
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("avatar.title")}</h2>
|
||||
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("avatar.explainer")}</p>
|
||||
<AvatarEditor
|
||||
src={avatarSrc}
|
||||
onSaved={() => {
|
||||
// The query string changes so the browser fetches the new
|
||||
// photo instead of the one it already has under this URL.
|
||||
setAvatarVersion((v) => v + 1);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === "security" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 2FA TOTP SECTION */}
|
||||
|
||||
Reference in new issue
Block a user