Compare commits

..

4 Commits

Author SHA1 Message Date
Vikhyath Mondreti
0fb6d9d451 remove comments 2026-01-13 00:38:42 -08:00
Vikhyath Mondreti
77241a1220 fix 2026-01-13 00:36:12 -08:00
Vikhyath Mondreti
a05003a2d3 feat(integrations): claude skills to add integrations, lemlist trigger + tools, remove test webhook url (#2785)
* feat(integrations): claude skills to add integrations, lemlist trigger + tools, remove test webhook url

* fix tests

* fix tools

* add more details to skill

* more details

* address greptile comments
2026-01-12 22:18:50 -08:00
Waleed
46417ddb8c feat(invitations): added FF to disable invitations, added to permission groups, added workspace members admin endpoints (#2783)
* feat(invitations): added FF to disable invitations, added to permission groups, added workspace members admin endpoints

* fix failing tests
2026-01-12 19:33:43 -08:00
26 changed files with 735 additions and 40 deletions

View File

@@ -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 |
<Callout type="warn">
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.

View File

@@ -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.

View File

@@ -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 |
<Callout type="warn">
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.

View File

@@ -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 |
<Callout type="warn">
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.

View File

@@ -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` | ワークスペース/組織への招待をグローバルに無効化 |
<Callout type="warn">
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。

View File

@@ -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` | 全局禁用工作区/组织邀请 |
<Callout type="warn">
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。

View File

@@ -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()

View File

@@ -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,

View File

@@ -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
// =============================================================================

View File

@@ -0,0 +1,232 @@
/**
* GET /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Get workspace member details.
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* PATCH /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Update member permissions.
*
* Body:
* - permissions: 'admin' | 'write' | 'read' - New permission level
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* 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<RouteParams>(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<RouteParams>(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<RouteParams>(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')
}
})

View File

@@ -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<AdminWorkspaceMember>
*
* 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<AdminWorkspaceMember & { action: 'created' | 'updated' }>
*
* 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<RouteParams>(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<RouteParams>(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<RouteParams>(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')
}
})

View File

@@ -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', () => {

View File

@@ -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 })
}

View File

@@ -34,6 +34,7 @@ export function PaneContextMenu({
disableAdmin = false,
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: PaneContextMenuProps) {
return (
<Popover
@@ -142,17 +143,21 @@ export function PaneContextMenu({
{isChatOpen ? 'Close Chat' : 'Open Chat'}
</PopoverItem>
{/* Admin action */}
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
{/* Admin action - hidden when invitations are disabled */}
{!isInvitationsDisabled && (
<>
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>
)

View File

@@ -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
}

View File

@@ -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}
/>
</>
)}

View File

@@ -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'}
</Button>

View File

@@ -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() {
</div>
)}
{/* Action: Invite New Members */}
{adminOrOwner && (
{/* Action: Invite New Members - hidden when invitations are disabled */}
{adminOrOwner && !isInvitationsDisabled && (
<div>
<MemberInvitationCard
inviteEmail={inviteEmail}

View File

@@ -18,6 +18,7 @@ import {
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('WorkspaceHeader')
@@ -151,12 +152,18 @@ export function WorkspaceHeader({
setIsMounted(true)
}, [])
const { isInvitationsDisabled } = usePermissionConfig()
// Listen for open-invite-modal event from context menu
useEffect(() => {
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({
</div>
{/* Workspace Actions */}
<div className='flex flex-shrink-0 items-center gap-[10px]'>
{/* Invite - hidden in collapsed mode */}
{!isCollapsed && (
{/* Invite - hidden in collapsed mode or when invitations are disabled */}
{!isCollapsed && !isInvitationsDisabled && (
<Badge className='cursor-pointer' onClick={() => setIsInviteModalOpen(true)}>
Invite
</Badge>

View File

@@ -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<PermissionGroupConfig | null> {
@@ -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<void> {
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()
}
}

View File

@@ -1088,25 +1088,28 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
const currentValue = subBlockStore.getValue(blockId, subblockId)
const valueActuallyChanged = currentValue !== value
subBlockStore.setValue(blockId, subblockId, value)
try {
const visited = options?._visited || new Set<string>()
if (visited.has(subblockId)) return
visited.add(subblockId)
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
const blockConfig = blockType ? getBlock(blockType) : null
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
const dependents = blockConfig.subBlocks.filter(
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
)
for (const dep of dependents) {
if (!dep?.id || dep.id === subblockId) continue
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
if (valueActuallyChanged) {
try {
const visited = options?._visited || new Set<string>()
if (visited.has(subblockId)) return
visited.add(subblockId)
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
const blockConfig = blockType ? getBlock(blockType) : null
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
const dependents = blockConfig.subBlocks.filter(
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
)
for (const dep of dependents) {
if (!dep?.id || dep.id === subblockId) continue
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
}
}
}
} catch {
// Best-effort; do not block on clearing
} catch {}
}
},
[

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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
*/

View File

@@ -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,
}
}

View File

@@ -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)