feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues#1256
feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues#1256waleedlatif1 merged 1 commit intostagingfrom
Conversation
…ixed UI in dark mode issues
There was a problem hiding this comment.
Greptile Summary
This PR introduces a resend invitation feature with cooldown mechanisms and fixes dark mode UI issues across the application. The changes span multiple areas of the codebase:
Resend Invitation Feature: The core functionality is implemented through a new POST endpoint at /api/workspaces/invitations/[invitationId]/route.ts that allows workspace admins to regenerate invitation tokens and resend emails. The frontend implementation in invite-modal.tsx adds comprehensive state management with cooldown tracking, success/error feedback, and a clean UI with rotating icons and dynamic tooltips. The system prevents spam by enforcing a 60-second cooldown between resend attempts per invitation.
Dark Mode UI Fixes: Several UI components were enhanced to provide proper contrast in dark mode. The Progress component was extended with an optional indicatorClassName prop to allow custom styling of progress indicators. This enhancement is then utilized in usage indicators and headers throughout the workspace sidebar, where explicit black/white colors ensure visibility across both light and dark themes.
UI/UX Improvements: Additional enhancements include hiding cost information for enterprise plans in team management (since they have custom pricing), improving skeleton loading states to show multiple rows, and shortening error messages for better user experience. The invitation acceptance flow was also improved with better error handling and appropriate redirects.
The changes integrate well with the existing codebase architecture, following established patterns for API endpoints, React state management, and component composition. The resend functionality reuses existing email sending logic and maintains consistency with the original invitation creation flow.
Confidence score: 4/5
- This PR introduces useful functionality with generally well-implemented features, but has some areas that could benefit from additional safeguards
- Score reflects solid implementation patterns and thorough UI improvements, but is lowered due to lack of server-side rate limiting for the resend feature
- Pay close attention to the new POST endpoint in
apps/sim/app/api/workspaces/invitations/[invitationId]/route.tsfor potential abuse scenarios
8 files reviewed, 5 comments
| 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), | ||
| })) |
There was a problem hiding this comment.
style: Duplicate environment mocking - consider extracting to a helper function to avoid repetition
| 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) |
There was a problem hiding this comment.
logic: potential memory leak if component unmounts while interval is running - consider storing interval reference and clearing on cleanup
| // 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 }) | ||
| } | ||
| } |
There was a problem hiding this comment.
logic: The resend functionality lacks rate limiting or cooldown mechanism mentioned in the PR title. Consider adding timestamp checks to prevent spam.
| const newToken = randomUUID() | ||
| const newExpiresAt = new Date() | ||
| newExpiresAt.setDate(newExpiresAt.getDate() + 7) |
There was a problem hiding this comment.
logic: Token regeneration creates a new expiry 7 days from now, which could extend invitation validity beyond the original intent if repeatedly resent.
| if (!result.success) { | ||
| return NextResponse.json( | ||
| { error: 'Failed to send invitation email. Please try again.' }, | ||
| { status: 500 } | ||
| ) | ||
| } |
There was a problem hiding this comment.
logic: Email sending failure returns 500 error, but the database has already been updated with new token. This creates inconsistent state.
| if (!result.success) { | |
| return NextResponse.json( | |
| { error: 'Failed to send invitation email. Please try again.' }, | |
| { 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) | |
| 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 } | |
| ) | |
| } | |
| await db | |
| .update(workspaceInvitation) | |
| .set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() }) | |
| .where(eq(workspaceInvitation.id, invitationId)) | |
| return NextResponse.json({ success: true }) | |
| } catch (error) { | |
| console.error('Error resending workspace invitation:', error) | |
| return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) | |
| } | |
| } |
…ixed UI in dark mode issues (simstudioai#1256)
Summary
add ability to resend invitations with cooldown, fixed UI in dark mode issues
Type of Change
Testing
Tested manually
Checklist