import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
import { PluginSlot } from "../../plugins/PluginSlot";
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
type Section = (typeof SECTIONS)[number];
export function Settings() {
const t = useT();
const params = useParams();
const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section;
return (
← {t("nav.mail")}
{t("settings.title")}
{section === "profile" && }
{section === "security" && }
{section === "accounts" && }
{section === "appearance" && }
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
Matches the accounts section width (max-w-5xl) with a gap above it. */}
{section === "accounts" && (
)}
);
}
function ProfileSection() {
const t = useT();
const { data } = useMe();
return (
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
{label}
{value}
);
}
function SecuritySection() {
const t = useT();
const queryClient = useQueryClient();
const { data: me } = useMe();
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
const [code, setCode] = useState("");
const [disabling, setDisabling] = useState(false);
const [disablePw, setDisablePw] = useState("");
const [error, setError] = useState("");
const sessions = useQuery({ queryKey: ["sessions"], queryFn: api.sessions });
const enabled = Boolean(me?.totp_enabled);
const startSetup = async () => {
const res = await api.totpSetup();
setSetup({ secret: res.secret, uri: res.provisioning_uri });
};
const confirm = async () => {
setError("");
try {
await api.totpEnable(code);
setSetup(null);
queryClient.invalidateQueries({ queryKey: ["me"] });
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
}
};
const disable = async () => {
setError("");
try {
await api.totpDisable(disablePw);
setDisabling(false);
setDisablePw("");
queryClient.invalidateQueries({ queryKey: ["me"] });
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
}
};
const revoke = useMutation({
mutationFn: (sid: string) => api.revokeSession(sid),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
});
return (
{t("settings.twoFactor")}
{enabled ? (
{t("settings.twoFactorOn")}
{disabling ? (
) : (
)}
) : setup ? (
{t("settings.scanQr")}
{t("settings.secret")}:
{setup.secret}
otpauth URI
{setup.uri}
setCode(e.target.value)}
/>
) : (
)}
{error &&
{error}
}
{t("settings.sessions")}
{(sessions.data ?? []).map((s) => (
-
{s.ua || "session"}{" "}
{s.current && ({t("settings.thisDevice")})}
{new Date(s.created).toLocaleString()}
{!s.current && (
)}
))}
);
}
function AccountsSection() {
const t = useT();
const navigate = useNavigate();
const queryClient = useQueryClient();
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
null,
);
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
});
const downloadAll = useMutation({
mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
});
const close = () => {
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["accounts"] });
};
return (
{t("settings.accounts")}
{(accounts.data ?? []).map((a) => (
-
{editing?.id === a.id && editing.mode === "account" ? (
setEditing(null)} onSaved={close} />
) : editing?.id === a.id && editing.mode === "signature" ? (
setEditing(null)} onSaved={close} />
) : (
{a.email}
{t("settings.syncState", { state: a.state })}
{a.sync_window !== "all" && (
<>
·
{t("settings.syncedPartially")}
>
)}
{a.sync_window !== "all" && (
)}
)}
))}
);
}
function AccountEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId) });
const [displayName, setDisplayName] = useState("");
const [color, setColor] = useState("#888888");
const [showServer, setShowServer] = useState(false);
const [imapHost, setImapHost] = useState("");
const [imapPort, setImapPort] = useState("993");
const [imapTls, setImapTls] = useState("ssl");
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState("465");
const [smtpTls, setSmtpTls] = useState("ssl");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
const d = detail.data;
if (!d) return;
setDisplayName(d.display_name);
setColor(d.color);
setImapHost(String(d.imap_config.host ?? ""));
setImapPort(String(d.imap_config.port ?? "993"));
setImapTls(String(d.imap_config.tls ?? "ssl"));
setSmtpHost(String(d.smtp_config.host ?? ""));
setSmtpPort(String(d.smtp_config.port ?? "465"));
setSmtpTls(String(d.smtp_config.tls ?? "ssl"));
}, [detail.data]);
const save = useMutation({
mutationFn: () => {
const patch: Parameters[1] = {
display_name: displayName,
color,
};
if (showServer) {
patch.imap = { host: imapHost, port: Number(imapPort), tls: imapTls, auth: "password" };
patch.smtp = { host: smtpHost, port: Number(smtpPort), tls: smtpTls, auth: "password" };
patch.password = password;
}
return api.updateAccount(accountId, patch);
},
onSuccess: onSaved,
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return {t("common.loading")}
;
const canSave = !save.isPending && (!showServer || password.length > 0);
return (
setColor(e.target.value)}
aria-label={t("settings.color")}
/>
setDisplayName(e.target.value)}
/>
{!showServer ? (
) : (
)}
{error &&
{error}
}
);
}
function SignatureEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const detail = useQuery({
queryKey: ["account", accountId],
queryFn: () => api.accountDetail(accountId),
});
const [signature, setSignature] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (detail.data) setSignature(detail.data.signature);
}, [detail.data]);
const save = useMutation({
mutationFn: () => api.updateAccount(accountId, { signature }),
onSuccess: onSaved,
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return {t("common.loading")}
;
return (
{t("settings.signature")} — {detail.data.email}
{error &&
{error}
}
);
}
function ServerRow({
label,
host,
port,
tls,
onHost,
onPort,
onTls,
}: {
label: string;
host: string;
port: string;
tls: string;
onHost: (v: string) => void;
onPort: (v: string) => void;
onTls: (v: string) => void;
}) {
const t = useT();
return (
{label}
onHost(e.target.value)}
/>
onPort(e.target.value)}
/>
);
}
function AppearanceSection() {
const t = useT();
const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
return (
{t("settings.appearance")}
{t("settings.theme")}
{(["light", "dark", "system"] as const).map((value) => (
))}
);
}