Private
Public Access
Drop accepted/revoked invites from the pending invites list (#61)
list_site_invites returned every invite ever sent, so the admin UI's "Pending invites" section kept showing accepted/revoked rows forever (just relabeled with a status badge) instead of dropping them. Filter the query to pending only, and have the revoke action remove its row from local state immediately instead of leaving a relabeled one behind. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,8 +52,14 @@ async def create_site_invite(
|
|||||||
|
|
||||||
|
|
||||||
async def list_site_invites(db: AsyncSession) -> list[SiteInvite]:
|
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(
|
result = await db.execute(
|
||||||
select(SiteInvite)
|
select(SiteInvite)
|
||||||
|
.where(SiteInvite.status == InviteStatus.pending)
|
||||||
.options(selectinload(SiteInvite.inviter))
|
.options(selectinload(SiteInvite.inviter))
|
||||||
.order_by(SiteInvite.created_at.desc())
|
.order_by(SiteInvite.created_at.desc())
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -194,3 +194,54 @@ async def test_list_site_invites(client, db_session, monkeypatch):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
emails = [i["email"] for i in resp.json()]
|
emails = [i["email"] for i in resp.json()]
|
||||||
assert "listed@example.com" in emails
|
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
|
||||||
|
|||||||
@@ -185,35 +185,6 @@
|
|||||||
margin-bottom: var(--sp-2);
|
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 {
|
.admin-token-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -305,8 +305,10 @@ export function AdminPage() {
|
|||||||
|
|
||||||
async function handleRevokeSiteInvite(invite: SiteInvite) {
|
async function handleRevokeSiteInvite(invite: SiteInvite) {
|
||||||
await withBusy(invite.id, async () => {
|
await withBusy(invite.id, async () => {
|
||||||
const updated = await revokeSiteInvite(invite.id)
|
await revokeSiteInvite(invite.id)
|
||||||
setSiteInvites((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))
|
// #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) => (
|
{siteInvites.map((invite) => (
|
||||||
<div key={invite.id} className="admin-token-row">
|
<div key={invite.id} className="admin-token-row">
|
||||||
<span className="admin-token-scopes">{invite.email}</span>
|
<span className="admin-token-scopes">{invite.email}</span>
|
||||||
<span className={`invite-status-badge invite-status-${invite.status}`}>{invite.status}</span>
|
|
||||||
<span className="admin-token-meta">
|
<span className="admin-token-meta">
|
||||||
{invite.status === 'pending' &&
|
Expires {new Date(invite.expires_at).toLocaleDateString()}
|
||||||
`Expires ${new Date(invite.expires_at).toLocaleDateString()}`}
|
|
||||||
</span>
|
</span>
|
||||||
{invite.status === 'pending' && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="admin-token-revoke"
|
||||||
className="admin-token-revoke"
|
disabled={busyId === invite.id}
|
||||||
disabled={busyId === invite.id}
|
onClick={() => handleRevokeSiteInvite(invite)}
|
||||||
onClick={() => handleRevokeSiteInvite(invite)}
|
>
|
||||||
>
|
Revoke
|
||||||
Revoke
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user