From ab71fcfc4935f10a0110ea9b62137b6b31dbabeb Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 4 Sep 2025 22:15:27 -0700 Subject: [PATCH] feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues (#1256) --- .../invitations/[invitationId]/route.test.ts | 15 ++ .../invitations/[invitationId]/route.ts | 96 +++++++++++ apps/sim/app/invite/[id]/utils.ts | 2 +- .../components/shared/usage-header.tsx | 6 +- .../team-seats-overview.tsx | 8 +- .../usage-indicator/usage-indicator.tsx | 8 +- .../components/invite-modal/invite-modal.tsx | 162 ++++++++++++++++-- apps/sim/components/ui/progress.tsx | 33 ++-- 8 files changed, 295 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts index fb6831a0f6..3958102d26 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -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' }, })) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index 8e0878809e..27701b9010 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/invite/[id]/utils.ts b/apps/sim/app/invite/[id]/utils.ts index 61c90f5867..b323f28ae8 100644 --- a/apps/sim/app/invite/[id]/utils.ts +++ b/apps/sim/app/invite/[id]/utils.ts @@ -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': diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx index b11c601252..22fccd993b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx @@ -79,7 +79,11 @@ export function UsageHeader({ - + {isBlocked && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx index 1be9ceaca4..b099c5dd90 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -100,9 +100,11 @@ export function TeamSeatsOverview({
Seats - - (${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each) - + {!checkEnterprisePlan(subscriptionData) ? ( + + (${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each) + + ) : null}
{usedSeats} used diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index 16e9648a8d..bb6760d481 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -92,8 +92,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
- {/* Progress Bar with color: yellow for warning, red for full/blocked */} - + {/* Progress Bar */} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx index 64cba3906a..7ff664ded3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal.tsx @@ -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> isSaving?: boolean @@ -67,6 +68,9 @@ interface PermissionsTableProps { permissionsLoading: boolean pendingInvitations: UserPermissions[] isPendingInvitationsLoading: boolean + resendingInvitationIds?: Record + resentInvitationIds?: Record + resendCooldowns?: Record } interface PendingInvitation { @@ -159,13 +163,18 @@ PermissionSelector.displayName = 'PermissionSelector' const PermissionsTableSkeleton = React.memo(() => (
-
- {/* Email skeleton - matches the actual email span dimensions */} - - - {/* Permission selector skeleton - matches PermissionSelector exact height */} - -
+ {Array.from({ length: 5 }).map((_, idx) => ( +
+ +
+ +
+ + +
+
+
+ ))}
)) @@ -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 = ({
{user.email} {isPendingInvitation && ( - - Sent + + {resendingInvitationIds && + user.invitationId && + resendingInvitationIds[user.invitationId] ? ( + <> + + Sending... + + ) : resentInvitationIds && + user.invitationId && + resentInvitationIds[user.invitationId] ? ( + Resent + ) : ( + Sent + )} )} {hasChanges && ( @@ -321,7 +347,7 @@ const PermissionsTable = ({
- {/* Permission selector and remove button container */} + {/* Permission selector and fixed-width action area to keep rows aligned */}
- {/* X button with consistent spacing - always reserve space */} -
+ {/* Fixed-width action area so selector stays inline across rows */} +
+ {isPendingInvitation && + currentUserIsAdmin && + user.invitationId && + onResendInvitation && ( + + + + + + + +

+ {resendCooldowns?.[user.invitationId!] + ? `Resend in ${resendCooldowns[user.invitationId!]}s` + : 'Resend invite'} +

+
+
+ )} {((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>({}) + const [resendCooldowns, setResendCooldowns] = useState>({}) + const [resentInvitationIds, setResentInvitationIds] = useState>({}) 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) => { 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} /> diff --git a/apps/sim/components/ui/progress.tsx b/apps/sim/components/ui/progress.tsx index b5a4a0ba57..56a6f56741 100644 --- a/apps/sim/components/ui/progress.tsx +++ b/apps/sim/components/ui/progress.tsx @@ -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, - React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( - - - -)) +interface ProgressProps extends React.ComponentPropsWithoutRef { + indicatorClassName?: string +} + +const Progress = React.forwardRef, ProgressProps>( + ({ className, value, indicatorClassName, ...props }, ref) => ( + + + + ) +) Progress.displayName = ProgressPrimitive.Root.displayName export { Progress }