feat(auth): account lifecycle — Google linking, passwords, suspension & deletion plumbing
- Link a Google account to a password account, and adopt the Google identity onto a matching email account instead of 500ing on a duplicate; set or change a password from Settings. - Expose has_google/has_password on /api/me for the Sign-in methods UI. - Mark Google logins email-verified (backfill existing rows, migration 0024); stop a routine login from clobbering an admin-assigned role (env ADMIN_EMAILS stays the bootstrap admin). - Suspension login-gates (password + Google callback + current_user) with a rate-limited 'suspended' notice; shared purge_user (cascade delete + access-request cleanup + Google-grant revoke) behind self- and admin-deletion; single app_base source for user-facing email links.
This commit is contained in:
parent
72cd8e6d65
commit
8c727dd99e
9 changed files with 507 additions and 43 deletions
31
backend/alembic/versions/0024_google_email_verified.py
Normal file
31
backend/alembic/versions/0024_google_email_verified.py
Normal 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
|
||||
|
|
@ -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 "<no email>")
|
||||
# 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }) }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue