d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db } from '@sim/db'
|
|
import { type InvitationMembershipIntent, permissions, user } from '@sim/db/schema'
|
|
import { normalizeEmail } from '@sim/utils/string'
|
|
import { and, eq, sql } from 'drizzle-orm'
|
|
import type { NextRequest } from 'next/server'
|
|
import { getUserOrganization } from '@/lib/billing/organizations/membership'
|
|
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
|
import {
|
|
type DirectGrantOutcome,
|
|
grantWorkspaceAccessDirectly,
|
|
} from '@/lib/invitations/direct-grant'
|
|
import {
|
|
cancelPendingInvitation,
|
|
createPendingInvitation,
|
|
findPendingGrantForWorkspaceEmail,
|
|
sendInvitationEmail,
|
|
} from '@/lib/invitations/send'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
import {
|
|
getWorkspaceWithOwner,
|
|
hasWorkspaceAdminAccess,
|
|
type PermissionType,
|
|
type WorkspaceWithOwner,
|
|
} from '@/lib/workspaces/permissions/utils'
|
|
import { getWorkspaceInvitePolicy, type WorkspaceInvitePolicy } from '@/lib/workspaces/policy'
|
|
import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check'
|
|
|
|
export interface WorkspaceInvitationContext {
|
|
workspaceId: string
|
|
inviterId: string
|
|
inviterName: string
|
|
inviterEmail?: string | null
|
|
workspaceDetails: WorkspaceWithOwner
|
|
invitePolicy: WorkspaceInvitePolicy
|
|
}
|
|
|
|
export interface WorkspaceInvitationResult {
|
|
id: string
|
|
workspaceId: string
|
|
email: string
|
|
permission: PermissionType
|
|
membershipIntent: InvitationMembershipIntent
|
|
expiresAt: Date | undefined
|
|
/** True when the user was granted access directly (no pending invitation). */
|
|
instantAdd?: boolean
|
|
/** Direct-grant outcome when `instantAdd` is true. */
|
|
outcome?: DirectGrantOutcome['outcome']
|
|
}
|
|
|
|
export class WorkspaceInvitationError extends Error {
|
|
status: number
|
|
email?: string
|
|
upgradeRequired?: boolean
|
|
|
|
constructor({
|
|
message,
|
|
status,
|
|
email,
|
|
upgradeRequired,
|
|
}: {
|
|
message: string
|
|
status: number
|
|
email?: string
|
|
upgradeRequired?: boolean
|
|
}) {
|
|
super(message)
|
|
this.name = 'WorkspaceInvitationError'
|
|
this.status = status
|
|
this.email = email
|
|
this.upgradeRequired = upgradeRequired
|
|
}
|
|
}
|
|
|
|
export async function prepareWorkspaceInvitationContext({
|
|
workspaceId,
|
|
inviterId,
|
|
inviterName,
|
|
inviterEmail,
|
|
}: {
|
|
workspaceId: string
|
|
inviterId: string
|
|
inviterName: string
|
|
inviterEmail?: string | null
|
|
}): Promise<WorkspaceInvitationContext> {
|
|
await validateInvitationsAllowed(inviterId, workspaceId)
|
|
|
|
const isAdmin = await hasWorkspaceAdminAccess(inviterId, workspaceId)
|
|
if (!isAdmin) {
|
|
throw new WorkspaceInvitationError({
|
|
message: 'You need admin permissions to invite users',
|
|
status: 403,
|
|
})
|
|
}
|
|
|
|
const workspaceDetails = await getWorkspaceWithOwner(workspaceId)
|
|
if (!workspaceDetails) {
|
|
throw new WorkspaceInvitationError({ message: 'Workspace not found', status: 404 })
|
|
}
|
|
|
|
const invitePolicy = await getWorkspaceInvitePolicy(workspaceDetails)
|
|
if (!invitePolicy.allowed) {
|
|
throw new WorkspaceInvitationError({
|
|
message: invitePolicy.reason ?? 'Invites are disabled for this workspace.',
|
|
status: 403,
|
|
upgradeRequired: invitePolicy.upgradeRequired,
|
|
})
|
|
}
|
|
|
|
return {
|
|
workspaceId,
|
|
inviterId,
|
|
inviterName,
|
|
inviterEmail,
|
|
workspaceDetails,
|
|
invitePolicy,
|
|
}
|
|
}
|
|
|
|
export async function createWorkspaceInvitation({
|
|
context,
|
|
email,
|
|
permission = 'read',
|
|
request,
|
|
}: {
|
|
context: WorkspaceInvitationContext
|
|
email: string
|
|
permission?: string
|
|
request: NextRequest
|
|
}): Promise<WorkspaceInvitationResult> {
|
|
const validPermissions: PermissionType[] = ['admin', 'write', 'read']
|
|
if (!validPermissions.includes(permission as PermissionType)) {
|
|
throw new WorkspaceInvitationError({
|
|
message: `Invalid permission: must be one of ${validPermissions.join(', ')}`,
|
|
status: 400,
|
|
email,
|
|
})
|
|
}
|
|
const invitationPermission = permission as PermissionType
|
|
|
|
const normalizedEmail = normalizeEmail(email)
|
|
let membershipIntent: InvitationMembershipIntent = 'internal'
|
|
|
|
const existingUser = await db
|
|
.select()
|
|
.from(user)
|
|
.where(sql`lower(${user.email}) = ${normalizedEmail}`)
|
|
.then((rows) => rows[0])
|
|
|
|
if (existingUser) {
|
|
const workspaceOrganizationId = context.workspaceDetails.organizationId
|
|
const existingMembership = workspaceOrganizationId
|
|
? await getUserOrganization(existingUser.id)
|
|
: null
|
|
|
|
const existingPermission = await db
|
|
.select()
|
|
.from(permissions)
|
|
.where(
|
|
and(
|
|
eq(permissions.entityId, context.workspaceId),
|
|
eq(permissions.entityType, 'workspace'),
|
|
eq(permissions.userId, existingUser.id)
|
|
)
|
|
)
|
|
.then((rows) => rows[0])
|
|
|
|
/**
|
|
* Already a workspace member: reject. Invites never change an existing
|
|
* member's permission — role changes go through the members list, not the
|
|
* invite flow. (The client also blocks re-inviting current teammates.)
|
|
*/
|
|
if (existingPermission) {
|
|
throw new WorkspaceInvitationError({
|
|
message: `${normalizedEmail} already has access to this workspace`,
|
|
status: 400,
|
|
email: normalizedEmail,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Invitee already belongs to the workspace's organization (and is not yet a
|
|
* member of this workspace): grant access directly, with no invitation or
|
|
* acceptance step.
|
|
*/
|
|
if (
|
|
workspaceOrganizationId &&
|
|
existingMembership &&
|
|
existingMembership.organizationId === workspaceOrganizationId
|
|
) {
|
|
const directGrant = await grantWorkspaceAccessDirectly({
|
|
userId: existingUser.id,
|
|
email: normalizedEmail,
|
|
workspaceId: context.workspaceId,
|
|
workspaceName: context.workspaceDetails.name,
|
|
permission: invitationPermission,
|
|
organizationId: workspaceOrganizationId,
|
|
actorId: context.inviterId,
|
|
actorName: context.inviterName,
|
|
actorEmail: context.inviterEmail,
|
|
request,
|
|
})
|
|
|
|
return {
|
|
id: existingUser.id,
|
|
workspaceId: context.workspaceId,
|
|
email: normalizedEmail,
|
|
permission: invitationPermission,
|
|
membershipIntent: 'internal',
|
|
expiresAt: undefined,
|
|
instantAdd: true,
|
|
outcome: directGrant.outcome,
|
|
}
|
|
}
|
|
|
|
if (workspaceOrganizationId) {
|
|
if (existingMembership && existingMembership.organizationId !== workspaceOrganizationId) {
|
|
membershipIntent = 'external'
|
|
} else if (context.invitePolicy.requiresSeat && !existingMembership) {
|
|
const seatValidation = await validateSeatAvailability(workspaceOrganizationId, 1)
|
|
if (!seatValidation.canInvite) {
|
|
throw new WorkspaceInvitationError({
|
|
message: seatValidation.reason || 'No available seats for this organization.',
|
|
status: 400,
|
|
email: normalizedEmail,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else if (context.invitePolicy.requiresSeat && context.invitePolicy.organizationId) {
|
|
const seatValidation = await validateSeatAvailability(context.invitePolicy.organizationId, 1)
|
|
if (!seatValidation.canInvite) {
|
|
throw new WorkspaceInvitationError({
|
|
message: seatValidation.reason || 'No available seats for this organization.',
|
|
status: 400,
|
|
email: normalizedEmail,
|
|
})
|
|
}
|
|
}
|
|
|
|
const existingInvitation = await findPendingGrantForWorkspaceEmail({
|
|
workspaceId: context.workspaceId,
|
|
email: normalizedEmail,
|
|
})
|
|
if (existingInvitation) {
|
|
throw new WorkspaceInvitationError({
|
|
message: `${normalizedEmail} has already been invited to this workspace`,
|
|
status: 400,
|
|
email: normalizedEmail,
|
|
})
|
|
}
|
|
|
|
const { invitationId, token } = await createPendingInvitation({
|
|
kind: 'workspace',
|
|
email: normalizedEmail,
|
|
inviterId: context.inviterId,
|
|
organizationId: context.workspaceDetails.organizationId,
|
|
membershipIntent,
|
|
role: 'member',
|
|
grants: [
|
|
{
|
|
workspaceId: context.workspaceId,
|
|
permission: invitationPermission,
|
|
},
|
|
],
|
|
})
|
|
|
|
try {
|
|
PlatformEvents.workspaceMemberInvited({
|
|
workspaceId: context.workspaceId,
|
|
invitedBy: context.inviterId,
|
|
inviteeEmail: normalizedEmail,
|
|
role: invitationPermission,
|
|
membershipIntent,
|
|
})
|
|
} catch {
|
|
/**
|
|
* Telemetry must not fail invitation creation.
|
|
*/
|
|
}
|
|
|
|
captureServerEvent(
|
|
context.inviterId,
|
|
'workspace_member_invited',
|
|
{
|
|
workspace_id: context.workspaceId,
|
|
invitee_role: invitationPermission,
|
|
membership_intent: membershipIntent,
|
|
},
|
|
{
|
|
groups: { workspace: context.workspaceId },
|
|
setOnce: { first_invitation_sent_at: new Date().toISOString() },
|
|
}
|
|
)
|
|
|
|
const emailResult = await sendInvitationEmail({
|
|
invitationId,
|
|
token,
|
|
kind: 'workspace',
|
|
email: normalizedEmail,
|
|
inviterName: context.inviterName,
|
|
organizationId: context.workspaceDetails.organizationId,
|
|
organizationRole: 'member',
|
|
grants: [{ workspaceId: context.workspaceId, permission: invitationPermission }],
|
|
})
|
|
|
|
if (!emailResult.success) {
|
|
await cancelPendingInvitation(invitationId)
|
|
throw new WorkspaceInvitationError({
|
|
message: emailResult.error || 'Failed to send invitation email',
|
|
status: 502,
|
|
email: normalizedEmail,
|
|
})
|
|
}
|
|
|
|
recordAudit({
|
|
workspaceId: context.workspaceId,
|
|
actorId: context.inviterId,
|
|
actorName: context.inviterName,
|
|
actorEmail: context.inviterEmail,
|
|
action: AuditAction.MEMBER_INVITED,
|
|
resourceType: AuditResourceType.WORKSPACE,
|
|
resourceId: context.workspaceId,
|
|
resourceName: normalizedEmail,
|
|
description: `Invited ${normalizedEmail} as ${invitationPermission}`,
|
|
metadata: {
|
|
targetEmail: normalizedEmail,
|
|
targetRole: invitationPermission,
|
|
membershipIntent,
|
|
workspaceName: context.workspaceDetails.name,
|
|
invitationId,
|
|
},
|
|
request,
|
|
})
|
|
|
|
return {
|
|
id: invitationId,
|
|
workspaceId: context.workspaceId,
|
|
email: normalizedEmail,
|
|
permission: invitationPermission,
|
|
membershipIntent,
|
|
expiresAt: undefined,
|
|
}
|
|
}
|