mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
1 Commits
feat/copil
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47eb060311 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Enterprise
|
||||
description: Enterprise features for organizations with advanced security and compliance requirements
|
||||
description: Enterprise features for business organizations
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
@@ -9,6 +9,28 @@ Sim Studio Enterprise provides advanced features for organizations with enhanced
|
||||
|
||||
---
|
||||
|
||||
## Access Control
|
||||
|
||||
Define permission groups to control what features and integrations team members can use.
|
||||
|
||||
### Features
|
||||
|
||||
- **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
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to **Settings** → **Access Control** in your workspace
|
||||
2. Create a permission group with your desired restrictions
|
||||
3. Add team members to the permission group
|
||||
|
||||
<Callout type="info">
|
||||
Users not assigned to any permission group have full access. Permission restrictions are enforced at both UI and execution time.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Bring Your Own Key (BYOK)
|
||||
|
||||
Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
|
||||
@@ -61,15 +83,38 @@ Enterprise authentication with SAML 2.0 and OIDC support for centralized identit
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted
|
||||
## Self-Hosted Configuration
|
||||
|
||||
For self-hosted deployments, enterprise features can be enabled via environment variables:
|
||||
For self-hosted deployments, enterprise features can be enabled via environment variables without requiring billing.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ORGANIZATIONS_ENABLED`, `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | Enable team/organization management |
|
||||
| `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 |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
|
||||
</Callout>
|
||||
### Organization Management
|
||||
|
||||
When billing is disabled, use the Admin API to manage organizations:
|
||||
|
||||
```bash
|
||||
# Create an organization
|
||||
curl -X POST https://your-instance/api/v1/admin/organizations \
|
||||
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Organization", "ownerId": "user-id-here"}'
|
||||
|
||||
# Add a member
|
||||
curl -X POST https://your-instance/api/v1/admin/organizations/{orgId}/members \
|
||||
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"userId": "user-id-here", "role": "admin"}'
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Enabling `ACCESS_CONTROL_ENABLED` automatically enables organizations, as access control requires organization membership.
|
||||
- BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
|
||||
|
||||
166
apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts
Normal file
166
apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('PermissionGroupBulkMembers')
|
||||
|
||||
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, groupId))
|
||||
.limit(1)
|
||||
|
||||
if (!group) return null
|
||||
|
||||
const [membership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) return null
|
||||
|
||||
return { group, role: membership.role }
|
||||
}
|
||||
|
||||
const bulkAddSchema = z.object({
|
||||
userIds: z.array(z.string()).optional(),
|
||||
addAllOrgMembers: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { userIds, addAllOrgMembers } = bulkAddSchema.parse(body)
|
||||
|
||||
let targetUserIds: string[] = []
|
||||
|
||||
if (addAllOrgMembers) {
|
||||
const orgMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, result.group.organizationId))
|
||||
|
||||
targetUserIds = orgMembers.map((m) => m.userId)
|
||||
} else if (userIds && userIds.length > 0) {
|
||||
const validMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, result.group.organizationId),
|
||||
inArray(member.userId, userIds)
|
||||
)
|
||||
)
|
||||
|
||||
targetUserIds = validMembers.map((m) => m.userId)
|
||||
}
|
||||
|
||||
if (targetUserIds.length === 0) {
|
||||
return NextResponse.json({ added: 0, moved: 0 })
|
||||
}
|
||||
|
||||
const existingMemberships = await db
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
userId: permissionGroupMember.userId,
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.where(inArray(permissionGroupMember.userId, targetUserIds))
|
||||
|
||||
const alreadyInThisGroup = new Set(
|
||||
existingMemberships.filter((m) => m.permissionGroupId === id).map((m) => m.userId)
|
||||
)
|
||||
const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid))
|
||||
|
||||
if (usersToAdd.length === 0) {
|
||||
return NextResponse.json({ added: 0, moved: 0 })
|
||||
}
|
||||
|
||||
const membershipsToDelete = existingMemberships.filter(
|
||||
(m) => m.permissionGroupId !== id && usersToAdd.includes(m.userId)
|
||||
)
|
||||
const movedCount = membershipsToDelete.length
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (membershipsToDelete.length > 0) {
|
||||
await tx.delete(permissionGroupMember).where(
|
||||
inArray(
|
||||
permissionGroupMember.id,
|
||||
membershipsToDelete.map((m) => m.id)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const newMembers = usersToAdd.map((userId) => ({
|
||||
id: crypto.randomUUID(),
|
||||
permissionGroupId: id,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
}))
|
||||
|
||||
await tx.insert(permissionGroupMember).values(newMembers)
|
||||
})
|
||||
|
||||
logger.info('Bulk added members to permission group', {
|
||||
permissionGroupId: id,
|
||||
addedCount: usersToAdd.length,
|
||||
movedCount,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ added: usersToAdd.length, moved: movedCount })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('permission_group_member_user_id_unique')
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'One or more users are already in a permission group' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
logger.error('Error bulk adding members to permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to add members' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
229
apps/sim/app/api/permission-groups/[id]/members/route.ts
Normal file
229
apps/sim/app/api/permission-groups/[id]/members/route.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('PermissionGroupMembers')
|
||||
|
||||
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, groupId))
|
||||
.limit(1)
|
||||
|
||||
if (!group) return null
|
||||
|
||||
const [membership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) return null
|
||||
|
||||
return { group, role: membership.role }
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const members = await db
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
userId: permissionGroupMember.userId,
|
||||
assignedAt: permissionGroupMember.assignedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.leftJoin(user, eq(permissionGroupMember.userId, user.id))
|
||||
.where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
|
||||
return NextResponse.json({ members })
|
||||
}
|
||||
|
||||
const addMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { userId } = addMemberSchema.parse(body)
|
||||
|
||||
const [orgMember] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!orgMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is not a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [existingMembership] = await db
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existingMembership?.permissionGroupId === id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already in this permission group' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const newMember = await db.transaction(async (tx) => {
|
||||
if (existingMembership) {
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.id, existingMembership.id))
|
||||
}
|
||||
|
||||
const memberData = {
|
||||
id: crypto.randomUUID(),
|
||||
permissionGroupId: id,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
}
|
||||
|
||||
await tx.insert(permissionGroupMember).values(memberData)
|
||||
return memberData
|
||||
})
|
||||
|
||||
logger.info('Added member to permission group', {
|
||||
permissionGroupId: id,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ member: newMember }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('permission_group_member_user_id_unique')
|
||||
) {
|
||||
return NextResponse.json({ error: 'User is already in a permission group' }, { status: 409 })
|
||||
}
|
||||
logger.error('Error adding member to permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to add member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const memberId = searchParams.get('memberId')
|
||||
|
||||
if (!memberId) {
|
||||
return NextResponse.json({ error: 'memberId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [memberToRemove] = await db
|
||||
.select()
|
||||
.from(permissionGroupMember)
|
||||
.where(
|
||||
and(eq(permissionGroupMember.id, memberId), eq(permissionGroupMember.permissionGroupId, id))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!memberToRemove) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId))
|
||||
|
||||
logger.info('Removed member from permission group', {
|
||||
permissionGroupId: id,
|
||||
memberId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error removing member from permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
212
apps/sim/app/api/permission-groups/[id]/route.ts
Normal file
212
apps/sim/app/api/permission-groups/[id]/route.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import {
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
|
||||
const logger = createLogger('PermissionGroup')
|
||||
|
||||
const configSchema = z.object({
|
||||
allowedIntegrations: z.array(z.string()).nullable().optional(),
|
||||
allowedModelProviders: z.array(z.string()).nullable().optional(),
|
||||
hideTraceSpans: z.boolean().optional(),
|
||||
hideKnowledgeBaseTab: z.boolean().optional(),
|
||||
hideCopilot: z.boolean().optional(),
|
||||
hideApiKeysTab: z.boolean().optional(),
|
||||
hideEnvironmentTab: z.boolean().optional(),
|
||||
hideFilesTab: z.boolean().optional(),
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
config: configSchema.optional(),
|
||||
})
|
||||
|
||||
async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, groupId))
|
||||
.limit(1)
|
||||
|
||||
if (!group) return null
|
||||
|
||||
const [membership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) return null
|
||||
|
||||
return { group, role: membership.role }
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroup: {
|
||||
...result.group,
|
||||
config: parsePermissionGroupConfig(result.group.config),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const updates = updateSchema.parse(body)
|
||||
|
||||
if (updates.name) {
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, result.group.organizationId),
|
||||
eq(permissionGroup.name, updates.name)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0 && existingGroup[0].id !== id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfig = parsePermissionGroupConfig(result.group.config)
|
||||
const newConfig: PermissionGroupConfig = updates.config
|
||||
? { ...currentConfig, ...updates.config }
|
||||
: currentConfig
|
||||
|
||||
await db
|
||||
.update(permissionGroup)
|
||||
.set({
|
||||
...(updates.name !== undefined && { name: updates.name }),
|
||||
...(updates.description !== undefined && { description: updates.description }),
|
||||
config: newConfig,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(permissionGroup.id, id))
|
||||
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, id))
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroup: {
|
||||
...updated,
|
||||
config: parsePermissionGroupConfig(updated.config),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error updating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getPermissionGroupWithAccess(id, session.user.id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (result.role !== 'admin' && result.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
await db.delete(permissionGroup).where(eq(permissionGroup.id, id))
|
||||
|
||||
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
185
apps/sim/app/api/permission-groups/route.ts
Normal file
185
apps/sim/app/api/permission-groups/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, desc, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
|
||||
const logger = createLogger('PermissionGroups')
|
||||
|
||||
const configSchema = z.object({
|
||||
allowedIntegrations: z.array(z.string()).nullable().optional(),
|
||||
allowedModelProviders: z.array(z.string()).nullable().optional(),
|
||||
hideTraceSpans: z.boolean().optional(),
|
||||
hideKnowledgeBaseTab: z.boolean().optional(),
|
||||
hideCopilot: z.boolean().optional(),
|
||||
hideApiKeysTab: z.boolean().optional(),
|
||||
hideEnvironmentTab: z.boolean().optional(),
|
||||
hideFilesTab: z.boolean().optional(),
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
organizationId: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
config: configSchema.optional(),
|
||||
})
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const membership = await db
|
||||
.select({ id: member.id, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (membership.length === 0) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const groups = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
creatorName: user.name,
|
||||
creatorEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
.orderBy(desc(permissionGroup.createdAt))
|
||||
|
||||
const groupsWithCounts = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const [memberCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, group.id))
|
||||
|
||||
return {
|
||||
...group,
|
||||
config: parsePermissionGroupConfig(group.config),
|
||||
memberCount: memberCount?.count ?? 0,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ permissionGroups: groupsWithCounts })
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const hasAccess = await hasAccessControlAccess(session.user.id)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access Control is an Enterprise feature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { organizationId, name, description, config } = createSchema.parse(body)
|
||||
|
||||
const membership = await db
|
||||
.select({ id: member.id, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
const role = membership[0]?.role
|
||||
if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) {
|
||||
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const orgExists = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (orgExists.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const groupConfig: PermissionGroupConfig = {
|
||||
...DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
...config,
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const newGroup = {
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
name,
|
||||
description: description || null,
|
||||
config: groupConfig,
|
||||
createdBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(permissionGroup).values(newGroup)
|
||||
|
||||
logger.info('Created permission group', {
|
||||
permissionGroupId: newGroup.id,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error creating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
72
apps/sim/app/api/permission-groups/user/route.ts
Normal file
72
apps/sim/app/api/permission-groups/user/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import { parsePermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Short-circuit: if org is not on enterprise plan, ignore permission configs
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!isEnterprise) {
|
||||
return NextResponse.json({
|
||||
permissionGroupId: null,
|
||||
groupName: null,
|
||||
config: null,
|
||||
})
|
||||
}
|
||||
|
||||
const [groupMembership] = await db
|
||||
.select({
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
config: permissionGroup.config,
|
||||
groupName: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, session.user.id),
|
||||
eq(permissionGroup.organizationId, organizationId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!groupMembership) {
|
||||
return NextResponse.json({
|
||||
permissionGroupId: null,
|
||||
groupName: null,
|
||||
config: null,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroupId: groupMembership.permissionGroupId,
|
||||
groupName: groupMembership.groupName,
|
||||
config: parsePermissionGroupConfig(groupMembership.config),
|
||||
})
|
||||
}
|
||||
169
apps/sim/app/api/v1/admin/access-control/route.ts
Normal file
169
apps/sim/app/api/v1/admin/access-control/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Admin Access Control (Permission Groups) API
|
||||
*
|
||||
* GET /api/v1/admin/access-control
|
||||
* List all permission groups with optional filtering.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId?: string - Filter by organization ID
|
||||
*
|
||||
* Response: { data: AdminPermissionGroup[], pagination: PaginationMeta }
|
||||
*
|
||||
* DELETE /api/v1/admin/access-control
|
||||
* Delete permission groups for an organization.
|
||||
* Used when an enterprise plan churns to clean up access control data.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId: string - Delete all permission groups for this organization
|
||||
*
|
||||
* Response: { success: true, deletedCount: number, membersRemoved: number }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminAccessControlAPI')
|
||||
|
||||
export interface AdminPermissionGroup {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationName: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
memberCount: number
|
||||
createdAt: string
|
||||
createdByUserId: string
|
||||
createdByEmail: string | null
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
|
||||
try {
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
organizationName: organization.name,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
createdByUserId: permissionGroup.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(organization, eq(permissionGroup.organizationId, organization.id))
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
|
||||
let groups
|
||||
if (organizationId) {
|
||||
groups = await baseQuery.where(eq(permissionGroup.organizationId, organizationId))
|
||||
} else {
|
||||
groups = await baseQuery
|
||||
}
|
||||
|
||||
const groupsWithCounts = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const [memberCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, group.id))
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
organizationId: group.organizationId,
|
||||
organizationName: group.organizationName,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
memberCount: memberCount?.count ?? 0,
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
createdByUserId: group.createdByUserId,
|
||||
createdByEmail: group.createdByEmail,
|
||||
} as AdminPermissionGroup
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('Admin API: Listed permission groups', {
|
||||
organizationId,
|
||||
count: groupsWithCounts.length,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
data: groupsWithCounts,
|
||||
pagination: {
|
||||
total: groupsWithCounts.length,
|
||||
limit: groupsWithCounts.length,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list permission groups', { error, organizationId })
|
||||
return internalErrorResponse('Failed to list permission groups')
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const organizationId = url.searchParams.get('organizationId')
|
||||
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
|
||||
|
||||
if (!organizationId) {
|
||||
return badRequestResponse('organizationId is required')
|
||||
}
|
||||
|
||||
try {
|
||||
const existingGroups = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
|
||||
if (existingGroups.length === 0) {
|
||||
logger.info('Admin API: No permission groups to delete', { organizationId })
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
membersRemoved: 0,
|
||||
message: 'No permission groups found for the given organization',
|
||||
})
|
||||
}
|
||||
|
||||
const groupIds = existingGroups.map((g) => g.id)
|
||||
|
||||
const [memberCountResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(permissionGroupMember)
|
||||
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
|
||||
|
||||
const membersToRemove = Number(memberCountResult?.count ?? 0)
|
||||
|
||||
// Members are deleted via cascade when permission groups are deleted
|
||||
await db.delete(permissionGroup).where(eq(permissionGroup.organizationId, organizationId))
|
||||
|
||||
logger.info('Admin API: Deleted permission groups', {
|
||||
organizationId,
|
||||
deletedCount: existingGroups.length,
|
||||
membersRemoved: membersToRemove,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: existingGroups.length,
|
||||
membersRemoved: membersToRemove,
|
||||
reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete permission groups', { error, organizationId })
|
||||
return internalErrorResponse('Failed to delete permission groups')
|
||||
}
|
||||
})
|
||||
@@ -36,6 +36,7 @@
|
||||
*
|
||||
* Organizations:
|
||||
* GET /api/v1/admin/organizations - List all organizations
|
||||
* POST /api/v1/admin/organizations - Create organization (requires ownerId)
|
||||
* GET /api/v1/admin/organizations/:id - Get organization details
|
||||
* PATCH /api/v1/admin/organizations/:id - Update organization
|
||||
* GET /api/v1/admin/organizations/:id/members - List organization members
|
||||
@@ -55,6 +56,10 @@
|
||||
* BYOK Keys:
|
||||
* GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
|
||||
* DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
|
||||
*
|
||||
* Access Control (Permission Groups):
|
||||
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
||||
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
||||
*/
|
||||
|
||||
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -39,6 +40,42 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
if (!isBillingEnabled) {
|
||||
const [[orgData], [memberCount]] = await Promise.all([
|
||||
db.select().from(organization).where(eq(organization.id, organizationId)).limit(1),
|
||||
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
|
||||
])
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const data: AdminOrganizationBillingSummary = {
|
||||
organizationId: orgData.id,
|
||||
organizationName: orgData.name,
|
||||
subscriptionPlan: 'none',
|
||||
subscriptionStatus: 'none',
|
||||
totalSeats: Number.MAX_SAFE_INTEGER,
|
||||
usedSeats: memberCount?.count || 0,
|
||||
availableSeats: Number.MAX_SAFE_INTEGER,
|
||||
totalCurrentUsage: 0,
|
||||
totalUsageLimit: Number.MAX_SAFE_INTEGER,
|
||||
minimumBillingAmount: 0,
|
||||
averageUsagePerMember: 0,
|
||||
usagePercentage: 0,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
membersOverLimit: 0,
|
||||
membersNearLimit: 0,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)`
|
||||
)
|
||||
|
||||
return singleResponse(data)
|
||||
}
|
||||
|
||||
const billingData = await getOrganizationBillingData(organizationId)
|
||||
|
||||
if (!billingData) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -182,7 +183,7 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
|
||||
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId, memberId } = await context.params
|
||||
const url = new URL(request.url)
|
||||
const skipBillingLogic = url.searchParams.get('skipBillingLogic') === 'true'
|
||||
const skipBillingLogic = !isBillingEnabled || url.searchParams.get('skipBillingLogic') === 'true'
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
|
||||
@@ -34,6 +34,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -221,14 +222,14 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
||||
userId: body.userId,
|
||||
organizationId,
|
||||
role: body.role,
|
||||
skipBillingLogic: !isBillingEnabled,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return badRequestResponse(result.error || 'Failed to add member')
|
||||
}
|
||||
|
||||
// Sync Pro subscription cancellation with Stripe (same as invitation flow)
|
||||
if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
|
||||
if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(
|
||||
|
||||
@@ -8,14 +8,32 @@
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminOrganization>
|
||||
*
|
||||
* POST /api/v1/admin/organizations
|
||||
*
|
||||
* Create a new organization.
|
||||
*
|
||||
* Body:
|
||||
* - name: string - Organization name (required)
|
||||
* - slug: string - Organization slug (optional, auto-generated from name if not provided)
|
||||
* - ownerId: string - User ID of the organization owner (required)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganization & { memberId: string }>
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { member, organization, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count } from 'drizzle-orm'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminOrganization,
|
||||
createPaginationMeta,
|
||||
@@ -47,3 +65,90 @@ export const GET = withAdminAuth(async (request) => {
|
||||
return internalErrorResponse('Failed to list organizations')
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = withAdminAuth(async (request) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||
return badRequestResponse('name is required')
|
||||
}
|
||||
|
||||
if (!body.ownerId || typeof body.ownerId !== 'string') {
|
||||
return badRequestResponse('ownerId is required')
|
||||
}
|
||||
|
||||
const [ownerData] = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, body.ownerId))
|
||||
.limit(1)
|
||||
|
||||
if (!ownerData) {
|
||||
return notFoundResponse('Owner user')
|
||||
}
|
||||
|
||||
const [existingMembership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, body.ownerId))
|
||||
.limit(1)
|
||||
|
||||
if (existingMembership) {
|
||||
return badRequestResponse(
|
||||
'User is already a member of another organization. Users can only belong to one organization at a time.'
|
||||
)
|
||||
}
|
||||
|
||||
const name = body.name.trim()
|
||||
const slug =
|
||||
body.slug?.trim() ||
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
const organizationId = randomUUID()
|
||||
const memberId = randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(organization).values({
|
||||
id: organizationId,
|
||||
name,
|
||||
slug,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await tx.insert(member).values({
|
||||
id: memberId,
|
||||
userId: body.ownerId,
|
||||
organizationId,
|
||||
role: 'owner',
|
||||
createdAt: now,
|
||||
})
|
||||
})
|
||||
|
||||
const [createdOrg] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
logger.info(`Admin API: Created organization ${organizationId}`, {
|
||||
name,
|
||||
slug,
|
||||
ownerId: body.ownerId,
|
||||
memberId,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
...toAdminOrganization(createdOrg),
|
||||
memberId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to create organization', { error })
|
||||
return internalErrorResponse('Failed to create organization')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
export { Knowledge as default } from './knowledge'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { Knowledge } from './knowledge'
|
||||
|
||||
interface KnowledgePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function KnowledgePage({ params }: KnowledgePageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Check permission group restrictions
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideKnowledgeBaseTab) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
return <Knowledge />
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
StatusBadge,
|
||||
TriggerBadge,
|
||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import { useLogDetailsUIStore } from '@/stores/logs/store'
|
||||
@@ -57,6 +58,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
|
||||
const { handleMouseDown } = useLogDetailsResize()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
@@ -264,7 +266,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
|
||||
{/* Workflow State */}
|
||||
{isWorkflowExecutionLog && log.executionId && (
|
||||
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
|
||||
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Workflow State
|
||||
@@ -282,12 +284,14 @@ export const LogDetails = memo(function LogDetails({
|
||||
)}
|
||||
|
||||
{/* Workflow Execution - Trace Spans */}
|
||||
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
/>
|
||||
)}
|
||||
{isWorkflowExecutionLog &&
|
||||
log.executionData?.traceSpans &&
|
||||
!permissionConfig.hideTraceSpans && (
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{log.files && log.files.length > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
@@ -32,6 +33,12 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Check permission group restrictions
|
||||
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||
if (permissionConfig?.hideTemplates) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
// Determine effective super user (DB flag AND UI mode enabled)
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -92,6 +93,8 @@ interface UseMentionDataProps {
|
||||
export function useMentionData(props: UseMentionDataProps) {
|
||||
const { workflowId, workspaceId } = props
|
||||
|
||||
const { config, isBlockAllowed } = usePermissionConfig()
|
||||
|
||||
const [pastChats, setPastChats] = useState<PastChat[]>([])
|
||||
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
|
||||
|
||||
@@ -101,6 +104,11 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
|
||||
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
|
||||
|
||||
// Reset blocks list when permission config changes
|
||||
useEffect(() => {
|
||||
setBlocksList([])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
|
||||
|
||||
@@ -252,7 +260,13 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
const { getAllBlocks } = await import('@/blocks')
|
||||
const all = getAllBlocks()
|
||||
const regularBlocks = all
|
||||
.filter((b: any) => b.type !== 'starter' && !b.hideFromToolbar && b.category === 'blocks')
|
||||
.filter(
|
||||
(b: any) =>
|
||||
b.type !== 'starter' &&
|
||||
!b.hideFromToolbar &&
|
||||
b.category === 'blocks' &&
|
||||
isBlockAllowed(b.type)
|
||||
)
|
||||
.map((b: any) => ({
|
||||
id: b.type,
|
||||
name: b.name || b.type,
|
||||
@@ -262,7 +276,13 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
|
||||
const toolBlocks = all
|
||||
.filter((b: any) => b.type !== 'starter' && !b.hideFromToolbar && b.category === 'tools')
|
||||
.filter(
|
||||
(b: any) =>
|
||||
b.type !== 'starter' &&
|
||||
!b.hideFromToolbar &&
|
||||
b.category === 'tools' &&
|
||||
isBlockAllowed(b.type)
|
||||
)
|
||||
.map((b: any) => ({
|
||||
id: b.type,
|
||||
name: b.name || b.type,
|
||||
@@ -276,7 +296,7 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
} finally {
|
||||
setIsLoadingBlocks(false)
|
||||
}
|
||||
}, [isLoadingBlocks, blocksList.length])
|
||||
}, [isLoadingBlocks, blocksList.length, isBlockAllowed])
|
||||
|
||||
/**
|
||||
* Ensures templates are loaded
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
@@ -132,10 +134,27 @@ export function ComboBox({
|
||||
// Determine the active value based on mode (preview vs. controlled vs. store)
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
// Permission-based filtering for model dropdowns
|
||||
const { isProviderAllowed, isLoading: isPermissionLoading } = usePermissionConfig()
|
||||
|
||||
// Evaluate static options if provided as a function
|
||||
const staticOptions = useMemo(() => {
|
||||
return typeof options === 'function' ? options() : options
|
||||
}, [options])
|
||||
const opts = typeof options === 'function' ? options() : options
|
||||
|
||||
if (subBlockId === 'model') {
|
||||
return opts.filter((opt) => {
|
||||
const modelId = typeof opt === 'string' ? opt : opt.id
|
||||
try {
|
||||
const providerId = getProviderFromModel(modelId)
|
||||
return isProviderAllowed(providerId)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return opts
|
||||
}, [options, subBlockId, isProviderAllowed])
|
||||
|
||||
// Normalize fetched options to match ComboBoxOption format
|
||||
const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => {
|
||||
@@ -147,6 +166,18 @@ export function ComboBox({
|
||||
let opts: ComboBoxOption[] =
|
||||
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
|
||||
|
||||
if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
|
||||
opts = opts.filter((opt) => {
|
||||
const modelId = typeof opt === 'string' ? opt : opt.id
|
||||
try {
|
||||
const providerId = getProviderFromModel(modelId)
|
||||
return isProviderAllowed(providerId)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Merge hydrated option if not already present
|
||||
if (hydratedOption) {
|
||||
const alreadyPresent = opts.some((o) =>
|
||||
@@ -158,7 +189,14 @@ export function ComboBox({
|
||||
}
|
||||
|
||||
return opts
|
||||
}, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption])
|
||||
}, [
|
||||
fetchOptions,
|
||||
normalizedFetchedOptions,
|
||||
staticOptions,
|
||||
hydratedOption,
|
||||
subBlockId,
|
||||
isProviderAllowed,
|
||||
])
|
||||
|
||||
// Convert options to Combobox format
|
||||
const comboboxOptions = useMemo((): ComboboxOption[] => {
|
||||
@@ -231,16 +269,34 @@ export function ComboBox({
|
||||
setStoreInitialized(true)
|
||||
}, [])
|
||||
|
||||
// Set default value once store is initialized and value is undefined
|
||||
// Check if current value is valid (exists in allowed options)
|
||||
const isValueValid = useMemo(() => {
|
||||
if (value === null || value === undefined) return false
|
||||
return evaluatedOptions.some((opt) => getOptionValue(opt) === value)
|
||||
}, [value, evaluatedOptions, getOptionValue])
|
||||
|
||||
// Set default value once store is initialized and permissions are loaded
|
||||
// Also reset if current value becomes invalid (e.g., provider was blocked)
|
||||
useEffect(() => {
|
||||
if (
|
||||
storeInitialized &&
|
||||
(value === null || value === undefined) &&
|
||||
defaultOptionValue !== undefined
|
||||
) {
|
||||
if (isPermissionLoading) return
|
||||
if (!storeInitialized) return
|
||||
if (defaultOptionValue === undefined) return
|
||||
|
||||
const needsDefault = value === null || value === undefined
|
||||
const needsReset = subBlockId === 'model' && value && !isValueValid
|
||||
|
||||
if (needsDefault || needsReset) {
|
||||
setStoreValue(defaultOptionValue)
|
||||
}
|
||||
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
|
||||
}, [
|
||||
storeInitialized,
|
||||
value,
|
||||
defaultOptionValue,
|
||||
setStoreValue,
|
||||
isPermissionLoading,
|
||||
subBlockId,
|
||||
isValueValid,
|
||||
])
|
||||
|
||||
// Clear fetched options and hydrated option when dependencies change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
type ComboboxOptionGroup,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hooks/queries/mcp'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -1009,18 +1011,23 @@ export function ToolInput({
|
||||
const provider = model ? getProviderFromModel(model) : ''
|
||||
const supportsToolControl = provider ? supportsToolUsageControl(provider) : false
|
||||
|
||||
const toolBlocks = getAllBlocks().filter(
|
||||
(block) =>
|
||||
(block.category === 'tools' ||
|
||||
block.type === 'api' ||
|
||||
block.type === 'webhook_request' ||
|
||||
block.type === 'workflow' ||
|
||||
block.type === 'knowledge' ||
|
||||
block.type === 'function') &&
|
||||
block.type !== 'evaluator' &&
|
||||
block.type !== 'mcp' &&
|
||||
block.type !== 'file'
|
||||
)
|
||||
const { filterBlocks, config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
const toolBlocks = useMemo(() => {
|
||||
const allToolBlocks = getAllBlocks().filter(
|
||||
(block) =>
|
||||
(block.category === 'tools' ||
|
||||
block.type === 'api' ||
|
||||
block.type === 'webhook_request' ||
|
||||
block.type === 'workflow' ||
|
||||
block.type === 'knowledge' ||
|
||||
block.type === 'function') &&
|
||||
block.type !== 'evaluator' &&
|
||||
block.type !== 'mcp' &&
|
||||
block.type !== 'file'
|
||||
)
|
||||
return filterBlocks(allToolBlocks)
|
||||
}, [filterBlocks])
|
||||
|
||||
const customFilter = useCallback((value: string, search: string) => {
|
||||
if (!search.trim()) return 1
|
||||
@@ -1608,33 +1615,37 @@ export function ToolInput({
|
||||
const groups: ComboboxOptionGroup[] = []
|
||||
|
||||
// Actions group (no section header)
|
||||
groups.push({
|
||||
items: [
|
||||
{
|
||||
label: 'Create Tool',
|
||||
value: 'action-create-tool',
|
||||
icon: WrenchIcon,
|
||||
onSelect: () => {
|
||||
setCustomToolModalOpen(true)
|
||||
setOpen(false)
|
||||
},
|
||||
disabled: isPreview,
|
||||
const actionItems: ComboboxOption[] = []
|
||||
if (!permissionConfig.disableCustomTools) {
|
||||
actionItems.push({
|
||||
label: 'Create Tool',
|
||||
value: 'action-create-tool',
|
||||
icon: WrenchIcon,
|
||||
onSelect: () => {
|
||||
setCustomToolModalOpen(true)
|
||||
setOpen(false)
|
||||
},
|
||||
{
|
||||
label: 'Add MCP Server',
|
||||
value: 'action-add-mcp',
|
||||
icon: McpIcon,
|
||||
onSelect: () => {
|
||||
setOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } }))
|
||||
},
|
||||
disabled: isPreview,
|
||||
disabled: isPreview,
|
||||
})
|
||||
}
|
||||
if (!permissionConfig.disableMcpTools) {
|
||||
actionItems.push({
|
||||
label: 'Add MCP Server',
|
||||
value: 'action-add-mcp',
|
||||
icon: McpIcon,
|
||||
onSelect: () => {
|
||||
setOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'mcp' } }))
|
||||
},
|
||||
],
|
||||
})
|
||||
disabled: isPreview,
|
||||
})
|
||||
}
|
||||
if (actionItems.length > 0) {
|
||||
groups.push({ items: actionItems })
|
||||
}
|
||||
|
||||
// Custom Tools section
|
||||
if (customTools.length > 0) {
|
||||
if (!permissionConfig.disableCustomTools && customTools.length > 0) {
|
||||
groups.push({
|
||||
section: 'Custom Tools',
|
||||
items: customTools.map((customTool) => ({
|
||||
@@ -1659,7 +1670,7 @@ export function ToolInput({
|
||||
}
|
||||
|
||||
// MCP Tools section
|
||||
if (availableMcpTools.length > 0) {
|
||||
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) {
|
||||
groups.push({
|
||||
section: 'MCP Tools',
|
||||
items: availableMcpTools.map((mcpTool) => {
|
||||
@@ -1736,6 +1747,8 @@ export function ToolInput({
|
||||
setStoreValue,
|
||||
handleMcpToolSelect,
|
||||
handleSelectTool,
|
||||
permissionConfig.disableCustomTools,
|
||||
permissionConfig.disableMcpTools,
|
||||
])
|
||||
|
||||
const toolRequiresOAuth = (toolId: string): boolean => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useToolbarStore } from '@/stores/panel/toolbar/store'
|
||||
|
||||
interface BlockItem {
|
||||
@@ -206,9 +207,16 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
triggersHeaderRef,
|
||||
})
|
||||
|
||||
// Permission config for filtering
|
||||
const { filterBlocks } = usePermissionConfig()
|
||||
|
||||
// Get static data (computed once and cached)
|
||||
const triggers = getTriggers()
|
||||
const blocks = getBlocks()
|
||||
const allTriggers = getTriggers()
|
||||
const allBlocks = getBlocks()
|
||||
|
||||
// Apply permission-based filtering to blocks and triggers
|
||||
const blocks = useMemo(() => filterBlocks(allBlocks), [filterBlocks, allBlocks])
|
||||
const triggers = useMemo(() => filterBlocks(allTriggers), [filterBlocks, allTriggers])
|
||||
|
||||
// Determine if triggers are at minimum height (blocks are fully expanded)
|
||||
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD
|
||||
|
||||
@@ -40,6 +40,7 @@ import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
|
||||
import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { usePanelStore } from '@/stores/panel/store'
|
||||
import type { PanelTab } from '@/stores/panel/types'
|
||||
@@ -92,6 +93,7 @@ export function Panel() {
|
||||
|
||||
// Hooks
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
|
||||
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry()
|
||||
const isRegistryLoading =
|
||||
@@ -438,18 +440,20 @@ export function Panel() {
|
||||
{/* Tabs */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-[8px] pt-[14px]'>
|
||||
<div className='flex gap-[4px]'>
|
||||
<Button
|
||||
className={`h-[28px] truncate rounded-[6px] border px-[8px] py-[5px] text-[12.5px] ${
|
||||
_hasHydrated && activeTab === 'copilot'
|
||||
? 'border-[var(--border-1)]'
|
||||
: 'border-transparent hover:border-[var(--border-1)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
data-tab-button='copilot'
|
||||
>
|
||||
Copilot
|
||||
</Button>
|
||||
{!permissionConfig.hideCopilot && (
|
||||
<Button
|
||||
className={`h-[28px] truncate rounded-[6px] border px-[8px] py-[5px] text-[12.5px] ${
|
||||
_hasHydrated && activeTab === 'copilot'
|
||||
? 'border-[var(--border-1)]'
|
||||
: 'border-transparent hover:border-[var(--border-1)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
data-tab-button='copilot'
|
||||
>
|
||||
Copilot
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={`h-[28px] rounded-[6px] border px-[8px] py-[5px] text-[12.5px] ${
|
||||
_hasHydrated && activeTab === 'toolbar'
|
||||
@@ -482,18 +486,20 @@ export function Panel() {
|
||||
|
||||
{/* Tab Content - Keep all tabs mounted but hidden to preserve state */}
|
||||
<div className='flex-1 overflow-hidden pt-[12px]'>
|
||||
<div
|
||||
className={
|
||||
_hasHydrated && activeTab === 'copilot'
|
||||
? 'h-full'
|
||||
: _hasHydrated
|
||||
? 'hidden'
|
||||
: 'h-full'
|
||||
}
|
||||
data-tab-content='copilot'
|
||||
>
|
||||
<Copilot ref={copilotRef} panelWidth={panelWidth} />
|
||||
</div>
|
||||
{!permissionConfig.hideCopilot && (
|
||||
<div
|
||||
className={
|
||||
_hasHydrated && activeTab === 'copilot'
|
||||
? 'h-full'
|
||||
: _hasHydrated
|
||||
? 'hidden'
|
||||
: 'h-full'
|
||||
}
|
||||
data-tab-content='copilot'
|
||||
>
|
||||
<Copilot ref={copilotRef} panelWidth={panelWidth} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
_hasHydrated && activeTab === 'editor'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/tri
|
||||
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
@@ -99,12 +100,14 @@ export function SearchModal({
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const brand = useBrandConfig()
|
||||
const { filterBlocks } = usePermissionConfig()
|
||||
|
||||
const blocks = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
|
||||
const allBlocks = getAllBlocks()
|
||||
const regularBlocks = allBlocks
|
||||
const filteredAllBlocks = filterBlocks(allBlocks)
|
||||
const regularBlocks = filteredAllBlocks
|
||||
.filter(
|
||||
(block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks'
|
||||
)
|
||||
@@ -138,16 +141,17 @@ export function SearchModal({
|
||||
},
|
||||
]
|
||||
|
||||
return [...regularBlocks, ...specialBlocks]
|
||||
}, [isOnWorkflowPage])
|
||||
return [...regularBlocks, ...filterBlocks(specialBlocks)]
|
||||
}, [isOnWorkflowPage, filterBlocks])
|
||||
|
||||
const triggers = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
|
||||
const allTriggers = getTriggersForSidebar()
|
||||
const filteredTriggers = filterBlocks(allTriggers)
|
||||
const priorityOrder = ['Start', 'Schedule', 'Webhook']
|
||||
|
||||
const sortedTriggers = allTriggers.sort((a, b) => {
|
||||
const sortedTriggers = filteredTriggers.sort((a, b) => {
|
||||
const aIndex = priorityOrder.indexOf(a.name)
|
||||
const bIndex = priorityOrder.indexOf(b.name)
|
||||
const aHasPriority = aIndex !== -1
|
||||
@@ -170,13 +174,14 @@ export function SearchModal({
|
||||
config: block,
|
||||
})
|
||||
)
|
||||
}, [isOnWorkflowPage])
|
||||
}, [isOnWorkflowPage, filterBlocks])
|
||||
|
||||
const tools = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
|
||||
const allBlocks = getAllBlocks()
|
||||
return allBlocks
|
||||
const filteredAllBlocks = filterBlocks(allBlocks)
|
||||
return filteredAllBlocks
|
||||
.filter((block) => block.category === 'tools')
|
||||
.map(
|
||||
(block): ToolItem => ({
|
||||
@@ -188,7 +193,7 @@ export function SearchModal({
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
}, [isOnWorkflowPage])
|
||||
}, [isOnWorkflowPage, filterBlocks])
|
||||
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -631,7 +631,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
variant='ghost'
|
||||
onClick={handleDeleteKey}
|
||||
disabled={deleteApiKeyMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -193,14 +193,14 @@ export function BYOK() {
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<span className='font-medium text-[14px]'>{provider.name}</span>
|
||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||
{existingKey ? existingKey.maskedKey : provider.description}
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{existingKey ? (
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Button variant='ghost' onClick={() => openEditModal(provider.id)}>
|
||||
<Button variant='default' onClick={() => openEditModal(provider.id)}>
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -334,7 +334,7 @@ export function Copilot() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
variant='ghost'
|
||||
onClick={handleDeleteKey}
|
||||
disabled={deleteKeyMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -1018,11 +1018,11 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button variant='ghost' onClick={() => setViewingSet(set)}>
|
||||
<Button variant='default' onClick={() => setViewingSet(set)}>
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
variant='ghost'
|
||||
onClick={() => handleDeleteClick(set)}
|
||||
disabled={deletingSetIds.has(set.id)}
|
||||
>
|
||||
|
||||
@@ -159,11 +159,11 @@ export function CustomTools() {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Button variant='ghost' onClick={() => setEditingTool(tool.id)}>
|
||||
<Button variant='default' onClick={() => setEditingTool(tool.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
variant='ghost'
|
||||
onClick={() => handleDeleteClick(tool.id)}
|
||||
disabled={deletingTools.has(tool.id)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AccessControl } from './access-control/access-control'
|
||||
export { ApiKeys } from './api-keys/api-keys'
|
||||
export { BYOK } from './byok/byok'
|
||||
export { Copilot } from './copilot/copilot'
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useDisconnectOAuthService,
|
||||
useOAuthConnections,
|
||||
} from '@/hooks/queries/oauth-connections'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
|
||||
const logger = createLogger('Integrations')
|
||||
|
||||
@@ -100,6 +101,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
|
||||
const { data: services = [], isPending } = useOAuthConnections()
|
||||
const connectService = useConnectOAuthService()
|
||||
const disconnectService = useDisconnectOAuthService()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [pendingService, setPendingService] = useState<string | null>(null)
|
||||
@@ -221,9 +223,17 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
|
||||
}
|
||||
}
|
||||
|
||||
// Group services by provider
|
||||
// Group services by provider, filtering by permission config
|
||||
const groupedServices = services.reduce(
|
||||
(acc, service) => {
|
||||
// Filter based on allowedIntegrations
|
||||
if (
|
||||
permissionConfig.allowedIntegrations !== null &&
|
||||
!permissionConfig.allowedIntegrations.includes(service.id)
|
||||
) {
|
||||
return acc
|
||||
}
|
||||
|
||||
// Find the provider for this service
|
||||
const providerKey =
|
||||
Object.keys(OAUTH_PROVIDERS).find((key) =>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Input } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import type { EnvVarDropdownConfig } from '../types'
|
||||
|
||||
interface FormattedInputProps {
|
||||
ref?: React.RefObject<HTMLInputElement | null>
|
||||
placeholder: string
|
||||
value: string
|
||||
scrollLeft: number
|
||||
showEnvVars: boolean
|
||||
envVarProps: EnvVarDropdownConfig
|
||||
className?: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onScroll: (scrollLeft: number) => void
|
||||
}
|
||||
|
||||
export function FormattedInput({
|
||||
ref,
|
||||
placeholder,
|
||||
value,
|
||||
scrollLeft,
|
||||
showEnvVars,
|
||||
envVarProps,
|
||||
className,
|
||||
onChange,
|
||||
onScroll,
|
||||
}: FormattedInputProps) {
|
||||
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
|
||||
onScroll(e.currentTarget.scrollLeft)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onScroll={handleScroll}
|
||||
onInput={handleScroll}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
|
||||
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
|
||||
{formatDisplayText(value)}
|
||||
</div>
|
||||
</div>
|
||||
{showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={envVarProps.onSelect}
|
||||
searchTerm={envVarProps.searchTerm}
|
||||
inputValue={value}
|
||||
cursorPosition={envVarProps.cursorPosition}
|
||||
workspaceId={envVarProps.workspaceId}
|
||||
onClose={envVarProps.onClose}
|
||||
className='w-full'
|
||||
maxHeight='200px'
|
||||
style={{ position: 'absolute', top: '100%', left: 0, zIndex: 99999 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { FormattedInput } from '../formatted-input/formatted-input'
|
||||
import type { EnvVarDropdownConfig, HeaderEntry, InputFieldType } from '../types'
|
||||
|
||||
interface HeaderRowProps {
|
||||
header: HeaderEntry
|
||||
index: number
|
||||
headerScrollLeft: Record<string, number>
|
||||
showEnvVars: boolean
|
||||
activeInputField: InputFieldType | null
|
||||
activeHeaderIndex: number | null
|
||||
envSearchTerm: string
|
||||
cursorPosition: number
|
||||
workspaceId: string
|
||||
onInputChange: (field: InputFieldType, value: string, index?: number) => void
|
||||
onHeaderScroll: (key: string, scrollLeft: number) => void
|
||||
onEnvVarSelect: (value: string) => void
|
||||
onEnvVarClose: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function HeaderRow({
|
||||
header,
|
||||
index,
|
||||
headerScrollLeft,
|
||||
showEnvVars,
|
||||
activeInputField,
|
||||
activeHeaderIndex,
|
||||
envSearchTerm,
|
||||
cursorPosition,
|
||||
workspaceId,
|
||||
onInputChange,
|
||||
onHeaderScroll,
|
||||
onEnvVarSelect,
|
||||
onEnvVarClose,
|
||||
onRemove,
|
||||
}: HeaderRowProps) {
|
||||
const isKeyActive =
|
||||
showEnvVars && activeInputField === 'header-key' && activeHeaderIndex === index
|
||||
const isValueActive =
|
||||
showEnvVars && activeInputField === 'header-value' && activeHeaderIndex === index
|
||||
|
||||
const envVarProps: EnvVarDropdownConfig = {
|
||||
searchTerm: envSearchTerm,
|
||||
cursorPosition,
|
||||
workspaceId,
|
||||
onSelect: onEnvVarSelect,
|
||||
onClose: onEnvVarClose,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex items-center gap-[8px]'>
|
||||
<FormattedInput
|
||||
placeholder='Name'
|
||||
value={header.key || ''}
|
||||
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
|
||||
showEnvVars={isKeyActive}
|
||||
envVarProps={envVarProps}
|
||||
className='flex-1'
|
||||
onChange={(e) => onInputChange('header-key', e.target.value, index)}
|
||||
onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)}
|
||||
/>
|
||||
|
||||
<FormattedInput
|
||||
placeholder='Value'
|
||||
value={header.value || ''}
|
||||
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
|
||||
showEnvVars={isValueActive}
|
||||
envVarProps={envVarProps}
|
||||
className='flex-1'
|
||||
onChange={(e) => onInputChange('header-value', e.target.value, index)}
|
||||
onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)}
|
||||
/>
|
||||
|
||||
<Button type='button' variant='ghost' onClick={onRemove} className='h-6 w-6 shrink-0 p-0'>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,2 @@
|
||||
export { FormField } from './form-field/form-field'
|
||||
export { FormattedInput } from './formatted-input/formatted-input'
|
||||
export { HeaderRow } from './header-row/header-row'
|
||||
export { McpServerSkeleton } from './mcp-server-skeleton/mcp-server-skeleton'
|
||||
export { formatTransportLabel, ServerListItem } from './server-list-item/server-list-item'
|
||||
export type {
|
||||
EnvVarDropdownConfig,
|
||||
HeaderEntry,
|
||||
InputFieldType,
|
||||
McpServerFormData,
|
||||
McpServerTestResult,
|
||||
} from './types'
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
export function formatTransportLabel(transport: string): string {
|
||||
return transport
|
||||
.split('-')
|
||||
.map((word) =>
|
||||
['http', 'sse', 'stdio'].includes(word.toLowerCase())
|
||||
? word.toUpperCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1)
|
||||
)
|
||||
.join('-')
|
||||
}
|
||||
|
||||
function formatToolsLabel(tools: any[], connectionStatus?: string): string {
|
||||
if (connectionStatus === 'error') {
|
||||
return 'Unable to connect'
|
||||
}
|
||||
const count = tools.length
|
||||
const plural = count !== 1 ? 's' : ''
|
||||
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
|
||||
return `${count} tool${plural}${names}`
|
||||
}
|
||||
|
||||
interface ServerListItemProps {
|
||||
server: any
|
||||
tools: any[]
|
||||
isDeleting: boolean
|
||||
isLoadingTools?: boolean
|
||||
isRefreshing?: boolean
|
||||
onRemove: () => void
|
||||
onViewDetails: () => void
|
||||
}
|
||||
|
||||
export function ServerListItem({
|
||||
server,
|
||||
tools,
|
||||
isDeleting,
|
||||
isLoadingTools = false,
|
||||
isRefreshing = false,
|
||||
onRemove,
|
||||
onViewDetails,
|
||||
}: ServerListItemProps) {
|
||||
const transportLabel = formatTransportLabel(server.transport || 'http')
|
||||
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
|
||||
const isError = server.connectionStatus === 'error'
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='max-w-[200px] truncate font-medium text-[14px]'>
|
||||
{server.name || 'Unnamed Server'}
|
||||
</span>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
|
||||
</div>
|
||||
<p
|
||||
className={`truncate text-[13px] ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
|
||||
>
|
||||
{isRefreshing
|
||||
? 'Refreshing...'
|
||||
: isLoadingTools && tools.length === 0
|
||||
? 'Loading...'
|
||||
: toolsLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px]'>
|
||||
<Button variant='ghost' onClick={onViewDetails}>
|
||||
Details
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={onRemove} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
|
||||
/**
|
||||
* Represents a single header entry in the form.
|
||||
* Using an array of objects allows duplicate keys during editing.
|
||||
*/
|
||||
export interface HeaderEntry {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface McpServerFormData {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
timeout?: number
|
||||
headers?: HeaderEntry[]
|
||||
}
|
||||
|
||||
export interface McpServerTestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type InputFieldType = 'url' | 'header-key' | 'header-value'
|
||||
|
||||
export interface EnvVarDropdownConfig {
|
||||
searchTerm: string
|
||||
cursorPosition: number
|
||||
workspaceId: string
|
||||
onSelect: (value: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { Plus, Search, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
@@ -16,13 +16,19 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
getIssueBadgeLabel,
|
||||
getIssueBadgeVariant,
|
||||
getMcpToolIssue,
|
||||
type McpToolIssue,
|
||||
} from '@/lib/mcp/tool-validation'
|
||||
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import {
|
||||
checkEnvVarTrigger,
|
||||
EnvVarDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
useCreateMcpServer,
|
||||
useDeleteMcpServer,
|
||||
@@ -35,15 +41,41 @@ import {
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
|
||||
import {
|
||||
FormattedInput,
|
||||
FormField,
|
||||
formatTransportLabel,
|
||||
HeaderRow,
|
||||
McpServerSkeleton,
|
||||
ServerListItem,
|
||||
} from './components'
|
||||
import { FormField, McpServerSkeleton } from './components'
|
||||
|
||||
/**
|
||||
* Represents a single header entry in the form.
|
||||
* Using an array of objects allows duplicate keys during editing.
|
||||
*/
|
||||
interface HeaderEntry {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface McpServerFormData {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
timeout?: number
|
||||
headers?: HeaderEntry[]
|
||||
}
|
||||
|
||||
interface McpServerTestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
type InputFieldType = 'url' | 'header-key' | 'header-value'
|
||||
|
||||
interface EnvVarDropdownConfig {
|
||||
searchTerm: string
|
||||
cursorPosition: number
|
||||
workspaceId: string
|
||||
onSelect: (value: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface McpTool {
|
||||
name: string
|
||||
@@ -71,6 +103,33 @@ const DEFAULT_FORM_DATA: McpServerFormData = {
|
||||
headers: [{ key: '', value: '' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a transport type string for display.
|
||||
*/
|
||||
function formatTransportLabel(transport: string): string {
|
||||
return transport
|
||||
.split('-')
|
||||
.map((word) =>
|
||||
['http', 'sse', 'stdio'].includes(word.toLowerCase())
|
||||
? word.toUpperCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1)
|
||||
)
|
||||
.join('-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a tools list for display in the server list.
|
||||
*/
|
||||
function formatToolsLabel(tools: McpTool[], connectionStatus?: string): string {
|
||||
if (connectionStatus === 'error') {
|
||||
return 'Unable to connect'
|
||||
}
|
||||
const count = tools.length
|
||||
const plural = count !== 1 ? 's' : ''
|
||||
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
|
||||
return `${count} tool${plural}${names}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the label for the test connection button based on current state.
|
||||
*/
|
||||
@@ -84,6 +143,198 @@ function getTestButtonLabel(
|
||||
return 'Test Connection'
|
||||
}
|
||||
|
||||
interface FormattedInputProps {
|
||||
ref?: React.RefObject<HTMLInputElement | null>
|
||||
placeholder: string
|
||||
value: string
|
||||
scrollLeft: number
|
||||
showEnvVars: boolean
|
||||
envVarProps: EnvVarDropdownConfig
|
||||
className?: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onScroll: (scrollLeft: number) => void
|
||||
}
|
||||
|
||||
function FormattedInput({
|
||||
ref,
|
||||
placeholder,
|
||||
value,
|
||||
scrollLeft,
|
||||
showEnvVars,
|
||||
envVarProps,
|
||||
className,
|
||||
onChange,
|
||||
onScroll,
|
||||
}: FormattedInputProps) {
|
||||
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
|
||||
onScroll(e.currentTarget.scrollLeft)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<EmcnInput
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onScroll={handleScroll}
|
||||
onInput={handleScroll}
|
||||
className='h-9 text-transparent caret-foreground placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
|
||||
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
|
||||
{formatDisplayText(value)}
|
||||
</div>
|
||||
</div>
|
||||
{showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={showEnvVars}
|
||||
onSelect={envVarProps.onSelect}
|
||||
searchTerm={envVarProps.searchTerm}
|
||||
inputValue={value}
|
||||
cursorPosition={envVarProps.cursorPosition}
|
||||
workspaceId={envVarProps.workspaceId}
|
||||
onClose={envVarProps.onClose}
|
||||
className='w-full'
|
||||
maxHeight='200px'
|
||||
style={{ position: 'absolute', top: '100%', left: 0, zIndex: 99999 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HeaderRowProps {
|
||||
header: HeaderEntry
|
||||
index: number
|
||||
headerScrollLeft: Record<string, number>
|
||||
showEnvVars: boolean
|
||||
activeInputField: InputFieldType | null
|
||||
activeHeaderIndex: number | null
|
||||
envSearchTerm: string
|
||||
cursorPosition: number
|
||||
workspaceId: string
|
||||
onInputChange: (field: InputFieldType, value: string, index?: number) => void
|
||||
onHeaderScroll: (key: string, scrollLeft: number) => void
|
||||
onEnvVarSelect: (value: string) => void
|
||||
onEnvVarClose: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
function HeaderRow({
|
||||
header,
|
||||
index,
|
||||
headerScrollLeft,
|
||||
showEnvVars,
|
||||
activeInputField,
|
||||
activeHeaderIndex,
|
||||
envSearchTerm,
|
||||
cursorPosition,
|
||||
workspaceId,
|
||||
onInputChange,
|
||||
onHeaderScroll,
|
||||
onEnvVarSelect,
|
||||
onEnvVarClose,
|
||||
onRemove,
|
||||
}: HeaderRowProps) {
|
||||
const isKeyActive =
|
||||
showEnvVars && activeInputField === 'header-key' && activeHeaderIndex === index
|
||||
const isValueActive =
|
||||
showEnvVars && activeInputField === 'header-value' && activeHeaderIndex === index
|
||||
|
||||
const envVarProps: EnvVarDropdownConfig = {
|
||||
searchTerm: envSearchTerm,
|
||||
cursorPosition,
|
||||
workspaceId,
|
||||
onSelect: onEnvVarSelect,
|
||||
onClose: onEnvVarClose,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex items-center gap-[8px]'>
|
||||
<FormattedInput
|
||||
placeholder='Name'
|
||||
value={header.key || ''}
|
||||
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
|
||||
showEnvVars={isKeyActive}
|
||||
envVarProps={envVarProps}
|
||||
className='flex-1'
|
||||
onChange={(e) => onInputChange('header-key', e.target.value, index)}
|
||||
onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)}
|
||||
/>
|
||||
|
||||
<FormattedInput
|
||||
placeholder='Value'
|
||||
value={header.value || ''}
|
||||
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
|
||||
showEnvVars={isValueActive}
|
||||
envVarProps={envVarProps}
|
||||
className='flex-1'
|
||||
onChange={(e) => onInputChange('header-value', e.target.value, index)}
|
||||
onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)}
|
||||
/>
|
||||
|
||||
<Button type='button' variant='ghost' onClick={onRemove} className='h-6 w-6 shrink-0 p-0'>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerListItemProps {
|
||||
server: McpServer
|
||||
tools: McpTool[]
|
||||
isDeleting: boolean
|
||||
isLoadingTools?: boolean
|
||||
isRefreshing?: boolean
|
||||
onRemove: () => void
|
||||
onViewDetails: () => void
|
||||
}
|
||||
|
||||
function ServerListItem({
|
||||
server,
|
||||
tools,
|
||||
isDeleting,
|
||||
isLoadingTools = false,
|
||||
isRefreshing = false,
|
||||
onRemove,
|
||||
onViewDetails,
|
||||
}: ServerListItemProps) {
|
||||
const transportLabel = formatTransportLabel(server.transport || 'http')
|
||||
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
|
||||
const isError = server.connectionStatus === 'error'
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='max-w-[200px] truncate font-medium text-[14px]'>
|
||||
{server.name || 'Unnamed Server'}
|
||||
</span>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
|
||||
</div>
|
||||
<p
|
||||
className={`truncate text-[13px] ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
|
||||
>
|
||||
{isRefreshing
|
||||
? 'Refreshing...'
|
||||
: isLoadingTools && tools.length === 0
|
||||
? 'Loading...'
|
||||
: toolsLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px]'>
|
||||
<Button variant='default' onClick={onViewDetails}>
|
||||
Details
|
||||
</Button>
|
||||
<Button variant='ghost' onClick={onRemove} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MCPProps {
|
||||
initialServerId?: string | null
|
||||
}
|
||||
|
||||
@@ -208,11 +208,11 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px]'>
|
||||
<Button variant='ghost' onClick={() => setToolToView(tool)}>
|
||||
<Button variant='default' onClick={() => setToolToView(tool)}>
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
variant='ghost'
|
||||
onClick={() => setToolToDelete(tool)}
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
@@ -605,11 +605,11 @@ export function WorkflowMcpServers() {
|
||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>{toolsLabel}</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px]'>
|
||||
<Button variant='ghost' onClick={() => setSelectedServerId(server.id)}>
|
||||
<Button variant='default' onClick={() => setSelectedServerId(server.id)}>
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
variant='ghost'
|
||||
onClick={() => setServerToDelete(server)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Files, KeySquare, LogIn, Mail, Server, Settings, User, Users, Wrench } from 'lucide-react'
|
||||
import {
|
||||
Files,
|
||||
KeySquare,
|
||||
LogIn,
|
||||
Mail,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
User,
|
||||
Users,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
Connections,
|
||||
@@ -29,6 +40,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import {
|
||||
AccessControl,
|
||||
ApiKeys,
|
||||
BYOK,
|
||||
Copilot,
|
||||
@@ -49,11 +61,13 @@ import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
|
||||
const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
@@ -66,6 +80,7 @@ type SettingsSection =
|
||||
| 'template-profile'
|
||||
| 'integrations'
|
||||
| 'credential-sets'
|
||||
| 'access-control'
|
||||
| 'apikeys'
|
||||
| 'byok'
|
||||
| 'files'
|
||||
@@ -77,7 +92,7 @@ type SettingsSection =
|
||||
| 'custom-tools'
|
||||
| 'workflow-mcp-servers'
|
||||
|
||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
|
||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise'
|
||||
|
||||
type NavigationItem = {
|
||||
id: SettingsSection
|
||||
@@ -96,11 +111,21 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
|
||||
{ key: 'tools', title: 'Tools' },
|
||||
{ key: 'subscription', title: 'Subscription' },
|
||||
{ key: 'system', title: 'System' },
|
||||
{ key: 'enterprise', title: 'Enterprise' },
|
||||
]
|
||||
|
||||
const allNavigationItems: NavigationItem[] = [
|
||||
{ id: 'general', label: 'General', icon: Settings, section: 'account' },
|
||||
{ id: 'template-profile', label: 'Template Profile', icon: User, section: 'account' },
|
||||
{
|
||||
id: 'access-control',
|
||||
label: 'Access Control',
|
||||
icon: ShieldCheck,
|
||||
section: 'enterprise',
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
selfHostedOverride: isAccessControlEnabled,
|
||||
},
|
||||
{
|
||||
id: 'subscription',
|
||||
label: 'Subscription',
|
||||
@@ -120,14 +145,6 @@ const allNavigationItems: NavigationItem[] = [
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
||||
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
|
||||
{
|
||||
id: 'credential-sets',
|
||||
label: 'Email Polling',
|
||||
icon: Mail,
|
||||
section: 'system',
|
||||
requiresHosted: true,
|
||||
selfHostedOverride: isCredentialSetsEnabled,
|
||||
},
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
|
||||
@@ -135,7 +152,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
id: 'byok',
|
||||
label: 'BYOK',
|
||||
icon: KeySquare,
|
||||
section: 'system',
|
||||
section: 'enterprise',
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
},
|
||||
@@ -147,11 +164,19 @@ const allNavigationItems: NavigationItem[] = [
|
||||
requiresHosted: true,
|
||||
},
|
||||
{ id: 'files', label: 'Files', icon: Files, section: 'system' },
|
||||
{
|
||||
id: 'credential-sets',
|
||||
label: 'Email Polling',
|
||||
icon: Mail,
|
||||
section: 'system',
|
||||
requiresHosted: true,
|
||||
selfHostedOverride: isCredentialSetsEnabled,
|
||||
},
|
||||
{
|
||||
id: 'sso',
|
||||
label: 'Single Sign-On',
|
||||
icon: LogIn,
|
||||
section: 'system',
|
||||
section: 'enterprise',
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
selfHostedOverride: isSSOEnabled,
|
||||
@@ -169,6 +194,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const environmentBeforeLeaveHandler = useRef<((onProceed: () => void) => void) | null>(null)
|
||||
const integrationsCloseHandler = useRef<((open: boolean) => void) | null>(null)
|
||||
|
||||
@@ -198,7 +224,29 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Permission group-based filtering
|
||||
if (item.id === 'template-profile' && permissionConfig.hideTemplates) {
|
||||
return false
|
||||
}
|
||||
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
|
||||
return false
|
||||
}
|
||||
if (item.id === 'environment' && permissionConfig.hideEnvironmentTab) {
|
||||
return false
|
||||
}
|
||||
if (item.id === 'files' && permissionConfig.hideFilesTab) {
|
||||
return false
|
||||
}
|
||||
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
|
||||
return false
|
||||
}
|
||||
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Self-hosted override allows showing the item when not on hosted
|
||||
if (item.selfHostedOverride && !isHosted) {
|
||||
// SSO has special logic: only show if no providers or user owns a provider
|
||||
if (item.id === 'sso') {
|
||||
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
|
||||
return !hasProviders || isSSOProviderOwner === true
|
||||
@@ -206,14 +254,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
// requiresTeam: must have team/enterprise plan AND be org admin/owner
|
||||
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// requiresEnterprise: must have enterprise plan AND be org admin/owner
|
||||
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// requiresHosted: only show on hosted environments
|
||||
if (item.requiresHosted && !isHosted) {
|
||||
return false
|
||||
}
|
||||
@@ -226,9 +277,10 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
hasEnterprisePlan,
|
||||
isOrgAdminOrOwner,
|
||||
isSSOProviderOwner,
|
||||
isSSOEnabled,
|
||||
ssoProvidersData?.providers?.length,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
permissionConfig,
|
||||
])
|
||||
|
||||
// Memoized callbacks to prevent infinite loops in child components
|
||||
@@ -461,6 +513,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'credential-sets' && <CredentialSets />}
|
||||
{activeSection === 'access-control' && <AccessControl />}
|
||||
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
useExportWorkspace,
|
||||
useImportWorkspace,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
@@ -71,6 +72,7 @@ export function Sidebar() {
|
||||
|
||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
||||
const { canEdit } = useUserPermissionsContext()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
/**
|
||||
* Sidebar state from store with hydration tracking to prevent SSR mismatch.
|
||||
@@ -238,39 +240,42 @@ export function Sidebar() {
|
||||
)
|
||||
|
||||
const footerNavigationItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'logs',
|
||||
label: 'Logs',
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
label: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
label: 'Knowledge Base',
|
||||
icon: Database,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
label: 'Help',
|
||||
icon: HelpCircle,
|
||||
onClick: () => setIsHelpModalOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: () => openSettingsModal(),
|
||||
},
|
||||
],
|
||||
[workspaceId]
|
||||
() =>
|
||||
[
|
||||
{
|
||||
id: 'logs',
|
||||
label: 'Logs',
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
label: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
hidden: permissionConfig.hideTemplates,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
label: 'Knowledge Base',
|
||||
icon: Database,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
hidden: permissionConfig.hideKnowledgeBaseTab,
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
label: 'Help',
|
||||
icon: HelpCircle,
|
||||
onClick: () => setIsHelpModalOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: () => openSettingsModal(),
|
||||
},
|
||||
].filter((item) => !item.hidden),
|
||||
[workspaceId, permissionConfig.hideTemplates, permissionConfig.hideKnowledgeBaseTab]
|
||||
)
|
||||
|
||||
const isLoading = workflowsLoading || sessionLoading
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from '@/executor/types'
|
||||
import { streamingResponseFormatProcessor } from '@/executor/utils'
|
||||
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
|
||||
import { validateBlockType } from '@/executor/utils/permission-check'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
@@ -54,7 +55,8 @@ export class BlockExecutor {
|
||||
})
|
||||
}
|
||||
|
||||
const isSentinel = isSentinelBlockType(block.metadata?.id ?? '')
|
||||
const blockType = block.metadata?.id ?? ''
|
||||
const isSentinel = isSentinelBlockType(blockType)
|
||||
|
||||
let blockLog: BlockLog | undefined
|
||||
if (!isSentinel) {
|
||||
@@ -74,6 +76,10 @@ export class BlockExecutor {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isSentinel && blockType) {
|
||||
await validateBlockType(ctx.userId, blockType, ctx)
|
||||
}
|
||||
|
||||
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
|
||||
|
||||
if (block.metadata?.id === BlockType.AGENT && resolvedInputs.tools) {
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
getCostMultiplier: vi.fn().mockReturnValue(1),
|
||||
isEmailVerificationEnabled: false,
|
||||
isBillingEnabled: false,
|
||||
isOrganizationsEnabled: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/providers/utils', () => ({
|
||||
|
||||
@@ -25,6 +25,12 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import { stringifyJSON } from '@/executor/utils/json'
|
||||
import {
|
||||
validateBlockType,
|
||||
validateCustomToolsAllowed,
|
||||
validateMcpToolsAllowed,
|
||||
validateModelProvider,
|
||||
} from '@/executor/utils/permission-check'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
@@ -50,8 +56,14 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
const filteredTools = await this.filterUnavailableMcpTools(ctx, inputs.tools || [])
|
||||
const filteredInputs = { ...inputs, tools: filteredTools }
|
||||
|
||||
// Validate tool permissions before processing
|
||||
await this.validateToolPermissions(ctx, filteredInputs.tools || [])
|
||||
|
||||
const responseFormat = this.parseResponseFormat(filteredInputs.responseFormat)
|
||||
const model = filteredInputs.model || AGENT.DEFAULT_MODEL
|
||||
|
||||
await validateModelProvider(ctx.userId, model, ctx)
|
||||
|
||||
const providerId = getProviderFromModel(model)
|
||||
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
|
||||
const streamingConfig = this.getStreamingConfig(ctx, block)
|
||||
@@ -143,6 +155,21 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return
|
||||
|
||||
const hasMcpTools = tools.some((t) => t.type === 'mcp')
|
||||
const hasCustomTools = tools.some((t) => t.type === 'custom-tool')
|
||||
|
||||
if (hasMcpTools) {
|
||||
await validateMcpToolsAllowed(ctx.userId, ctx)
|
||||
}
|
||||
|
||||
if (hasCustomTools) {
|
||||
await validateCustomToolsAllowed(ctx.userId, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
private async filterUnavailableMcpTools(
|
||||
ctx: ExecutionContext,
|
||||
tools: ToolInput[]
|
||||
@@ -212,6 +239,9 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
const otherResults = await Promise.all(
|
||||
otherTools.map(async (tool) => {
|
||||
try {
|
||||
if (tool.type && tool.type !== 'custom-tool' && tool.type !== 'mcp') {
|
||||
await validateBlockType(ctx.userId, tool.type, ctx)
|
||||
}
|
||||
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
|
||||
return await this.createCustomTool(ctx, tool)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BlockType, DEFAULTS, EVALUATOR, HTTP } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { buildAPIUrl, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
|
||||
import { validateModelProvider } from '@/executor/utils/permission-check'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
@@ -36,6 +37,9 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
bedrockSecretKey: inputs.bedrockSecretKey,
|
||||
bedrockRegion: inputs.bedrockRegion,
|
||||
}
|
||||
|
||||
await validateModelProvider(ctx.userId, evaluatorConfig.model, ctx)
|
||||
|
||||
const providerId = getProviderFromModel(evaluatorConfig.model)
|
||||
|
||||
let finalApiKey: string | undefined = evaluatorConfig.apiKey
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ROUTER,
|
||||
} from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { validateModelProvider } from '@/executor/utils/permission-check'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
@@ -73,6 +74,8 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
bedrockRegion: inputs.bedrockRegion,
|
||||
}
|
||||
|
||||
await validateModelProvider(ctx.userId, routerConfig.model, ctx)
|
||||
|
||||
const providerId = getProviderFromModel(routerConfig.model)
|
||||
|
||||
try {
|
||||
@@ -211,6 +214,8 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
bedrockRegion: inputs.bedrockRegion,
|
||||
}
|
||||
|
||||
await validateModelProvider(ctx.userId, routerConfig.model, ctx)
|
||||
|
||||
const providerId = getProviderFromModel(routerConfig.model)
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
@@ -152,6 +153,9 @@ export interface ExecutionContext {
|
||||
userId?: string
|
||||
isDeployedContext?: boolean
|
||||
|
||||
permissionConfig?: PermissionGroupConfig | null
|
||||
permissionConfigLoaded?: boolean
|
||||
|
||||
blockStates: ReadonlyMap<string, BlockState>
|
||||
executedBlocks: ReadonlySet<string>
|
||||
|
||||
|
||||
186
apps/sim/executor/utils/permission-check.ts
Normal file
186
apps/sim/executor/utils/permission-check.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import {
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('PermissionCheck')
|
||||
|
||||
export class ProviderNotAllowedError extends Error {
|
||||
constructor(providerId: string, model: string) {
|
||||
super(
|
||||
`Provider "${providerId}" is not allowed for model "${model}" based on your permission group settings`
|
||||
)
|
||||
this.name = 'ProviderNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class IntegrationNotAllowedError extends Error {
|
||||
constructor(blockType: string) {
|
||||
super(`Integration "${blockType}" is not allowed based on your permission group settings`)
|
||||
this.name = 'IntegrationNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class McpToolsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('MCP tools are not allowed based on your permission group settings')
|
||||
this.name = 'McpToolsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomToolsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Custom tools are not allowed based on your permission group settings')
|
||||
this.name = 'CustomToolsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserPermissionConfig(
|
||||
userId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
const [membership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId)
|
||||
if (!isEnterprise) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [groupMembership] = await db
|
||||
.select({ config: permissionGroup.config })
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.userId, userId),
|
||||
eq(permissionGroup.organizationId, membership.organizationId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!groupMembership) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsePermissionGroupConfig(groupMembership.config)
|
||||
}
|
||||
|
||||
export async function getPermissionConfig(
|
||||
userId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ctx) {
|
||||
if (ctx.permissionConfigLoaded) {
|
||||
return ctx.permissionConfig ?? null
|
||||
}
|
||||
|
||||
const config = await getUserPermissionConfig(userId)
|
||||
ctx.permissionConfig = config
|
||||
ctx.permissionConfigLoaded = true
|
||||
return config
|
||||
}
|
||||
|
||||
return getUserPermissionConfig(userId)
|
||||
}
|
||||
|
||||
export async function validateModelProvider(
|
||||
userId: string | undefined,
|
||||
model: string,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, ctx)
|
||||
|
||||
if (!config || config.allowedModelProviders === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const providerId = getProviderFromModel(model)
|
||||
|
||||
if (!config.allowedModelProviders.includes(providerId)) {
|
||||
logger.warn('Model provider blocked by permission group', { userId, model, providerId })
|
||||
throw new ProviderNotAllowedError(providerId, model)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateBlockType(
|
||||
userId: string | undefined,
|
||||
blockType: string,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, ctx)
|
||||
|
||||
if (!config || config.allowedIntegrations === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!config.allowedIntegrations.includes(blockType)) {
|
||||
logger.warn('Integration blocked by permission group', { userId, blockType })
|
||||
throw new IntegrationNotAllowedError(blockType)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateMcpToolsAllowed(
|
||||
userId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableMcpTools) {
|
||||
logger.warn('MCP tools blocked by permission group', { userId })
|
||||
throw new McpToolsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateCustomToolsAllowed(
|
||||
userId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableCustomTools) {
|
||||
logger.warn('Custom tools blocked by permission group', { userId })
|
||||
throw new CustomToolsNotAllowedError()
|
||||
}
|
||||
}
|
||||
282
apps/sim/hooks/queries/permission-groups.ts
Normal file
282
apps/sim/hooks/queries/permission-groups.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
|
||||
export interface PermissionGroup {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
config: PermissionGroupConfig
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
creatorName: string | null
|
||||
creatorEmail: string | null
|
||||
memberCount: number
|
||||
}
|
||||
|
||||
export interface PermissionGroupMember {
|
||||
id: string
|
||||
userId: string
|
||||
assignedAt: string
|
||||
userName: string | null
|
||||
userEmail: string | null
|
||||
userImage: string | null
|
||||
}
|
||||
|
||||
export interface UserPermissionConfig {
|
||||
permissionGroupId: string | null
|
||||
groupName: string | null
|
||||
config: PermissionGroupConfig | null
|
||||
}
|
||||
|
||||
export const permissionGroupKeys = {
|
||||
all: ['permissionGroups'] as const,
|
||||
list: (organizationId?: string) =>
|
||||
['permissionGroups', 'list', organizationId ?? 'none'] as const,
|
||||
detail: (id?: string) => ['permissionGroups', 'detail', id ?? 'none'] as const,
|
||||
members: (id?: string) => ['permissionGroups', 'members', id ?? 'none'] as const,
|
||||
userConfig: (organizationId?: string) =>
|
||||
['permissionGroups', 'userConfig', organizationId ?? 'none'] as const,
|
||||
}
|
||||
|
||||
interface PermissionGroupsResponse {
|
||||
permissionGroups?: PermissionGroup[]
|
||||
}
|
||||
|
||||
export function usePermissionGroups(organizationId?: string, enabled = true) {
|
||||
return useQuery<PermissionGroup[]>({
|
||||
queryKey: permissionGroupKeys.list(organizationId),
|
||||
queryFn: async () => {
|
||||
const data = await fetchJson<PermissionGroupsResponse>('/api/permission-groups', {
|
||||
searchParams: { organizationId: organizationId ?? '' },
|
||||
})
|
||||
return data.permissionGroups ?? []
|
||||
},
|
||||
enabled: Boolean(organizationId) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
interface PermissionGroupDetailResponse {
|
||||
permissionGroup?: PermissionGroup
|
||||
}
|
||||
|
||||
export function usePermissionGroup(id?: string, enabled = true) {
|
||||
return useQuery<PermissionGroup | null>({
|
||||
queryKey: permissionGroupKeys.detail(id),
|
||||
queryFn: async () => {
|
||||
const data = await fetchJson<PermissionGroupDetailResponse>(`/api/permission-groups/${id}`)
|
||||
return data.permissionGroup ?? null
|
||||
},
|
||||
enabled: Boolean(id) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
interface MembersResponse {
|
||||
members?: PermissionGroupMember[]
|
||||
}
|
||||
|
||||
export function usePermissionGroupMembers(permissionGroupId?: string) {
|
||||
return useQuery<PermissionGroupMember[]>({
|
||||
queryKey: permissionGroupKeys.members(permissionGroupId),
|
||||
queryFn: async () => {
|
||||
const data = await fetchJson<MembersResponse>(
|
||||
`/api/permission-groups/${permissionGroupId}/members`
|
||||
)
|
||||
return data.members ?? []
|
||||
},
|
||||
enabled: Boolean(permissionGroupId),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUserPermissionConfig(organizationId?: string) {
|
||||
return useQuery<UserPermissionConfig>({
|
||||
queryKey: permissionGroupKeys.userConfig(organizationId),
|
||||
queryFn: async () => {
|
||||
const data = await fetchJson<UserPermissionConfig>('/api/permission-groups/user', {
|
||||
searchParams: { organizationId: organizationId ?? '' },
|
||||
})
|
||||
return data
|
||||
},
|
||||
enabled: Boolean(organizationId),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export interface CreatePermissionGroupData {
|
||||
organizationId: string
|
||||
name: string
|
||||
description?: string
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
}
|
||||
|
||||
export function useCreatePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreatePermissionGroupData) => {
|
||||
const response = await fetch('/api/permission-groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to create permission group')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.list(variables.organizationId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface UpdatePermissionGroupData {
|
||||
id: string
|
||||
organizationId: string
|
||||
name?: string
|
||||
description?: string | null
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
}
|
||||
|
||||
export function useUpdatePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: UpdatePermissionGroupData) => {
|
||||
const response = await fetch(`/api/permission-groups/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to update permission group')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.list(variables.organizationId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.detail(variables.id) })
|
||||
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface DeletePermissionGroupParams {
|
||||
permissionGroupId: string
|
||||
organizationId: string
|
||||
}
|
||||
|
||||
export function useDeletePermissionGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ permissionGroupId }: DeletePermissionGroupParams) => {
|
||||
const response = await fetch(`/api/permission-groups/${permissionGroupId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to delete permission group')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.list(variables.organizationId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddPermissionGroupMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: { permissionGroupId: string; userId: string }) => {
|
||||
const response = await fetch(`/api/permission-groups/${data.permissionGroupId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: data.userId }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to add member')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.members(variables.permissionGroupId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemovePermissionGroupMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: { permissionGroupId: string; memberId: string }) => {
|
||||
const response = await fetch(
|
||||
`/api/permission-groups/${data.permissionGroupId}/members?memberId=${data.memberId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to remove member')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.members(variables.permissionGroupId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface BulkAddMembersData {
|
||||
permissionGroupId: string
|
||||
userIds?: string[]
|
||||
addAllOrgMembers?: boolean
|
||||
}
|
||||
|
||||
export function useBulkAddPermissionGroupMembers() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ permissionGroupId, ...data }: BulkAddMembersData) => {
|
||||
const response = await fetch(`/api/permission-groups/${permissionGroupId}/members/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to add members')
|
||||
}
|
||||
return response.json() as Promise<{ added: number; moved: number }>
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionGroupKeys.members(variables.permissionGroupId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: ['permissionGroups', 'userConfig'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
71
apps/sim/hooks/use-permission-config.ts
Normal file
71
apps/sim/hooks/use-permission-config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
|
||||
|
||||
export interface PermissionConfigResult {
|
||||
config: PermissionGroupConfig
|
||||
isLoading: boolean
|
||||
isInPermissionGroup: boolean
|
||||
filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]
|
||||
filterProviders: (providerIds: string[]) => string[]
|
||||
isBlockAllowed: (blockType: string) => boolean
|
||||
isProviderAllowed: (providerId: string) => boolean
|
||||
}
|
||||
|
||||
export function usePermissionConfig(): PermissionConfigResult {
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
|
||||
const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id)
|
||||
|
||||
const config = useMemo(() => {
|
||||
if (!permissionData?.config) {
|
||||
return DEFAULT_PERMISSION_GROUP_CONFIG
|
||||
}
|
||||
return permissionData.config
|
||||
}, [permissionData])
|
||||
|
||||
const isInPermissionGroup = !!permissionData?.permissionGroupId
|
||||
|
||||
const isBlockAllowed = useMemo(() => {
|
||||
return (blockType: string) => {
|
||||
if (config.allowedIntegrations === null) return true
|
||||
return config.allowedIntegrations.includes(blockType)
|
||||
}
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const isProviderAllowed = useMemo(() => {
|
||||
return (providerId: string) => {
|
||||
if (config.allowedModelProviders === null) return true
|
||||
return config.allowedModelProviders.includes(providerId)
|
||||
}
|
||||
}, [config.allowedModelProviders])
|
||||
|
||||
const filterBlocks = useMemo(() => {
|
||||
return <T extends { type: string }>(blocks: T[]): T[] => {
|
||||
if (config.allowedIntegrations === null) return blocks
|
||||
return blocks.filter((block) => config.allowedIntegrations!.includes(block.type))
|
||||
}
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const filterProviders = useMemo(() => {
|
||||
return (providerIds: string[]): string[] => {
|
||||
if (config.allowedModelProviders === null) return providerIds
|
||||
return providerIds.filter((id) => config.allowedModelProviders!.includes(id))
|
||||
}
|
||||
}, [config.allowedModelProviders])
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoading,
|
||||
isInPermissionGroup,
|
||||
filterBlocks,
|
||||
filterProviders,
|
||||
isBlockAllowed,
|
||||
isProviderAllowed,
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
isEmailPasswordEnabled,
|
||||
isEmailVerificationEnabled,
|
||||
isHosted,
|
||||
isOrganizationsEnabled,
|
||||
isRegistrationDisabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
@@ -2352,8 +2353,15 @@ export const auth = betterAuth({
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(isOrganizationsEnabled
|
||||
? [
|
||||
organization({
|
||||
allowUserToCreateOrganization: async (user) => {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
const dbSubscriptions = await db
|
||||
.select()
|
||||
.from(schema.subscription)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import {
|
||||
isAccessControlEnabled,
|
||||
isCredentialSetsEnabled,
|
||||
isHosted,
|
||||
isProd,
|
||||
@@ -274,6 +275,33 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an organization has an enterprise plan
|
||||
* Used for Access Control (Permission Groups) feature gating
|
||||
*/
|
||||
export async function isOrganizationOnEnterprisePlan(organizationId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isAccessControlEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
return !!orgSub && checkEnterprisePlan(orgSub)
|
||||
} catch (error) {
|
||||
logger.error('Error checking organization enterprise plan status', { error, organizationId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to credential sets (email polling) feature
|
||||
* Returns true if:
|
||||
@@ -316,6 +344,27 @@ export async function hasSSOAccess(userId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to Access Control (Permission Groups) feature
|
||||
* Returns true if:
|
||||
* - ACCESS_CONTROL_ENABLED env var is set (self-hosted override), OR
|
||||
* - User is admin/owner of an enterprise organization
|
||||
*
|
||||
* In non-production environments, returns true for convenience.
|
||||
*/
|
||||
export async function hasAccessControlAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (isAccessControlEnabled && !isHosted) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isEnterpriseOrgAdminOrOwner(userId)
|
||||
} catch (error) {
|
||||
logger.error('Error checking access control access', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded their cost limit based on current period usage
|
||||
*/
|
||||
|
||||
@@ -10,10 +10,12 @@ export * from '@/lib/billing/core/subscription'
|
||||
export {
|
||||
getHighestPrioritySubscription as getActiveSubscription,
|
||||
getUserSubscriptionState as getSubscriptionState,
|
||||
hasAccessControlAccess,
|
||||
hasCredentialSetsAccess,
|
||||
hasSSOAccess,
|
||||
isEnterpriseOrgAdminOrOwner,
|
||||
isEnterprisePlan as hasEnterprisePlan,
|
||||
isOrganizationOnEnterprisePlan,
|
||||
isOrganizationOnTeamOrEnterprisePlan,
|
||||
isProPlan as hasProPlan,
|
||||
isTeamOrgAdminOrOwner,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
const logger = createLogger('SeatManagement')
|
||||
@@ -34,7 +35,20 @@ export async function validateSeatAvailability(
|
||||
additionalSeats = 1
|
||||
): Promise<SeatValidationResult> {
|
||||
try {
|
||||
// Get organization subscription directly (referenceId = organizationId)
|
||||
if (!isBillingEnabled) {
|
||||
const memberCount = await db
|
||||
.select({ count: count() })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
const currentSeats = memberCount[0]?.count || 0
|
||||
return {
|
||||
canInvite: true,
|
||||
currentSeats,
|
||||
maxSeats: Number.MAX_SAFE_INTEGER,
|
||||
availableSeats: Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await getOrganizationSubscription(organizationId)
|
||||
|
||||
if (!subscription) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { escapeRegExp } from '@/executor/constants'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import type { ChatContext } from '@/stores/panel/copilot/types'
|
||||
|
||||
export type AgentContextType =
|
||||
@@ -104,7 +105,11 @@ export async function processContextsServer(
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'blocks' && (ctx as any).blockId) {
|
||||
return await processBlockMetadata((ctx as any).blockId, ctx.label ? `@${ctx.label}` : '@')
|
||||
return await processBlockMetadata(
|
||||
(ctx as any).blockId,
|
||||
ctx.label ? `@${ctx.label}` : '@',
|
||||
userId
|
||||
)
|
||||
}
|
||||
if (ctx.kind === 'templates' && (ctx as any).templateId) {
|
||||
return await processTemplateFromDb(
|
||||
@@ -355,8 +360,21 @@ async function processKnowledgeFromDb(
|
||||
}
|
||||
}
|
||||
|
||||
async function processBlockMetadata(blockId: string, tag: string): Promise<AgentContext | null> {
|
||||
async function processBlockMetadata(
|
||||
blockId: string,
|
||||
tag: string,
|
||||
userId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
if (userId) {
|
||||
const permissionConfig = await getUserPermissionConfig(userId)
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
||||
logger.debug('Block not allowed by permission group', { blockId, userId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse registry to match get_blocks_metadata tool result
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
const { tools: toolsRegistry } = await import('@/tools/registry')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check, Loader2, Plus, X, XCircle } from 'lucide-react'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
@@ -31,6 +32,20 @@ interface ManageCustomToolArgs {
|
||||
|
||||
const API_ENDPOINT = '/api/tools/custom'
|
||||
|
||||
async function checkCustomToolsPermission(): Promise<void> {
|
||||
const activeOrgResponse = await client.organization.getFullOrganization()
|
||||
const organizationId = activeOrgResponse.data?.id
|
||||
if (!organizationId) return
|
||||
|
||||
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
|
||||
if (!response.ok) return
|
||||
|
||||
const data = await response.json()
|
||||
if (data?.config?.disableCustomTools) {
|
||||
throw new Error('Custom tools are not allowed based on your permission group settings')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client tool for creating, editing, and deleting custom tools via the copilot.
|
||||
*/
|
||||
@@ -164,7 +179,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
} catch (e: any) {
|
||||
logger.error('execute failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to manage custom tool')
|
||||
await this.markToolComplete(500, e?.message || 'Failed to manage custom tool', {
|
||||
success: false,
|
||||
error: e?.message || 'Failed to manage custom tool',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +207,8 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
throw new Error('Operation is required')
|
||||
}
|
||||
|
||||
await checkCustomToolsPermission()
|
||||
|
||||
const { operation, toolId, schema, code } = args
|
||||
|
||||
// Get workspace ID from the workflow registry
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check, Loader2, Server, X, XCircle } from 'lucide-react'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
@@ -25,6 +26,20 @@ interface ManageMcpToolArgs {
|
||||
|
||||
const API_ENDPOINT = '/api/mcp/servers'
|
||||
|
||||
async function checkMcpToolsPermission(): Promise<void> {
|
||||
const activeOrgResponse = await client.organization.getFullOrganization()
|
||||
const organizationId = activeOrgResponse.data?.id
|
||||
if (!organizationId) return
|
||||
|
||||
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
|
||||
if (!response.ok) return
|
||||
|
||||
const data = await response.json()
|
||||
if (data?.config?.disableMcpTools) {
|
||||
throw new Error('MCP tools are not allowed based on your permission group settings')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client tool for creating, editing, and deleting MCP tool servers via the copilot.
|
||||
*/
|
||||
@@ -145,7 +160,10 @@ export class ManageMcpToolClientTool extends BaseClientTool {
|
||||
} catch (e: any) {
|
||||
logger.error('execute failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to manage MCP tool')
|
||||
await this.markToolComplete(500, e?.message || 'Failed to manage MCP tool', {
|
||||
success: false,
|
||||
error: e?.message || 'Failed to manage MCP tool',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +185,8 @@ export class ManageMcpToolClientTool extends BaseClientTool {
|
||||
throw new Error('Operation is required')
|
||||
}
|
||||
|
||||
await checkMcpToolsPermission()
|
||||
|
||||
const { operation, serverId, config } = args
|
||||
|
||||
const { hydration } = useWorkflowRegistry.getState()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
|
||||
@@ -298,13 +299,20 @@ export const getBlockConfigServerTool: BaseServerTool<
|
||||
GetBlockConfigResultType
|
||||
> = {
|
||||
name: 'get_block_config',
|
||||
async execute({
|
||||
blockType,
|
||||
operation,
|
||||
}: GetBlockConfigInputType): Promise<GetBlockConfigResultType> {
|
||||
async execute(
|
||||
{ blockType, operation }: GetBlockConfigInputType,
|
||||
context?: { userId: string }
|
||||
): Promise<GetBlockConfigResultType> {
|
||||
const logger = createLogger('GetBlockConfigServerTool')
|
||||
logger.debug('Executing get_block_config', { blockType, operation })
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) {
|
||||
throw new Error(`Block "${blockType}" is not available`)
|
||||
}
|
||||
|
||||
const blockConfig = blockRegistry[blockType]
|
||||
if (!blockConfig) {
|
||||
throw new Error(`Block not found: ${blockType}`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GetBlockOptionsResultType,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
|
||||
export const getBlockOptionsServerTool: BaseServerTool<
|
||||
@@ -13,10 +14,20 @@ export const getBlockOptionsServerTool: BaseServerTool<
|
||||
GetBlockOptionsResultType
|
||||
> = {
|
||||
name: 'get_block_options',
|
||||
async execute({ blockId }: GetBlockOptionsInputType): Promise<GetBlockOptionsResultType> {
|
||||
async execute(
|
||||
{ blockId }: GetBlockOptionsInputType,
|
||||
context?: { userId: string }
|
||||
): Promise<GetBlockOptionsResultType> {
|
||||
const logger = createLogger('GetBlockOptionsServerTool')
|
||||
logger.debug('Executing get_block_options', { blockId })
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
||||
throw new Error(`Block "${blockId}" is not available`)
|
||||
}
|
||||
|
||||
const blockConfig = blockRegistry[blockId]
|
||||
if (!blockConfig) {
|
||||
throw new Error(`Block not found: ${blockId}`)
|
||||
|
||||
@@ -6,16 +6,20 @@ import {
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
|
||||
export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||
ReturnType<typeof GetBlocksAndToolsInput.parse>,
|
||||
ReturnType<typeof GetBlocksAndToolsResult.parse>
|
||||
> = {
|
||||
name: 'get_blocks_and_tools',
|
||||
async execute() {
|
||||
async execute(_args: unknown, context?: { userId: string }) {
|
||||
const logger = createLogger('GetBlocksAndToolsServerTool')
|
||||
logger.debug('Executing get_blocks_and_tools')
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
type BlockListItem = {
|
||||
type: string
|
||||
name: string
|
||||
@@ -25,7 +29,11 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||
const blocks: BlockListItem[] = []
|
||||
|
||||
Object.entries(blockRegistry)
|
||||
.filter(([, blockConfig]: [string, BlockConfig]) => !blockConfig.hideFromToolbar)
|
||||
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
if (blockConfig.hideFromToolbar) return false
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return false
|
||||
return true
|
||||
})
|
||||
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
blocks.push({
|
||||
type: blockType,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
@@ -105,16 +106,23 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
ReturnType<typeof GetBlocksMetadataResult.parse>
|
||||
> = {
|
||||
name: 'get_blocks_metadata',
|
||||
async execute({
|
||||
blockIds,
|
||||
}: ReturnType<typeof GetBlocksMetadataInput.parse>): Promise<
|
||||
ReturnType<typeof GetBlocksMetadataResult.parse>
|
||||
> {
|
||||
async execute(
|
||||
{ blockIds }: ReturnType<typeof GetBlocksMetadataInput.parse>,
|
||||
context?: { userId: string }
|
||||
): Promise<ReturnType<typeof GetBlocksMetadataResult.parse>> {
|
||||
const logger = createLogger('GetBlocksMetadataServerTool')
|
||||
logger.debug('Executing get_blocks_metadata', { count: blockIds?.length })
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
const result: Record<string, CopilotBlockMetadata> = {}
|
||||
for (const blockId of blockIds || []) {
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
||||
logger.debug('Block not allowed by permission group', { blockId })
|
||||
continue
|
||||
}
|
||||
|
||||
let metadata: any
|
||||
|
||||
if (SPECIAL_BLOCKS_METADATA[blockId]) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
|
||||
export const GetTriggerBlocksInput = z.object({})
|
||||
export const GetTriggerBlocksResult = z.object({
|
||||
@@ -14,14 +15,18 @@ export const getTriggerBlocksServerTool: BaseServerTool<
|
||||
ReturnType<typeof GetTriggerBlocksResult.parse>
|
||||
> = {
|
||||
name: 'get_trigger_blocks',
|
||||
async execute() {
|
||||
async execute(_args: unknown, context?: { userId: string }) {
|
||||
const logger = createLogger('GetTriggerBlocksServerTool')
|
||||
logger.debug('Executing get_trigger_blocks')
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
const triggerBlockIds: string[] = []
|
||||
|
||||
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
if (blockConfig.hideFromToolbar) return
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return
|
||||
|
||||
if (blockConfig.category === 'triggers') {
|
||||
triggerBlockIds.push(blockType)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
@@ -13,6 +14,7 @@ import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
|
||||
import { getAllBlocks, getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { EDGE, normalizeName } from '@/executor/constants'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
@@ -49,6 +51,8 @@ interface ValidationError {
|
||||
type SkippedItemType =
|
||||
| 'block_not_found'
|
||||
| 'invalid_block_type'
|
||||
| 'block_not_allowed'
|
||||
| 'tool_not_allowed'
|
||||
| 'invalid_edge_target'
|
||||
| 'invalid_edge_source'
|
||||
| 'invalid_source_handle'
|
||||
@@ -558,7 +562,9 @@ function createBlockFromParams(
|
||||
blockId: string,
|
||||
params: any,
|
||||
parentId?: string,
|
||||
errorsCollector?: ValidationError[]
|
||||
errorsCollector?: ValidationError[],
|
||||
permissionConfig?: PermissionGroupConfig | null,
|
||||
skippedItems?: SkippedItem[]
|
||||
): any {
|
||||
const blockConfig = getAllBlocks().find((b) => b.type === params.type)
|
||||
|
||||
@@ -626,9 +632,14 @@ function createBlockFromParams(
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for tools - normalize to restore sanitized fields
|
||||
// Special handling for tools - normalize and filter disallowed
|
||||
if (key === 'tools' && Array.isArray(value)) {
|
||||
sanitizedValue = normalizeTools(value)
|
||||
sanitizedValue = filterDisallowedTools(
|
||||
normalizeTools(value),
|
||||
permissionConfig ?? null,
|
||||
blockId,
|
||||
skippedItems ?? []
|
||||
)
|
||||
}
|
||||
|
||||
// Special handling for responseFormat - normalize to ensure consistent format
|
||||
@@ -1093,12 +1104,69 @@ interface ApplyOperationsResult {
|
||||
skippedItems: SkippedItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block type is allowed by the permission group config
|
||||
*/
|
||||
function isBlockTypeAllowed(
|
||||
blockType: string,
|
||||
permissionConfig: PermissionGroupConfig | null
|
||||
): boolean {
|
||||
if (!permissionConfig || permissionConfig.allowedIntegrations === null) {
|
||||
return true
|
||||
}
|
||||
return permissionConfig.allowedIntegrations.includes(blockType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out tools that are not allowed by the permission group config
|
||||
* Returns both the allowed tools and any skipped tool items for logging
|
||||
*/
|
||||
function filterDisallowedTools(
|
||||
tools: any[],
|
||||
permissionConfig: PermissionGroupConfig | null,
|
||||
blockId: string,
|
||||
skippedItems: SkippedItem[]
|
||||
): any[] {
|
||||
if (!permissionConfig) {
|
||||
return tools
|
||||
}
|
||||
|
||||
const allowedTools: any[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'tool_not_allowed',
|
||||
operationType: 'add',
|
||||
blockId,
|
||||
reason: `Custom tool "${tool.title || tool.customToolId || 'unknown'}" is not allowed by permission group - tool not added`,
|
||||
details: { toolType: 'custom-tool', toolId: tool.customToolId },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (tool.type === 'mcp' && permissionConfig.disableMcpTools) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'tool_not_allowed',
|
||||
operationType: 'add',
|
||||
blockId,
|
||||
reason: `MCP tool "${tool.title || 'unknown'}" is not allowed by permission group - tool not added`,
|
||||
details: { toolType: 'mcp', serverId: tool.params?.serverId },
|
||||
})
|
||||
continue
|
||||
}
|
||||
allowedTools.push(tool)
|
||||
}
|
||||
|
||||
return allowedTools
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply operations directly to the workflow JSON state
|
||||
*/
|
||||
function applyOperationsToWorkflowState(
|
||||
workflowState: any,
|
||||
operations: EditWorkflowOperation[]
|
||||
operations: EditWorkflowOperation[],
|
||||
permissionConfig: PermissionGroupConfig | null = null
|
||||
): ApplyOperationsResult {
|
||||
// Deep clone the workflow state to avoid mutations
|
||||
const modifiedState = JSON.parse(JSON.stringify(workflowState))
|
||||
@@ -1297,9 +1365,14 @@ function applyOperationsToWorkflowState(
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for tools - normalize to restore sanitized fields
|
||||
// Special handling for tools - normalize and filter disallowed
|
||||
if (key === 'tools' && Array.isArray(value)) {
|
||||
sanitizedValue = normalizeTools(value)
|
||||
sanitizedValue = filterDisallowedTools(
|
||||
normalizeTools(value),
|
||||
permissionConfig,
|
||||
block_id,
|
||||
skippedItems
|
||||
)
|
||||
}
|
||||
|
||||
// Special handling for responseFormat - normalize to ensure consistent format
|
||||
@@ -1401,6 +1474,14 @@ function applyOperationsToWorkflowState(
|
||||
reason: `Invalid block type "${params.type}" - type change skipped`,
|
||||
details: { requestedType: params.type },
|
||||
})
|
||||
} else if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_not_allowed',
|
||||
operationType: 'edit',
|
||||
blockId: block_id,
|
||||
reason: `Block type "${params.type}" is not allowed by permission group - type change skipped`,
|
||||
details: { requestedType: params.type },
|
||||
})
|
||||
} else {
|
||||
block.type = params.type
|
||||
}
|
||||
@@ -1503,7 +1584,9 @@ function applyOperationsToWorkflowState(
|
||||
childId,
|
||||
childBlock,
|
||||
block_id,
|
||||
validationErrors
|
||||
validationErrors,
|
||||
permissionConfig,
|
||||
skippedItems
|
||||
)
|
||||
modifiedState.blocks[childId] = childBlockState
|
||||
|
||||
@@ -1680,8 +1763,27 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if block type is allowed by permission group
|
||||
if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_not_allowed',
|
||||
operationType: 'add',
|
||||
blockId: block_id,
|
||||
reason: `Block type "${params.type}" is not allowed by permission group - block not added`,
|
||||
details: { requestedType: params.type },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Create new block with proper structure
|
||||
const newBlock = createBlockFromParams(block_id, params, undefined, validationErrors)
|
||||
const newBlock = createBlockFromParams(
|
||||
block_id,
|
||||
params,
|
||||
undefined,
|
||||
validationErrors,
|
||||
permissionConfig,
|
||||
skippedItems
|
||||
)
|
||||
|
||||
// Set loop/parallel data on parent block BEFORE adding to blocks (strict validation)
|
||||
if (params.nestedNodes) {
|
||||
@@ -1760,7 +1862,9 @@ function applyOperationsToWorkflowState(
|
||||
childId,
|
||||
childBlock,
|
||||
block_id,
|
||||
validationErrors
|
||||
validationErrors,
|
||||
permissionConfig,
|
||||
skippedItems
|
||||
)
|
||||
modifiedState.blocks[childId] = childBlockState
|
||||
|
||||
@@ -1882,9 +1986,14 @@ function applyOperationsToWorkflowState(
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for tools - normalize to restore sanitized fields
|
||||
// Special handling for tools - normalize and filter disallowed
|
||||
if (key === 'tools' && Array.isArray(value)) {
|
||||
sanitizedValue = normalizeTools(value)
|
||||
sanitizedValue = filterDisallowedTools(
|
||||
normalizeTools(value),
|
||||
permissionConfig,
|
||||
block_id,
|
||||
skippedItems
|
||||
)
|
||||
}
|
||||
|
||||
// Special handling for responseFormat - normalize to ensure consistent format
|
||||
@@ -1920,8 +2029,27 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if block type is allowed by permission group
|
||||
if (!isContainerType && !isBlockTypeAllowed(params.type, permissionConfig)) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_not_allowed',
|
||||
operationType: 'insert_into_subflow',
|
||||
blockId: block_id,
|
||||
reason: `Block type "${params.type}" is not allowed by permission group - block not inserted`,
|
||||
details: { requestedType: params.type, subflowId },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Create new block as child of subflow
|
||||
const newBlock = createBlockFromParams(block_id, params, subflowId, validationErrors)
|
||||
const newBlock = createBlockFromParams(
|
||||
block_id,
|
||||
params,
|
||||
subflowId,
|
||||
validationErrors,
|
||||
permissionConfig,
|
||||
skippedItems
|
||||
)
|
||||
modifiedState.blocks[block_id] = newBlock
|
||||
}
|
||||
|
||||
@@ -2223,12 +2351,15 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
|
||||
workflowState = fromDb.workflowState
|
||||
}
|
||||
|
||||
// Get permission config for the user
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
|
||||
// Apply operations directly to the workflow state
|
||||
const {
|
||||
state: modifiedWorkflowState,
|
||||
validationErrors,
|
||||
skippedItems,
|
||||
} = applyOperationsToWorkflowState(workflowState, operations)
|
||||
} = applyOperationsToWorkflowState(workflowState, operations, permissionConfig)
|
||||
|
||||
// Get workspaceId for selector validation
|
||||
let workspaceId: string | undefined
|
||||
|
||||
@@ -251,6 +251,12 @@ export const env = createEnv({
|
||||
// Credential Sets (Email Polling) - for self-hosted deployments
|
||||
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
|
||||
|
||||
// Access Control (Permission Groups) - for self-hosted deployments
|
||||
ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements)
|
||||
|
||||
// Organizations - for self-hosted deployments
|
||||
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
|
||||
|
||||
// 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
|
||||
@@ -329,6 +335,8 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
|
||||
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
|
||||
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_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
|
||||
},
|
||||
|
||||
@@ -358,6 +366,8 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: process.env.NEXT_PUBLIC_TRIGGER_DEV_ENABLED,
|
||||
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
|
||||
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_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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Environment utility functions for consistent environment detection across the application
|
||||
*/
|
||||
import { env, getEnv, isFalsy, isTruthy } from './env'
|
||||
import { env, isFalsy, isTruthy } from './env'
|
||||
|
||||
/**
|
||||
* Is the application running in production mode
|
||||
@@ -21,9 +21,7 @@ export const isTest = env.NODE_ENV === 'test'
|
||||
/**
|
||||
* Is this the hosted version of the application
|
||||
*/
|
||||
export const isHosted =
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
export const isHosted = true
|
||||
|
||||
/**
|
||||
* Is billing enforcement enabled
|
||||
@@ -86,6 +84,20 @@ export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
|
||||
*/
|
||||
export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED)
|
||||
|
||||
/**
|
||||
* Is access control (permission groups) enabled via env var override
|
||||
* This bypasses plan requirements for self-hosted deployments
|
||||
*/
|
||||
export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED)
|
||||
|
||||
/**
|
||||
* Is organizations enabled
|
||||
* True if billing is enabled (orgs come with billing), OR explicitly enabled via env var,
|
||||
* OR if access control is enabled (access control requires organizations)
|
||||
*/
|
||||
export const isOrganizationsEnabled =
|
||||
isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled
|
||||
|
||||
/**
|
||||
* Is E2B enabled for remote code execution
|
||||
*/
|
||||
|
||||
51
apps/sim/lib/permission-groups/types.ts
Normal file
51
apps/sim/lib/permission-groups/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface PermissionGroupConfig {
|
||||
allowedIntegrations: string[] | null
|
||||
allowedModelProviders: string[] | null
|
||||
// Platform Configuration
|
||||
hideTraceSpans: boolean
|
||||
hideKnowledgeBaseTab: boolean
|
||||
hideCopilot: boolean
|
||||
hideApiKeysTab: boolean
|
||||
hideEnvironmentTab: boolean
|
||||
hideFilesTab: boolean
|
||||
disableMcpTools: boolean
|
||||
disableCustomTools: boolean
|
||||
hideTemplates: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
|
||||
allowedIntegrations: null,
|
||||
allowedModelProviders: null,
|
||||
hideTraceSpans: false,
|
||||
hideKnowledgeBaseTab: false,
|
||||
hideCopilot: false,
|
||||
hideApiKeysTab: false,
|
||||
hideEnvironmentTab: false,
|
||||
hideFilesTab: false,
|
||||
disableMcpTools: false,
|
||||
disableCustomTools: false,
|
||||
hideTemplates: false,
|
||||
}
|
||||
|
||||
export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return DEFAULT_PERMISSION_GROUP_CONFIG
|
||||
}
|
||||
|
||||
const c = config as Record<string, unknown>
|
||||
|
||||
return {
|
||||
allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null,
|
||||
allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null,
|
||||
hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false,
|
||||
hideKnowledgeBaseTab:
|
||||
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
|
||||
hideCopilot: typeof c.hideCopilot === 'boolean' ? c.hideCopilot : false,
|
||||
hideApiKeysTab: typeof c.hideApiKeysTab === 'boolean' ? c.hideApiKeysTab : false,
|
||||
hideEnvironmentTab: typeof c.hideEnvironmentTab === 'boolean' ? c.hideEnvironmentTab : false,
|
||||
hideFilesTab: typeof c.hideFilesTab === 'boolean' ? c.hideFilesTab : false,
|
||||
disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false,
|
||||
disableCustomTools: typeof c.disableCustomTools === 'boolean' ? c.disableCustomTools : false,
|
||||
hideTemplates: typeof c.hideTemplates === 'boolean' ? c.hideTemplates : false,
|
||||
}
|
||||
}
|
||||
29
packages/db/migrations/0137_yellow_korath.sql
Normal file
29
packages/db/migrations/0137_yellow_korath.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "permission_group" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"config" jsonb DEFAULT '{}' NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "permission_group_member" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"permission_group_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"assigned_by" text,
|
||||
"assigned_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "permission_group" ADD CONSTRAINT "permission_group_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group" ADD CONSTRAINT "permission_group_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" ADD CONSTRAINT "permission_group_member_permission_group_id_permission_group_id_fk" FOREIGN KEY ("permission_group_id") REFERENCES "public"."permission_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" ADD CONSTRAINT "permission_group_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "permission_group_member" ADD CONSTRAINT "permission_group_member_assigned_by_user_id_fk" FOREIGN KEY ("assigned_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "permission_group_organization_id_idx" ON "permission_group" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "permission_group_created_by_idx" ON "permission_group" USING btree ("created_by");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "permission_group_org_name_unique" ON "permission_group" USING btree ("organization_id","name");--> statement-breakpoint
|
||||
CREATE INDEX "permission_group_member_group_id_idx" ON "permission_group_member" USING btree ("permission_group_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "permission_group_member_user_id_unique" ON "permission_group_member" USING btree ("user_id");
|
||||
9571
packages/db/migrations/meta/0137_snapshot.json
Normal file
9571
packages/db/migrations/meta/0137_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -953,6 +953,13 @@
|
||||
"when": 1767905804764,
|
||||
"tag": "0136_pretty_jack_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 137,
|
||||
"version": "7",
|
||||
"when": 1767924777319,
|
||||
"tag": "0137_yellow_korath",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1877,3 +1877,48 @@ export const credentialSetInvitation = pgTable(
|
||||
expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt),
|
||||
})
|
||||
)
|
||||
|
||||
export const permissionGroup = pgTable(
|
||||
'permission_group',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
config: jsonb('config').notNull().default('{}'),
|
||||
createdBy: text('created_by')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
organizationIdIdx: index('permission_group_organization_id_idx').on(table.organizationId),
|
||||
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
|
||||
orgNameUnique: uniqueIndex('permission_group_org_name_unique').on(
|
||||
table.organizationId,
|
||||
table.name
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const permissionGroupMember = pgTable(
|
||||
'permission_group_member',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
permissionGroupId: text('permission_group_id')
|
||||
.notNull()
|
||||
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
assignedBy: text('assigned_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
permissionGroupIdIdx: index('permission_group_member_group_id_idx').on(table.permissionGroupId),
|
||||
userIdUnique: uniqueIndex('permission_group_member_user_id_unique').on(table.userId),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user