diff --git a/apps/docs/content/docs/de/enterprise/index.mdx b/apps/docs/content/docs/de/enterprise/index.mdx index 109b196491..82682f260b 100644 --- a/apps/docs/content/docs/de/enterprise/index.mdx +++ b/apps/docs/content/docs/de/enterprise/index.mdx @@ -70,6 +70,7 @@ Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgeb |----------|-------------| | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC | | `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger | +| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Workspace-/Organisations-Einladungen global deaktivieren | BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen. diff --git a/apps/docs/content/docs/en/enterprise/index.mdx b/apps/docs/content/docs/en/enterprise/index.mdx index 9c4c937cfe..29abe99844 100644 --- a/apps/docs/content/docs/en/enterprise/index.mdx +++ b/apps/docs/content/docs/en/enterprise/index.mdx @@ -17,7 +17,7 @@ Define permission groups to control what features and integrations team members - **Allowed Model Providers** - Restrict which AI providers users can access (OpenAI, Anthropic, Google, etc.) - **Allowed Blocks** - Control which workflow blocks are available -- **Platform Settings** - Hide Knowledge Base, disable MCP tools, or disable custom tools +- **Platform Settings** - Hide Knowledge Base, disable MCP tools, disable custom tools, or disable invitations ### Setup @@ -68,6 +68,7 @@ For self-hosted deployments, enterprise features can be enabled via environment | `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups for access restrictions | | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC | | `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers | +| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Globally disable workspace/organization invitations | ### Organization Management @@ -87,6 +88,23 @@ curl -X POST https://your-instance/api/v1/admin/organizations/{orgId}/members \ -d '{"userId": "user-id-here", "role": "admin"}' ``` +### Workspace Members + +When invitations are disabled, use the Admin API to manage workspace memberships directly: + +```bash +# Add a user to a workspace +curl -X POST https://your-instance/api/v1/admin/workspaces/{workspaceId}/members \ + -H "x-admin-key: YOUR_ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"userId": "user-id-here", "permissions": "write"}' + +# Remove a user from a workspace +curl -X DELETE "https://your-instance/api/v1/admin/workspaces/{workspaceId}/members?userId=user-id-here" \ + -H "x-admin-key: YOUR_ADMIN_API_KEY" +``` + ### Notes - Enabling `ACCESS_CONTROL_ENABLED` automatically enables organizations, as access control requires organization membership. +- When `DISABLE_INVITATIONS` is set, users cannot send invitations. Use the Admin API to manage workspace and organization memberships instead. diff --git a/apps/docs/content/docs/es/enterprise/index.mdx b/apps/docs/content/docs/es/enterprise/index.mdx index 48c3f59241..2137bb5366 100644 --- a/apps/docs/content/docs/es/enterprise/index.mdx +++ b/apps/docs/content/docs/es/enterprise/index.mdx @@ -70,6 +70,7 @@ Para implementaciones self-hosted, las funciones enterprise se pueden activar me |----------|-------------| | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Inicio de sesión único con SAML/OIDC | | `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Grupos de sondeo para activadores de correo electrónico | +| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Desactivar globalmente invitaciones a espacios de trabajo/organizaciones | BYOK solo está disponible en Sim Studio alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno. diff --git a/apps/docs/content/docs/fr/enterprise/index.mdx b/apps/docs/content/docs/fr/enterprise/index.mdx index 46efa6b6ac..c3eb71122e 100644 --- a/apps/docs/content/docs/fr/enterprise/index.mdx +++ b/apps/docs/content/docs/fr/enterprise/index.mdx @@ -70,6 +70,7 @@ Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent |----------|-------------| | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC | | `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail | +| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Désactiver globalement les invitations aux espaces de travail/organisations | BYOK est uniquement disponible sur Sim Studio hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement. diff --git a/apps/docs/content/docs/ja/enterprise/index.mdx b/apps/docs/content/docs/ja/enterprise/index.mdx index a08a5a51d5..1fc8dd6c5a 100644 --- a/apps/docs/content/docs/ja/enterprise/index.mdx +++ b/apps/docs/content/docs/ja/enterprise/index.mdx @@ -69,6 +69,7 @@ Sim Studioのホストキーの代わりに、AIモデルプロバイダー用 |----------|-------------| | `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン | | `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ | +| `DISABLE_INVITATIONS`、`NEXT_PUBLIC_DISABLE_INVITATIONS` | ワークスペース/組織への招待をグローバルに無効化 | BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。 diff --git a/apps/docs/content/docs/zh/enterprise/index.mdx b/apps/docs/content/docs/zh/enterprise/index.mdx index 045a14ea4d..edef31636b 100644 --- a/apps/docs/content/docs/zh/enterprise/index.mdx +++ b/apps/docs/content/docs/zh/enterprise/index.mdx @@ -69,6 +69,7 @@ Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织 |----------|-------------| | `SSO_ENABLED`,`NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 | | `CREDENTIAL_SETS_ENABLED`,`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 | +| `DISABLE_INVITATIONS`,`NEXT_PUBLIC_DISABLE_INVITATIONS` | 全局禁用工作区/组织邀请 | BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。 diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index f72705e90e..124d709574 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -26,6 +26,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { + InvitationsNotAllowedError, + validateInvitationsAllowed, +} from '@/executor/utils/permission-check' const logger = createLogger('OrganizationInvitations') @@ -116,6 +120,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + await validateInvitationsAllowed(session.user.id) + const { id: organizationId } = await params const url = new URL(request.url) const validateOnly = url.searchParams.get('validate') === 'true' @@ -427,6 +433,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }, }) } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } + logger.error('Failed to create organization invitations', { organizationId: (await params).id, error, @@ -486,10 +496,7 @@ export async function DELETE( and( eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId), - or( - eq(invitation.status, 'pending'), - eq(invitation.status, 'rejected') // Allow cancelling rejected invitations too - ) + or(eq(invitation.status, 'pending'), eq(invitation.status, 'rejected')) ) ) .returning() diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index 82e60e0eab..e76bece6eb 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -17,6 +17,12 @@ * Workspaces: * GET /api/v1/admin/workspaces - List all workspaces * GET /api/v1/admin/workspaces/:id - Get workspace details + * GET /api/v1/admin/workspaces/:id/members - List workspace members + * POST /api/v1/admin/workspaces/:id/members - Add/update workspace member + * DELETE /api/v1/admin/workspaces/:id/members?userId=X - Remove workspace member + * GET /api/v1/admin/workspaces/:id/members/:mid - Get workspace member details + * PATCH /api/v1/admin/workspaces/:id/members/:mid - Update workspace member permissions + * DELETE /api/v1/admin/workspaces/:id/members/:mid - Remove workspace member by ID * GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows * DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows * GET /api/v1/admin/workspaces/:id/folders - List workspace folders @@ -95,6 +101,7 @@ export type { AdminWorkflowDetail, AdminWorkspace, AdminWorkspaceDetail, + AdminWorkspaceMember, DbMember, DbOrganization, DbSubscription, diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index fbc12ae7ec..114563a372 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -518,6 +518,22 @@ export interface AdminMemberDetail extends AdminMember { billingBlocked: boolean } +// ============================================================================= +// Workspace Member Types +// ============================================================================= + +export interface AdminWorkspaceMember { + id: string + workspaceId: string + userId: string + permissions: 'admin' | 'write' | 'read' + createdAt: string + updatedAt: string + userName: string + userEmail: string + userImage: string | null +} + // ============================================================================= // User Billing Types // ============================================================================= diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts new file mode 100644 index 0000000000..49092d86df --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -0,0 +1,232 @@ +/** + * GET /api/v1/admin/workspaces/[id]/members/[memberId] + * + * Get workspace member details. + * + * Response: AdminSingleResponse + * + * PATCH /api/v1/admin/workspaces/[id]/members/[memberId] + * + * Update member permissions. + * + * Body: + * - permissions: 'admin' | 'write' | 'read' - New permission level + * + * Response: AdminSingleResponse + * + * DELETE /api/v1/admin/workspaces/[id]/members/[memberId] + * + * Remove member from workspace. + * + * Response: AdminSingleResponse<{ removed: true, memberId: string, userId: string }> + */ + +import { db } from '@sim/db' +import { permissions, user, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' +import type { AdminWorkspaceMember } from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminWorkspaceMemberDetailAPI') + +interface RouteParams { + id: string + memberId: string +} + +export const GET = withAdminAuthParams(async (_, context) => { + const { id: workspaceId, memberId } = await context.params + + try { + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [memberData] = await db + .select({ + id: permissions.id, + userId: permissions.userId, + permissionType: permissions.permissionType, + createdAt: permissions.createdAt, + updatedAt: permissions.updatedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where( + and( + eq(permissions.id, memberId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (!memberData) { + return notFoundResponse('Workspace member') + } + + const data: AdminWorkspaceMember = { + id: memberData.id, + workspaceId, + userId: memberData.userId, + permissions: memberData.permissionType, + createdAt: memberData.createdAt.toISOString(), + updatedAt: memberData.updatedAt.toISOString(), + userName: memberData.userName, + userEmail: memberData.userEmail, + userImage: memberData.userImage, + } + + logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`) + + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId }) + return internalErrorResponse('Failed to get workspace member') + } +}) + +export const PATCH = withAdminAuthParams(async (request, context) => { + const { id: workspaceId, memberId } = await context.params + + try { + const body = await request.json() + + if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { + return badRequestResponse('permissions must be "admin", "write", or "read"') + } + + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [existingMember] = await db + .select({ + id: permissions.id, + userId: permissions.userId, + permissionType: permissions.permissionType, + createdAt: permissions.createdAt, + }) + .from(permissions) + .where( + and( + eq(permissions.id, memberId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (!existingMember) { + return notFoundResponse('Workspace member') + } + + const now = new Date() + + await db + .update(permissions) + .set({ permissionType: body.permissions, updatedAt: now }) + .where(eq(permissions.id, memberId)) + + const [userData] = await db + .select({ name: user.name, email: user.email, image: user.image }) + .from(user) + .where(eq(user.id, existingMember.userId)) + .limit(1) + + const data: AdminWorkspaceMember = { + id: existingMember.id, + workspaceId, + userId: existingMember.userId, + permissions: body.permissions, + createdAt: existingMember.createdAt.toISOString(), + updatedAt: now.toISOString(), + userName: userData?.name ?? '', + userEmail: userData?.email ?? '', + userImage: userData?.image ?? null, + } + + logger.info(`Admin API: Updated member ${memberId} permissions to ${body.permissions}`, { + workspaceId, + previousPermissions: existingMember.permissionType, + }) + + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId }) + return internalErrorResponse('Failed to update workspace member') + } +}) + +export const DELETE = withAdminAuthParams(async (_, context) => { + const { id: workspaceId, memberId } = await context.params + + try { + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [existingMember] = await db + .select({ + id: permissions.id, + userId: permissions.userId, + }) + .from(permissions) + .where( + and( + eq(permissions.id, memberId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (!existingMember) { + return notFoundResponse('Workspace member') + } + + await db.delete(permissions).where(eq(permissions.id, memberId)) + + logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { + userId: existingMember.userId, + }) + + return singleResponse({ + removed: true, + memberId, + userId: existingMember.userId, + workspaceId, + }) + } catch (error) { + logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId }) + return internalErrorResponse('Failed to remove workspace member') + } +}) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts new file mode 100644 index 0000000000..687198506c --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -0,0 +1,298 @@ +/** + * GET /api/v1/admin/workspaces/[id]/members + * + * List all members of a workspace with their permission details. + * + * Query Parameters: + * - limit: number (default: 50, max: 250) + * - offset: number (default: 0) + * + * Response: AdminListResponse + * + * POST /api/v1/admin/workspaces/[id]/members + * + * Add a user to a workspace with a specific permission level. + * If the user already has permissions, updates their permission level. + * + * Body: + * - userId: string - User ID to add + * - permissions: 'admin' | 'write' | 'read' - Permission level + * + * Response: AdminSingleResponse + * + * DELETE /api/v1/admin/workspaces/[id]/members + * + * Remove a user from a workspace. + * + * Query Parameters: + * - userId: string - User ID to remove + * + * Response: AdminSingleResponse<{ removed: true }> + */ + +import crypto from 'crypto' +import { db } from '@sim/db' +import { permissions, user, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, count, eq } from 'drizzle-orm' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + listResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' +import { + type AdminWorkspaceMember, + createPaginationMeta, + parsePaginationParams, +} from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminWorkspaceMembersAPI') + +interface RouteParams { + id: string +} + +export const GET = withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) + + try { + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [countResult, membersData] = await Promise.all([ + db + .select({ count: count() }) + .from(permissions) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), + db + .select({ + id: permissions.id, + userId: permissions.userId, + permissionType: permissions.permissionType, + createdAt: permissions.createdAt, + updatedAt: permissions.updatedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + .orderBy(permissions.createdAt) + .limit(limit) + .offset(offset), + ]) + + const total = countResult[0].count + const data: AdminWorkspaceMember[] = membersData.map((m) => ({ + id: m.id, + workspaceId, + userId: m.userId, + permissions: m.permissionType, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + userName: m.userName, + userEmail: m.userEmail, + userImage: m.userImage, + })) + + const pagination = createPaginationMeta(total, limit, offset) + + logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`) + + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list workspace members', { error, workspaceId }) + return internalErrorResponse('Failed to list workspace members') + } +}) + +export const POST = withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + + try { + const body = await request.json() + + if (!body.userId || typeof body.userId !== 'string') { + return badRequestResponse('userId is required') + } + + if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { + return badRequestResponse('permissions must be "admin", "write", or "read"') + } + + const [workspaceData] = await db + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [userData] = await db + .select({ id: user.id, name: user.name, email: user.email, image: user.image }) + .from(user) + .where(eq(user.id, body.userId)) + .limit(1) + + if (!userData) { + return notFoundResponse('User') + } + + const [existingPermission] = await db + .select({ + id: permissions.id, + permissionType: permissions.permissionType, + createdAt: permissions.createdAt, + updatedAt: permissions.updatedAt, + }) + .from(permissions) + .where( + and( + eq(permissions.userId, body.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (existingPermission) { + if (existingPermission.permissionType !== body.permissions) { + const now = new Date() + await db + .update(permissions) + .set({ permissionType: body.permissions, updatedAt: now }) + .where(eq(permissions.id, existingPermission.id)) + + logger.info( + `Admin API: Updated user ${body.userId} permissions in workspace ${workspaceId}`, + { + previousPermissions: existingPermission.permissionType, + newPermissions: body.permissions, + } + ) + + return singleResponse({ + id: existingPermission.id, + workspaceId, + userId: body.userId, + permissions: body.permissions as 'admin' | 'write' | 'read', + createdAt: existingPermission.createdAt.toISOString(), + updatedAt: now.toISOString(), + userName: userData.name, + userEmail: userData.email, + userImage: userData.image, + action: 'updated' as const, + }) + } + + return singleResponse({ + id: existingPermission.id, + workspaceId, + userId: body.userId, + permissions: existingPermission.permissionType, + createdAt: existingPermission.createdAt.toISOString(), + updatedAt: existingPermission.updatedAt.toISOString(), + userName: userData.name, + userEmail: userData.email, + userImage: userData.image, + action: 'already_member' as const, + }) + } + + const now = new Date() + const permissionId = crypto.randomUUID() + + await db.insert(permissions).values({ + id: permissionId, + userId: body.userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: body.permissions, + createdAt: now, + updatedAt: now, + }) + + logger.info(`Admin API: Added user ${body.userId} to workspace ${workspaceId}`, { + permissions: body.permissions, + permissionId, + }) + + return singleResponse({ + id: permissionId, + workspaceId, + userId: body.userId, + permissions: body.permissions as 'admin' | 'write' | 'read', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + userName: userData.name, + userEmail: userData.email, + userImage: userData.image, + action: 'created' as const, + }) + } catch (error) { + logger.error('Admin API: Failed to add workspace member', { error, workspaceId }) + return internalErrorResponse('Failed to add workspace member') + } +}) + +export const DELETE = withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const userId = url.searchParams.get('userId') + + try { + if (!userId) { + return badRequestResponse('userId query parameter is required') + } + + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [existingPermission] = await db + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (!existingPermission) { + return notFoundResponse('Workspace member') + } + + await db.delete(permissions).where(eq(permissions.id, existingPermission.id)) + + logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`) + + return singleResponse({ removed: true, userId, workspaceId }) + } catch (error) { + logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, userId }) + return internalErrorResponse('Failed to remove workspace member') + } +}) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index b47e7bf362..f56e9d0120 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -101,6 +101,16 @@ describe('Workspace Invitations API Route', () => { eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })), inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })), })) + + vi.doMock('@/executor/utils/permission-check', () => ({ + validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), + InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error { + constructor() { + super('Invitations are not allowed based on your permission group settings') + this.name = 'InvitationsNotAllowedError' + } + }, + })) }) describe('GET /api/workspaces/invitations', () => { diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 06ad14d34d..bd70b9dc9c 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -18,6 +18,10 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { + InvitationsNotAllowedError, + validateInvitationsAllowed, +} from '@/executor/utils/permission-check' export const dynamic = 'force-dynamic' @@ -76,6 +80,8 @@ export async function POST(req: NextRequest) { } try { + await validateInvitationsAllowed(session.user.id) + const { workspaceId, email, role = 'member', permission = 'read' } = await req.json() if (!workspaceId || !email) { @@ -213,6 +219,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, invitation: invitationData }) } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } logger.error('Error creating workspace invitation:', error) return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx index e7a98f1e8a..a5bba68b46 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx @@ -34,6 +34,7 @@ export function PaneContextMenu({ disableAdmin = false, canUndo = false, canRedo = false, + isInvitationsDisabled = false, }: PaneContextMenuProps) { return ( - {/* Admin action */} - - { - onInvite() - onClose() - }} - > - Invite to Workspace - + {/* Admin action - hidden when invitations are disabled */} + {!isInvitationsDisabled && ( + <> + + { + onInvite() + onClose() + }} + > + Invite to Workspace + + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts index 53b5246cc4..ed0ecd26ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts @@ -94,4 +94,6 @@ export interface PaneContextMenuProps { canUndo?: boolean /** Whether redo is available */ canRedo?: boolean + /** Whether invitations are disabled (feature flag or permission group) */ + isInvitationsDisabled?: boolean } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index bbaad51aa8..2ac4f3cb72 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -64,6 +64,7 @@ import { getBlock } from '@/blocks' import { isAnnotationOnlyBlock } from '@/executor/constants' import { useWorkspaceEnvironment } from '@/hooks/queries/environment' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { usePermissionConfig } from '@/hooks/use-permission-config' import { useStreamCleanup } from '@/hooks/use-stream-cleanup' import { useChatStore } from '@/stores/chat/store' import { useCopilotTrainingStore } from '@/stores/copilot-training/store' @@ -281,6 +282,9 @@ const WorkflowContent = React.memo(() => { // Panel open states for context menu const isVariablesOpen = useVariablesStore((state) => state.isOpen) const isChatOpen = useChatStore((state) => state.isChatOpen) + + // Permission config for invitation control + const { isInvitationsDisabled } = usePermissionConfig() const snapGrid: [number, number] = useMemo( () => [snapToGridSize, snapToGridSize], [snapToGridSize] @@ -3426,6 +3430,7 @@ const WorkflowContent = React.memo(() => { disableAdmin={!effectivePermissions.canAdmin} canUndo={canUndo} canRedo={canRedo} + isInvitationsDisabled={isInvitationsDisabled} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx index 034f028290..fc7bf1dcda 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx @@ -342,6 +342,12 @@ export function AccessControl() { category: 'Logs', configKey: 'hideTraceSpans' as const, }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations' as const, + }, ], [] ) @@ -869,7 +875,8 @@ export function AccessControl() { !editingConfig?.hideFilesTab && !editingConfig?.disableMcpTools && !editingConfig?.disableCustomTools && - !editingConfig?.hideTraceSpans + !editingConfig?.hideTraceSpans && + !editingConfig?.disableInvitations setEditingConfig((prev) => prev ? { @@ -883,6 +890,7 @@ export function AccessControl() { disableMcpTools: allVisible, disableCustomTools: allVisible, hideTraceSpans: allVisible, + disableInvitations: allVisible, } : prev ) @@ -896,7 +904,8 @@ export function AccessControl() { !editingConfig?.hideFilesTab && !editingConfig?.disableMcpTools && !editingConfig?.disableCustomTools && - !editingConfig?.hideTraceSpans + !editingConfig?.hideTraceSpans && + !editingConfig?.disableInvitations ? 'Deselect All' : 'Select All'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx index 6f3f7e0b57..b78de6ed98 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx @@ -33,11 +33,13 @@ import { } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' import { useAdminWorkspaces } from '@/hooks/queries/workspace' +import { usePermissionConfig } from '@/hooks/use-permission-config' const logger = createLogger('TeamManagement') export function TeamManagement() { const { data: session } = useSession() + const { isInvitationsDisabled } = usePermissionConfig() const { data: organizationsData } = useOrganizations() const activeOrganization = organizationsData?.activeOrganization @@ -385,8 +387,8 @@ export function TeamManagement() { )} - {/* Action: Invite New Members */} - {adminOrOwner && ( + {/* Action: Invite New Members - hidden when invitations are disabled */} + {adminOrOwner && !isInvitationsDisabled && (
{ - const handleOpenInvite = () => setIsInviteModalOpen(true) + const handleOpenInvite = () => { + if (!isInvitationsDisabled) { + setIsInviteModalOpen(true) + } + } window.addEventListener('open-invite-modal', handleOpenInvite) return () => window.removeEventListener('open-invite-modal', handleOpenInvite) - }, []) + }, [isInvitationsDisabled]) /** * Focus the inline list rename input when it becomes active @@ -458,8 +465,8 @@ export function WorkspaceHeader({
{/* Workspace Actions */}
- {/* Invite - hidden in collapsed mode */} - {!isCollapsed && ( + {/* Invite - hidden in collapsed mode or when invitations are disabled */} + {!isCollapsed && !isInvitationsDisabled && ( setIsInviteModalOpen(true)}> Invite diff --git a/apps/sim/executor/utils/permission-check.ts b/apps/sim/executor/utils/permission-check.ts index 5e24df54ce..aabcea6e99 100644 --- a/apps/sim/executor/utils/permission-check.ts +++ b/apps/sim/executor/utils/permission-check.ts @@ -42,6 +42,13 @@ export class CustomToolsNotAllowedError extends Error { } } +export class InvitationsNotAllowedError extends Error { + constructor() { + super('Invitations are not allowed based on your permission group settings') + this.name = 'InvitationsNotAllowedError' + } +} + export async function getUserPermissionConfig( userId: string ): Promise { @@ -184,3 +191,30 @@ export async function validateCustomToolsAllowed( throw new CustomToolsNotAllowedError() } } + +/** + * Validates if the user is allowed to send invitations. + * Also checks the global feature flag. + */ +export async function validateInvitationsAllowed(userId: string | undefined): Promise { + const { isInvitationsDisabled } = await import('@/lib/core/config/feature-flags') + if (isInvitationsDisabled) { + logger.warn('Invitations blocked by feature flag') + throw new InvitationsNotAllowedError() + } + + if (!userId) { + return + } + + const config = await getUserPermissionConfig(userId) + + if (!config) { + return + } + + if (config.disableInvitations) { + logger.warn('Invitations blocked by permission group', { userId }) + throw new InvitationsNotAllowedError() + } +} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 584930f060..55e14c4b9f 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { getEnv, isTruthy } from '@/lib/core/config/env' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -14,6 +15,7 @@ export interface PermissionConfigResult { filterProviders: (providerIds: string[]) => string[] isBlockAllowed: (blockType: string) => boolean isProviderAllowed: (providerId: string) => boolean + isInvitationsDisabled: boolean } export function usePermissionConfig(): PermissionConfigResult { @@ -59,6 +61,11 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [config.allowedModelProviders]) + const isInvitationsDisabled = useMemo(() => { + const featureFlagDisabled = isTruthy(getEnv('NEXT_PUBLIC_DISABLE_INVITATIONS')) + return featureFlagDisabled || config.disableInvitations + }, [config.disableInvitations]) + return { config, isLoading, @@ -67,5 +74,6 @@ export function usePermissionConfig(): PermissionConfigResult { filterProviders, isBlockAllowed, isProviderAllowed, + isInvitationsDisabled, } } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index c2be459174..de11e88746 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -257,6 +257,9 @@ export const env = createEnv({ // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) + // Invitations - for self-hosted deployments + DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) + // SSO Configuration (for script-based registration) SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type @@ -337,6 +340,7 @@ export const env = createEnv({ NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) + NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms }, @@ -368,6 +372,7 @@ export const env = createEnv({ NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED, NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED, NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED, + NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED, diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 33317ba1cf..2a57e569da 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -103,6 +103,12 @@ export const isOrganizationsEnabled = */ export const isE2bEnabled = isTruthy(env.E2B_ENABLED) +/** + * Are invitations disabled globally + * When true, workspace invitations are disabled for all users + */ +export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS) + /** * Get cost multiplier based on environment */ diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 810f06a305..3c82dcc457 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -11,6 +11,7 @@ export interface PermissionGroupConfig { disableMcpTools: boolean disableCustomTools: boolean hideTemplates: boolean + disableInvitations: boolean } export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = { @@ -25,6 +26,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = { disableMcpTools: false, disableCustomTools: false, hideTemplates: false, + disableInvitations: false, } export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig { @@ -47,5 +49,6 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false, disableCustomTools: typeof c.disableCustomTools === 'boolean' ? c.disableCustomTools : false, hideTemplates: typeof c.hideTemplates === 'boolean' ? c.hideTemplates : false, + disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false, } } diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 24e794a9ca..2141fff2a8 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -147,6 +147,10 @@ app: BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google") BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") + # Invitation Control + DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally + NEXT_PUBLIC_DISABLE_INVITATIONS: "" # Set to "true" to hide invitation UI elements + # SSO Configuration (Enterprise Single Sign-On) # Set to "true" AFTER running the SSO registration script SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)