Skip to content

Commit 85f1d96

Browse files
authored
feat(ee): enterprise feature flags, permission group platform controls, audit logs ui, delete account (#4115)
* feat(ee): enterprise feature flags, permission group platform controls, audit logs ui, delete account * fix(settings): improve sidebar skeleton fidelity and fix credit purchase org cache invalidation - Bump skeleton icon and text from 16/14px to 24px to better match real nav item visual weight - Add orgId support to usePurchaseCredits so org billing/subscription caches are invalidated on credit purchase, matching the pattern used by useUpgradeSubscription - Polish ColorInput in whitelabeling settings with auto-prefix and select-on-focus UX * revert(settings): remove delete account feature * fix(settings): address pr review — atomic autoAddNewMembers, extract query hook, fix types and signal forwarding * chore(helm): add CREDENTIAL_SETS_ENABLED to values.yaml * fix(access-control): dynamic platform category columns, atomic permission group delete * fix(access-control): restore triggers section in blocks tab * fix(access-control): merge triggers into tools section in blocks tab * upgrade tubro * fix(access-control): fix Select All state when config has stale blacklisted provider IDs * fix(access-control): derive platform Select All from features list; revert turbo schema version * fix(access-control): fix blocks Select All check, filter empty platform columns * revert(settings): restore original skeleton icon and text sizes
1 parent bc31710 commit 85f1d96

File tree

26 files changed

+873
-521
lines changed

26 files changed

+873
-521
lines changed

apps/docs/content/docs/en/enterprise/index.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ For self-hosted deployments, enterprise features can be enabled via environment
6969
| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups for access restrictions |
7070
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
7171
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
72+
| `INBOX_ENABLED`, `NEXT_PUBLIC_INBOX_ENABLED` | Sim Mailer inbox for outbound email |
73+
| `WHITELABELING_ENABLED`, `NEXT_PUBLIC_WHITELABELING_ENABLED` | Custom branding and white-labeling |
74+
| `AUDIT_LOGS_ENABLED`, `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | Audit logging for compliance and monitoring |
7275
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Globally disable workspace/organization invitations |
7376

7477
### Organization Management

apps/sim/app/api/permission-groups/[id]/route.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,18 @@ const configSchema = z.object({
2121
hideKnowledgeBaseTab: z.boolean().optional(),
2222
hideTablesTab: z.boolean().optional(),
2323
hideCopilot: z.boolean().optional(),
24+
hideIntegrationsTab: z.boolean().optional(),
25+
hideSecretsTab: z.boolean().optional(),
2426
hideApiKeysTab: z.boolean().optional(),
27+
hideInboxTab: z.boolean().optional(),
2528
hideEnvironmentTab: z.boolean().optional(),
2629
hideFilesTab: z.boolean().optional(),
2730
disableMcpTools: z.boolean().optional(),
2831
disableCustomTools: z.boolean().optional(),
2932
disableSkills: z.boolean().optional(),
3033
hideTemplates: z.boolean().optional(),
3134
disableInvitations: z.boolean().optional(),
35+
disablePublicApi: z.boolean().optional(),
3236
hideDeployApi: z.boolean().optional(),
3337
hideDeployMcp: z.boolean().optional(),
3438
hideDeployA2a: z.boolean().optional(),
@@ -151,31 +155,34 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
151155
? { ...currentConfig, ...updates.config }
152156
: currentConfig
153157

154-
// If setting autoAddNewMembers to true, unset it on other groups in the org first
155-
if (updates.autoAddNewMembers === true) {
156-
await db
157-
.update(permissionGroup)
158-
.set({ autoAddNewMembers: false, updatedAt: new Date() })
159-
.where(
160-
and(
161-
eq(permissionGroup.organizationId, result.group.organizationId),
162-
eq(permissionGroup.autoAddNewMembers, true)
158+
const now = new Date()
159+
160+
await db.transaction(async (tx) => {
161+
if (updates.autoAddNewMembers === true) {
162+
await tx
163+
.update(permissionGroup)
164+
.set({ autoAddNewMembers: false, updatedAt: now })
165+
.where(
166+
and(
167+
eq(permissionGroup.organizationId, result.group.organizationId),
168+
eq(permissionGroup.autoAddNewMembers, true)
169+
)
163170
)
164-
)
165-
}
171+
}
166172

167-
await db
168-
.update(permissionGroup)
169-
.set({
170-
...(updates.name !== undefined && { name: updates.name }),
171-
...(updates.description !== undefined && { description: updates.description }),
172-
...(updates.autoAddNewMembers !== undefined && {
173-
autoAddNewMembers: updates.autoAddNewMembers,
174-
}),
175-
config: newConfig,
176-
updatedAt: new Date(),
177-
})
178-
.where(eq(permissionGroup.id, id))
173+
await tx
174+
.update(permissionGroup)
175+
.set({
176+
...(updates.name !== undefined && { name: updates.name }),
177+
...(updates.description !== undefined && { description: updates.description }),
178+
...(updates.autoAddNewMembers !== undefined && {
179+
autoAddNewMembers: updates.autoAddNewMembers,
180+
}),
181+
config: newConfig,
182+
updatedAt: now,
183+
})
184+
.where(eq(permissionGroup.id, id))
185+
})
179186

180187
const [updated] = await db
181188
.select()
@@ -245,8 +252,10 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
245252
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
246253
}
247254

248-
await db.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id))
249-
await db.delete(permissionGroup).where(eq(permissionGroup.id, id))
255+
await db.transaction(async (tx) => {
256+
await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id))
257+
await tx.delete(permissionGroup).where(eq(permissionGroup.id, id))
258+
})
250259

251260
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
252261

apps/sim/app/api/permission-groups/route.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,18 @@ const configSchema = z.object({
2323
hideKnowledgeBaseTab: z.boolean().optional(),
2424
hideTablesTab: z.boolean().optional(),
2525
hideCopilot: z.boolean().optional(),
26+
hideIntegrationsTab: z.boolean().optional(),
27+
hideSecretsTab: z.boolean().optional(),
2628
hideApiKeysTab: z.boolean().optional(),
29+
hideInboxTab: z.boolean().optional(),
2730
hideEnvironmentTab: z.boolean().optional(),
2831
hideFilesTab: z.boolean().optional(),
2932
disableMcpTools: z.boolean().optional(),
3033
disableCustomTools: z.boolean().optional(),
3134
disableSkills: z.boolean().optional(),
3235
hideTemplates: z.boolean().optional(),
3336
disableInvitations: z.boolean().optional(),
37+
disablePublicApi: z.boolean().optional(),
3438
hideDeployApi: z.boolean().optional(),
3539
hideDeployMcp: z.boolean().optional(),
3640
hideDeployA2a: z.boolean().optional(),
@@ -167,19 +171,6 @@ export async function POST(req: Request) {
167171
...config,
168172
}
169173

170-
// If autoAddNewMembers is true, unset it on any existing groups first
171-
if (autoAddNewMembers) {
172-
await db
173-
.update(permissionGroup)
174-
.set({ autoAddNewMembers: false, updatedAt: new Date() })
175-
.where(
176-
and(
177-
eq(permissionGroup.organizationId, organizationId),
178-
eq(permissionGroup.autoAddNewMembers, true)
179-
)
180-
)
181-
}
182-
183174
const now = new Date()
184175
const newGroup = {
185176
id: generateId(),
@@ -193,7 +184,20 @@ export async function POST(req: Request) {
193184
autoAddNewMembers: autoAddNewMembers || false,
194185
}
195186

196-
await db.insert(permissionGroup).values(newGroup)
187+
await db.transaction(async (tx) => {
188+
if (autoAddNewMembers) {
189+
await tx
190+
.update(permissionGroup)
191+
.set({ autoAddNewMembers: false, updatedAt: now })
192+
.where(
193+
and(
194+
eq(permissionGroup.organizationId, organizationId),
195+
eq(permissionGroup.autoAddNewMembers, true)
196+
)
197+
)
198+
}
199+
await tx.insert(permissionGroup).values(newGroup)
200+
})
197201

198202
logger.info('Created permission group', {
199203
permissionGroupId: newGroup.id,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { NextResponse } from 'next/server'
2+
import { getSession } from '@/lib/auth'
3+
import { getBlacklistedProvidersFromEnv } from '@/lib/core/config/feature-flags'
4+
5+
export async function GET() {
6+
const session = await getSession()
7+
if (!session?.user?.id) {
8+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9+
}
10+
11+
return NextResponse.json({
12+
blacklistedProviders: getBlacklistedProvidersFromEnv(),
13+
})
14+
}

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
22
import type { Metadata } from 'next'
3+
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
34
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
45
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
5-
import { prefetchGeneralSettings, prefetchUserProfile } from './prefetch'
6+
import { prefetchGeneralSettings, prefetchSubscriptionData, prefetchUserProfile } from './prefetch'
67
import { SettingsPage } from './settings'
78

89
const SECTION_TITLES: Record<string, string> = {
@@ -11,6 +12,7 @@ const SECTION_TITLES: Record<string, string> = {
1112
secrets: 'Secrets',
1213
'template-profile': 'Template Profile',
1314
'access-control': 'Access Control',
15+
'audit-logs': 'Audit Logs',
1416
apikeys: 'Sim Keys',
1517
byok: 'BYOK',
1618
subscription: 'Subscription',
@@ -46,6 +48,7 @@ export default async function SettingsSectionPage({
4648

4749
void prefetchGeneralSettings(queryClient)
4850
void prefetchUserProfile(queryClient)
51+
if (isBillingEnabled) void prefetchSubscriptionData(queryClient)
4952

5053
return (
5154
<HydrationBoundary state={dehydrate(queryClient)}>

apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
22
import { headers } from 'next/headers'
33
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
44
import { generalSettingsKeys, mapGeneralSettingsResponse } from '@/hooks/queries/general-settings'
5+
import { subscriptionKeys } from '@/hooks/queries/subscription'
56
import { mapUserProfileResponse, userProfileKeys } from '@/hooks/queries/user-profile'
67

78
/**
@@ -35,6 +36,28 @@ export function prefetchGeneralSettings(queryClient: QueryClient) {
3536
})
3637
}
3738

39+
/**
40+
* Prefetch subscription data server-side via internal API fetch.
41+
* Uses the same query key as the client `useSubscriptionData` hook (with includeOrg=false)
42+
* so data is shared via HydrationBoundary — ensuring the settings sidebar renders
43+
* with the correct Team/Enterprise tabs on the first paint, with no flash.
44+
*/
45+
export function prefetchSubscriptionData(queryClient: QueryClient) {
46+
return queryClient.prefetchQuery({
47+
queryKey: subscriptionKeys.user(false),
48+
queryFn: async () => {
49+
const fwdHeaders = await getForwardedHeaders()
50+
const baseUrl = getInternalApiBaseUrl()
51+
const response = await fetch(`${baseUrl}/api/billing?context=user`, {
52+
headers: fwdHeaders,
53+
})
54+
if (!response.ok) throw new Error(`Subscription prefetch failed: ${response.status}`)
55+
return response.json()
56+
},
57+
staleTime: 5 * 60 * 1000,
58+
})
59+
}
60+
3861
/**
3962
* Prefetch user profile server-side via internal API fetch.
4063
* Uses the same query keys as the client `useUserProfile` hook

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation'
66
import { usePostHog } from 'posthog-js/react'
77
import { Skeleton } from '@/components/emcn'
88
import { useSession } from '@/lib/auth/auth-client'
9+
import { cn } from '@/lib/core/utils/cn'
910
import { captureEvent } from '@/lib/posthog/client'
1011
import { AdminSkeleton } from '@/app/workspace/[workspaceId]/settings/components/admin/admin-skeleton'
1112
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
@@ -198,7 +199,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
198199
}, [effectiveSection, sessionLoading, posthog])
199200

200201
return (
201-
<div>
202+
<div className={cn(effectiveSection === 'access-control' && 'flex h-full flex-col')}>
202203
<h2 className='mb-7 font-medium text-[22px] text-[var(--text-primary)]'>{label}</h2>
203204
{effectiveSection === 'general' && <General />}
204205
{effectiveSection === 'integrations' && <Integrations />}

apps/sim/app/workspace/[workspaceId]/settings/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
22
return (
33
<div className='h-full overflow-y-auto [scrollbar-gutter:stable]'>
4-
<div className='mx-auto flex min-h-full max-w-[900px] flex-col px-[26px] pt-9 pb-[52px]'>
4+
<div className='mx-auto flex min-h-full max-w-[940px] flex-col px-[26px] pt-9 pb-[52px]'>
55
{children}
66
</div>
77
</div>

apps/sim/app/workspace/[workspaceId]/settings/navigation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
7474
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
7575
const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
7676
const isInboxEnabled = isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED'))
77+
const isWhitelabelingEnabled = isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED'))
78+
const isAuditLogsEnabled = isTruthy(getEnv('NEXT_PUBLIC_AUDIT_LOGS_ENABLED'))
7779

7880
export const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
7981
export { isCredentialSetsEnabled }
@@ -106,6 +108,7 @@ export const allNavigationItems: NavigationItem[] = [
106108
section: 'enterprise',
107109
requiresHosted: true,
108110
requiresEnterprise: true,
111+
selfHostedOverride: isAuditLogsEnabled,
109112
},
110113
{
111114
id: 'subscription',
@@ -181,7 +184,7 @@ export const allNavigationItems: NavigationItem[] = [
181184
section: 'enterprise',
182185
requiresHosted: true,
183186
requiresEnterprise: true,
184-
selfHostedOverride: isBillingEnabled,
187+
selfHostedOverride: isWhitelabelingEnabled,
185188
},
186189
{
187190
id: 'admin',

0 commit comments

Comments
 (0)