PR: #427: feat(team): resend expired invitations [Story-1751] by @ayush-pramaan
View PR →
Story 1751 - pramaan-app - Team invitations

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

AreaChangeWhy
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

Members 3 Pending 1 Expired 1 Deactivated 1

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

MetricAfter resendEvidence
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

Verification

Files changed

8 file(s), +196 / -15 lines

Path Role + -
src/components/settings/team/pending-invite-actions.stories.tsxComponent+8-0
src/components/settings/team/pending-invite-row.stories.tsxComponent+8-0
src/components/settings/team/pending-invite-row.tsxComponent+1-1
src/components/settings/team/presentation.test.tsComponent+41-0
src/components/settings/team/presentation.tsComponent+36-1
src/components/settings/team/team-list-page.test.tsxComponent+92-6
src/lib/team-invitations/email.tsLibrary+3-0
src/lib/team-invitations/reconcile.tsLibrary+7-7