Files
siftlode/backend/app/routes/me.py
T
peter 44f626f732 feat(admin): delete a whitelist entry, and stop hiding a broken mail setup
Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.

- DELETE /api/admin/invites/{id} with a delete control on both the pending
  requests and the previously-decided list. Deny keeps the row so the person
  cannot immediately re-ask; delete forgets the address. Refused with 409 while
  a User row exists for it, because is_allowed reads that row and removing it
  would revoke a live user's access with no other sign — the list ships
  has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
  records an email_undelivered notification for every admin when a message is
  skipped or rejected, naming the message that was lost, throttled to one an
  hour. /api/me carries email_configured, driving a standing warning on
  Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
  views are history entries (useHistorySubview), so Back returns to the previous
  view instead of only moving the address bar.

Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
2026-09-01 23:09:25 +02:00

181 lines
7.4 KiB
Python

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import (
count_admins,
current_user,
google_enabled,
has_read_scope,
has_write_scope,
is_allowed,
optional_current_user,
purge_user,
)
from app import email as email_mod
from app import sysconfig
from app.db import get_db
from app.models import Invite, User
router = APIRouter(prefix="/api/me", tags=["me"])
@router.get("/accounts")
def my_accounts(
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> list[dict]:
"""The accounts that have authenticated in this browser session (switchable without a
new Google sign-in). The active one is flagged."""
ids = request.session.get("account_ids") or [user.id]
rows = {u.id: u for u in db.query(User).filter(User.id.in_(ids)).all()}
out = []
for uid in ids: # preserve recency order from the session
u = rows.get(uid)
if u is not None:
out.append(
{
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"avatar_url": u.avatar_url,
"active": u.id == user.id,
}
)
return out
class SwitchAccountIn(BaseModel):
user_id: int
@router.post("/switch")
def switch_account(
body: SwitchAccountIn,
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Switch the active account to another one already authenticated in this browser. No
Google round-trip — but only accounts present in the session list (proof they signed in
here) are allowed, and access is re-checked in case the invite was revoked since."""
target = body.user_id
if target not in (request.session.get("account_ids") or []):
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
u = db.get(User, target)
if u is None:
raise HTTPException(status_code=404, detail="That account no longer exists.")
if not is_allowed(db, u.email):
raise HTTPException(status_code=403, detail="That account no longer has access.")
if u.is_suspended:
# Don't make a suspended account the active one — the next request's current_user would
# see the suspension and clear the WHOLE wallet session, logging out every account here.
raise HTTPException(status_code=403, detail="That account is suspended.")
# Same reasoning for a REVOKED session (a password reset/change or "log out everywhere" bumped the
# account's epoch): making it the session default would trip current_user's epoch check on the next
# request and clear the whole wallet. Mirror the suspend guard — drop the stale account from the
# switch list and refuse, rather than logging everyone out. (B-core-9)
epochs = request.session.get("epochs") or {}
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
request.session["account_ids"] = [
a for a in (request.session.get("account_ids") or []) if a != target
]
raise HTTPException(
status_code=403, detail="That account was signed out elsewhere — sign in again."
)
request.session["user_id"] = target
return {"ok": True, "user_id": target}
@router.get("")
def get_me(
user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
) -> dict:
# The app's bootstrap probe: return 200 with `authenticated: False` when logged out (rather
# than 401) so the public landing never logs a failed /api/me to the browser console. Other
# protected endpoints still 401, so mid-session expiry is still caught by the global handler.
if user is None:
return {"authenticated": False}
pending_invites = 0
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
# config costs verification links, password resets and access-request notices with nothing
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
email_configured = True
if user.role == "admin":
pending_invites = (
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
or 0
)
email_configured = email_mod.email_enabled()
return {
"authenticated": True,
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"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,
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
# YouTube-access affordances and the onboarding nudge when it isn't configured).
"google_enabled": google_enabled(db),
# Whether the optional Plex module is enabled (UI shows the Plex feed source when so).
"plex_enabled": sysconfig.get_bool(db, "plex_enabled"),
"can_read": has_read_scope(user),
"can_write": has_write_scope(user),
"pending_invites": pending_invites,
"email_configured": email_configured,
"preferences": user.preferences or {},
}
@router.delete("/account")
def delete_my_account(
request: Request,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""GDPR self-service erasure: permanently delete the signed-in account and ALL its personal
data — OAuth tokens, subscriptions, tags, watch/save/hide states, playlists, notifications —
which all cascade on the users row. Quota-audit events are kept but anonymised (FK SET NULL).
The shared demo account can't be deleted, and the last remaining admin can't delete itself
(that would lock everyone out of the admin surfaces)."""
if user.is_demo:
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
if user.role == "admin" and count_admins(db) <= 1:
raise HTTPException(
status_code=400,
detail="You're the only admin — promote another admin before deleting your account.",
)
user_id = user.id
# 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:
request.session["account_ids"] = remaining
request.session["user_id"] = remaining[-1]
else:
request.session.clear()
return {"deleted": True}
@router.put("/preferences")
def update_preferences(
preferences: dict,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
# Merge so partial updates don't wipe other keys.
merged = dict(user.preferences or {})
merged.update(preferences)
user.preferences = merged
db.add(user)
db.commit()
return {"preferences": merged}