feat(webapp): campaign invites with expiry, usage limits and join approval (#643) #647
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/643-campaign-invites"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements #643, including its interaction comment with #644. It fixes two problems:
INVITE_CODEset, nobody could sign up through the UI.bb77cebremoved the login field but kept the gate.⚠️ Behaviour changes for operators
campaigns.invite_codeinto an invite expiringnow() + 7 days, then drops the column. GMs create new links from the member list.POST /api/campaigns/joinresponse changed, from aCampaignResponseto{outcome: joined|requested|already_member, campaign_id, campaign_name}. The old response included the webhook URL and share token, which a user waiting for approval must not receive. "Already a member" is now a 200 withalready_memberinstead of a 400. The bot never calls this endpoint (grep: noinviteunderbot/), so there is noBOT_CONTRACT_VERSIONbump.archived. An archived campaign's invite gets the same generic 404 as any dead one, which reverses #387's rule. "This campaign is archived" would name a campaign to anyone holding an old link.CampaignResponse. Only people who manage membership can list invites.Design
invite_service.redeem_invite. The Join box, the/join/<code>page and the sign-up callback all call it.SELECT … FOR UPDATEandpopulate_existing, so a stale copy already in the session can't slip past the lock.campaign_join_requests, not as a status oncampaign_members, so every existing membership check refuses them by default.routers/auth.py):logintakes the campaign code only from anextthat passes the existing/join/<code>allowlist, and stores it in the OIDC state.callbacklets a new user in ifINVITE_CODEis unset or matches, or if that invite is redeemable now.strused to crash into "A database error occurred"), and the broad except now logs the exception./join/<code>?requested=1if approval is pending. A plain/join/<code>would say "no longer valid" if they had taken the last use./auth/login?invite_code=…still works; it's how a fresh install's first admin gets in (.env.examplesays so).can_manage_membershipgates listing, creating and revoking invites, the approval toggle, and approving and declining requests. It is GM-only today; #644 adds the owner check there. The toggle has its own route (PUT /campaigns/{id}/join-approval), because the campaign PATCH isrequire_gmand would block a player-owner.UI
InvitePanelis now an invite list: label, link, "expires in…", "x of y used", Copy and Revoke. "New invite link" offers 1 / 7 / 30 days and 1 / 5 / 10 / unlimited uses. It also has the "New members need my approval" toggle.JoinCampaignpage and the Dashboard Join box handle "waiting for approval" and the generic dead-invite message. The Join box also accepts a pasted full link.Migration
d3e4f5a6b7c8(onc2d3e4f5a6b7)c2d3e4f5a6b7with two campaigns that had codes and one without:webapp/CLAUDE.md's migration chain now lists it.Tests
test_campaign_invites.py: service, routes, migration, and the race, where two connections redeem amax_uses=1invite and exactly one succeeds.test_auth_callback.py: the OIDC callback had no tests at all before this.FOR UPDATEmakes the race test see['joined','joined'].ruff0.4.4 and version-sync clean.Known, not fixed here
fetch_oidc_discovery()in the callback is unwrapped, so an unreachable provider at that step returns a 500 instead of the error page.usersrows behind, making "first user" tests depend on run order. The one affected test was made independent of that.Closes #643
🤖 Generated with Claude Code
A campaign had one invite_code that anyone could use any number of times, for ever. And with INVITE_CODE set, nobody could sign up through the UI: the login page lost its code field but the callback gate stayed, so it compared "" against the instance code and refused everyone. Data (migration d3e4f5a6b7c8, raw SQL): - campaign_invites: plaintext code (a GM re-copies live links; expiry, not hashing, bounds a leak), label, created_by SET NULL, expires_at, max_uses, use_count, revoked_at. - invite_redemptions: the audit trail, outcome TEXT + CHECK. - campaign_join_requests: pending members kept OUT of campaign_members, so no membership check can admit them by missing a status filter. - campaigns.join_requires_approval. - Backfill: every invite_code becomes "Original invite link", no use limit, expiring now() + 7 days; then the column is dropped. The downgrade restores each campaign's newest live code. One way in: invite_service.redeem_invite locks the invite row (FOR UPDATE), refuses unknown/revoked/expired/used-up invites and trashed or archived campaigns with one generic message, consumes no use for an existing member or pending requester, then joins as a player or queues a request. The dashboard Join box, /join/<code> and the OIDC sign-up all use it; the two duplicated lookups are gone. POST /api/campaigns/join now answers {outcome, campaign_id, campaign_name} instead of a CampaignResponse, because a requester is not a member. The unauthenticated preview reveals a campaign only for a redeemable invite and drops its `archived` flag. Sign-up gate: login lifts the code from an allowlisted /join/<code> next into the OIDC state. A new user passes if INVITE_CODE is unset or matched, or that campaign invite is redeemable now; the user row and the redemption share a savepoint, so a refusal leaves no account. Bytes in hmac.compare_digest, the broad except logs, and the refusal text no longer points at a field that does not exist. A player admitted by an invite goes straight to the campaign, or to /join/<code>?requested=1. "GM or owner": invites, the approval toggle and approve/decline all authorise through invite_service.can_manage_membership (via require_membership_manager). GM-only today; #644 fills in the owner half there. The toggle is its own PUT route rather than a PATCH field for the same reason. UI: InvitePanel is an invite list (label, link, expiry, "x of y used", Copy, Revoke; new links 1/7/30 days, 1/5/10/unlimited uses) with the approval toggle; Regenerate is gone. The member list shows requests waiting for approval with Approve/Decline. JoinCampaign and the dashboard Join box handle "requested" and the generic dead-invite message. Bot untouched (no invite code under bot/), so no contract bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>