Files
webmail-client/frontend/src/routes/settings/Settings.tsx
T

303 lines
12 KiB
TypeScript
Raw Normal View History

2026-07-17 23:20:34 +02:00
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
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 (
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link to="/mail" className="text-sm text-app-accent">
← {t("nav.mail")}
</Link>
<span className="font-semibold">{t("settings.title")}</span>
</header>
<div className="flex min-h-0 flex-1">
<nav className="w-52 shrink-0 border-r border-app-border p-2">
{SECTIONS.map((s) => (
<Link
key={s}
to={`/settings/${s}`}
className={`block rounded px-3 py-2 text-sm ${
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
}`}
>
{t(`settings.${s}` as const)}
</Link>
))}
</nav>
<main className="flex-1 overflow-y-auto p-6">
{section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />}
{section === "accounts" && <AccountsSection />}
{section === "appearance" && <AppearanceSection />}
</main>
</div>
</div>
);
}
function ProfileSection() {
const t = useT();
const { data } = useMe();
return (
<section className="max-w-md space-y-3">
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
</section>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{label}</span>
<span>{value}</span>
</div>
);
}
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 (
<section className="max-w-md space-y-6">
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
{enabled ? (
<div className="space-y-2">
<p className="text-sm text-emerald-500">{t("settings.twoFactorOn")}</p>
{disabling ? (
<div className="space-y-2 rounded-lg border border-app-border p-3">
<p className="text-sm text-app-muted">{t("settings.disable2faPrompt")}</p>
<div className="flex gap-2">
<input
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
type="password"
value={disablePw}
onChange={(e) => setDisablePw(e.target.value)}
autoFocus
/>
<button className="rounded bg-red-600 px-3 py-1 text-sm text-white" onClick={disable}>
{t("settings.disable")}
</button>
<button className="px-2 text-sm text-app-muted" onClick={() => setDisabling(false)}>
{t("common.cancel")}
</button>
</div>
</div>
) : (
<button
className="rounded border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover"
onClick={() => setDisabling(true)}
>
{t("settings.disable2fa")}
</button>
)}
</div>
) : setup ? (
<div className="space-y-3 rounded-lg border border-app-border p-3">
<p className="text-sm text-app-muted">{t("settings.scanQr")}</p>
<div className="flex gap-4">
<div className="shrink-0 rounded bg-white p-2 ring-1 ring-gray-200">
<QRCodeSVG value={setup.uri} size={160} level="M" />
</div>
<div className="min-w-0 space-y-1">
<p className="text-xs text-app-muted">
{t("settings.secret")}:
<br />
<code className="break-all">{setup.secret}</code>
</p>
<details className="text-xs text-app-faint">
<summary className="cursor-pointer">otpauth URI</summary>
<div className="mt-1 break-all rounded bg-app-surface px-2 py-1">{setup.uri}</div>
</details>
</div>
</div>
<div className="flex gap-2">
<input
className="w-32 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<button className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg" onClick={confirm}>
{t("settings.confirm")}
</button>
</div>
</div>
) : (
<button className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg" onClick={startSetup}>
{t("settings.enable2fa")}
</button>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("settings.sessions")}</h2>
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{(sessions.data ?? []).map((s) => (
<li key={s.sid} className="flex items-center justify-between px-3 py-2 text-sm">
<span>
{s.ua || "session"}{" "}
{s.current && <span className="text-xs text-emerald-500">({t("settings.thisDevice")})</span>}
<span className="block text-xs text-app-faint">
{new Date(s.created).toLocaleString()}
</span>
</span>
{!s.current && (
<button className="text-xs text-red-500" onClick={() => revoke.mutate(s.sid)}>
{t("settings.revoke")}
</button>
)}
</li>
))}
</ul>
</div>
</section>
);
}
function AccountsSection() {
const t = useT();
const navigate = useNavigate();
const queryClient = useQueryClient();
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
});
return (
<section className="max-w-lg space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
<button
className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg"
onClick={() => navigate("/settings/accounts/new")}
>
{t("settings.addAccount")}
</button>
</div>
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{(accounts.data ?? []).map((a) => (
<li key={a.id} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
<span className="text-xs text-app-faint">{t("settings.syncState", { state: a.state })}</span>
</span>
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
}}
>
{t("common.remove")}
</button>
</li>
))}
</ul>
</section>
);
}
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 (
<section className="max-w-md space-y-3">
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.theme")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{(["light", "dark", "system"] as const).map((value) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`rounded px-3 py-1 ${
theme === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
}`}
>
{t(`theme.${value}` as const)}
</button>
))}
</div>
</div>
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.threadOrder")}</span>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1"
value={settings.data?.thread_order ?? "asc"}
onChange={(e) => patch.mutate({ thread_order: e.target.value as "asc" | "desc" })}
>
<option value="asc">{t("mail.orderOldest")}</option>
<option value="desc">{t("mail.orderNewest")}</option>
</select>
</label>
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
</section>
);
}