Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user