Skip to content

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

Merged
waleedlatif1 merged 1 commit intostagingfrom
sim-58
Sep 5, 2025
Merged

feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues#1256
waleedlatif1 merged 1 commit intostagingfrom
sim-58

Conversation

@waleedlatif1
Copy link
Collaborator

Summary

add ability to resend invitations with cooldown, fixed UI in dark mode issues

Type of Change

  • Bug fix
  • New feature

Testing

Tested manually

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel
Copy link

vercel bot commented Sep 5, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
sim Building Building Preview Comment Sep 5, 2025 5:14am
1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
docs Skipped Skipped Sep 5, 2025 5:14am

@waleedlatif1 waleedlatif1 merged commit ab71fcf into staging Sep 5, 2025
4 of 5 checks passed
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts for potential abuse scenarios

8 files reviewed, 5 comments

Edit Code Review Bot Settings | Greptile

Comment on lines +386 to +395
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),
}))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Duplicate environment mocking - consider extracting to a helper function to avoid repetition

Comment on lines +865 to +877
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: potential memory leak if component unmounts while interval is running - consider storing interval reference and clearing on cleanup

Comment on lines +250 to +332
// 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 })
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The resend functionality lacks rate limiting or cooldown mechanism mentioned in the PR title. Consider adding timestamp checks to prevent spam.

Comment on lines +292 to +294
const newToken = randomUUID()
const newExpiresAt = new Date()
newExpiresAt.setDate(newExpiresAt.getDate() + 7)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Token regeneration creates a new expiry 7 days from now, which could extend invitation validity beyond the original intent if repeatedly resent.

Comment on lines +320 to +325
if (!result.success) {
return NextResponse.json(
{ error: 'Failed to send invitation email. Please try again.' },
{ status: 500 }
)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Email sending failure returns 500 error, but the database has already been updated with new token. This creates inconsistent state.

Suggested change
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 })
}
}

@waleedlatif1 waleedlatif1 deleted the sim-58 branch September 5, 2025 05:15
arenadeveloper02 pushed a commit to arenadeveloper02/p2-sim that referenced this pull request Sep 19, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant