From 2aa13a64338a11ac17bf591f3723d81dcf760caf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 19:52:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20account=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20Google=20linking,=20passwords,=20suspension=20&=20deletion?= =?UTF-8?q?=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../versions/0024_google_email_verified.py | 31 +++ backend/app/auth.py | 254 ++++++++++++++++-- backend/app/config.py | 8 + backend/app/routes/me.py | 18 +- frontend/src/components/SettingsPanel.tsx | 125 ++++++++- frontend/src/i18n/locales/de/settings.json | 29 +- frontend/src/i18n/locales/en/settings.json | 29 +- frontend/src/i18n/locales/hu/settings.json | 29 +- frontend/src/lib/api.ts | 27 ++ 9 files changed, 507 insertions(+), 43 deletions(-) create mode 100644 backend/alembic/versions/0024_google_email_verified.py diff --git a/backend/alembic/versions/0024_google_email_verified.py b/backend/alembic/versions/0024_google_email_verified.py new file mode 100644 index 0000000..7b7af53 --- /dev/null +++ b/backend/alembic/versions/0024_google_email_verified.py @@ -0,0 +1,31 @@ +"""backfill email_verified for Google accounts + +Revision ID: 0024_google_email_verified +Revises: 0023_user_suspension +Create Date: 2026-06-19 + +A Google sign-in proves the email, but earlier Google signups were created with +email_verified=false (the callback didn't set it). Backfill those so the flag reflects reality — +matters because password-login gates on email_verified, so a Google user who later adds a password +must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0024_google_email_verified" +down_revision: Union[str, None] = "0023_user_suspension" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "UPDATE users SET email_verified = true " + "WHERE google_sub IS NOT NULL AND email_verified = false" + ) + + +def downgrade() -> None: + # Not reversible — we can't tell which rows were flipped. No-op. + pass diff --git a/backend/app/auth.py b/backend/app/auth.py index 4139b4f..e3fc79a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -4,10 +4,11 @@ import re import secrets from datetime import datetime, timedelta, timezone +import httpx from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session from app import email as email_mod @@ -16,7 +17,7 @@ from app.config import settings from app.db import get_db from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.ratelimit import RateLimiter -from app.security import encrypt, hash_password, verify_password +from app.security import decrypt, encrypt, hash_password, verify_password # Email+password auth tuning. PASSWORD_MIN_LEN = 10 @@ -25,6 +26,22 @@ RESET_TTL = timedelta(hours=1) _register_limiter = RateLimiter(max_events=5, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300) +# At most one "your account is suspended" email per address per hour, so a suspended user who +# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them. +_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600) + + +def operator_contact() -> str | None: + """Who a blocked user should contact — the first configured admin email, if any.""" + return next(iter(sorted(settings.admin_email_set)), None) + + +def _notify_suspended(background: BackgroundTasks, email: str) -> None: + """Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid) + sign-in was just blocked. Gated by verified credentials at the call site, so this can't be + triggered by someone who doesn't control the account.""" + if _suspend_email_limiter.allow(email): + background.add_task(email_mod.send_account_suspended, email, operator_contact()) _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") @@ -190,7 +207,16 @@ async def callback( if not userinfo or not userinfo.get("sub"): raise HTTPException(status_code=400, detail="No user info returned by Google") + sub = userinfo["sub"] email = (userinfo.get("email") or "").lower() + + # Linking mode: an already-authenticated account is attaching this Google identity (a + # password account enabling YouTube or connecting SSO). The marker is set by /auth/link and + # /auth/upgrade; when present we attach to the current user instead of identifying by sub. + link_uid = request.session.pop("oauth_link_uid", None) + if link_uid is not None: + return _complete_link(request, db, link_uid, sub, userinfo, token) + if not is_allowed(db, email): log.warning("Login denied (not approved): %s", email or "") # A denied Google login doubles as an access request: record it for the admin and @@ -206,14 +232,49 @@ async def callback( return RedirectResponse(url="/?access=requested") return RedirectResponse(url="/?access=denied") - user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none() + user = db.query(User).filter(User.google_sub == sub).one_or_none() if user is None: - user = User(google_sub=userinfo["sub"], email=email) - db.add(user) + # No account carries this Google identity yet. If one already exists for this email — + # e.g. an email+password registration — adopt the Google identity onto it instead of + # inserting a duplicate (which would collide on the unique email and 500). Google has + # verified the address, so this safely unifies password + Google sign-in on one account. + existing = db.query(User).filter(User.email == email).one_or_none() + if existing is not None: + if existing.google_sub is not None and existing.google_sub != sub: + # A different Google identity is already bound to this email — refuse rather than + # hijack it. (Near-impossible with real Google accounts: email↔sub is stable.) + log.warning("Google login email collision (different sub): %s", email) + return RedirectResponse(url="/?access=denied") + user = existing + user.google_sub = sub + # Google verified the email and is_allowed granted access, so finish activating a + # previously pending password registration (otherwise current_user would bounce it). + user.email_verified = True + user.is_active = True + else: + user = User(google_sub=sub, email=email) + db.add(user) + # A suspended account can't sign in by any method. Block before establishing the session and + # tell the (verified) owner why — a successful Google auth proves they control this account. + if user.is_suspended: + log.info("Suspended account blocked at Google login: %s", email) + _notify_suspended(background, user.email) + return RedirectResponse(url="/?login=suspended") user.email = email user.display_name = userinfo.get("name") user.avatar_url = userinfo.get("picture") - user.role = "admin" if email in settings.admin_email_set else "user" + # ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't + # be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a + # routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a + # default for a brand-new account (its role attribute is still unset before flush). + if email in settings.admin_email_set: + user.role = "admin" + elif not user.role: + user.role = "user" + # Google attests the email, so a Google sign-in implicitly verifies it. Setting this also lets + # a Google user who later adds a password sign in (password-login gates on email_verified). + if userinfo.get("email_verified", True): + user.email_verified = True # Default UI language from the Google-reported locale on first login (if the user hasn't # picked one yet); unsupported locales fall back to English. prefs = dict(user.preferences or {}) @@ -223,19 +284,7 @@ async def callback( user.preferences = prefs db.flush() - tok = user.token or OAuthToken(user=user) - # Google only returns a refresh_token on (re)consent; keep the previous one otherwise. - if token.get("refresh_token"): - tok.refresh_token_enc = encrypt(token["refresh_token"]) - tok.access_token = token.get("access_token") - expires_at = token.get("expires_at") - tok.expiry = ( - datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None - ) - # include_granted_scopes=true means Google returns the union of all scopes the user - # has ever granted this app, so this correctly reflects read/write upgrades too. - tok.scopes = token.get("scope") or BASE_SCOPES - db.add(tok) + _store_token(db, user, token) db.commit() # Multi-session: remember every account that has authenticated in this browser so the @@ -249,6 +298,94 @@ async def callback( return RedirectResponse(url="/") +def _store_token(db: Session, user: User, token: dict) -> None: + """Persist the OAuth grant on the user's single token row, creating it if absent. Google + only returns a refresh_token on (re)consent, so the previous one is kept otherwise. + include_granted_scopes=true means `scope` is the union of everything granted, so this + reflects read/write upgrades correctly.""" + tok = user.token or OAuthToken(user=user) + if token.get("refresh_token"): + tok.refresh_token_enc = encrypt(token["refresh_token"]) + tok.access_token = token.get("access_token") + expires_at = token.get("expires_at") + tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None + tok.scopes = token.get("scope") or BASE_SCOPES + db.add(tok) + + +def revoke_google_token(token: str | None) -> None: + """Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh + token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes + silently carried over via include_granted_scopes). Fail-soft: never blocks the caller — used + from account deletion, where our own data is already gone regardless of Google's response.""" + if not token: + return + try: + resp = httpx.post( + "https://oauth2.googleapis.com/revoke", + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + log.info("Google token revoke: status=%s", resp.status_code) + except Exception: + log.warning("Google token revoke failed", exc_info=True) + + +def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: + """Hard-delete a user and ALL their personal data (subscriptions, tags, video states, + playlists, notifications, tokens — all cascade on the users row), erase their access-request + row, and revoke their Google grant. Shared by GDPR self-deletion and admin deletion. Does NOT + touch any browser session — each caller handles its own.""" + email = user.email.lower() + user_id = user.id + tok = user.token + google_token = (decrypt(tok.refresh_token_enc) or tok.access_token) if tok else None + # Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table. + db.execute(delete(User).where(User.id == user_id)) + db.execute(delete(Invite).where(Invite.email == email)) + db.commit() + if google_token: + background.add_task(revoke_google_token, google_token) + # Confirm the erasure to the (now former) user — GDPR good practice, and covers both the + # self-service and admin deletion paths since both funnel through here. + background.add_task(email_mod.send_account_deleted, email, operator_contact()) + + +def _complete_link( + request: Request, db: Session, link_uid: int, sub: str, userinfo: dict, token: dict +): + """Attach the just-authorized Google identity to the already-signed-in account that started + the link/upgrade. No is_allowed gate — they're already an active user. Refuses to hijack a + Google identity owned by another account, or to silently swap a different linked identity.""" + # Consume the companion marker up front so it never lingers across the early returns below. + explicit = request.session.pop("oauth_link_explicit", False) + # The marker must match the live session user (guards a stale/forged marker). + if link_uid != request.session.get("user_id"): + return RedirectResponse(url="/?link=error") + user = db.get(User, link_uid) + if user is None or user.is_demo: + return RedirectResponse(url="/?link=error") + other = ( + db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none() + ) + if other is not None: + return RedirectResponse(url="/?link=conflict") + if user.google_sub is not None and user.google_sub != sub: + return RedirectResponse(url="/?link=mismatch") + user.google_sub = sub + if not user.display_name: + user.display_name = userinfo.get("name") + if not user.avatar_url: + user.avatar_url = userinfo.get("picture") + _store_token(db, user, token) + db.commit() + log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope")) + # An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade + # (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before. + return RedirectResponse(url="/?link=ok" if explicit else "/") + + @router.post("/logout") async def logout(request: Request): """Sign out the active account. If other accounts have authenticated in this browser, @@ -313,8 +450,7 @@ def _establish_session(request: Request, user: User) -> None: def _app_base() -> str: """Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback).""" - u = settings.oauth_redirect_url - return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/") + return settings.app_base def _hash_token(raw: str) -> str: @@ -381,17 +517,24 @@ def register( existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if existing is None: + # Without working SMTP we can't deliver a verification link, so email ownership can't + # gate sign-in. Admin approval stays the real gate (is_active=False); mark the account + # verified so the flow still completes on a no-SMTP self-host. See email.email_enabled(). + email_ok = email_mod.email_enabled() user = User( email=email, password_hash=hash_password(password), is_active=False, - email_verified=False, + email_verified=not email_ok, ) db.add(user) db.flush() upsert_pending_invite(db, email) # admin-approval gate - raw = _issue_token(db, user, "verify", VERIFY_TTL) - background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}") + if email_ok: + raw = _issue_token(db, user, "verify", VERIFY_TTL) + background.add_task( + email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}" + ) if settings.admin_email_set: background.add_task( email_mod.send_admin_new_request, sorted(settings.admin_email_set), email @@ -413,11 +556,14 @@ def verify_email(token: str, db: Session = Depends(get_db)): @router.post("/password-login") def password_login( - payload: dict, request: Request, db: Session = Depends(get_db) + payload: dict, + request: Request, + background: BackgroundTasks, + db: Session = Depends(get_db), ) -> dict: """Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration). Once the password is proven correct, the owner gets a specific reason if their account is - still pending — that's not an enumeration leak (they already hold the password).""" + still pending or suspended — that's not an enumeration leak (they already hold the password).""" if not _login_limiter.allow(_client_ip(request)): raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") email = (payload.get("email") or "").strip().lower() @@ -425,6 +571,13 @@ def password_login( user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if user is None or user.is_demo or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid email or password.") + if user.is_suspended: + _notify_suspended(background, user.email) + op = operator_contact() + detail = "Your account has been suspended." + if op: + detail += f" If you have questions, contact the operator at {op}." + raise HTTPException(status_code=403, detail=detail) if not user.email_verified: raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).") if not user.is_active: @@ -484,7 +637,8 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") user = db.get(User, user_id) - if user is None or not user.is_active: + if user is None or not user.is_active or user.is_suspended: + # Suspended/deactivated mid-session → drop the session so the block takes effect at once. request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") # Always keep the active account in the switchable list — covers sessions created before @@ -506,6 +660,49 @@ def require_human(user: User = Depends(current_user)) -> User: return user +@router.get("/link") +async def link_google(request: Request, user: User = Depends(current_user)): + """Start linking a Google account to the signed-in (e.g. password) account. Requests only + the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback + sees `oauth_link_uid` and attaches the identity instead of creating a new account.""" + if user.is_demo: + return RedirectResponse(url="/") + request.session["oauth_link_uid"] = user.id + request.session["oauth_link_explicit"] = True + return await oauth.google.authorize_redirect( + request, + settings.oauth_redirect_url, + access_type="offline", + prompt="select_account", + include_granted_scopes="true", + scope=BASE_SCOPES, + ) + + +@router.post("/set-password") +def set_password( + payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """Set or change the signed-in account's password. Setting a first password (Google-only + account) needs no current password — the session already proves identity. Changing an + existing one requires the current password, so a hijacked session can't silently rotate it.""" + if user.is_demo: + raise HTTPException(status_code=403, detail="Not available in the demo account.") + new_password = payload.get("password") or "" + if len(new_password) < PASSWORD_MIN_LEN: + raise HTTPException( + status_code=400, + detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.", + ) + if user.password_hash: + if not verify_password(payload.get("current_password") or "", user.password_hash): + raise HTTPException(status_code=403, detail="Current password is incorrect.") + user.password_hash = hash_password(new_password) + db.commit() + log.info("Password set/changed: uid=%s", user.id) + return {"ok": True} + + @router.get("/upgrade") async def upgrade( request: Request, access: str = "read", user: User = Depends(current_user) @@ -519,6 +716,9 @@ async def upgrade( # straight home (no YouTube link is ever attached to it) rather than shown a raw 403. if user.is_demo: return RedirectResponse(url="/") + # Attach the grant to THIS account in the callback. Without it a password account (no + # google_sub) would get a brand-new, separate Google user created instead of being linked. + request.session["oauth_link_uid"] = user.id scope = WRITE_SCOPES if access == "write" else READ_SCOPES return await oauth.google.authorize_redirect( request, diff --git a/backend/app/config.py b/backend/app/config.py index da51958..7d454a6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -119,6 +119,14 @@ class Settings(BaseSettings): def admin_email_set(self) -> set[str]: return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()} + @property + def app_base(self) -> str: + """Public origin of the deployed app, derived from the OAuth redirect URL + (.../auth/callback). The single source for user-facing links (verification, reset, + approval emails); dev and prod differ, so links must always come from config.""" + u = self.oauth_redirect_url + return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/") + @property def session_https_only(self) -> bool: """Mark the session cookie Secure when we're served over HTTPS. We treat an diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 9631344..6d05883 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,8 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException, Request -from sqlalchemy import delete, func, select +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from sqlalchemy import func, select from sqlalchemy.orm import Session -from app.auth import current_user, has_read_scope, has_write_scope, is_allowed +from app.auth import current_user, has_read_scope, has_write_scope, is_allowed, purge_user from app.db import get_db from app.models import Invite, User @@ -78,6 +78,8 @@ def get_me( "avatar_url": user.avatar_url, "role": user.role, "is_demo": user.is_demo, + "has_google": user.google_sub is not None, + "has_password": user.password_hash is not None, "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, @@ -88,6 +90,7 @@ def get_me( @router.delete("/account") def delete_my_account( request: Request, + background: BackgroundTasks, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -105,13 +108,10 @@ def delete_my_account( status_code=400, detail="You're the only admin — promote another admin before deleting your account.", ) - email = user.email.lower() user_id = user.id - # Raw delete so Postgres applies the ON DELETE CASCADE / SET NULL on every dependent table. - db.execute(delete(User).where(User.id == user_id)) - # Erase the access-request/whitelist row for this email too (full erasure; they'd re-request). - db.execute(delete(Invite).where(Invite.email == email)) - db.commit() + # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the + # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row. + purge_user(db, user, background) # Drop this account from the browser session; switch to another signed-in account if any. remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id] if remaining: diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 954f675..465e988 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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(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 ( +
+
+
+
{t("settings.account.googleLink.title")}
+

+ {me.has_google + ? t("settings.account.googleLink.connectedHint") + : t("settings.account.googleLink.connectHint")} +

+
+ {me.has_google ? ( + + {t("settings.account.googleLink.connected")} + + ) : ( + + )} +
+ +
+
+
+
{t("settings.account.password.title")}
+

+ {me.has_password + ? t("settings.account.password.setHint") + : t("settings.account.password.unsetHint")} +

+
+ +
+ {open && ( +
+ {me.has_password && ( + setCurrent(e.target.value)} + placeholder={t("settings.account.password.current")} + autoComplete="current-password" + className={inputCls} + /> + )} + setNext(e.target.value)} + placeholder={t("settings.account.password.new")} + autoComplete="new-password" + className={inputCls} + /> + {err &&

{err}

} + +
+ )} +
+
+ ); +} + 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 }) { + {!me.is_demo && } + {me.is_demo ? (

diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index 24d1ab0..0f7bced 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -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", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index e679450..91c992b 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 5e682ae..77c5119 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 313c4ea..1220e1c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { 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 => req("/api/admin/users"), setUserRole: (id: number, role: "user" | "admin"): Promise => req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), + setUserSuspended: (id: number, suspended: boolean): Promise => + 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 => 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 }) }),