Add the ability to resend unaccepted invites (#60)

Previously the only way to resend was to re-invite the same email from
scratch, creating a whole new invite row. Adds a "Resend" action next
to Revoke on each pending invite -- rotates the token and refreshes the
7-day expiry on the same row (the old link stops working the moment
it's used, same instinct as a password-reset resend), then re-sends
the invite email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 18:29:24 -06:00
co-authored by Claude Sonnet 5
parent aeaaef96ec
commit 66e9c80422
6 changed files with 168 additions and 15 deletions
+73
View File
@@ -191,6 +191,79 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
assert complete.status_code == 400
async def test_resend_site_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
assert resp.status_code == 403
async def test_resend_site_invite_unknown_id_404s(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
assert resp.status_code == 404
async def test_resend_site_invite_new_link_works_and_old_one_doesnt(client, db_session, monkeypatch):
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)
resp = await client.post("/api/admin/invites", json={"email": "resend-me@example.com"})
invite_id = resp.json()["id"]
original_expires_at = datetime.fromisoformat(resp.json()["expires_at"])
old_token = _extract_token(_plain_text(calls[0]["message"]))
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
assert resend.status_code == 200, resend.text
assert resend.json()["id"] == invite_id
assert resend.json()["status"] == "pending"
# A fresh 7-day window, not whatever was left on the original.
new_expires_at = datetime.fromisoformat(resend.json()["expires_at"])
assert new_expires_at > original_expires_at
assert len(calls) == 2
new_token = _extract_token(_plain_text(calls[1]["message"]))
assert new_token != old_token
# The old link is dead -- resending rotates the token, it doesn't just
# repeat it.
old_validate = await client.get(f"/api/signup/validate?token={old_token}")
assert old_validate.status_code == 400
new_validate = await client.get(f"/api/signup/validate?token={new_token}")
assert new_validate.status_code == 200
assert new_validate.json()["email"] == "resend-me@example.com"
complete = await client.post(
"/api/signup",
json={
"token": new_token,
"username": "resentuser",
"password": "password123",
"password_confirm": "password123",
},
)
assert complete.status_code == 200, complete.text
async def test_resend_site_invite_rejects_non_pending(client, db_session, monkeypatch):
_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": "already-revoked@example.com"})
invite_id = resp.json()["id"]
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
assert revoke.status_code == 200
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
assert resend.status_code == 400
async def test_list_site_invites(client, db_session, monkeypatch):
_fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")