Originally created by: sami-bre
Fixes AF-244.
Adding someone to an organization decided what to send them from user.email_verified_at, an account-level flag. That gave three different outcomes:
| Who you add | What happened |
|---|---|
| Brand new email | A /set-password link was created and emailed. Correct. |
| Exists, never set a password | A new link was minted and their previous link was silently killed. |
| Exists and active | Nothing was sent. Membership granted. |
Row two is the reported bug. Someone invited to org A who hadn't clicked their link yet lost it the moment another admin added them to org B — any copy of it, in their inbox or saved by an admin, started returning 400. They were then pushed through the sign-up screen again with a different link.
The cause was prepare_invite in api/domains/auth/service.py: for an existing but unverified user it called invalidate_unused_for_user_with_session and then minted a fresh token.
An invite link belongs to the user account, not to a membership. It is minted once, when the user record is created. No org operation replaces it or cancels it.
| Who you add | Link created? | Existing token | Membership |
|---|---|---|---|
| Email not in the system | Yes — one link, emailed | n/a | Immediate |
| Exists, never set a password | No | Kept; expiry pushed to now + 24h | Immediate |
| Exists and active | No | Untouched | Immediate |
Adding an existing pending user to another org only pushes the token's expires_at out, so the link they already hold keeps working — and an already-expired one comes back to life instead of locking a fresh member out. The token value itself never changes.
invite_user went through prepare_invite, so once that stopped issuing links for existing users both resend endpoints would have broken — the org one with a 500, the platform one with a misleading 409. It's replaced by AuthService.resend_invite, which mints on purpose.remove_member revoked pending invites unconditionally. With one token shared across all of a user's orgs that would break onboarding for every org they're still in, so it now fires only when no membership is left.Membership stays immediate on add. The ticket's original "must accept an invite before becoming a member" framing is deliberately not implemented here: it needs per-membership invite state (user_organization has no status or accepted_at column, and is_pending is derived from account-level email_verified_at). That's a much larger change and belongs in its own ticket.
A pending user added to a second org gets no notification at all. Their existing link quietly gains 24 more hours; they find out about the new org when they log in. That follows directly from "don't send a second invite" — worth a look in review.
Five new integration tests in api/tests/integration/test_organization_members.py:
expires_at moves forwardPlus an e2e case for the "Member added / no invite sent" dialog.
All green locally:
| Check | Result |
|---|---|
make check-api |
pass |
make check-ui |
pass |
make check-migrations |
pass |
make test-api |
1872 passed |
make test-ui |
241 passed |
Also verified against a running local stack. Before, case C returned HTTP 400 on the original link; now:
A brand-new : {"invite_link":"…/set-password?token=wiWd…","is_pending":true}
B active : {"invite_link":null,"is_pending":false}
C pending : {"invite_link":null,"is_pending":true}
C old token now returns HTTP 200
No migration — no schema changes.
🤖 Generated with Claude Code
Originally posted by: dominykas-aai-labs
1. Expired tokens are revived, but a pending user with no token is left locked out
Two near-identical situations get opposite treatment, and both are pinned as intended:
test_expired_invite_is_revived_by_a_later_add— a link that died three days ago goes live again, so the fresh member isn't locked out.test_adding_pending_user_without_a_token_issues_no_link— a pending user with no token gets nothing, and is locked out until an admin resends.The route into the second case is ordinary: invite P to org A, remove them from A (which invalidates their token), later add them to org B. Membership is granted, no link exists, no email goes out, and the dialog tells the admin an invite wasn't needed.
refresh_unused_expiry_for_user_with_session(api/domains/auth/repository.py) filters onis_used == Falseonly, which is what makes expired rows eligible for revival. Revival also means a link that expired weeks ago becomes valid again with nobody notified.The AC decides neither case, so flagging rather than blocking — but it's worth being a conscious decision.
2. Dialog copy is wrong for a pending user
ui/src/features/organizations/components/add-member-dialog.tsxsays:For AC row 2 — someone who has never set a password — that's misleading. They can't sign in, and the admin gets no hint that "Resend invite" exists or that the person is holding an unclicked link.
result.member.isPendingis already in the payload (ui/src/features/organizations/schemas.ts), so the two cases can be worded apart.This also matters because after "no new invite was sent", the natural admin move is Resend invite — which mints a replacement and kills the org A link, reproducing the original bug's user-visible outcome one click later.
3. Nobody tells the added user anything
A pending user added to a second org receives no email; their link silently gains 24 hours and they discover the org at login. An active user added to an org also receives nothing — that part matches AC row 3 and predates this branch. Already flagged in the commit body as a known consequence; raising it here so it gets its own ticket rather than being lost.