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 72cd8e6d65
commit 8c727dd99e
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">

View file

@ -61,7 +61,32 @@
"deleteTitle": "Konto löschen?",
"deleteConfirm": "Dies löscht dein Konto und alle deine Daten (Abos, Tags, angesehen/gespeichert/ausgeblendet, Playlists, Einstellungen) dauerhaft. Es kann nicht rückgängig gemacht werden.",
"deleteConfirmButton": "Alles löschen",
"demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto."
"demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto.",
"signInMethods": "Anmeldemethoden",
"googleLink": {
"title": "Google-Konto",
"connected": "Verbunden",
"connectedHint": "Dein Google-Konto ist verknüpft. Du kannst dich mit Google anmelden und unten den YouTube-Zugriff erteilen.",
"connectHint": "Verknüpfe ein Google-Konto, um dich mit Google anzumelden und den YouTube-Zugriff für deinen Feed zu aktivieren.",
"connect": "Google verknüpfen",
"linked": "Google-Konto verknüpft.",
"conflict": "Dieses Google-Konto ist bereits mit einem anderen Siftlode-Konto verknüpft.",
"mismatch": "Das ist ein anderes Google-Konto als das bereits hier verknüpfte. Melde dich mit dem verknüpften an.",
"error": "Das Google-Konto konnte nicht verknüpft werden. Bitte versuche es erneut."
},
"password": {
"title": "Passwort",
"setHint": "Ein Passwort ist gesetzt. Du kannst dich mit E-Mail und Passwort anmelden.",
"unsetHint": "Noch kein Passwort — lege eines fest, um dich mit deiner E-Mail anzumelden (zusätzlich zu oder anstelle von Google).",
"set": "Passwort festlegen",
"change": "Passwort ändern",
"current": "Aktuelles Passwort",
"new": "Neues Passwort (min. 10 Zeichen)",
"save": "Passwort speichern",
"saving": "Speichern…",
"saved": "Passwort für {{email}} aktualisiert.",
"failed": "Das Passwort konnte nicht aktualisiert werden."
}
},
"demo": {
"title": "Demo-Zugang",
@ -87,7 +112,7 @@
"addPlaceholder": "E-Mail direkt hinzufügen…",
"add": "Hinzufügen",
"decided": "{{count}} entschieden",
"approved": "Genehmigt — sie können sich jetzt anmelden",
"approved": "{{email}} genehmigt — sie können sich jetzt anmelden",
"approveFailed": "Genehmigung fehlgeschlagen",
"denyFailed": "Ablehnung fehlgeschlagen",
"addedToWhitelist": "Zur Whitelist hinzugefügt",

View file

@ -61,7 +61,32 @@
"deleteTitle": "Delete your account?",
"deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.",
"deleteConfirmButton": "Delete everything",
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account."
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account.",
"signInMethods": "Sign-in methods",
"googleLink": {
"title": "Google account",
"connected": "Connected",
"connectedHint": "Your Google account is linked. You can sign in with Google and grant YouTube access below.",
"connectHint": "Link a Google account to sign in with Google and to enable YouTube access for your feed.",
"connect": "Connect Google",
"linked": "Google account linked.",
"conflict": "That Google account is already linked to a different Siftlode account.",
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
"error": "Couldn't link the Google account. Please try again."
},
"password": {
"title": "Password",
"setHint": "A password is set. You can sign in with your email and password.",
"unsetHint": "No password yet — set one to sign in with your email instead of (or as well as) Google.",
"set": "Set password",
"change": "Change password",
"current": "Current password",
"new": "New password (min. 10 characters)",
"save": "Save password",
"saving": "Saving…",
"saved": "Password updated for {{email}}.",
"failed": "Couldn't update the password."
}
},
"demo": {
"title": "Demo access",
@ -87,7 +112,7 @@
"addPlaceholder": "Add an email directly…",
"add": "Add",
"decided": "{{count}} decided",
"approved": "Approved — they can sign in now",
"approved": "Approved {{email}} — they can sign in now",
"approveFailed": "Approve failed",
"denyFailed": "Deny failed",
"addedToWhitelist": "Added to the whitelist",

View file

@ -61,7 +61,32 @@
"deleteTitle": "Törlöd a fiókodat?",
"deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.",
"deleteConfirmButton": "Minden törlése",
"demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz."
"demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.",
"signInMethods": "Bejelentkezési módok",
"googleLink": {
"title": "Google-fiók",
"connected": "Csatlakoztatva",
"connectedHint": "A Google-fiókod össze van kapcsolva. Bejelentkezhetsz Google-lel, és alább engedélyezheted a YouTube-hozzáférést.",
"connectHint": "Kapcsolj össze egy Google-fiókot, hogy Google-lel jelentkezhess be, és engedélyezhesd a YouTube-hozzáférést a feededhez.",
"connect": "Google összekapcsolása",
"linked": "Google-fiók összekapcsolva.",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
},
"password": {
"title": "Jelszó",
"setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",
"unsetHint": "Még nincs jelszó — állíts be egyet, hogy e-mail-címmel is bejelentkezhess (a Google mellett vagy helyett).",
"set": "Jelszó beállítása",
"change": "Jelszó módosítása",
"current": "Jelenlegi jelszó",
"new": "Új jelszó (min. 10 karakter)",
"save": "Jelszó mentése",
"saving": "Mentés…",
"saved": "Jelszó frissítve ehhez: {{email}}.",
"failed": "Nem sikerült frissíteni a jelszót."
}
},
"demo": {
"title": "Demo hozzáférés",
@ -87,7 +112,7 @@
"addPlaceholder": "E-mail közvetlen hozzáadása…",
"add": "Hozzáadás",
"decided": "{{count}} eldöntve",
"approved": "Jóváhagyva — most már be tud jelentkezni",
"approved": "Jóváhagyva: {{email}} — most már be tud jelentkezni",
"approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen",
"addedToWhitelist": "Hozzáadva a fehérlistához",

View file

@ -9,6 +9,8 @@ export interface Me {
avatar_url: string | null;
role: string;
is_demo: boolean;
has_google: boolean;
has_password: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
@ -199,6 +201,15 @@ interface ReqConfig {
quiet?: boolean;
}
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in (public pages
// legitimately 401 on /api/me), so it's safe to call for every 401.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn;
}
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET";
@ -251,6 +262,10 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
if (RETRIABLE_GATEWAY.has(r.status)) {
markConnectivityLost();
} else if (r.status === 401) {
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the
// app shell decide what to do (drop to the login page if we were signed in). Not a modal.
onUnauthorized?.();
} else if (cfg.quiet) {
/* caller handles the error inline — no global modal */
} else if (r.status >= 500) {
@ -470,6 +485,7 @@ export interface AdminUserRow {
display_name: string | null;
role: string; // "user" | "admin"
is_active: boolean;
is_suspended: boolean;
email_verified: boolean;
is_demo: boolean;
has_password: boolean;
@ -599,6 +615,10 @@ export const api = {
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
req(`/api/admin/users/${id}`, { method: "DELETE" }),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, {
@ -624,6 +644,13 @@ export const api = {
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
// Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true }
),
// --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),