The demo account — one shared user that whitelisted emails entered from the login page, reset on a schedule — goes end to end (owner decision, E34): demo.py and the reset job, users.is_demo and demo_whitelist (migration 0098 deletes the demo user, its audit rows, the reset job's override and notices, then drops the column and table; the downgrade restores the empty schema), the /auth/demo entry, the admin whitelist and reset, the Demo tab, the banner, the login page's "Try the demo", and every is_demo branch in routes, userscope, scheduler and the UI. require_human only ever refused the demo account, so its 87 uses become current_user (owner-approved); require_write_scope keeps its own check. The login page's "or" divider now shows only beside the Google button: without the demo entry under it, an instance without Google would have said "or" to nothing. A contract test (removedFeatures) fails on the word anywhere in the backend, frontend, e2e or scripts — seen red on a planted hit. Size ceilings tightened for the modules that shrank; admin.py is back under the limit.
243 lines
10 KiB
Python
243 lines
10 KiB
Python
from typing import Any
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
|
from pydantic import BaseModel, RootModel
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import (
|
|
count_admins,
|
|
count_pending_activations,
|
|
current_user,
|
|
drop_from_wallet,
|
|
google_enabled,
|
|
has_read_scope,
|
|
has_write_scope,
|
|
is_allowed,
|
|
optional_current_user,
|
|
purge_user,
|
|
)
|
|
from app import debugsession, diagnostics
|
|
from app.issues import gitea
|
|
from app import email as email_mod
|
|
from app import logs
|
|
from app import sysconfig
|
|
from app.db import get_db
|
|
from app.models import Invite, User
|
|
from app.utils import iso
|
|
from app.refusal import refused
|
|
from app.routes._common import not_found
|
|
|
|
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 refused(403, "not_your_account")
|
|
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
|
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
|
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
|
# wallet session, logging out every account here. (B-core-9)
|
|
u = db.get(User, target)
|
|
if u is None:
|
|
drop_from_wallet(request.session, target)
|
|
raise not_found("account")
|
|
if not is_allowed(db, u.email):
|
|
raise refused(403, "account_no_access")
|
|
if not u.is_active:
|
|
drop_from_wallet(request.session, target)
|
|
raise refused(403, "account_no_access")
|
|
if u.is_suspended:
|
|
drop_from_wallet(request.session, target)
|
|
raise refused(403, "account_suspended")
|
|
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
|
epochs = request.session.get("epochs") or {}
|
|
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
|
drop_from_wallet(request.session, target)
|
|
raise refused(403, "account_signed_out")
|
|
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
|
|
pending_activations = 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
|
|
)
|
|
# Counted separately from pending_invites rather than folded into it: the two are different
|
|
# facts (a request awaiting a decision vs a decided address whose sign-up never got let in),
|
|
# and quietly widening one number would make an existing field mean something new.
|
|
pending_activations = count_pending_activations(db)
|
|
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
|
|
return {
|
|
"authenticated": True,
|
|
"id": user.id,
|
|
"email": user.email,
|
|
"display_name": user.display_name,
|
|
"avatar_url": user.avatar_url,
|
|
"role": user.role,
|
|
"has_google": user.google_sub is not None,
|
|
# The address Google answers with, when it DIFFERS from the account's own. Same string for
|
|
# most people, and then this is None so no surface has to decide whether to show it — the
|
|
# comparison lives here, once, instead of in every consumer.
|
|
"google_email": (
|
|
user.google_email if user.google_email and user.google_email != user.email else None
|
|
),
|
|
"has_password": user.password_hash is not None,
|
|
# The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the
|
|
# user does not recognise is something they can see (null: none since 0.88).
|
|
"last_login_at": iso(user.last_login_at),
|
|
# 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"),
|
|
# Which live-search source runs, so the search button can say what a search costs: "scrape"
|
|
# spends no API quota, "api" spends 100 units a page.
|
|
"search_source": sysconfig.get_str(db, "search_source"),
|
|
"can_read": has_read_scope(user),
|
|
"can_write": has_write_scope(user),
|
|
"pending_invites": pending_invites,
|
|
# Sign-ups an admin still has to let in (roadmap UX-25): approved BEFORE they registered, so
|
|
# nothing ever reached the Access-requests queue and the badge used to read zero.
|
|
"pending_activations": pending_activations,
|
|
"email_configured": email_configured,
|
|
"preferences": user.preferences or {},
|
|
# The consented diagnostics level in force and when it lapses (E22 S5). The client applies
|
|
# it to its own log and streams that log while it is raised.
|
|
"diagnostics": diagnostics.state(db, user),
|
|
# The live debug session, if one is recording (E16) — a reload resumes it.
|
|
"debug_session": _live_session(db, user),
|
|
# Where a bug report goes (E16 S4): the tracker's repository, or None — download only.
|
|
"issues": gitea.target(db),
|
|
}
|
|
|
|
|
|
def _live_session(db: Session, user: User) -> dict | None:
|
|
session = debugsession.open_session(db, user)
|
|
return None if session is None else debugsession.view(db, session)
|
|
|
|
|
|
@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 last remaining admin can't delete itself (that would lock everyone out of the admin
|
|
surfaces)."""
|
|
if user.role == "admin" and count_admins(db) <= 1:
|
|
raise refused(400, "only_admin_delete")
|
|
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}
|
|
|
|
|
|
class PreferencesIn(RootModel[dict[str, Any]]):
|
|
"""Free-form UI preferences — a JSON object of any keys (theme, filters, …), merged into the
|
|
stored ones. A model rather than a bare `dict` parameter so a non-object body is a 422 at the
|
|
boundary like every other request body."""
|
|
|
|
|
|
@router.put("/preferences")
|
|
def update_preferences(
|
|
preferences: PreferencesIn,
|
|
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.root)
|
|
user.preferences = merged
|
|
db.add(user)
|
|
db.commit()
|
|
return {"ok": True, "preferences": merged}
|
|
|
|
|
|
class DiagnosticsIn(BaseModel):
|
|
level: diagnostics.DiagnosticsLevel
|
|
|
|
|
|
@router.put("/diagnostics")
|
|
def update_diagnostics(
|
|
body: DiagnosticsIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the
|
|
base. The expiry is the server's: a raised level always lapses after
|
|
`diagnostics.ELEVATION_HOURS`, whatever the client asks."""
|
|
diagnostics.set_level(user, body.level)
|
|
db.commit()
|
|
# The rest of THIS request, and the process's elevation registry, follow the new level now.
|
|
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
|
|
return {"ok": True, "diagnostics": diagnostics.state(db, user)}
|