Skip to content

Commit 967589f

Browse files
Add invitation reminder and acceptance flows for teams
- show pending team invites with Send reminder and Revoke actions in settings - send org invitation emails with accept-invitation link and new accept page - support resend flag in invite API and cover team invite workflow in Playwright
1 parent 5710707 commit 967589f

9 files changed

Lines changed: 571 additions & 22 deletions

File tree

components/settings/SettingsOrganization.vue

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -261,19 +261,30 @@
261261
<p class="text-xs text-muted-foreground">
262262
Invited as <span class="capitalize">{{ inv.role }}</span>
263263
<span v-if="inv.teamName"> to {{ inv.teamName }}</span>
264-
&middot; Expires {{ formatDate(inv.expiresAt) }}
264+
&middot; Sent {{ formatDate(inv.createdAt) }} &middot; Expires {{ formatDate(inv.expiresAt) }}
265265
</p>
266266
</div>
267-
<Button
268-
variant="ghost"
269-
size="sm"
270-
class="text-red-600 hover:text-red-700 shrink-0"
271-
:disabled="cancellingInvitationId === inv.id"
272-
@click="cancelInvitation(inv.id)"
273-
>
274-
<Icon v-if="cancellingInvitationId === inv.id" name="lucide:loader-2" class="h-4 w-4 animate-spin" />
275-
<span v-else>Cancel</span>
276-
</Button>
267+
<div class="flex items-center gap-2 shrink-0">
268+
<Button
269+
variant="outline"
270+
size="sm"
271+
:disabled="resendingInvitationId === inv.id || cancellingInvitationId === inv.id"
272+
@click="resendInvitation(inv)"
273+
>
274+
<Icon v-if="resendingInvitationId === inv.id" name="lucide:loader-2" class="h-4 w-4 animate-spin" />
275+
<span v-else>Send reminder</span>
276+
</Button>
277+
<Button
278+
variant="ghost"
279+
size="sm"
280+
class="text-red-600 hover:text-red-700"
281+
:disabled="cancellingInvitationId === inv.id || resendingInvitationId === inv.id"
282+
@click="cancelInvitation(inv.id)"
283+
>
284+
<Icon v-if="cancellingInvitationId === inv.id" name="lucide:loader-2" class="h-4 w-4 animate-spin" />
285+
<span v-else>Revoke</span>
286+
</Button>
287+
</div>
277288
</div>
278289
</div>
279290
</CardContent>
@@ -442,6 +453,7 @@ export default {
442453
443454
// Cancel invitation
444455
cancellingInvitationId: null,
456+
resendingInvitationId: null,
445457
}
446458
},
447459
@@ -814,7 +826,7 @@ export default {
814826
this.cancellingInvitationId = invitationId
815827
await authClient.organization.cancelInvitation({ invitationId })
816828
this.pendingInvitations = this.pendingInvitations.filter((inv) => inv.id !== invitationId)
817-
toast.success('Invitation cancelled')
829+
toast.success('Invitation revoked')
818830
} catch (error) {
819831
console.error('Error cancelling invitation:', error)
820832
toast.error('Failed to cancel invitation')
@@ -823,6 +835,31 @@ export default {
823835
}
824836
},
825837
838+
async resendInvitation(invitation) {
839+
if (!this.organizationDetails?.id) return
840+
try {
841+
this.resendingInvitationId = invitation.id
842+
await $fetch('/api/organizations/invite', {
843+
method: 'POST',
844+
body: {
845+
organizationId: this.organizationDetails.id,
846+
email: invitation.email,
847+
role: invitation.role,
848+
...(invitation.teamId ? { teamId: invitation.teamId } : {}),
849+
resend: true,
850+
},
851+
})
852+
await this.loadPendingInvitations()
853+
toast.success('Invitation reminder sent')
854+
} catch (error) {
855+
console.error('Error resending invitation:', error)
856+
const message = error?.data?.statusMessage || error?.data?.error?.message || 'Failed to resend invitation'
857+
toast.error(message)
858+
} finally {
859+
this.resendingInvitationId = null
860+
}
861+
},
862+
826863
async updateOrganization() {
827864
if (!this.organizationDetails?.slug || !this.canEditOrganization) return
828865

components/settings/SettingsTeam.vue

Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,72 @@
164164
</CardContent>
165165
</Card>
166166

167+
<!-- Pending Team Invitations -->
168+
<Card
169+
v-if="currentTeam && !isDefaultTeam(currentTeam) && (isLoadingInvitations || pendingInvitations.length > 0)"
170+
data-testid="team-pending-invitations"
171+
>
172+
<CardHeader>
173+
<CardTitle class="flex items-center gap-2">
174+
<Icon name="lucide:mail" class="h-5 w-5" />
175+
Pending Invites
176+
</CardTitle>
177+
<CardDescription
178+
>Outstanding invitations for {{ currentTeam.name }} that have not yet been accepted.</CardDescription
179+
>
180+
</CardHeader>
181+
<CardContent>
182+
<div v-if="isLoadingInvitations" class="space-y-2">
183+
<Skeleton class="h-14 w-full" />
184+
<Skeleton class="h-14 w-full" />
185+
</div>
186+
<div v-else class="space-y-2">
187+
<div
188+
v-for="invitation in pendingInvitations"
189+
:key="invitation.id"
190+
data-testid="team-pending-invitation-row"
191+
class="flex items-center justify-between rounded-lg border p-3"
192+
>
193+
<div class="min-w-0">
194+
<p class="font-medium truncate">{{ invitation.email }}</p>
195+
<p class="text-xs text-muted-foreground">
196+
Invited {{ formatDate(invitation.createdAt) }} &middot; Expires {{ formatDate(invitation.expiresAt) }}
197+
</p>
198+
</div>
199+
<div class="flex items-center gap-2 shrink-0">
200+
<Button
201+
variant="outline"
202+
size="sm"
203+
:disabled="resendingInvitationId === invitation.id || cancellingInvitationId === invitation.id"
204+
@click="resendInvitation(invitation)"
205+
>
206+
<Icon
207+
v-if="resendingInvitationId === invitation.id"
208+
name="lucide:loader-2"
209+
class="h-4 w-4 animate-spin"
210+
/>
211+
<span v-else>Send reminder</span>
212+
</Button>
213+
<Button
214+
variant="ghost"
215+
size="sm"
216+
class="text-red-600 hover:text-red-700"
217+
:disabled="cancellingInvitationId === invitation.id || resendingInvitationId === invitation.id"
218+
@click="cancelInvitation(invitation.id)"
219+
>
220+
<Icon
221+
v-if="cancellingInvitationId === invitation.id"
222+
name="lucide:loader-2"
223+
class="h-4 w-4 animate-spin"
224+
/>
225+
<span v-else>Revoke</span>
226+
</Button>
227+
</div>
228+
</div>
229+
</div>
230+
</CardContent>
231+
</Card>
232+
167233
<!-- Danger Zone -->
168234
<Card v-if="currentTeam && !isDefaultTeam(currentTeam)" class="border-red-200">
169235
<CardHeader>
@@ -235,7 +301,7 @@
235301
</div>
236302
<DialogFooter>
237303
<Button variant="outline" @click="showInviteDialog = false">Cancel</Button>
238-
<Button :disabled="isInviting || !inviteEmail.trim()" @click="inviteToTeam">
304+
<Button :disabled="isInviting || !isValidInviteEmail" @click="inviteToTeam">
239305
<Icon v-if="isInviting" name="lucide:loader-2" class="h-4 w-4 mr-2 animate-spin" />
240306
Send Invite
241307
</Button>
@@ -287,13 +353,17 @@ export default {
287353
teamName: '',
288354
teamSlug: '',
289355
teamMembers: [],
356+
pendingInvitations: [],
290357
isLoading: true,
291358
isLoadingMembers: false,
359+
isLoadingInvitations: false,
292360
isSavingTeam: false,
293361
isCreatingTeam: false,
294362
isInviting: false,
295363
isDeletingTeam: false,
296364
switchingTeamId: null,
365+
cancellingInvitationId: null,
366+
resendingInvitationId: null,
297367
showCreateTeamDialog: false,
298368
showInviteDialog: false,
299369
showDeleteTeamDialog: false,
@@ -318,6 +388,9 @@ export default {
318388
(this.teamSlug.trim() && this.teamSlug.trim() !== this.currentTeam?.slug)
319389
)
320390
},
391+
isValidInviteEmail() {
392+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.inviteEmail.trim())
393+
},
321394
additionalTeams() {
322395
return this.allTeams.filter((t) => !this.isDefaultTeam(t))
323396
},
@@ -397,7 +470,7 @@ export default {
397470
async handleActiveTeamChanged() {
398471
await this.syncActiveTeamContext()
399472
this.allTeams = await this.fetchAllTeams()
400-
await this.loadTeamMembers()
473+
await Promise.all([this.loadTeamMembers(), this.loadPendingInvitations()])
401474
},
402475
403476
async resolveOrganizationId() {
@@ -447,7 +520,7 @@ export default {
447520
this.allTeams = allTeams
448521
this.organizations = organizations
449522
450-
await this.loadTeamMembers()
523+
await Promise.all([this.loadTeamMembers(), this.loadPendingInvitations()])
451524
} catch (error) {
452525
console.error('Error during team settings initialization:', error)
453526
toast.error('Failed to load team settings')
@@ -489,6 +562,40 @@ export default {
489562
}
490563
},
491564
565+
async resolveOrganizationSlug() {
566+
if (this.activeOrganization?.slug) {
567+
return this.activeOrganization.slug
568+
}
569+
570+
const organization = this.organizations.find((entry) => entry.id === this.currentTeam?.organizationId)
571+
return organization?.slug || null
572+
},
573+
574+
async loadPendingInvitations() {
575+
if (!this.currentTeam?.id || this.isDefaultTeam(this.currentTeam)) {
576+
this.pendingInvitations = []
577+
return
578+
}
579+
580+
const organizationSlug = await this.resolveOrganizationSlug()
581+
if (!organizationSlug) {
582+
this.pendingInvitations = []
583+
return
584+
}
585+
586+
try {
587+
this.isLoadingInvitations = true
588+
const response = await $fetch(`/api/orgs/${organizationSlug}/invitations`)
589+
const invitations = response?.data || []
590+
this.pendingInvitations = invitations.filter((invitation) => invitation.teamId === this.currentTeam.id)
591+
} catch (error) {
592+
console.error('Error loading pending team invitations:', error)
593+
this.pendingInvitations = []
594+
} finally {
595+
this.isLoadingInvitations = false
596+
}
597+
},
598+
492599
async switchToTeam(team) {
493600
if (team.id === this.currentTeam?.id || this.switchingTeamId) return
494601
@@ -515,7 +622,7 @@ export default {
515622
this.activeOrganization = await this.fetchActiveOrganization()
516623
}
517624
518-
await this.loadTeamMembers()
625+
await Promise.all([this.loadTeamMembers(), this.loadPendingInvitations()])
519626
520627
// Notify other components (sidebar TeamSwitcher, pages)
521628
if (import.meta.client) {
@@ -610,7 +717,7 @@ export default {
610717
611718
await this.syncActiveTeamContext()
612719
this.allTeams = await this.fetchAllTeams()
613-
await this.loadTeamMembers()
720+
await Promise.all([this.loadTeamMembers(), this.loadPendingInvitations()])
614721
615722
// Notify other components
616723
if (import.meta.client) {
@@ -656,6 +763,7 @@ export default {
656763
})
657764
this.showInviteDialog = false
658765
this.inviteEmail = ''
766+
await this.loadPendingInvitations()
659767
toast.success('Invitation sent')
660768
} catch (error) {
661769
console.error('Error inviting team member:', error)
@@ -668,6 +776,50 @@ export default {
668776
}
669777
},
670778
779+
async resendInvitation(invitation) {
780+
const organizationId = await this.resolveOrganizationId()
781+
if (!organizationId || !this.currentTeam?.id) {
782+
toast.error('No active workspace context')
783+
return
784+
}
785+
786+
try {
787+
this.resendingInvitationId = invitation.id
788+
await $fetch('/api/organizations/invite', {
789+
method: 'POST',
790+
body: {
791+
organizationId,
792+
email: invitation.email,
793+
role: invitation.role,
794+
teamId: this.currentTeam.id,
795+
resend: true,
796+
},
797+
})
798+
await this.loadPendingInvitations()
799+
toast.success('Invitation reminder sent')
800+
} catch (error) {
801+
console.error('Error resending team invitation:', error)
802+
const message = error?.data?.statusMessage || error?.data?.error?.message || 'Failed to resend invitation'
803+
toast.error(message)
804+
} finally {
805+
this.resendingInvitationId = null
806+
}
807+
},
808+
809+
async cancelInvitation(invitationId) {
810+
try {
811+
this.cancellingInvitationId = invitationId
812+
await authClient.organization.cancelInvitation({ invitationId })
813+
this.pendingInvitations = this.pendingInvitations.filter((invitation) => invitation.id !== invitationId)
814+
toast.success('Invitation revoked')
815+
} catch (error) {
816+
console.error('Error cancelling team invitation:', error)
817+
toast.error('Failed to revoke invitation')
818+
} finally {
819+
this.cancellingInvitationId = null
820+
}
821+
},
822+
671823
async removeTeamMember(userId) {
672824
if (!this.currentTeam?.id) return
673825
try {
@@ -696,7 +848,7 @@ export default {
696848
this.allTeams = this.allTeams.filter((t) => t.id !== deletedTeamId)
697849
698850
await this.syncActiveTeamContext()
699-
await this.loadTeamMembers()
851+
await Promise.all([this.loadTeamMembers(), this.loadPendingInvitations()])
700852
701853
// Notify other components
702854
if (import.meta.client) {

0 commit comments

Comments
 (0)