diff --git a/apps/docs/content/docs/en/enterprise/index.mdx b/apps/docs/content/docs/en/enterprise/index.mdx index c5b451d83d..3e5acdf5e2 100644 --- a/apps/docs/content/docs/en/enterprise/index.mdx +++ b/apps/docs/content/docs/en/enterprise/index.mdx @@ -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 + + + Users not assigned to any permission group have full access. Permission restrictions are enforced at both UI and execution time. + + +--- + ## 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 without requiring billing. -For self-hosted deployments, enterprise features can be enabled via environment variables: +### 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 | - - BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables. - +### 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. diff --git a/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts b/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts new file mode 100644 index 0000000000..c6e3faa2d2 --- /dev/null +++ b/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/permission-groups/[id]/members/route.ts b/apps/sim/app/api/permission-groups/[id]/members/route.ts new file mode 100644 index 0000000000..4979da755e --- /dev/null +++ b/apps/sim/app/api/permission-groups/[id]/members/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/permission-groups/[id]/route.ts b/apps/sim/app/api/permission-groups/[id]/route.ts new file mode 100644 index 0000000000..5e1486ff26 --- /dev/null +++ b/apps/sim/app/api/permission-groups/[id]/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/permission-groups/route.ts b/apps/sim/app/api/permission-groups/route.ts new file mode 100644 index 0000000000..a3c3a7512b --- /dev/null +++ b/apps/sim/app/api/permission-groups/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/permission-groups/user/route.ts b/apps/sim/app/api/permission-groups/user/route.ts new file mode 100644 index 0000000000..e41c826533 --- /dev/null +++ b/apps/sim/app/api/permission-groups/user/route.ts @@ -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), + }) +} diff --git a/apps/sim/app/api/v1/admin/access-control/route.ts b/apps/sim/app/api/v1/admin/access-control/route.ts new file mode 100644 index 0000000000..7da37edc8e --- /dev/null +++ b/apps/sim/app/api/v1/admin/access-control/route.ts @@ -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`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') + } +}) diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index f41409bf90..ad91e0c447 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -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' diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 952b437144..b563699830 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -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(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) { diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 2496c363c6..d3691a6720 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -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(async (request, context) = export const DELETE = withAdminAuthParams(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 diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 797831b887..cc9cee6320 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -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(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( diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index f19f822467..5cac5aba07 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -8,14 +8,32 @@ * - offset: number (default: 0) * * Response: AdminListResponse + * + * 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 */ +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') + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index d458d683c7..a5c1eadeb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -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 +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 611f1b8976..8287c0f6a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -13,6 +13,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' @@ -53,6 +54,7 @@ export const LogDetails = memo(function LogDetails({ const scrollAreaRef = useRef(null) const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) const { handleMouseDown } = useLogDetailsResize() + const { config: permissionConfig } = usePermissionConfig() useEffect(() => { if (scrollAreaRef.current) { @@ -260,7 +262,7 @@ export const LogDetails = memo(function LogDetails({ {/* Workflow State */} - {isWorkflowExecutionLog && log.executionId && ( + {isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
Workflow State @@ -278,12 +280,14 @@ export const LogDetails = memo(function LogDetails({ )} {/* Workflow Execution - Trace Spans */} - {isWorkflowExecutionLog && log.executionData?.traceSpans && ( - - )} + {isWorkflowExecutionLog && + log.executionData?.traceSpans && + !permissionConfig.hideTraceSpans && ( + + )} {/* Files */} {log.files && log.files.length > 0 && ( diff --git a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx index 0e49d77b5e..9955c24331 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx @@ -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 }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index 79695526fb..476623e8b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -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([]) const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) @@ -101,6 +104,11 @@ export function useMentionData(props: UseMentionDataProps) { const [blocksList, setBlocksList] = useState([]) const [isLoadingBlocks, setIsLoadingBlocks] = useState(false) + // Reset blocks list when permission config changes + useEffect(() => { + setBlocksList([]) + }, [config.allowedIntegrations]) + const [templatesList, setTemplatesList] = useState([]) 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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index c5b8f67e2e..0565fb998c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -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(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 6823e303b4..6532c60600 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -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' @@ -1003,18 +1005,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 @@ -1602,33 +1609,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) => ({ @@ -1653,7 +1664,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) => { @@ -1730,6 +1741,8 @@ export function ToolInput({ setStoreValue, handleMcpToolSelect, handleSelectTool, + permissionConfig.disableCustomTools, + permissionConfig.disableMcpTools, ]) const toolRequiresOAuth = (toolId: string): boolean => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 303067dd92..8c7bb3d038 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -25,6 +25,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 { @@ -192,9 +193,16 @@ export const Toolbar = forwardRef(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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 7dda151af0..c6fb11539d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -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 */}
- + {!permissionConfig.hideCopilot && ( + + )} +
+ +
+ {filteredMembers.length === 0 ? ( +

+ No members found matching "{searchTerm}" +

+ ) : ( +
+ {filteredMembers.map((member: any) => { + const name = member.user?.name || 'Unknown' + const email = member.user?.email || '' + const avatarInitial = name.charAt(0).toUpperCase() + const isSelected = selectedMemberIds.has(member.userId) + + return ( + + ) + })} +
+ )} +
+
+ )} + + + + + + + + ) +} + +function AccessControlSkeleton() { + return ( +
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ ) +} + +export function AccessControl() { + const { data: session } = useSession() + const { data: organizationsData, isPending: orgsLoading } = useOrganizations() + const { data: subscriptionData, isPending: subLoading } = useSubscriptionData() + + const activeOrganization = organizationsData?.activeOrganization + const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data) + const hasEnterprisePlan = subscriptionStatus.isEnterprise + const userRole = getUserRole(activeOrganization, session?.user?.email) + const isOwner = userRole === 'owner' + const isAdmin = userRole === 'admin' + const isOrgAdminOrOwner = isOwner || isAdmin + const canManage = hasEnterprisePlan && isOrgAdminOrOwner && !!activeOrganization?.id + + const queryEnabled = !!activeOrganization?.id + const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups( + activeOrganization?.id, + queryEnabled + ) + + // Show loading while dependencies load, or while permission groups query is pending + const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading) + const { data: organization } = useOrganization(activeOrganization?.id || '') + + const createPermissionGroup = useCreatePermissionGroup() + const updatePermissionGroup = useUpdatePermissionGroup() + const deletePermissionGroup = useDeletePermissionGroup() + const bulkAddMembers = useBulkAddPermissionGroupMembers() + + const [searchTerm, setSearchTerm] = useState('') + const [showCreateModal, setShowCreateModal] = useState(false) + const [viewingGroup, setViewingGroup] = useState(null) + const [newGroupName, setNewGroupName] = useState('') + const [newGroupDescription, setNewGroupDescription] = useState('') + const [createError, setCreateError] = useState(null) + const [deletingGroup, setDeletingGroup] = useState<{ id: string; name: string } | null>(null) + const [deletingGroupIds, setDeletingGroupIds] = useState>(new Set()) + + const { data: members = [], isPending: membersLoading } = usePermissionGroupMembers( + viewingGroup?.id + ) + const removeMember = useRemovePermissionGroupMember() + + const [showConfigModal, setShowConfigModal] = useState(false) + const [editingConfig, setEditingConfig] = useState(null) + const [showAddMembersModal, setShowAddMembersModal] = useState(false) + const [selectedMemberIds, setSelectedMemberIds] = useState>(new Set()) + const [providerSearchTerm, setProviderSearchTerm] = useState('') + const [integrationSearchTerm, setIntegrationSearchTerm] = useState('') + const [platformSearchTerm, setPlatformSearchTerm] = useState('') + const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) + + const platformFeatures = useMemo( + () => [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab' as const, + }, + { + id: 'hide-templates', + label: 'Templates', + category: 'Sidebar', + configKey: 'hideTemplates' as const, + }, + { + id: 'hide-copilot', + label: 'Copilot', + category: 'Workflow Panel', + configKey: 'hideCopilot' as const, + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab' as const, + }, + { + id: 'hide-environment', + label: 'Environment', + category: 'Settings Tabs', + configKey: 'hideEnvironmentTab' as const, + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab' as const, + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools' as const, + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools' as const, + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans' as const, + }, + ], + [] + ) + + const filteredPlatformFeatures = useMemo(() => { + if (!platformSearchTerm.trim()) return platformFeatures + const search = platformSearchTerm.toLowerCase() + return platformFeatures.filter( + (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + ) + }, [platformFeatures, platformSearchTerm]) + + const platformCategories = useMemo(() => { + const categories: Record = {} + for (const feature of filteredPlatformFeatures) { + if (!categories[feature.category]) { + categories[feature.category] = [] + } + categories[feature.category].push(feature) + } + return categories + }, [filteredPlatformFeatures]) + + const hasConfigChanges = useMemo(() => { + if (!viewingGroup || !editingConfig) return false + const original = viewingGroup.config + return JSON.stringify(original) !== JSON.stringify(editingConfig) + }, [viewingGroup, editingConfig]) + + const allBlocks = useMemo(() => { + // Filter out hidden blocks and start_trigger (which should never be disabled) + const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger') + return blocks.sort((a, b) => { + // Group by category: triggers first, then blocks, then tools + const categoryOrder = { triggers: 0, blocks: 1, tools: 2 } + const catA = categoryOrder[a.category] ?? 3 + const catB = categoryOrder[b.category] ?? 3 + if (catA !== catB) return catA - catB + return a.name.localeCompare(b.name) + }) + }, []) + const allProviderIds = useMemo(() => getAllProviderIds(), []) + + const filteredProviders = useMemo(() => { + if (!providerSearchTerm.trim()) return allProviderIds + const query = providerSearchTerm.toLowerCase() + return allProviderIds.filter((id) => id.toLowerCase().includes(query)) + }, [allProviderIds, providerSearchTerm]) + + const filteredBlocks = useMemo(() => { + if (!integrationSearchTerm.trim()) return allBlocks + const query = integrationSearchTerm.toLowerCase() + return allBlocks.filter((b) => b.name.toLowerCase().includes(query)) + }, [allBlocks, integrationSearchTerm]) + + const orgMembers = useMemo(() => { + return organization?.members || [] + }, [organization]) + + const filteredGroups = useMemo(() => { + if (!searchTerm.trim()) return permissionGroups + const searchLower = searchTerm.toLowerCase() + return permissionGroups.filter((g) => g.name.toLowerCase().includes(searchLower)) + }, [permissionGroups, searchTerm]) + + const handleCreatePermissionGroup = useCallback(async () => { + if (!newGroupName.trim() || !activeOrganization?.id) return + setCreateError(null) + try { + const result = await createPermissionGroup.mutateAsync({ + organizationId: activeOrganization.id, + name: newGroupName.trim(), + description: newGroupDescription.trim() || undefined, + }) + setShowCreateModal(false) + setNewGroupName('') + setNewGroupDescription('') + } catch (error) { + logger.error('Failed to create permission group', error) + if (error instanceof Error) { + setCreateError(error.message) + } else { + setCreateError('Failed to create permission group') + } + } + }, [newGroupName, newGroupDescription, activeOrganization?.id, createPermissionGroup]) + + const handleCloseCreateModal = useCallback(() => { + setShowCreateModal(false) + setNewGroupName('') + setNewGroupDescription('') + setCreateError(null) + }, []) + + const handleBackToList = useCallback(() => { + setViewingGroup(null) + }, []) + + const handleDeleteClick = useCallback((group: PermissionGroup) => { + setDeletingGroup({ id: group.id, name: group.name }) + }, []) + + const confirmDelete = useCallback(async () => { + if (!deletingGroup || !activeOrganization?.id) return + setDeletingGroupIds((prev) => new Set(prev).add(deletingGroup.id)) + try { + await deletePermissionGroup.mutateAsync({ + permissionGroupId: deletingGroup.id, + organizationId: activeOrganization.id, + }) + setDeletingGroup(null) + if (viewingGroup?.id === deletingGroup.id) { + setViewingGroup(null) + } + } catch (error) { + logger.error('Failed to delete permission group', error) + } finally { + setDeletingGroupIds((prev) => { + const next = new Set(prev) + next.delete(deletingGroup.id) + return next + }) + } + }, [deletingGroup, activeOrganization?.id, deletePermissionGroup, viewingGroup?.id]) + + const handleRemoveMember = useCallback( + async (memberId: string) => { + if (!viewingGroup) return + try { + await removeMember.mutateAsync({ + permissionGroupId: viewingGroup.id, + memberId, + }) + } catch (error) { + logger.error('Failed to remove member', error) + } + }, + [viewingGroup, removeMember] + ) + + const handleOpenConfigModal = useCallback(() => { + if (!viewingGroup) return + setEditingConfig({ ...viewingGroup.config }) + setShowConfigModal(true) + }, [viewingGroup]) + + const handleSaveConfig = useCallback(async () => { + if (!viewingGroup || !editingConfig || !activeOrganization?.id) return + try { + await updatePermissionGroup.mutateAsync({ + id: viewingGroup.id, + organizationId: activeOrganization.id, + config: editingConfig, + }) + setShowConfigModal(false) + setEditingConfig(null) + setProviderSearchTerm('') + setIntegrationSearchTerm('') + setPlatformSearchTerm('') + setViewingGroup((prev) => (prev ? { ...prev, config: editingConfig } : null)) + } catch (error) { + logger.error('Failed to update config', error) + } + }, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup]) + + const handleOpenAddMembersModal = useCallback(() => { + const existingMemberUserIds = new Set(members.map((m) => m.userId)) + setSelectedMemberIds(new Set()) + setShowAddMembersModal(true) + }, [members]) + + const handleAddSelectedMembers = useCallback(async () => { + if (!viewingGroup || selectedMemberIds.size === 0) return + try { + await bulkAddMembers.mutateAsync({ + permissionGroupId: viewingGroup.id, + userIds: Array.from(selectedMemberIds), + }) + setShowAddMembersModal(false) + setSelectedMemberIds(new Set()) + } catch (error) { + logger.error('Failed to add members', error) + } + }, [viewingGroup, selectedMemberIds, bulkAddMembers]) + + const toggleIntegration = useCallback( + (blockType: string) => { + if (!editingConfig) return + const current = editingConfig.allowedIntegrations + if (current === null) { + const allExcept = allBlocks.map((b) => b.type).filter((t) => t !== blockType) + setEditingConfig({ ...editingConfig, allowedIntegrations: allExcept }) + } else if (current.includes(blockType)) { + const updated = current.filter((t) => t !== blockType) + setEditingConfig({ + ...editingConfig, + allowedIntegrations: updated.length === allBlocks.length ? null : updated, + }) + } else { + const updated = [...current, blockType] + setEditingConfig({ + ...editingConfig, + allowedIntegrations: updated.length === allBlocks.length ? null : updated, + }) + } + }, + [editingConfig, allBlocks] + ) + + const toggleProvider = useCallback( + (providerId: string) => { + if (!editingConfig) return + const current = editingConfig.allowedModelProviders + if (current === null) { + const allExcept = allProviderIds.filter((p) => p !== providerId) + setEditingConfig({ ...editingConfig, allowedModelProviders: allExcept }) + } else if (current.includes(providerId)) { + const updated = current.filter((p) => p !== providerId) + setEditingConfig({ + ...editingConfig, + allowedModelProviders: updated.length === allProviderIds.length ? null : updated, + }) + } else { + const updated = [...current, providerId] + setEditingConfig({ + ...editingConfig, + allowedModelProviders: updated.length === allProviderIds.length ? null : updated, + }) + } + }, + [editingConfig, allProviderIds] + ) + + const isIntegrationAllowed = useCallback( + (blockType: string) => { + if (!editingConfig) return true + return ( + editingConfig.allowedIntegrations === null || + editingConfig.allowedIntegrations.includes(blockType) + ) + }, + [editingConfig] + ) + + const isProviderAllowed = useCallback( + (providerId: string) => { + if (!editingConfig) return true + return ( + editingConfig.allowedModelProviders === null || + editingConfig.allowedModelProviders.includes(providerId) + ) + }, + [editingConfig] + ) + + const availableMembersToAdd = useMemo(() => { + const existingMemberUserIds = new Set(members.map((m) => m.userId)) + return orgMembers.filter((m: any) => !existingMemberUserIds.has(m.userId)) + }, [orgMembers, members]) + + if (isLoading) { + return + } + + if (viewingGroup) { + return ( + <> +
+
+
+

