mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-13 08:57:55 -05:00
Compare commits
28 Commits
feat/integ
...
v0.5.58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf3d73ee6 | ||
|
|
6717ce89f4 | ||
|
|
a05003a2d3 | ||
|
|
46417ddb8c | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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プロバイダーキーを直接設定します。
|
||||
|
||||
@@ -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 提供商密钥。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
298
apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts
Normal file
298
apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user