Files
siftlode/backend/app/routes/admin.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

470 lines
17 KiB
Python

"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
from collections.abc import Iterable
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app.audit import AuditAction
from app.auth import (
admin_user,
count_admins,
get_or_create_demo_user,
operator_contact,
purge_user,
)
from app.db import get_db
from app.demo import reset_demo_state
from app.models import (
DemoWhitelist,
Invite,
User,
)
from app.routes.auth import revoke_preset_password_if_unverified
from app.utils import iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
"""Which of these addresses already have an account. Batched, so listing the invites costs one
extra query. An invite whose address has an account must not be deletable: `is_allowed` reads
this row, so removing it would silently revoke a live user's access."""
wanted = {e.lower() for e in emails if e}
if not wanted:
return set()
rows = db.execute(select(User.email).where(User.email.in_(wanted))).scalars()
return {e.lower() for e in rows if e}
def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
return {
"id": inv.id,
"email": inv.email,
"status": inv.status,
"note": inv.note,
"requested_at": iso(inv.requested_at),
"decided_at": iso(inv.decided_at),
"decided_by": inv.decided_by,
# Drives the delete affordance: an entry backed by an account is not removable here.
"has_account": has_account,
}
@router.get("/invites")
def list_invites(
status: str | None = None,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> list[dict]:
stmt = select(Invite)
if status:
stmt = stmt.where(Invite.status == status)
# Pending first, then most-recently requested.
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
order = {"pending": 0, "approved": 1, "denied": 2}
rows.sort(key=lambda i: order.get(i.status, 9))
accounts = _accounts_for(db, (i.email for i in rows))
return [_serialize(i, has_account=(i.email or "").lower() in accounts) for i in rows]
def _activate_user_by_email(db: Session, email: str) -> None:
"""Approving access activates a matching (e.g. pending password-registration) account so
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
if user is not None and not user.is_active:
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
# verified must not hand a working login to a password set on it by whoever registered the
# address (a pre-emption). Shared single-source helper so the two paths can't drift.
revoke_preset_password_if_unverified(user)
user.is_active = True
db.commit()
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
# mailing a rejection to an already-active account.
if inv.status != "pending":
raise HTTPException(status_code=409, detail="Invite already decided")
inv.status = "approved" if approved else "denied"
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
audit.record(
db,
admin.id,
AuditAction.INVITE_APPROVE if approved else AuditAction.INVITE_DENY,
target_type="invite",
target_id=inv.id,
summary=inv.email,
)
db.commit()
return inv
@router.post("/invites/{invite_id}/approve")
def approve_invite(
invite_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
inv = _decide(db, invite_id, admin, approved=True)
_activate_user_by_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@router.post("/invites/{invite_id}/deny")
def deny_invite(
invite_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
# Symmetry with approve: the requester hears back either way, instead of a silent limbo. No
# account is activated, so this is an email only (the requester has no in-app inbox yet).
inv = _decide(db, invite_id, admin, approved=False)
background.add_task(email_mod.send_access_denied, inv.email)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
class AddInviteIn(BaseModel):
email: str = ""
@router.post("/invites")
def add_invite(
body: AddInviteIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
email = body.email.strip().lower()
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is None:
inv = Invite(email=email)
db.add(inv)
inv.status = "approved"
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
audit.record(
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
)
db.commit()
_activate_user_by_email(db, email)
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
@router.delete("/invites/{invite_id}")
def delete_invite(
invite_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that
never became an account (an approval added by mistake, or one that is no longer wanted). Denying
a request keeps the row so the person cannot immediately re-ask; deleting it forgets them, and a
fresh request from that address starts over as pending.
Refused once an account exists: `is_allowed` reads this row, so removing it would silently revoke
a live user's access. Delete the USER instead — purge_user removes the invite along with it."""
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
if _accounts_for(db, [inv.email]):
raise HTTPException(
status_code=409,
detail="That address already has an account — delete the account instead.",
)
audit.record(
db,
admin.id,
AuditAction.INVITE_DELETE,
target_type="invite",
target_id=inv.id,
summary=inv.email,
)
db.delete(inv)
db.commit()
return {"deleted": invite_id}
# --- Users & roles --------------------------------------------------------------------
def _serialize_user(u: User) -> dict:
return {
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"role": u.role,
"is_active": u.is_active,
"is_suspended": u.is_suspended,
"email_verified": u.email_verified,
"is_demo": u.is_demo,
"has_password": u.password_hash is not None,
"has_google": u.google_sub is not None,
"created_at": iso(u.created_at),
}
@router.get("/users")
def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(User).order_by(User.created_at.desc())).scalars().all()
return [_serialize_user(u) for u in rows]
class SetRoleIn(BaseModel):
role: str
@router.patch("/users/{user_id}/role")
def set_user_role(
user_id: int,
body: SetRoleIn,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
role = body.role
if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.")
if (
target.role == "admin"
and role == "user"
and not target.is_suspended
and count_admins(db, active_only=True) <= 1
):
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role
if changed:
audit.record(
db,
admin.id,
AuditAction.USER_ROLE_CHANGE,
target_type="user",
target_id=target.id,
summary=target.email,
before={"role": target.role},
after={"role": role},
)
target.role = role
db.commit()
if changed:
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
return _serialize_user(target)
class SetSuspendedIn(BaseModel):
suspended: bool = False
@router.patch("/users/{user_id}/suspend")
def set_user_suspended(
user_id: int,
body: SetSuspendedIn,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
live session is dropped on its next request. Guards: can't suspend the demo account, can't
suspend yourself, and can't suspend the last active admin (that would lock admin out). On
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in)."""
suspended = body.suspended
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
if (
suspended
and target.role == "admin"
and not target.is_suspended
and count_admins(db, active_only=True) <= 1
):
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
was_suspended = target.is_suspended
if was_suspended != suspended:
audit.record(
db,
admin.id,
AuditAction.USER_SUSPEND if suspended else AuditAction.USER_UNSUSPEND,
target_type="user",
target_id=target.id,
summary=target.email,
)
target.is_suspended = suspended
db.commit()
if was_suspended and not suspended:
background.add_task(email_mod.send_account_unsuspended, target.email, operator_contact())
return _serialize_user(target)
@router.delete("/users/{user_id}")
def admin_delete_user(
user_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves
(cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards:
not the demo account, not yourself (use Settings → Account), and not the last admin."""
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be deleted.")
if target.id == admin.id:
raise HTTPException(
status_code=400, detail="Delete your own account from Settings → Account."
)
if (
target.role == "admin"
and not target.is_suspended
and count_admins(db, active_only=True) <= 1
):
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
audit.record(
db,
admin.id,
AuditAction.USER_DELETE,
target_type="user",
target_id=target.id,
summary=target.email,
)
purge_user(db, target, background)
return {"deleted": user_id}
# --- Demo account: email whitelist + state reset -------------------------------------
def _serialize_demo(row: DemoWhitelist) -> dict:
return {
"id": row.id,
"email": row.email,
"note": row.note,
"added_by": row.added_by,
"created_at": iso(row.created_at),
}
@router.get("/demo/whitelist")
def list_demo_whitelist(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
rows = (
db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc())).scalars().all()
)
return [_serialize_demo(r) for r in rows]
class AddDemoWhitelistIn(BaseModel):
email: str = ""
note: str | None = None
@router.post("/demo/whitelist")
def add_demo_whitelist(
body: AddDemoWhitelistIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Add an email that may enter the shared demo account from the login page (no Google
sign-in). Idempotent — re-adding an existing email just returns it."""
email = body.email.strip().lower()
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
if row is None:
row = DemoWhitelist(
email=email,
note=(body.note or "").strip() or None,
added_by=admin.email,
)
db.add(row)
audit.record(
db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email
)
db.commit()
return _serialize_demo(row)
@router.delete("/demo/whitelist/{entry_id}")
def remove_demo_whitelist(
entry_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
row = db.get(DemoWhitelist, entry_id)
if row is not None:
audit.record(
db,
admin.id,
AuditAction.DEMO_REMOVE,
target_type="demo",
target_id=row.email,
summary=row.email,
)
db.delete(row)
db.commit()
return {"deleted": entry_id}
@router.post("/demo/reset")
def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
same automatically (admin-tunable interval); this is the admin's on-demand button."""
demo = get_or_create_demo_user(db)
seeded = reset_demo_state(db, demo)
audit.record(
db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded"
)
db.commit()
return {"reset": True, "playlists_seeded": seeded}
@router.post("/purge-discovery")
def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The
scheduled discovery-cleanup job does the same on its interval; this is the on-demand button."""
from app.sync.explore import purge_unkept_explored, purge_unkept_search
result = {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}
audit.record(
db,
admin.id,
AuditAction.DISCOVERY_PURGE,
summary=", ".join(f"{k}: {v}" for k, v in result.items()) or None,
after=result,
)
db.commit()
return result