fix: address the feature review — accurate copy, reachable controls, real coverage

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.
This commit is contained in:
2026-09-01 23:31:49 +02:00
parent 44f626f732
commit 85043013f0
14 changed files with 298 additions and 55 deletions
+27 -15
View File
@@ -17,10 +17,12 @@ from email.message import EmailMessage
from email.utils import formatdate
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import sysconfig
from app.config import settings
from app.db import SessionLocal
from app.models import User
from app.notifications import create_notification
from app.ratelimit import RateLimiter
@@ -39,8 +41,6 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
if not _alert_throttle.allow(reason):
return
try:
from app.models import User # local: app.models imports settle before this ever runs
with SessionLocal() as db:
admins = (
db.execute(
@@ -54,6 +54,9 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
.all()
)
if not admins:
# Nothing was recorded, so don't let this cost the next hour of alerts on the very
# path whose job is to stop drops going unseen. Same on the failure branch below.
_alert_throttle.reset()
return
for uid in admins:
create_notification(
@@ -68,24 +71,33 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
)
db.commit()
except Exception:
_alert_throttle.reset()
log.exception("Could not record the undelivered-email notice (%r)", subject)
def _smtp() -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
session because email is often sent from a BackgroundTask, outside a request's db."""
with SessionLocal() as db:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
def _read_smtp(db: Session) -> dict:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
def email_enabled() -> bool:
c = _smtp()
def _smtp(db: Session | None = None) -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short session when
the caller has none, because email is usually sent from a BackgroundTask outside a request's db.
A caller that IS inside a request should pass its session: reading the password decrypts a Fernet
value, and /api/me polls this for every admin tab."""
if db is not None:
return _read_smtp(db)
with SessionLocal() as own:
return _read_smtp(own)
def email_enabled(db: Session | None = None) -> bool:
c = _smtp(db)
return bool(c["host"] and c["user"] and c["password"])
+16 -6
View File
@@ -19,6 +19,7 @@ from app.auth import (
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 (
@@ -175,12 +176,19 @@ def delete_invite(
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.
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: `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."""
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")
@@ -189,6 +197,8 @@ def delete_invite(
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,
@@ -199,7 +209,7 @@ def delete_invite(
)
db.delete(inv)
db.commit()
return {"deleted": invite_id}
return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env}
# --- Users & roles --------------------------------------------------------------------
+1 -1
View File
@@ -108,7 +108,7 @@ def get_me(
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
or 0
)
email_configured = email_mod.email_enabled()
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
return {
"authenticated": True,
"id": user.id,
+46 -1
View File
@@ -27,7 +27,7 @@ def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_u
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 200 and r.json() == {"deleted": listed["id"]}
assert r.status_code == 200 and r.json()["deleted"] == listed["id"]
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == "mistake@example.com")).scalar_one_or_none()
@@ -56,6 +56,50 @@ def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user
)
def test_a_pending_request_from_a_registration_is_not_deletable(db, make_user, as_user):
# The shape the ordinary sign-up produces: _register_account writes the User (inactive) AND the
# pending Invite together. The invite is that account's approval gate, so deleting it would
# strand the person — they could neither sign in nor be approved, and re-registering is a silent
# no-op. Managed from Users & roles instead, which is what the UI and the 409 both say.
admin = make_user(email="admin@example.com", role="admin")
make_user(email="signup@example.com", is_active=False, email_verified=False)
db.add(Invite(email="signup@example.com", status="pending"))
db.commit()
client = as_user(admin)
listed = _invites(client)["signup@example.com"]
assert listed["has_account"] is True # the UI shows a disabled control, not a missing one
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 409
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == "signup@example.com")).scalar_one_or_none()
is not None
)
def test_deleting_an_env_allow_listed_address_reports_that_it_kept_access(
db, make_user, as_user, monkeypatch
):
# Removing the row is NOT always a revoke: is_allowed falls back to the env bootstrap lists once
# no invite exists. The admin has to be told, or they think they removed access and did not.
from app.config import settings
monkeypatch.setattr(
type(settings), "allowed_email_set", property(lambda self: {"ops@example.com"})
)
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="ops@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
r = client.delete(f"/api/admin/invites/{_invites(client)['ops@example.com']['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is True
def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
# A pending row is deletable too — "not now, and don't keep the record" is a different decision
# from Deny, which keeps the row precisely so the person cannot immediately re-ask.
@@ -67,6 +111,7 @@ def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
r = client.delete(f"/api/admin/invites/{_invites(client)['asker@example.com']['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is False # nothing in the env allow-lists
assert "asker@example.com" not in _invites(client)
+58
View File
@@ -0,0 +1,58 @@
"""/api/me carries whether this instance can actually send email.
It drives the operator warnings on the Configuration and Access-requests screens. Admin-only,
because it is an operator concern and a regular user has nothing to do with it.
"""
from app import email as email_mod
_PW = "correct horse battery"
def _signed_in(client, make_user, **kw):
"""/api/me resolves its user through optional_current_user, which the as_user override does not
cover — so sign in for real and read the bootstrap payload the SPA actually gets."""
user = make_user(password=_PW, **kw)
assert (
client.post("/auth/password-login", json={"email": user.email, "password": _PW}).status_code
== 200
)
r = client.get("/api/me")
assert r.status_code == 200 and r.json()["authenticated"] is True
return r.json()
def test_an_admin_is_told_when_email_is_not_configured(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is False
def test_an_admin_sees_nothing_wrong_when_email_works(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is True
def test_a_regular_user_is_never_warned_about_the_mail_setup(client, make_user, monkeypatch):
# Not their problem, and the flag must not become a way to probe the instance's configuration.
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="plain@example.com")
assert me["email_configured"] is True
def test_the_flag_reuses_the_request_session(client, make_user, monkeypatch):
# /api/me is polled once a minute per open tab; reading the SMTP settings decrypts a Fernet
# value, so it must not also check out a second pooled connection each time.
seen: list[object] = []
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: seen.append(db) or True)
_signed_in(client, make_user, email="admin@example.com", role="admin")
assert seen and seen[0] is not None