+ {viewingGroup.name} +

+ +
+ {viewingGroup.description && ( +

{viewingGroup.description}

+ )} +
+ +
+
+
+ + Members + + +
+ + {membersLoading ? ( +
+ {[1, 2].map((i) => ( +
+
+ +
+ + +
+
+
+ ))} +
+ ) : members.length === 0 ? ( +

+ No members yet. Click "Add" to get started. +

+ ) : ( +
+ {members.map((member) => { + const name = member.userName || 'Unknown' + const avatarInitial = name.charAt(0).toUpperCase() + + return ( +
+
+ + {member.userImage && } + + {avatarInitial} + + + +
+
+ + {name} + +
+
+ {member.userEmail} +
+
+
+ + +
+ ) + })} +
+ )} +
+
+ +
+ +
+
+ + { + if (!open && hasConfigChanges) { + setShowUnsavedChanges(true) + } else { + setShowConfigModal(open) + if (!open) { + setProviderSearchTerm('') + setIntegrationSearchTerm('') + setPlatformSearchTerm('') + } + } + }} + > + + Configure Permissions + + + Model Providers + Blocks + Platform + + + + +
+
+
+ + setProviderSearchTerm(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-[13px] leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ +
+
+ {filteredProviders.map((providerId) => { + const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon + const providerName = + PROVIDER_DEFINITIONS[providerId]?.name || + providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + return ( +
+ toggleProvider(providerId)} + /> +
+ {ProviderIcon && } +
+ {providerName} +
+ ) + })} +
+
+
+
+ + + +
+
+
+ + setIntegrationSearchTerm(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-[13px] leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ +
+
+ {filteredBlocks.map((block) => { + const BlockIcon = block.icon + return ( +
+ toggleIntegration(block.type)} + /> +
+ {BlockIcon && ( + + )} +
+ {block.name} +
+ ) + })} +
+
+
+
+ + + +
+
+
+ + setPlatformSearchTerm(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-[13px] leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ +
+
+ {Object.entries(platformCategories).map(([category, features]) => ( +
+ + {category} + +
+ {features.map((feature) => ( +
+ + setEditingConfig((prev) => + prev + ? { ...prev, [feature.configKey]: checked !== true } + : prev + ) + } + /> + +
+ ))} +
+
+ ))} +
+
+
+
+
+ + + + +
+
+ + + + Unsaved Changes + +

