chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user