Skip to content

Commit 96e3914

Browse files
committed
feat(ee): enterprise feature flags, permission group platform controls, audit logs ui, delete account
1 parent 30c5e82 commit 96e3914

25 files changed

Lines changed: 952 additions & 453 deletions

File tree

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: 4 additions & 0 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(),

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

Lines changed: 4 additions & 0 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(),
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/api/users/me/route.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { db } from '@sim/db'
2+
import { user, workspace } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, ne, sql } from 'drizzle-orm'
5+
import { NextResponse } from 'next/server'
6+
import { getSession } from '@/lib/auth'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { captureServerEvent } from '@/lib/posthog/server'
9+
10+
const logger = createLogger('DeleteUserAPI')
11+
12+
export const dynamic = 'force-dynamic'
13+
14+
export async function DELETE() {
15+
const requestId = generateRequestId()
16+
17+
try {
18+
const session = await getSession()
19+
20+
if (!session?.user?.id) {
21+
logger.warn(`[${requestId}] Unauthorized account deletion attempt`)
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const userId = session.user.id
26+
27+
captureServerEvent(userId, 'user_deleted', {})
28+
29+
await db.transaction(async (tx) => {
30+
await tx
31+
.update(workspace)
32+
.set({ billedAccountUserId: sql`owner_id` })
33+
.where(and(eq(workspace.billedAccountUserId, userId), ne(workspace.ownerId, userId)))
34+
35+
await tx.delete(workspace).where(eq(workspace.ownerId, userId))
36+
37+
await tx.delete(user).where(eq(user.id, userId))
38+
})
39+
40+
logger.info(`[${requestId}] User account deleted`, { userId })
41+
42+
return NextResponse.json({ success: true })
43+
} catch (error) {
44+
logger.error(`[${requestId}] Account deletion error`, error)
45+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
46+
}
47+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
22
import type { Metadata } from 'next'
33
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
44
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
5-
import { prefetchGeneralSettings, prefetchUserProfile } from './prefetch'
5+
import { prefetchGeneralSettings, prefetchSubscriptionData, prefetchUserProfile } from './prefetch'
66
import { SettingsPage } from './settings'
77

88
const SECTION_TITLES: Record<string, string> = {
@@ -46,6 +46,7 @@ export default async function SettingsSectionPage({
4646

4747
void prefetchGeneralSettings(queryClient)
4848
void prefetchUserProfile(queryClient)
49+
void prefetchSubscriptionData(queryClient)
4950

5051
return (
5152
<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/components/general/general.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/
2828
import { useBrandConfig } from '@/ee/whitelabeling'
2929
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
3030
import {
31+
useDeleteAccount,
3132
useResetPassword,
3233
useUpdateUserProfile,
3334
useUserProfile,
@@ -79,6 +80,10 @@ export function General() {
7980
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
8081
const resetPassword = useResetPassword()
8182

83+
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
84+
const [deleteConfirmText, setDeleteConfirmText] = useState('')
85+
const deleteAccount = useDeleteAccount()
86+
8287
const [uploadError, setUploadError] = useState<string | null>(null)
8388

8489
const snapToGridValue = settings?.snapToGridSize ?? 0
@@ -166,6 +171,23 @@ export function General() {
166171
}
167172
}
168173

174+
const handleDeleteAccountConfirm = async () => {
175+
deleteAccount.mutate(undefined, {
176+
onSuccess: async () => {
177+
try {
178+
await Promise.all([signOut(), clearUserData()])
179+
router.push('/login')
180+
} catch (error) {
181+
logger.error('Error during account cleanup', { error })
182+
router.push('/login')
183+
}
184+
},
185+
onError: (error) => {
186+
logger.error('Error deleting account:', error)
187+
},
188+
})
189+
}
190+
169191
const handleResetPasswordConfirm = async () => {
170192
if (!profile?.email) return
171193

@@ -467,6 +489,20 @@ export function General() {
467489
time.
468490
</p>
469491

492+
{isHosted && !isAuthDisabled && (
493+
<div className='flex items-center justify-between border-t pt-4'>
494+
<div>
495+
<Label>Delete account</Label>
496+
<p className='text-[var(--text-muted)] text-small'>
497+
Permanently delete your account and all associated data.
498+
</p>
499+
</div>
500+
<Button onClick={() => setShowDeleteAccountModal(true)} variant='active'>
501+
Delete account
502+
</Button>
503+
</div>
504+
)}
505+
470506
{isTrainingEnabled && (
471507
<div className='flex items-center justify-between'>
472508
<Label htmlFor='training-controls'>Training controls</Label>
@@ -500,6 +536,68 @@ export function General() {
500536
)}
501537
</div>
502538

539+
{/* Delete Account Confirmation Modal */}
540+
<Modal
541+
open={showDeleteAccountModal}
542+
onOpenChange={(open) => {
543+
setShowDeleteAccountModal(open)
544+
if (!open) {
545+
setDeleteConfirmText('')
546+
deleteAccount.reset()
547+
}
548+
}}
549+
>
550+
<ModalContent size='sm'>
551+
<ModalHeader>Delete Account</ModalHeader>
552+
<ModalBody>
553+
<p className='text-[var(--text-secondary)]'>
554+
This will permanently delete your account and all associated data, including
555+
workspaces, workflows, API keys, and execution history.{' '}
556+
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
557+
</p>
558+
<div className='mt-3'>
559+
<label
560+
htmlFor='delete-account-confirm'
561+
className='mb-1.5 block text-[var(--text-secondary)] text-sm'
562+
>
563+
Type{' '}
564+
<span className='font-medium text-[var(--text-primary)]'>delete my account</span> to
565+
confirm
566+
</label>
567+
<input
568+
id='delete-account-confirm'
569+
type='text'
570+
value={deleteConfirmText}
571+
onChange={(e) => setDeleteConfirmText(e.target.value)}
572+
className='w-full rounded-md border border-[var(--border)] bg-transparent px-3 py-2 text-[var(--text-primary)] text-sm placeholder:text-[var(--text-tertiary)] focus:border-[var(--border-1)] focus:outline-none'
573+
placeholder='delete my account'
574+
disabled={deleteAccount.isPending}
575+
/>
576+
</div>
577+
{deleteAccount.error && (
578+
<p className='mt-2 text-[var(--text-error)] text-small'>
579+
{deleteAccount.error.message}
580+
</p>
581+
)}
582+
</ModalBody>
583+
<ModalFooter>
584+
<Button
585+
onClick={() => setShowDeleteAccountModal(false)}
586+
disabled={deleteAccount.isPending}
587+
>
588+
Cancel
589+
</Button>
590+
<Button
591+
variant='destructive'
592+
onClick={handleDeleteAccountConfirm}
593+
disabled={deleteAccount.isPending || deleteConfirmText !== 'delete my account'}
594+
>
595+
{deleteAccount.isPending ? 'Deleting...' : 'Delete Account'}
596+
</Button>
597+
</ModalFooter>
598+
</ModalContent>
599+
</Modal>
600+
503601
{/* Password Reset Confirmation Modal */}
504602
<Modal open={showResetPasswordModal} onOpenChange={setShowResetPasswordModal}>
505603
<ModalContent size='sm'>

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>

0 commit comments

Comments
 (0)