Round 1 on the feature diff found 15; the substantive ones: - The delete control VANISHED on any invite whose address had registered, which is the ordinary sign-up shape (register writes the User and the pending invite together). The guard stays broad — that row is the account's approval gate, and orphaning it strands someone who can then neither sign in nor be approved — but the control is now a disabled button carrying the reason, on both lists, so an admin never sees a row silently missing a button its neighbours have. - Deleting an entry is not always a revoke: is_allowed falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS lists once no row exists, and an ADMIN_EMAILS address is force-promoted on its next Google sign-in. The route now reports still_allowed_by_env and the UI says so instead of claiming access was removed. - "Deny keeps the row so they can't immediately re-ask" was simply false — upsert_pending_invite reopens a denied row. Corrected everywhere it was stated. - The Configuration warning reads me.email_configured, which save() did not invalidate, so it kept warning about fields the admin had just filled in. - The read-only variant was an aria-hidden span explained only by a Tooltip that the hints toggle can switch off; now a disabled button with title + aria-label. - The delete error path threw away the 409's reason (the stale-list case) and left the stale row on screen. - The throttle window was spent even when no notice was written, silencing the next hour on the path whose job is to stop silent drops. - email_enabled() opened a second session and decrypted the SMTP password on every /api/me poll; it now takes the caller's session. - welcomeMode.dom.test.ts never rendered the card, so reverting the history wiring to useState left it green. A render test now drives the real links and fails on that revert (verified). Plus /api/me flag tests, the registration-shaped pending case, the env-allowlist case, and a release note that matches what the code does.
480 lines
18 KiB
Python
480 lines
18 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.config import settings
|
|
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). Deny
|
|
records a decision and keeps the row for the audit trail; delete forgets the address. Neither
|
|
stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one
|
|
simply comes back as a fresh pending request.
|
|
|
|
Refused once an account exists for the address, whatever its state: this row is the account's
|
|
approval gate, and orphaning the account from it would leave a person who can neither sign in nor
|
|
be approved. Delete the USER instead — purge_user removes the invite along with it.
|
|
|
|
Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed`
|
|
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
|
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
|
promoted to admin on its next Google sign-in). The admin has to hear that, not guess 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.",
|
|
)
|
|
email = (inv.email or "").lower()
|
|
still_allowed_by_env = email in settings.allowed_email_set or email in settings.admin_email_set
|
|
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, "still_allowed_by_env": still_allowed_by_env}
|
|
|
|
|
|
# --- 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
|