Skip to content

Commit ab71fcf

Browse files
authored
feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues (#1256)
1 parent 864622c commit ab71fcf

File tree

8 files changed

+295
-35
lines changed

8 files changed

+295
-35
lines changed

apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
6464
vi.doMock('@/lib/env', () => ({
6565
env: {
6666
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
67+
BILLING_ENABLED: false,
6768
},
69+
isTruthy: (value: any) =>
70+
typeof value === 'string'
71+
? value.toLowerCase() === 'true' || value === '1'
72+
: Boolean(value),
6873
}))
6974

7075
mockTransaction = vi.fn()
@@ -378,6 +383,16 @@ describe('Workspace Invitation [invitationId] API Route', () => {
378383
vi.doMock('@/lib/permissions/utils', () => ({
379384
hasWorkspaceAdminAccess: vi.fn(),
380385
}))
386+
vi.doMock('@/lib/env', () => ({
387+
env: {
388+
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
389+
BILLING_ENABLED: false,
390+
},
391+
isTruthy: (value: any) =>
392+
typeof value === 'string'
393+
? value.toLowerCase() === 'true' || value === '1'
394+
: Boolean(value),
395+
}))
381396
vi.doMock('@/db/schema', () => ({
382397
workspaceInvitation: { id: 'id' },
383398
}))

apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { randomUUID } from 'crypto'
2+
import { render } from '@react-email/render'
23
import { and, eq } from 'drizzle-orm'
34
import { type NextRequest, NextResponse } from 'next/server'
5+
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
46
import { getSession } from '@/lib/auth'
7+
import { sendEmail } from '@/lib/email/mailer'
8+
import { getFromEmailAddress } from '@/lib/email/utils'
59
import { env } from '@/lib/env'
610
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
711
import { db } from '@/db'
@@ -48,6 +52,14 @@ export async function GET(
4852
.then((rows) => rows[0])
4953

5054
if (!invitation) {
55+
if (isAcceptFlow) {
56+
return NextResponse.redirect(
57+
new URL(
58+
`/invite/${invitationId}?error=invalid-token`,
59+
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
60+
)
61+
)
62+
}
5163
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
5264
}
5365

@@ -234,3 +246,87 @@ export async function DELETE(
234246
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
235247
}
236248
}
249+
250+
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
251+
export async function POST(
252+
_req: NextRequest,
253+
{ params }: { params: Promise<{ invitationId: string }> }
254+
) {
255+
const { invitationId } = await params
256+
const session = await getSession()
257+
258+
if (!session?.user?.id) {
259+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
260+
}
261+
262+
try {
263+
const invitation = await db
264+
.select()
265+
.from(workspaceInvitation)
266+
.where(eq(workspaceInvitation.id, invitationId))
267+
.then((rows) => rows[0])
268+
269+
if (!invitation) {
270+
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
271+
}
272+
273+
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
274+
if (!hasAdminAccess) {
275+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
276+
}
277+
278+
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
279+
return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 })
280+
}
281+
282+
const ws = await db
283+
.select()
284+
.from(workspace)
285+
.where(eq(workspace.id, invitation.workspaceId))
286+
.then((rows) => rows[0])
287+
288+
if (!ws) {
289+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
290+
}
291+
292+
const newToken = randomUUID()
293+
const newExpiresAt = new Date()
294+
newExpiresAt.setDate(newExpiresAt.getDate() + 7)
295+
296+
await db
297+
.update(workspaceInvitation)
298+
.set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() })
299+
.where(eq(workspaceInvitation.id, invitationId))
300+
301+
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
302+
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}`
303+
304+
const emailHtml = await render(
305+
WorkspaceInvitationEmail({
306+
workspaceName: ws.name,
307+
inviterName: session.user.name || session.user.email || 'A user',
308+
invitationLink,
309+
})
310+
)
311+
312+
const result = await sendEmail({
313+
to: invitation.email,
314+
subject: `You've been invited to join "${ws.name}" on Sim`,
315+
html: emailHtml,
316+
from: getFromEmailAddress(),
317+
emailType: 'transactional',
318+
})
319+
320+
if (!result.success) {
321+
return NextResponse.json(
322+
{ error: 'Failed to send invitation email. Please try again.' },
323+
{ status: 500 }
324+
)
325+
}
326+
327+
return NextResponse.json({ success: true })
328+
} catch (error) {
329+
console.error('Error resending workspace invitation:', error)
330+
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
331+
}
332+
}

apps/sim/app/invite/[id]/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function getErrorMessage(reason: string): string {
99
case 'already-processed':
1010
return 'This invitation has already been accepted or declined.'
1111
case 'email-mismatch':
12-
return 'This invitation was sent to a different email address. Please log in with the correct account or contact the person who invited you.'
12+
return 'This invitation was sent to a different email address. Please log in with the correct account.'
1313
case 'workspace-not-found':
1414
return 'The workspace associated with this invitation could not be found.'
1515
case 'user-not-found':

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ export function UsageHeader({
7979
</div>
8080
</div>
8181

82-
<Progress value={isBlocked ? 100 : progress} className='h-2' />
82+
<Progress
83+
value={isBlocked ? 100 : progress}
84+
className='h-2'
85+
indicatorClassName='bg-black dark:bg-white'
86+
/>
8387

8488
{isBlocked && (
8589
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,11 @@ export function TeamSeatsOverview({
100100
<div className='flex items-center justify-between'>
101101
<div className='flex items-center gap-2'>
102102
<span className='font-medium text-sm'>Seats</span>
103-
<span className='text-muted-foreground text-xs'>
104-
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
105-
</span>
103+
{!checkEnterprisePlan(subscriptionData) ? (
104+
<span className='text-muted-foreground text-xs'>
105+
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
106+
</span>
107+
) : null}
106108
</div>
107109
<div className='flex items-center gap-1 text-xs tabular-nums'>
108110
<span className='text-muted-foreground'>{usedSeats} used</span>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
9292
</span>
9393
</div>
9494

95-
{/* Progress Bar with color: yellow for warning, red for full/blocked */}
96-
<Progress value={isBlocked ? 100 : progressPercentage} className='h-2' />
95+
{/* Progress Bar */}
96+
<Progress
97+
value={isBlocked ? 100 : progressPercentage}
98+
className='h-2'
99+
indicatorClassName='bg-black dark:bg-white'
100+
/>
97101
</div>
98102
</div>
99103
)

0 commit comments

Comments
 (0)