feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues (#1256)

This commit is contained in:
Waleed
2025-09-04 22:15:27 -07:00
committed by GitHub
parent 864622c1dc
commit ab71fcfc49
8 changed files with 295 additions and 35 deletions

View File

@@ -64,7 +64,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
BILLING_ENABLED: false,
},
isTruthy: (value: any) =>
typeof value === 'string'
? value.toLowerCase() === 'true' || value === '1'
: Boolean(value),
}))
mockTransaction = vi.fn()
@@ -378,6 +383,16 @@ describe('Workspace Invitation [invitationId] API Route', () => {
vi.doMock('@/lib/permissions/utils', () => ({
hasWorkspaceAdminAccess: vi.fn(),
}))
vi.doMock('@/lib/env', () => ({
env: {
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
BILLING_ENABLED: false,
},
isTruthy: (value: any) =>
typeof value === 'string'
? value.toLowerCase() === 'true' || value === '1'
: Boolean(value),
}))
vi.doMock('@/db/schema', () => ({
workspaceInvitation: { id: 'id' },
}))

View File

@@ -1,7 +1,11 @@
import { randomUUID } from 'crypto'
import { render } from '@react-email/render'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
import { getSession } from '@/lib/auth'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { env } from '@/lib/env'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { db } from '@/db'
@@ -48,6 +52,14 @@ export async function GET(
.then((rows) => rows[0])
if (!invitation) {
if (isAcceptFlow) {
return NextResponse.redirect(
new URL(
`/invite/${invitationId}?error=invalid-token`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
}
@@ -234,3 +246,87 @@ export async function DELETE(
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
}
}
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const invitation = await db
.select()
.from(workspaceInvitation)
.where(eq(workspaceInvitation.id, invitationId))
.then((rows) => rows[0])
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
if (!hasAdminAccess) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 })
}
const ws = await db
.select()
.from(workspace)
.where(eq(workspace.id, invitation.workspaceId))
.then((rows) => rows[0])
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
const newToken = randomUUID()
const newExpiresAt = new Date()
newExpiresAt.setDate(newExpiresAt.getDate() + 7)
await db
.update(workspaceInvitation)
.set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() })
.where(eq(workspaceInvitation.id, invitationId))
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}`
const emailHtml = await render(
WorkspaceInvitationEmail({
workspaceName: ws.name,
inviterName: session.user.name || session.user.email || 'A user',
invitationLink,
})
)
const result = await sendEmail({
to: invitation.email,
subject: `You've been invited to join "${ws.name}" on Sim`,
html: emailHtml,
from: getFromEmailAddress(),
emailType: 'transactional',
})
if (!result.success) {
return NextResponse.json(
{ error: 'Failed to send invitation email. Please try again.' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error resending workspace invitation:', error)
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
}
}

View File

@@ -9,7 +9,7 @@ export function getErrorMessage(reason: string): string {
case 'already-processed':
return 'This invitation has already been accepted or declined.'
case 'email-mismatch':
return 'This invitation was sent to a different email address. Please log in with the correct account or contact the person who invited you.'
return 'This invitation was sent to a different email address. Please log in with the correct account.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':

View File

@@ -79,7 +79,11 @@ export function UsageHeader({
</div>
</div>
<Progress value={isBlocked ? 100 : progress} className='h-2' />
<Progress
value={isBlocked ? 100 : progress}
className='h-2'
indicatorClassName='bg-black dark:bg-white'
/>
{isBlocked && (
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>

View File

@@ -100,9 +100,11 @@ export function TeamSeatsOverview({
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm'>Seats</span>
<span className='text-muted-foreground text-xs'>
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
</span>
{!checkEnterprisePlan(subscriptionData) ? (
<span className='text-muted-foreground text-xs'>
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
</span>
) : null}
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<span className='text-muted-foreground'>{usedSeats} used</span>

View File

@@ -92,8 +92,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
</span>
</div>
{/* Progress Bar with color: yellow for warning, red for full/blocked */}
<Progress value={isBlocked ? 100 : progressPercentage} className='h-2' />
{/* Progress Bar */}
<Progress
value={isBlocked ? 100 : progressPercentage}
className='h-2'
indicatorClassName='bg-black dark:bg-white'
/>
</div>
</div>
)

View File

@@ -1,7 +1,7 @@
'use client'
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2, X } from 'lucide-react'
import { Loader2, RotateCw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
@@ -60,6 +60,7 @@ interface PermissionsTableProps {
onPermissionChange: (userId: string, permissionType: PermissionType) => void
onRemoveMember?: (userId: string, email: string) => void
onRemoveInvitation?: (invitationId: string, email: string) => void
onResendInvitation?: (invitationId: string, email: string) => void
disabled?: boolean
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
isSaving?: boolean
@@ -67,6 +68,9 @@ interface PermissionsTableProps {
permissionsLoading: boolean
pendingInvitations: UserPermissions[]
isPendingInvitationsLoading: boolean
resendingInvitationIds?: Record<string, boolean>
resentInvitationIds?: Record<string, boolean>
resendCooldowns?: Record<string, number>
}
interface PendingInvitation {
@@ -159,13 +163,18 @@ PermissionSelector.displayName = 'PermissionSelector'
const PermissionsTableSkeleton = React.memo(() => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-2 py-2'>
{/* Email skeleton - matches the actual email span dimensions */}
<Skeleton className='h-5 w-40' />
{/* Permission selector skeleton - matches PermissionSelector exact height */}
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
</div>
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className='flex items-center justify-between gap-2 py-2'>
<Skeleton className='h-5 w-40' />
<div className='flex items-center gap-2'>
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
<div className='flex w-10 items-center gap-1 sm:w-12'>
<Skeleton className='h-4 w-4 rounded' />
<Skeleton className='h-4 w-4 rounded' />
</div>
</div>
</div>
))}
</div>
))
@@ -183,6 +192,10 @@ const PermissionsTable = ({
permissionsLoading,
pendingInvitations,
isPendingInvitationsLoading,
onResendInvitation,
resendingInvitationIds,
resentInvitationIds,
resendCooldowns,
}: PermissionsTableProps) => {
const { data: session } = useSession()
const userPerms = useUserPermissionsContext()
@@ -309,8 +322,21 @@ const PermissionsTable = ({
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
{isPendingInvitation && (
<span className='inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
Sent
<span className='inline-flex items-center gap-1 rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
{resendingInvitationIds &&
user.invitationId &&
resendingInvitationIds[user.invitationId] ? (
<>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Sending...</span>
</>
) : resentInvitationIds &&
user.invitationId &&
resentInvitationIds[user.invitationId] ? (
<span>Resent</span>
) : (
<span>Sent</span>
)}
</span>
)}
{hasChanges && (
@@ -321,7 +347,7 @@ const PermissionsTable = ({
</div>
</div>
{/* Permission selector and remove button container */}
{/* Permission selector and fixed-width action area to keep rows aligned */}
<div className='flex flex-shrink-0 items-center gap-2'>
<PermissionSelector
value={user.permissionType}
@@ -335,8 +361,45 @@ const PermissionsTable = ({
className='w-auto'
/>
{/* X button with consistent spacing - always reserve space */}
<div className='flex h-4 w-4 items-center justify-center'>
{/* Fixed-width action area so selector stays inline across rows */}
<div className='flex h-4 w-10 items-center justify-center gap-1 sm:w-12'>
{isPendingInvitation &&
currentUserIsAdmin &&
user.invitationId &&
onResendInvitation && (
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
size='icon'
onClick={() => onResendInvitation(user.invitationId!, user.email)}
disabled={
disabled ||
isSaving ||
resendingInvitationIds?.[user.invitationId!] ||
(resendCooldowns && resendCooldowns[user.invitationId!] > 0)
}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
{resendingInvitationIds?.[user.invitationId!] ? (
<Loader2 className='h-3.5 w-3.5 animate-spin' />
) : (
<RotateCw className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Resend invite</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{resendCooldowns?.[user.invitationId!]
? `Resend in ${resendCooldowns[user.invitationId!]}s`
: 'Resend invite'}
</p>
</TooltipContent>
</Tooltip>
)}
{((canShowRemoveButton && onRemoveMember) ||
(isPendingInvitation &&
currentUserIsAdmin &&
@@ -408,6 +471,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
email: string
} | null>(null)
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
const [resentInvitationIds, setResentInvitationIds] = useState<Record<string, boolean>>({})
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -748,6 +814,72 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvitationToRemove(null)
}, [])
const handleResendInvitation = useCallback(
async (invitationId: string, email: string) => {
if (!workspaceId || !userPerms.canAdmin) return
const secondsLeft = resendCooldowns[invitationId]
if (secondsLeft && secondsLeft > 0) return
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setErrorMessage(null)
try {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to resend invitation')
}
setSuccessMessage(`Invitation resent to ${email}`)
setTimeout(() => setSuccessMessage(null), 3000)
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
} catch (error) {
logger.error('Error resending invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to resend invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
}
},
[workspaceId, userPerms.canAdmin, resendCooldowns]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
@@ -989,6 +1121,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
onPermissionChange={handlePermissionChange}
onRemoveMember={handleRemoveMemberClick}
onRemoveInvitation={handleRemoveInvitationClick}
onResendInvitation={handleResendInvitation}
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
existingUserPermissionChanges={existingUserPermissionChanges}
isSaving={isSaving}
@@ -996,6 +1129,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
isPendingInvitationsLoading={isPendingInvitationsLoading}
resendingInvitationIds={resendingInvitationIds}
resentInvitationIds={resentInvitationIds}
resendCooldowns={resendCooldowns}
/>
</form>

View File

@@ -4,21 +4,24 @@ import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<ProgressPrimitive.Indicator
className='h-full w-full flex-1 bg-primary transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string
}
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, ProgressProps>(
({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn('h-full w-full flex-1 bg-primary transition-all', indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
)
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }