Expired invitations can now be resent without offering revoke
The Expired tab keeps using the existing invitation row, but writable admins now get the existing Resend action. The action calls the existing reset endpoint, which creates a fresh pending Clerk invitation from the expired record.
Problem
Story-1750 made expired invitations visible, but intentionally left them read-only. Once a firm admin sees an expired invite, the recovery path should be direct: resend it, then let the fresh pending invite appear in Pending.
What changed
| Area | Change | Why |
|---|---|---|
PendingInviteRow |
Renders PendingInviteActions for expired rows when canWrite is true. |
The row should not decide which invitation actions exist; the action component already owns status-specific options. |
PendingInviteActions |
Kept the existing canRevoke = status !== "expired" rule. |
Expired rows show Resend only. Clerk revoke remains pending-only and is never offered for expired rows. |
useResetTeamInvitation |
Continues calling POST /api/internal/team-invitations/{id}/reset. |
No new endpoint is needed; the server reset path already creates the replacement pending invitation. |
useTeamMutations |
Uses the existing reset mutation success path that invalidates team invitations. | The active query refetch brings back Clerk's current invitation list; the presentation layer handles old expired records that Clerk keeps. |
presentation.ts |
Hides an expired row when the same normalized email has a newer pending or accepted invite. | Clerk cannot revoke an already-expired invitation, so the old expired record may persist after the resend creates a fresh pending invite. |
User-facing flow
On an expired row, the action menu opens with one option:
Revoke is absent for expired invitations. After Resend succeeds, the refetched list can contain both the old expired row and the fresh pending row. The Expired tab still becomes empty because the expired row is superseded by the newer pending invite for the same normalized email.
Count boundary
| Metric | After resend | Evidence |
|---|---|---|
| Expired tab count | Drops by one | The expired-row derivation excludes old expired rows superseded by newer pending or accepted rows for the same normalized email. |
| Pending tab count | Increases by one | The reset endpoint returns a fresh pending invitation, and the list refetch shows it under Pending. |
| Seat usage | Uses the unchanged pending-only formula | seats.active + pendingInvitations.length naturally reflects the fresh pending invite. |
| Error path | Unchanged | resetInvitation() continues using the standard reset toast and error toast path. |
Acceptance evidence
- Done Expired rows expose Resend for writable admins.
- Done Revoke is not rendered for expired rows.
- Done Resend calls the existing
/resetendpoint. - Done The unit test drives expired -> reset -> refetch with both old expired and new pending records -> Pending.
- Done Presentation tests cover the normalized-email supersession rule directly.
- Done Storybook has explicit expired resend-only states for the action menu and row.
- Done No new user-facing strings or color literals were added.
Verification
pnpm lint- passed with one unrelated existingno-img-elementwarning insrc/components/add-matter/avatar.tsx.pnpm test- 124 files passed, 799 tests passed.pnpm build- passed.pnpm storybook:build- passed.git diff --check- passed.
Files changed
8 file(s), +196 / -15 lines
| Path | Role | + | - |
|---|---|---|---|
| src/components/settings/team/pending-invite-actions.stories.tsx | Component | +8 | -0 |
| src/components/settings/team/pending-invite-row.stories.tsx | Component | +8 | -0 |
| src/components/settings/team/pending-invite-row.tsx | Component | +1 | -1 |
| src/components/settings/team/presentation.test.ts | Component | +41 | -0 |
| src/components/settings/team/presentation.ts | Component | +36 | -1 |
| src/components/settings/team/team-list-page.test.tsx | Component | +92 | -6 |
| src/lib/team-invitations/email.ts | Library | +3 | -0 |
| src/lib/team-invitations/reconcile.ts | Library | +7 | -7 |