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:
+27
-15
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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 --------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user