Compare commits

..

28 Commits

Author SHA1 Message Date
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Waleed
6717ce89f4 fix(slack): remove duplicate effect that cleared subblocks on cred change (#2788) 2026-01-13 00:47:17 -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
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
26 changed files with 716 additions and 53 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

@@ -35,7 +35,6 @@ import { getDependsOnFields } from '@/blocks/utils'
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -613,34 +612,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
[isDeploying, setDeploymentStatus, refetchDeployment]
)
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
/**
* Clear credential-dependent fields when credential changes to prevent
* stale data from persisting with new credentials.
*/
const prevCredRef = useRef<string | undefined>(undefined)
useEffect(() => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return
const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id]
if (!current) return
const credValue = current.credential
const cred =
typeof credValue === 'object' && credValue !== null && 'value' in credValue
? ((credValue as { value?: unknown }).value as string | undefined)
: (credValue as string | undefined)
if (prevCredRef.current !== cred) {
const hadPreviousCredential = prevCredRef.current !== undefined
prevCredRef.current = cred
if (hadPreviousCredential) {
const keys = Object.keys(current)
const dependentKeys = keys.filter((k) => k !== 'credential')
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
}
}
}, [id, collaborativeSetSubblockValue])
const currentStoreBlock = currentWorkflow.getBlockById(id)
const isStarterBlock = type === 'starter'

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

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