diff --git a/backend/app/services/site_invite_service.py b/backend/app/services/site_invite_service.py index 9b0262f..b51d9d3 100644 --- a/backend/app/services/site_invite_service.py +++ b/backend/app/services/site_invite_service.py @@ -52,8 +52,14 @@ async def create_site_invite( async def list_site_invites(db: AsyncSession) -> list[SiteInvite]: + # Pending only (#61) -- the admin UI's only consumer of this list labels + # it "Pending invites" and had no way to drop a row once it was accepted + # or revoked, since the backend returned every invite ever sent forever. + # An accepted/revoked invite has nothing further to act on here; its + # history already lives in the audit log ("user.invite"/"invite.revoke"). result = await db.execute( select(SiteInvite) + .where(SiteInvite.status == InviteStatus.pending) .options(selectinload(SiteInvite.inviter)) .order_by(SiteInvite.created_at.desc()) ) diff --git a/backend/tests/test_site_invites.py b/backend/tests/test_site_invites.py index be35958..34c7fec 100644 --- a/backend/tests/test_site_invites.py +++ b/backend/tests/test_site_invites.py @@ -194,3 +194,54 @@ async def test_list_site_invites(client, db_session, monkeypatch): assert resp.status_code == 200 emails = [i["email"] for i in resp.json()] assert "listed@example.com" in emails + + +async def test_list_site_invites_excludes_revoked_invite(client, db_session, monkeypatch): + # #61: the admin UI labels this list "Pending invites" -- a revoked + # invite has nothing left to act on and must actually drop out of it, + # not just get relabeled in place. + _fake_smtp(monkeypatch) + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await _configure_smtp(client) + + resp = await client.post("/api/admin/invites", json={"email": "revoked-from-list@example.com"}) + invite_id = resp.json()["id"] + + revoke = await client.delete(f"/api/admin/invites/{invite_id}") + assert revoke.status_code == 200 + + listed = await client.get("/api/admin/invites") + emails = [i["email"] for i in listed.json()] + assert "revoked-from-list@example.com" not in emails + + +async def test_list_site_invites_excludes_accepted_invite(client, db_session, monkeypatch): + # Same gap, the other trigger: completing signup accepts the invite + # out-of-band from the admin's own session, but it must still be gone + # from the pending list on the admin's next fetch. + calls = _fake_smtp(monkeypatch) + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await _configure_smtp(client) + + await client.post("/api/admin/invites", json={"email": "accepted-from-list@example.com"}) + token = _extract_token(calls[0]["message"].get_content()) + + complete = await client.post( + "/api/signup", + json={ + "token": token, + "username": "acceptedfromlist", + "password": "password123", + "password_confirm": "password123", + }, + ) + assert complete.status_code == 200, complete.text + + # Signup logs the new user's session in on `client` -- switch back to + # the admin to check the list the way the admin actually would. + await login_as(client, "admin1") + listed = await client.get("/api/admin/invites") + emails = [i["email"] for i in listed.json()] + assert "accepted-from-list@example.com" not in emails diff --git a/frontend/src/pages/AdminPage.css b/frontend/src/pages/AdminPage.css index e1625c4..a99ac79 100644 --- a/frontend/src/pages/AdminPage.css +++ b/frontend/src/pages/AdminPage.css @@ -185,35 +185,6 @@ margin-bottom: var(--sp-2); } -.invite-status-badge { - display: inline-flex; - align-items: center; - border-radius: var(--radius-pill); - font-size: 0.68rem; - font-weight: 800; - padding: 2px 8px; - text-transform: capitalize; - flex: none; -} - -.invite-status-pending { - border: 1px solid var(--ds-border); - background: transparent; - color: var(--ds-muted); -} - -.invite-status-accepted { - border: 1px solid color-mix(in srgb, var(--ds-accent) 50%, transparent); - background: color-mix(in srgb, var(--ds-accent) 14%, transparent); - color: var(--ds-accent); -} - -.invite-status-revoked { - border: 1px solid color-mix(in srgb, var(--ds-danger) 50%, transparent); - background: color-mix(in srgb, var(--ds-danger) 14%, transparent); - color: var(--ds-danger); -} - .admin-token-list { display: flex; flex-direction: column; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2588ac3..5070de4 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -305,8 +305,10 @@ export function AdminPage() { async function handleRevokeSiteInvite(invite: SiteInvite) { await withBusy(invite.id, async () => { - const updated = await revokeSiteInvite(invite.id) - setSiteInvites((prev) => prev.map((i) => (i.id === updated.id ? updated : i))) + await revokeSiteInvite(invite.id) + // #61: the list is pending-only (server-filtered), so a revoked + // invite drops out of it rather than sticking around relabeled. + setSiteInvites((prev) => prev.filter((i) => i.id !== invite.id)) }) } @@ -417,21 +419,17 @@ export function AdminPage() { {siteInvites.map((invite) => (