303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|