feat(auth): account lifecycle — Google linking, passwords, suspension & deletion plumbing

- Link a Google account to a password account, and adopt the Google identity onto a matching
  email account instead of 500ing on a duplicate; set or change a password from Settings.
- Expose has_google/has_password on /api/me for the Sign-in methods UI.
- Mark Google logins email-verified (backfill existing rows, migration 0024); stop a routine
  login from clobbering an admin-assigned role (env ADMIN_EMAILS stays the bootstrap admin).
- Suspension login-gates (password + Google callback + current_user) with a rate-limited
  'suspended' notice; shared purge_user (cascade delete + access-request cleanup + Google-grant
  revoke) behind self- and admin-deletion; single app_base source for user-facing email links.
This commit is contained in:
npeter83 2026-06-19 19:52:02 +02:00
parent c0dde06920
commit 2aa13a6433
9 changed files with 507 additions and 43 deletions

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
@ -353,6 +354,125 @@ function AccessRow({
);
}
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
// directly with inline errors. Demo accounts never see this (handled by the caller).
function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
setBusy(true);
try {
await api.setPassword(next, me.has_password ? current : undefined);
setOpen(false);
setCurrent("");
setNext("");
// Refresh `me` so has_password flips and the section switches to "Change password".
await qc.invalidateQueries({ queryKey: ["me"] });
notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }) });
} catch (e: any) {
setErr(e?.detail ?? t("settings.account.password.failed"));
} finally {
setBusy(false);
}
};
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
return (
<Section title={t("settings.account.signInMethods")}>
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_google
? t("settings.account.googleLink.connectedHint")
: t("settings.account.googleLink.connectHint")}
</p>
</div>
{me.has_google ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
{t("settings.account.googleLink.connected")}
</span>
) : (
<button
onClick={() => {
window.location.href = "/auth/link";
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{t("settings.account.googleLink.connect")}
</button>
)}
</div>
<div className="border-t border-border mt-1 pt-1">
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_password
? t("settings.account.password.setHint")
: t("settings.account.password.unsetHint")}
</p>
</div>
<button
onClick={() => {
setOpen((o) => !o);
setErr(null);
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{me.has_password
? t("settings.account.password.change")
: t("settings.account.password.set")}
</button>
</div>
{open && (
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
{me.has_password && (
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder={t("settings.account.password.current")}
autoComplete="current-password"
className={inputCls}
/>
)}
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder={t("settings.account.password.new")}
autoComplete="new-password"
className={inputCls}
/>
{err && <p className="text-xs text-red-400">{err}</p>}
<button
type="submit"
disabled={busy || !next}
className="px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</button>
</form>
)}
</div>
</Section>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation();
const confirm = useConfirm();
@ -366,7 +486,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
if (!ok) return;
try {
await api.deleteAccount();
window.location.reload(); // session cleared server-side → lands on the welcome page
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1";
} catch {
/* the global error dialog surfaces the reason (e.g. last admin) */
}
@ -388,6 +509,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div>
</Section>
{!me.is_demo && <SignInMethods me={me} />}
{me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">