+ You have unsaved changes. Do you want to save them before closing? +

+
+ + + + +
+
+ + + + ) + } + + return ( + <> +
+
+
+ + setSearchTerm(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ +
+ +
+ {filteredGroups.length === 0 && searchTerm.trim() ? ( +
+ No results found matching "{searchTerm}" +
+ ) : permissionGroups.length === 0 ? ( +
+ Click "Create" above to get started +
+ ) : ( +
+ {filteredGroups.map((group) => ( +
+
+ {group.name} + + {group.memberCount} member{group.memberCount !== 1 ? 's' : ''} + +
+
+ + +
+
+ ))} +
+ )} +
+
+ + + + Create Permission Group + +
+
+ + { + setNewGroupName(e.target.value) + if (createError) setCreateError(null) + }} + placeholder='e.g., Marketing Team' + /> +
+
+ + setNewGroupDescription(e.target.value)} + placeholder='e.g., Limited access for marketing users' + /> +
+ {createError &&

{createError}

} +
+
+ + + + +
+
+ + setDeletingGroup(null)}> + + Delete Permission Group + +

+ Are you sure you want to delete{' '} + {deletingGroup?.name}? + All members will be removed from this group.{' '} + This action cannot be undone. +

+
+ + + + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx index 9dba88d0fe..95b1346b83 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx @@ -631,7 +631,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { Cancel
{existingKey ? (
-
-
- -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/index.ts index 5862dd823b..a044207bda 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/server-list-item/server-list-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/server-list-item/server-list-item.tsx deleted file mode 100644 index c5ad6f8098..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/server-list-item/server-list-item.tsx +++ /dev/null @@ -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 ( -
-
-
- - {server.name || 'Unnamed Server'} - - ({transportLabel}) -
-

- {isRefreshing - ? 'Refreshing...' - : isLoadingTools && tools.length === 0 - ? 'Loading...' - : toolsLabel} -

-
-
- - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/types.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/types.ts deleted file mode 100644 index 5b1bfd90d9..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/types.ts +++ /dev/null @@ -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 -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index beab9c41a5..c4b8aa9d0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -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 + placeholder: string + value: string + scrollLeft: number + showEnvVars: boolean + envVarProps: EnvVarDropdownConfig + className?: string + onChange: (e: React.ChangeEvent) => void + onScroll: (scrollLeft: number) => void +} + +function FormattedInput({ + ref, + placeholder, + value, + scrollLeft, + showEnvVars, + envVarProps, + className, + onChange, + onScroll, +}: FormattedInputProps) { + const handleScroll = (e: React.UIEvent) => { + onScroll(e.currentTarget.scrollLeft) + } + + return ( +
+ +
+
+ {formatDisplayText(value)} +
+
+ {showEnvVars && ( + + )} +
+ ) +} + +interface HeaderRowProps { + header: HeaderEntry + index: number + headerScrollLeft: Record + 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 ( +
+ onInputChange('header-key', e.target.value, index)} + onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)} + /> + + onInputChange('header-value', e.target.value, index)} + onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)} + /> + + +
+ ) +} + +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 ( +
+
+
+ + {server.name || 'Unnamed Server'} + + ({transportLabel}) +
+

+ {isRefreshing + ? 'Refreshing...' + : isLoadingTools && tools.length === 0 + ? 'Loading...' + : toolsLabel} +

+
+
+ + +
+
+ ) +} + interface MCPProps { initialServerId?: string | null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 8736bf59d0..0c1b6a4efe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -208,11 +208,11 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro

-
-