Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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),
}))
Comment on lines +386 to +395
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

vi.doMock('@/db/schema', () => ({
workspaceInvitation: { id: 'id' },
}))
Expand Down
96 changes: 96 additions & 0 deletions apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 })
}

Expand Down Expand Up @@ -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)
Comment on lines +292 to +294
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.


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 }
)
}
Comment on lines +320 to +325
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 })
}
}


return NextResponse.json({ success: true })
} catch (error) {
console.error('Error resending workspace invitation:', error)
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
}
}
Comment on lines +250 to +332
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.

2 changes: 1 addition & 1 deletion apps/sim/app/invite/[id]/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)
Expand Down
Loading