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:
parent
c0dde06920
commit
2aa13a6433
9 changed files with 507 additions and 43 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue