mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues (#1256)
This commit is contained in:
@@ -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' },
|
||||
}))
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user