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

@ -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

View file

@ -4,10 +4,11 @@ import re
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import httpx
from authlib.integrations.starlette_client import OAuth, OAuthError from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import email as email_mod from app import email as email_mod
@ -16,7 +17,7 @@ from app.config import settings
from app.db import get_db from app.db import get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter 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. # Email+password auth tuning.
PASSWORD_MIN_LEN = 10 PASSWORD_MIN_LEN = 10
@ -25,6 +26,22 @@ RESET_TTL = timedelta(hours=1)
_register_limiter = RateLimiter(max_events=5, window_seconds=300) _register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, 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]+$") _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@ -190,7 +207,16 @@ async def callback(
if not userinfo or not userinfo.get("sub"): if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google") raise HTTPException(status_code=400, detail="No user info returned by Google")
sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower() 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): if not is_allowed(db, email):
log.warning("Login denied (not approved): %s", email or "<no email>") log.warning("Login denied (not approved): %s", email or "<no email>")
# A denied Google login doubles as an access request: record it for the admin and # 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=requested")
return RedirectResponse(url="/?access=denied") 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: if user is None:
user = User(google_sub=userinfo["sub"], email=email) # 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) 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.email = email
user.display_name = userinfo.get("name") user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture") 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 # 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. # picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {}) prefs = dict(user.preferences or {})
@ -223,19 +284,7 @@ async def callback(
user.preferences = prefs user.preferences = prefs
db.flush() db.flush()
tok = user.token or OAuthToken(user=user) _store_token(db, user, token)
# 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)
db.commit() db.commit()
# Multi-session: remember every account that has authenticated in this browser so the # Multi-session: remember every account that has authenticated in this browser so the
@ -249,6 +298,94 @@ async def callback(
return RedirectResponse(url="/") 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") @router.post("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser, """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: def _app_base() -> str:
"""Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback).""" """Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback)."""
u = settings.oauth_redirect_url return settings.app_base
return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/")
def _hash_token(raw: str) -> str: 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() existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if existing is 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( user = User(
email=email, email=email,
password_hash=hash_password(password), password_hash=hash_password(password),
is_active=False, is_active=False,
email_verified=False, email_verified=not email_ok,
) )
db.add(user) db.add(user)
db.flush() db.flush()
upsert_pending_invite(db, email) # admin-approval gate upsert_pending_invite(db, email) # admin-approval gate
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL) raw = _issue_token(db, user, "verify", VERIFY_TTL)
background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}") background.add_task(
email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}"
)
if settings.admin_email_set: if settings.admin_email_set:
background.add_task( background.add_task(
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email 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") @router.post("/password-login")
def 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: ) -> dict:
"""Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration). """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 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)): if not _login_limiter.allow(_client_ip(request)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
email = (payload.get("email") or "").strip().lower() 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() 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): 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.") 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: if not user.email_verified:
raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).") raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).")
if not user.is_active: if not user.is_active:
@ -484,7 +637,8 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id) 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() request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the active account in the switchable list — covers sessions created before # 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 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") @router.get("/upgrade")
async def upgrade( async def upgrade(
request: Request, access: str = "read", user: User = Depends(current_user) 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. # straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo: if user.is_demo:
return RedirectResponse(url="/") 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 scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await oauth.google.authorize_redirect( return await oauth.google.authorize_redirect(
request, request,

View file

@ -119,6 +119,14 @@ class Settings(BaseSettings):
def admin_email_set(self) -> set[str]: def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()} 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 @property
def session_https_only(self) -> bool: def session_https_only(self) -> bool:
"""Mark the session cookie Secure when we're served over HTTPS. We treat an """Mark the session cookie Secure when we're served over HTTPS. We treat an

View file

@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import delete, func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session 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.db import get_db
from app.models import Invite, User from app.models import Invite, User
@ -78,6 +78,8 @@ def get_me(
"avatar_url": user.avatar_url, "avatar_url": user.avatar_url,
"role": user.role, "role": user.role,
"is_demo": user.is_demo, "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_read": has_read_scope(user),
"can_write": has_write_scope(user), "can_write": has_write_scope(user),
"pending_invites": pending_invites, "pending_invites": pending_invites,
@ -88,6 +90,7 @@ def get_me(
@router.delete("/account") @router.delete("/account")
def delete_my_account( def delete_my_account(
request: Request, request: Request,
background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@ -105,13 +108,10 @@ def delete_my_account(
status_code=400, status_code=400,
detail="You're the only admin — promote another admin before deleting your account.", detail="You're the only admin — promote another admin before deleting your account.",
) )
email = user.email.lower()
user_id = user.id user_id = user.id
# Raw delete so Postgres applies the ON DELETE CASCADE / SET NULL on every dependent table. # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
db.execute(delete(User).where(User.id == user_id)) # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
# Erase the access-request/whitelist row for this email too (full erasure; they'd re-request). purge_user(db, user, background)
db.execute(delete(Invite).where(Invite.email == email))
db.commit()
# Drop this account from the browser session; switch to another signed-in account if any. # 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] remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id]
if remaining: if remaining:

View file

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react"; import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; 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 }) { function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
@ -366,7 +486,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
if (!ok) return; if (!ok) return;
try { try {
await api.deleteAccount(); 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 { } catch {
/* the global error dialog surfaces the reason (e.g. last admin) */ /* the global error dialog surfaces the reason (e.g. last admin) */
} }
@ -388,6 +509,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div> </div>
</Section> </Section>
{!me.is_demo && <SignInMethods me={me} />}
{me.is_demo ? ( {me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}> <Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed"> <p className="text-xs text-muted leading-relaxed">

View file

@ -61,7 +61,32 @@
"deleteTitle": "Konto löschen?", "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.", "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", "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": { "demo": {
"title": "Demo-Zugang", "title": "Demo-Zugang",
@ -87,7 +112,7 @@
"addPlaceholder": "E-Mail direkt hinzufügen…", "addPlaceholder": "E-Mail direkt hinzufügen…",
"add": "Hinzufügen", "add": "Hinzufügen",
"decided": "{{count}} entschieden", "decided": "{{count}} entschieden",
"approved": "Genehmigt — sie können sich jetzt anmelden", "approved": "{{email}} genehmigt — sie können sich jetzt anmelden",
"approveFailed": "Genehmigung fehlgeschlagen", "approveFailed": "Genehmigung fehlgeschlagen",
"denyFailed": "Ablehnung fehlgeschlagen", "denyFailed": "Ablehnung fehlgeschlagen",
"addedToWhitelist": "Zur Whitelist hinzugefügt", "addedToWhitelist": "Zur Whitelist hinzugefügt",

View file

@ -61,7 +61,32 @@
"deleteTitle": "Delete your account?", "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.", "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", "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": { "demo": {
"title": "Demo access", "title": "Demo access",
@ -87,7 +112,7 @@
"addPlaceholder": "Add an email directly…", "addPlaceholder": "Add an email directly…",
"add": "Add", "add": "Add",
"decided": "{{count}} decided", "decided": "{{count}} decided",
"approved": "Approved — they can sign in now", "approved": "Approved {{email}} — they can sign in now",
"approveFailed": "Approve failed", "approveFailed": "Approve failed",
"denyFailed": "Deny failed", "denyFailed": "Deny failed",
"addedToWhitelist": "Added to the whitelist", "addedToWhitelist": "Added to the whitelist",

View file

@ -61,7 +61,32 @@
"deleteTitle": "Törlöd a fiókodat?", "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.", "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", "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": { "demo": {
"title": "Demo hozzáférés", "title": "Demo hozzáférés",
@ -87,7 +112,7 @@
"addPlaceholder": "E-mail közvetlen hozzáadása…", "addPlaceholder": "E-mail közvetlen hozzáadása…",
"add": "Hozzáadás", "add": "Hozzáadás",
"decided": "{{count}} eldöntve", "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", "approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen", "denyFailed": "Az elutasítás sikertelen",
"addedToWhitelist": "Hozzáadva a fehérlistához", "addedToWhitelist": "Hozzáadva a fehérlistához",

View file

@ -9,6 +9,8 @@ export interface Me {
avatar_url: string | null; avatar_url: string | null;
role: string; role: string;
is_demo: boolean; is_demo: boolean;
has_google: boolean;
has_password: boolean;
can_read: boolean; can_read: boolean;
can_write: boolean; can_write: boolean;
pending_invites: number; pending_invites: number;
@ -199,6 +201,15 @@ interface ReqConfig {
quiet?: boolean; 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> { async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET"; const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? 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. // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
if (RETRIABLE_GATEWAY.has(r.status)) { if (RETRIABLE_GATEWAY.has(r.status)) {
markConnectivityLost(); 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) { } else if (cfg.quiet) {
/* caller handles the error inline — no global modal */ /* caller handles the error inline — no global modal */
} else if (r.status >= 500) { } else if (r.status >= 500) {
@ -470,6 +485,7 @@ export interface AdminUserRow {
display_name: string | null; display_name: string | null;
role: string; // "user" | "admin" role: string; // "user" | "admin"
is_active: boolean; is_active: boolean;
is_suspended: boolean;
email_verified: boolean; email_verified: boolean;
is_demo: boolean; is_demo: boolean;
has_password: boolean; has_password: boolean;
@ -599,6 +615,10 @@ export const api = {
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"), adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> => setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), 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"), schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> => updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, { 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 }), req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }), 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 --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }), req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),