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.
This commit is contained in:
2026-09-01 23:09:25 +02:00
parent 24c93cbb83
commit 44f626f732
25 changed files with 636 additions and 23 deletions
+54 -5
View File
@@ -1,6 +1,7 @@
"""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
@@ -31,7 +32,18 @@ from app.utils import iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _serialize(inv: Invite) -> dict:
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,
@@ -40,6 +52,8 @@ def _serialize(inv: Invite) -> dict:
"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,
}
@@ -56,7 +70,8 @@ def list_invites(
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))
return [_serialize(i) for i in rows]
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:
@@ -107,7 +122,7 @@ def approve_invite(
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)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@router.post("/invites/{invite_id}/deny")
@@ -121,7 +136,7 @@ def deny_invite(
# 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)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
class AddInviteIn(BaseModel):
@@ -150,7 +165,41 @@ def add_invite(
)
db.commit()
_activate_user_by_email(db, email)
return _serialize(inv)
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 --------------------------------------------------------------------
+7
View File
@@ -13,6 +13,7 @@ from app.auth import (
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
@@ -98,11 +99,16 @@ def get_me(
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,
@@ -121,6 +127,7 @@ def get_me(
"can_read": has_read_scope(user),
"can_write": has_write_scope(user),
"pending_invites": pending_invites,
"email_configured": email_configured,
"preferences": user.preferences or {},
}