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
This commit is contained in:
Waleed
2026-04-11 20:41:37 -07:00
committed by GitHub
parent bc31710c1c
commit 85f1d96859
26 changed files with 875 additions and 523 deletions
@@ -69,6 +69,9 @@ For self-hosted deployments, enterprise features can be enabled via environment
| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups for access restrictions |
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
| `INBOX_ENABLED`, `NEXT_PUBLIC_INBOX_ENABLED` | Sim Mailer inbox for outbound email |
| `WHITELABELING_ENABLED`, `NEXT_PUBLIC_WHITELABELING_ENABLED` | Custom branding and white-labeling |
| `AUDIT_LOGS_ENABLED`, `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | Audit logging for compliance and monitoring |
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Globally disable workspace/organization invitations |
### Organization Management
@@ -21,7 +21,10 @@ const configSchema = z.object({
hideKnowledgeBaseTab: z.boolean().optional(),
hideTablesTab: z.boolean().optional(),
hideCopilot: z.boolean().optional(),
hideIntegrationsTab: z.boolean().optional(),
hideSecretsTab: z.boolean().optional(),
hideApiKeysTab: z.boolean().optional(),
hideInboxTab: z.boolean().optional(),
hideEnvironmentTab: z.boolean().optional(),
hideFilesTab: z.boolean().optional(),
disableMcpTools: z.boolean().optional(),
@@ -29,6 +32,7 @@ const configSchema = z.object({
disableSkills: z.boolean().optional(),
hideTemplates: z.boolean().optional(),
disableInvitations: z.boolean().optional(),
disablePublicApi: z.boolean().optional(),
hideDeployApi: z.boolean().optional(),
hideDeployMcp: z.boolean().optional(),
hideDeployA2a: z.boolean().optional(),
@@ -151,31 +155,34 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
? { ...currentConfig, ...updates.config }
: currentConfig
// If setting autoAddNewMembers to true, unset it on other groups in the org first
if (updates.autoAddNewMembers === true) {
await db
.update(permissionGroup)
.set({ autoAddNewMembers: false, updatedAt: new Date() })
.where(
and(
eq(permissionGroup.organizationId, result.group.organizationId),
eq(permissionGroup.autoAddNewMembers, true)
)
)
}
const now = new Date()
await db
.update(permissionGroup)
.set({
...(updates.name !== undefined && { name: updates.name }),
...(updates.description !== undefined && { description: updates.description }),
...(updates.autoAddNewMembers !== undefined && {
autoAddNewMembers: updates.autoAddNewMembers,
}),
config: newConfig,
updatedAt: new Date(),
})
.where(eq(permissionGroup.id, id))
await db.transaction(async (tx) => {
if (updates.autoAddNewMembers === true) {
await tx
.update(permissionGroup)
.set({ autoAddNewMembers: false, updatedAt: now })
.where(
and(
eq(permissionGroup.organizationId, result.group.organizationId),
eq(permissionGroup.autoAddNewMembers, true)
)
)
}
await tx
.update(permissionGroup)
.set({
...(updates.name !== undefined && { name: updates.name }),
...(updates.description !== undefined && { description: updates.description }),
...(updates.autoAddNewMembers !== undefined && {
autoAddNewMembers: updates.autoAddNewMembers,
}),
config: newConfig,
updatedAt: now,
})
.where(eq(permissionGroup.id, id))
})
const [updated] = await db
.select()
@@ -245,8 +252,10 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
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))
await db.transaction(async (tx) => {
await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id))
await tx.delete(permissionGroup).where(eq(permissionGroup.id, id))
})
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
+18 -14
View File
@@ -23,7 +23,10 @@ const configSchema = z.object({
hideKnowledgeBaseTab: z.boolean().optional(),
hideTablesTab: z.boolean().optional(),
hideCopilot: z.boolean().optional(),
hideIntegrationsTab: z.boolean().optional(),
hideSecretsTab: z.boolean().optional(),
hideApiKeysTab: z.boolean().optional(),
hideInboxTab: z.boolean().optional(),
hideEnvironmentTab: z.boolean().optional(),
hideFilesTab: z.boolean().optional(),
disableMcpTools: z.boolean().optional(),
@@ -31,6 +34,7 @@ const configSchema = z.object({
disableSkills: z.boolean().optional(),
hideTemplates: z.boolean().optional(),
disableInvitations: z.boolean().optional(),
disablePublicApi: z.boolean().optional(),
hideDeployApi: z.boolean().optional(),
hideDeployMcp: z.boolean().optional(),
hideDeployA2a: z.boolean().optional(),
@@ -167,19 +171,6 @@ export async function POST(req: Request) {
...config,
}
// If autoAddNewMembers is true, unset it on any existing groups first
if (autoAddNewMembers) {
await db
.update(permissionGroup)
.set({ autoAddNewMembers: false, updatedAt: new Date() })
.where(
and(
eq(permissionGroup.organizationId, organizationId),
eq(permissionGroup.autoAddNewMembers, true)
)
)
}
const now = new Date()
const newGroup = {
id: generateId(),
@@ -193,7 +184,20 @@ export async function POST(req: Request) {
autoAddNewMembers: autoAddNewMembers || false,
}
await db.insert(permissionGroup).values(newGroup)
await db.transaction(async (tx) => {
if (autoAddNewMembers) {
await tx
.update(permissionGroup)
.set({ autoAddNewMembers: false, updatedAt: now })
.where(
and(
eq(permissionGroup.organizationId, organizationId),
eq(permissionGroup.autoAddNewMembers, true)
)
)
}
await tx.insert(permissionGroup).values(newGroup)
})
logger.info('Created permission group', {
permissionGroupId: newGroup.id,
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getBlacklistedProvidersFromEnv } from '@/lib/core/config/feature-flags'
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.json({
blacklistedProviders: getBlacklistedProvidersFromEnv(),
})
}
@@ -1,8 +1,9 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { prefetchGeneralSettings, prefetchUserProfile } from './prefetch'
import { prefetchGeneralSettings, prefetchSubscriptionData, prefetchUserProfile } from './prefetch'
import { SettingsPage } from './settings'
const SECTION_TITLES: Record<string, string> = {
@@ -11,6 +12,7 @@ const SECTION_TITLES: Record<string, string> = {
secrets: 'Secrets',
'template-profile': 'Template Profile',
'access-control': 'Access Control',
'audit-logs': 'Audit Logs',
apikeys: 'Sim Keys',
byok: 'BYOK',
subscription: 'Subscription',
@@ -46,6 +48,7 @@ export default async function SettingsSectionPage({
void prefetchGeneralSettings(queryClient)
void prefetchUserProfile(queryClient)
if (isBillingEnabled) void prefetchSubscriptionData(queryClient)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
@@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
import { headers } from 'next/headers'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { generalSettingsKeys, mapGeneralSettingsResponse } from '@/hooks/queries/general-settings'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import { mapUserProfileResponse, userProfileKeys } from '@/hooks/queries/user-profile'
/**
@@ -35,6 +36,28 @@ export function prefetchGeneralSettings(queryClient: QueryClient) {
})
}
/**
* Prefetch subscription data server-side via internal API fetch.
* Uses the same query key as the client `useSubscriptionData` hook (with includeOrg=false)
* so data is shared via HydrationBoundary — ensuring the settings sidebar renders
* with the correct Team/Enterprise tabs on the first paint, with no flash.
*/
export function prefetchSubscriptionData(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: subscriptionKeys.user(false),
queryFn: async () => {
const fwdHeaders = await getForwardedHeaders()
const baseUrl = getInternalApiBaseUrl()
const response = await fetch(`${baseUrl}/api/billing?context=user`, {
headers: fwdHeaders,
})
if (!response.ok) throw new Error(`Subscription prefetch failed: ${response.status}`)
return response.json()
},
staleTime: 5 * 60 * 1000,
})
}
/**
* Prefetch user profile server-side via internal API fetch.
* Uses the same query keys as the client `useUserProfile` hook
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Skeleton } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureEvent } from '@/lib/posthog/client'
import { AdminSkeleton } from '@/app/workspace/[workspaceId]/settings/components/admin/admin-skeleton'
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
@@ -198,7 +199,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
}, [effectiveSection, sessionLoading, posthog])
return (
<div>
<div className={cn(effectiveSection === 'access-control' && 'flex h-full flex-col')}>
<h2 className='mb-7 font-medium text-[22px] text-[var(--text-primary)]'>{label}</h2>
{effectiveSection === 'general' && <General />}
{effectiveSection === 'integrations' && <Integrations />}
@@ -1,7 +1,7 @@
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className='h-full overflow-y-auto [scrollbar-gutter:stable]'>
<div className='mx-auto flex min-h-full max-w-[900px] flex-col px-[26px] pt-9 pb-[52px]'>
<div className='mx-auto flex min-h-full max-w-[940px] flex-col px-[26px] pt-9 pb-[52px]'>
{children}
</div>
</div>
@@ -74,6 +74,8 @@ const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
const isInboxEnabled = isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED'))
const isWhitelabelingEnabled = isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED'))
const isAuditLogsEnabled = isTruthy(getEnv('NEXT_PUBLIC_AUDIT_LOGS_ENABLED'))
export const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
export { isCredentialSetsEnabled }
@@ -106,6 +108,7 @@ export const allNavigationItems: NavigationItem[] = [
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: isAuditLogsEnabled,
},
{
id: 'subscription',
@@ -181,7 +184,7 @@ export const allNavigationItems: NavigationItem[] = [
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: isBillingEnabled,
selfHostedOverride: isWhitelabelingEnabled,
},
{
id: 'admin',
@@ -34,7 +34,23 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
const SKELETON_SECTIONS = [3, 2, 2] as const
const SKELETON_SECTIONS = sectionConfig
.map(({ key }) =>
Math.min(
allNavigationItems.filter(
(item) =>
item.section === key &&
!(item.hideWhenBillingDisabled && !isBillingEnabled) &&
!item.requiresTeam &&
!item.requiresEnterprise &&
!item.requiresSuperUser &&
!item.requiresAdminRole &&
item.id !== 'template-profile'
).length,
3
)
)
.filter((count) => count > 0)
interface SettingsSidebarProps {
isCollapsed?: boolean
@@ -61,14 +77,16 @@ export function SettingsSidebar({
const { data: session, isPending: sessionLoading } = useSession()
const { data: organizationsData, isLoading: orgsLoading } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({
const { data: subscriptionData, isLoading: subscriptionLoading } = useSubscriptionData({
enabled: isBillingEnabled,
staleTime: 5 * 60 * 1000,
})
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({
enabled: !isHosted,
})
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
const { config: permissionConfig, isLoading: permissionLoading } = usePermissionConfig()
const userEmail = session?.user?.email
const userId = session?.user?.id
@@ -100,9 +118,18 @@ export function SettingsSidebar({
if (item.id === 'template-profile') {
return false
}
if (item.id === 'integrations' && permissionConfig.hideIntegrationsTab) {
return false
}
if (item.id === 'secrets' && permissionConfig.hideSecretsTab) {
return false
}
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return false
}
if (item.id === 'inbox' && permissionConfig.hideInboxTab) {
return false
}
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
return false
}
@@ -244,113 +271,102 @@ export function SettingsSidebar({
!isCollapsed && 'overflow-y-auto overflow-x-hidden'
)}
>
{sessionLoading || orgsLoading ? (
isCollapsed ? (
<>
{SKELETON_SECTIONS.map((count, sectionIdx) => (
<div key={sectionIdx} className='flex flex-col gap-0.5 px-2'>
{Array.from({ length: count }, (_, i) => (
<div key={i} className='mx-0.5 flex h-[30px] items-center px-2'>
<Skeleton className='h-[16px] w-[16px] rounded-sm' />
</div>
))}
</div>
))}
</>
) : (
Array.from({ length: 3 }, (_, i) => (
<div key={i} className='sidebar-collapse-hide flex flex-shrink-0 flex-col'>
<div className='px-4 pb-1.5'>
{sessionLoading ||
orgsLoading ||
(isBillingEnabled && subscriptionLoading) ||
permissionLoading ||
(!isHosted && isLoadingSSO)
? SKELETON_SECTIONS.map((count, i) => (
<div key={i} className='flex flex-shrink-0 flex-col'>
<div className='sidebar-collapse-hide px-4 pb-1.5'>
<Skeleton className='h-[14px] w-[64px] rounded-sm' />
</div>
<div className='flex flex-col gap-0.5 px-2'>
{Array.from({ length: i === 0 ? 3 : 2 }, (_, j) => (
<div key={j} className='mx-0.5 flex h-[30px] items-center px-2'>
<Skeleton className='h-[24px] w-full rounded-sm' />
{Array.from({ length: count }, (_, j) => (
<div key={j} className='mx-0.5 flex h-[30px] items-center gap-2 px-2'>
<Skeleton className='h-[16px] w-[16px] flex-shrink-0 rounded-sm' />
<Skeleton className='sidebar-collapse-hide h-[14px] w-full rounded-sm' />
</div>
))}
</div>
</div>
))
)
) : (
sectionConfig.map(({ key, title }) => {
const sectionItems = navigationItems.filter((item) => item.section === key)
if (sectionItems.length === 0) return null
: sectionConfig.map(({ key, title }) => {
const sectionItems = navigationItems.filter((item) => item.section === key)
if (sectionItems.length === 0) return null
return (
<div key={key} className='flex flex-shrink-0 flex-col'>
<div className='px-4 pb-1.5'>
<div className='font-base text-[var(--text-icon)] text-small'>{title}</div>
</div>
<div className='flex flex-col gap-0.5 px-2'>
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
const itemClassName = cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px]',
!active && 'hover-hover:bg-[var(--surface-hover)]',
active && 'bg-[var(--surface-active)]'
)
const content = (
<>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-base text-[var(--text-body)]'>
{item.label}
</span>
{isLocked && (
<span className='ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
Max
return (
<div key={key} className='flex flex-shrink-0 flex-col'>
<div className='px-4 pb-1.5'>
<div className='font-base text-[var(--text-icon)] text-small'>{title}</div>
</div>
<div className='flex flex-col gap-0.5 px-2'>
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
const itemClassName = cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px]',
!active && 'hover-hover:bg-[var(--surface-hover)]',
active && 'bg-[var(--surface-active)]'
)
const content = (
<>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-base text-[var(--text-body)]'>
{item.label}
</span>
)}
</>
)
{isLocked && (
<span className='ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
Max
</span>
)}
</>
)
const element = item.externalUrl ? (
<a
href={item.externalUrl}
target='_blank'
rel='noopener noreferrer'
className={itemClassName}
>
{content}
</a>
) : (
<button
type='button'
className={itemClassName}
onMouseEnter={() => handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
onClick={() => {
const section = item.id as SettingsSection
if (section === activeSection) return
if (!requestNavigation(section)) {
setShowDiscardDialog(true)
return
}
router.replace(getSettingsHref({ section }), { scroll: false })
}}
>
{content}
</button>
)
const element = item.externalUrl ? (
<a
href={item.externalUrl}
target='_blank'
rel='noopener noreferrer'
className={itemClassName}
>
{content}
</a>
) : (
<button
type='button'
className={itemClassName}
onMouseEnter={() => handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
onClick={() => {
const section = item.id as SettingsSection
if (section === activeSection) return
if (!requestNavigation(section)) {
setShowDiscardDialog(true)
return
}
router.replace(getSettingsHref({ section }), { scroll: false })
}}
>
{content}
</button>
)
return (
<SidebarTooltip
key={`${item.id}-${isCollapsed}`}
label={item.label}
enabled={showCollapsedTooltips}
>
{element}
</SidebarTooltip>
)
})}
return (
<SidebarTooltip
key={`${item.id}-${isCollapsed}`}
label={item.label}
enabled={showCollapsedTooltips}
>
{element}
</SidebarTooltip>
)
})}
</div>
</div>
</div>
)
})
)}
)
})}
</div>
<Modal open={showDiscardDialog} onOpenChange={(open) => !open && handleCancelDiscard()}>
@@ -102,7 +102,7 @@ ModalOverlay.displayName = 'ModalOverlay'
* Each size uses viewport units with sensible min/max constraints.
*/
const MODAL_SIZES = {
sm: 'w-[90vw] max-w-[400px]',
sm: 'w-[90vw] max-w-[440px]',
md: 'w-[90vw] max-w-[500px]',
lg: 'w-[90vw] max-w-[600px]',
xl: 'w-[90vw] max-w-[800px]',
@@ -120,7 +120,7 @@ export interface ModalContentProps
showClose?: boolean
/**
* Modal size variant with responsive viewport-based sizing.
* - sm: max 400px (dialogs, confirmations)
* - sm: max 440px (dialogs, confirmations)
* - md: max 500px (default, forms)
* - lg: max 600px (content-heavy modals)
* - xl: max 800px (complex editors)
+1 -1
View File
@@ -3554,7 +3554,7 @@ export function FireworksIcon(props: SVGProps<SVGSVGElement>) {
>
<path
d='M314.333 110.167L255.98 251.729l-58.416-141.562h-37.459l64 154.75c5.23 12.854 17.771 21.312 31.646 21.312s26.417-8.437 31.646-21.27l64.396-154.792h-37.459zm24.917 215.666L446 216.583l-14.562-34.77-116.584 119.562c-9.708 9.958-12.541 24.833-7.146 37.646 5.292 12.73 17.792 21.083 31.584 21.083l.042.063L506 359.75l-14.562-34.77-152.146.853h-.042zM66 216.5l14.563-34.77 116.583 119.562a34.592 34.592 0 017.146 37.646C199 351.667 186.5 360.02 172.708 360.02l-166.666-.375-.042.042 14.563-34.771 152.145.875L66 216.5z'
fill='currentColor'
fill='#5019c5'
/>
</svg>
)
@@ -41,6 +41,7 @@ import {
useRemovePermissionGroupMember,
useUpdatePermissionGroup,
} from '@/ee/access-control/hooks/permission-groups'
import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
@@ -48,10 +49,19 @@ import { getAllProviderIds } from '@/providers/utils'
const logger = createLogger('AccessControl')
interface OrgMember {
userId: string
user: {
name: string | null
email: string
image?: string | null
}
}
interface AddMembersModalProps {
open: boolean
onOpenChange: (open: boolean) => void
availableMembers: any[]
availableMembers: OrgMember[]
selectedMemberIds: Set<string>
setSelectedMemberIds: React.Dispatch<React.SetStateAction<Set<string>>>
onAddMembers: () => void
@@ -72,7 +82,7 @@ function AddMembersModal({
const filteredMembers = useMemo(() => {
if (!searchTerm.trim()) return availableMembers
const query = searchTerm.toLowerCase()
return availableMembers.filter((m: any) => {
return availableMembers.filter((m) => {
const name = m.user?.name || ''
const email = m.user?.email || ''
return name.toLowerCase().includes(query) || email.toLowerCase().includes(query)
@@ -81,12 +91,12 @@ function AddMembersModal({
const allFilteredSelected = useMemo(() => {
if (filteredMembers.length === 0) return false
return filteredMembers.every((m: any) => selectedMemberIds.has(m.userId))
return filteredMembers.every((m) => selectedMemberIds.has(m.userId))
}, [filteredMembers, selectedMemberIds])
const handleToggleAll = () => {
if (allFilteredSelected) {
const filteredIds = new Set(filteredMembers.map((m: any) => m.userId))
const filteredIds = new Set(filteredMembers.map((m) => m.userId))
setSelectedMemberIds((prev) => {
const next = new Set(prev)
filteredIds.forEach((id) => next.delete(id))
@@ -95,7 +105,7 @@ function AddMembersModal({
} else {
setSelectedMemberIds((prev) => {
const next = new Set(prev)
filteredMembers.forEach((m: any) => next.add(m.userId))
filteredMembers.forEach((m) => next.add(m.userId))
return next
})
}
@@ -140,7 +150,7 @@ function AddMembersModal({
className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button variant='primary' onClick={handleToggleAll}>
<Button variant='default' onClick={handleToggleAll}>
{allFilteredSelected ? 'Deselect All' : 'Select All'}
</Button>
</div>
@@ -152,7 +162,7 @@ function AddMembersModal({
</p>
) : (
<div className='flex flex-col'>
{filteredMembers.map((member: any) => {
{filteredMembers.map((member) => {
const name = member.user?.name || 'Unknown'
const email = member.user?.email || ''
const avatarInitial = name.charAt(0).toUpperCase()
@@ -313,18 +323,24 @@ export function AccessControl() {
category: 'Workflow Panel',
configKey: 'hideCopilot' as const,
},
{
id: 'hide-integrations',
label: 'Integrations',
category: 'Settings Tabs',
configKey: 'hideIntegrationsTab' as const,
},
{
id: 'hide-secrets',
label: 'Secrets',
category: 'Settings Tabs',
configKey: 'hideSecretsTab' 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',
@@ -391,6 +407,12 @@ export function AccessControl() {
category: 'Collaboration',
configKey: 'disableInvitations' as const,
},
{
id: 'hide-inbox',
label: 'Sim Mailer',
category: 'Features',
configKey: 'hideInboxTab' as const,
},
{
id: 'disable-public-api',
label: 'Public API',
@@ -420,6 +442,29 @@ export function AccessControl() {
return categories
}, [filteredPlatformFeatures])
const platformCategoryColumns = useMemo(() => {
const categoryGroups = [
['Sidebar', 'Deploy Tabs', 'Collaboration'],
['Workflow Panel', 'Tools', 'Features'],
['Settings Tabs', 'Logs'],
]
const assignedCategories = new Set(categoryGroups.flat())
const unassigned = Object.keys(platformCategories).filter((c) => !assignedCategories.has(c))
const groups = unassigned.length > 0 ? [...categoryGroups, unassigned] : categoryGroups
return groups
.map((column) =>
column
.map((category) => ({
category,
features: platformCategories[category] ?? [],
}))
.filter((section) => section.features.length > 0)
)
.filter((column) => column.length > 0)
}, [platformCategories])
const hasConfigChanges = useMemo(() => {
if (!viewingGroup || !editingConfig) return false
const original = viewingGroup.config
@@ -436,7 +481,14 @@ export function AccessControl() {
return a.name.localeCompare(b.name)
})
}, [])
const allProviderIds = useMemo(() => getAllProviderIds(), [])
const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: showConfigModal })
const allProviderIds = useMemo(() => {
const allIds = getAllProviderIds()
const blacklist = blacklistedProvidersData?.blacklistedProviders ?? []
if (blacklist.length === 0) return allIds
return allIds.filter((id) => !blacklist.includes(id.toLowerCase()))
}, [blacklistedProvidersData])
const filteredProviders = useMemo(() => {
if (!providerSearchTerm.trim()) return allProviderIds
@@ -450,6 +502,16 @@ export function AccessControl() {
return allBlocks.filter((b) => b.name.toLowerCase().includes(query))
}, [allBlocks, integrationSearchTerm])
const filteredCoreBlocks = useMemo(() => {
return filteredBlocks.filter((block) => block.category === 'blocks')
}, [filteredBlocks])
const filteredToolBlocks = useMemo(() => {
return filteredBlocks
.filter((block) => block.category === 'tools' || block.category === 'triggers')
.sort((a, b) => a.name.localeCompare(b.name))
}, [filteredBlocks])
const orgMembers = useMemo(() => {
return organization?.members || []
}, [organization])
@@ -677,7 +739,7 @@ export function AccessControl() {
const availableMembersToAdd = useMemo(() => {
const existingMemberUserIds = new Set(members.map((m) => m.userId))
return orgMembers.filter((m: any) => !existingMemberUserIds.has(m.userId))
return orgMembers.filter((m) => !existingMemberUserIds.has(m.userId))
}, [orgMembers, members])
if (isLoading) {
@@ -841,249 +903,259 @@ export function AccessControl() {
}
}}
>
<ModalContent size='xl' className='max-h-[80vh]'>
<ModalContent size='xl' className='h-[76vh]'>
<ModalHeader>Configure Permissions</ModalHeader>
<ModalTabs defaultValue='providers'>
<ModalTabs defaultValue='providers' className='flex min-h-0 flex-1 flex-col'>
<ModalTabsList>
<ModalTabsTrigger value='providers'>Model Providers</ModalTabsTrigger>
<ModalTabsTrigger value='blocks'>Blocks</ModalTabsTrigger>
<ModalTabsTrigger value='platform'>Platform</ModalTabsTrigger>
</ModalTabsList>
<ModalTabsContent value='providers'>
<ModalBody className='h-[400px]'>
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<BaseInput
placeholder='Search providers...'
value={providerSearchTerm}
onChange={(e) => setProviderSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
variant='primary'
onClick={() => {
const allAllowed =
editingConfig?.allowedModelProviders === null ||
editingConfig?.allowedModelProviders?.length === allProviderIds.length
setEditingConfig((prev) =>
prev ? { ...prev, allowedModelProviders: allAllowed ? [] : null } : prev
)
}}
>
{editingConfig?.allowedModelProviders === null ||
editingConfig?.allowedModelProviders?.length === allProviderIds.length
? 'Deselect All'
: 'Select All'}
</Button>
<ModalBody className='min-h-0 flex-1'>
<ModalTabsContent value='providers'>
<div className='flex items-center gap-2 pb-3'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<BaseInput
placeholder='Search providers...'
value={providerSearchTerm}
onChange={(e) => setProviderSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='grid max-h-[340px] grid-cols-3 gap-2 overflow-y-auto'>
{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 (
<div key={providerId} className='flex items-center gap-2'>
<Checkbox
checked={isProviderAllowed(providerId)}
onCheckedChange={() => toggleProvider(providerId)}
/>
<div className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
{ProviderIcon && <ProviderIcon className='!h-[16px] !w-[16px]' />}
</div>
<span className='truncate font-medium text-sm'>{providerName}</span>
</div>
<Button
variant='default'
className='h-8'
onClick={() => {
const allAllowed =
editingConfig?.allowedModelProviders === null ||
allProviderIds.every((id) =>
editingConfig?.allowedModelProviders?.includes(id)
)
setEditingConfig((prev) =>
prev ? { ...prev, allowedModelProviders: allAllowed ? [] : null } : prev
)
})}
</div>
}}
>
{editingConfig?.allowedModelProviders === null ||
allProviderIds.every((id) =>
editingConfig?.allowedModelProviders?.includes(id)
)
? 'Deselect All'
: 'Select All'}
</Button>
</div>
</ModalBody>
</ModalTabsContent>
<ModalTabsContent value='blocks'>
<ModalBody className='h-[400px]'>
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<BaseInput
placeholder='Search blocks...'
value={integrationSearchTerm}
onChange={(e) => setIntegrationSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
variant='primary'
onClick={() => {
const allAllowed =
editingConfig?.allowedIntegrations === null ||
editingConfig?.allowedIntegrations?.length === allBlocks.length
setEditingConfig((prev) =>
prev
? {
...prev,
allowedIntegrations: allAllowed ? ['start_trigger'] : null,
}
: prev
)
}}
>
{editingConfig?.allowedIntegrations === null ||
editingConfig?.allowedIntegrations?.length === allBlocks.length
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className='grid max-h-[340px] grid-cols-3 gap-2 overflow-y-auto'>
{filteredBlocks.map((block) => {
const BlockIcon = block.icon
return (
<div key={block.type} className='flex items-center gap-2'>
<Checkbox
checked={isIntegrationAllowed(block.type)}
onCheckedChange={() => toggleIntegration(block.type)}
/>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: block.bgColor }}
>
{BlockIcon && (
<BlockIcon className='!h-[10px] !w-[10px] text-white' />
)}
</div>
<span className='truncate font-medium text-sm'>{block.name}</span>
<div className='grid grid-cols-3 gap-x-2 gap-y-0.5'>
{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())
const checkboxId = `provider-${providerId}`
return (
<label
key={providerId}
htmlFor={checkboxId}
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-2)]'
>
<Checkbox
id={checkboxId}
checked={isProviderAllowed(providerId)}
onCheckedChange={() => toggleProvider(providerId)}
/>
<div className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
{ProviderIcon && <ProviderIcon className='!h-[16px] !w-[16px]' />}
</div>
)
})}
</div>
<span className='truncate font-medium text-sm'>{providerName}</span>
</label>
)
})}
</div>
</ModalBody>
</ModalTabsContent>
</ModalTabsContent>
<ModalTabsContent value='platform'>
<ModalBody className='h-[400px]'>
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<BaseInput
placeholder='Search features...'
value={platformSearchTerm}
onChange={(e) => setPlatformSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
variant='primary'
onClick={() => {
const allVisible =
!editingConfig?.hideKnowledgeBaseTab &&
!editingConfig?.hideTablesTab &&
!editingConfig?.hideTemplates &&
!editingConfig?.hideCopilot &&
!editingConfig?.hideApiKeysTab &&
!editingConfig?.hideEnvironmentTab &&
!editingConfig?.hideFilesTab &&
!editingConfig?.disableMcpTools &&
!editingConfig?.disableCustomTools &&
!editingConfig?.disableSkills &&
!editingConfig?.hideTraceSpans &&
!editingConfig?.disableInvitations &&
!editingConfig?.disablePublicApi &&
!editingConfig?.hideDeployApi &&
!editingConfig?.hideDeployMcp &&
!editingConfig?.hideDeployA2a &&
!editingConfig?.hideDeployChatbot &&
!editingConfig?.hideDeployTemplate
setEditingConfig((prev) =>
prev
? {
...prev,
hideKnowledgeBaseTab: allVisible,
hideTablesTab: allVisible,
hideTemplates: allVisible,
hideCopilot: allVisible,
hideApiKeysTab: allVisible,
hideEnvironmentTab: allVisible,
hideFilesTab: allVisible,
disableMcpTools: allVisible,
disableCustomTools: allVisible,
disableSkills: allVisible,
hideTraceSpans: allVisible,
disableInvitations: allVisible,
disablePublicApi: allVisible,
hideDeployApi: allVisible,
hideDeployMcp: allVisible,
hideDeployA2a: allVisible,
hideDeployChatbot: allVisible,
hideDeployTemplate: allVisible,
}
: prev
)
}}
>
{!editingConfig?.hideKnowledgeBaseTab &&
!editingConfig?.hideTablesTab &&
!editingConfig?.hideTemplates &&
!editingConfig?.hideCopilot &&
!editingConfig?.hideApiKeysTab &&
!editingConfig?.hideEnvironmentTab &&
!editingConfig?.hideFilesTab &&
!editingConfig?.disableMcpTools &&
!editingConfig?.disableCustomTools &&
!editingConfig?.disableSkills &&
!editingConfig?.hideTraceSpans &&
!editingConfig?.disableInvitations &&
!editingConfig?.disablePublicApi &&
!editingConfig?.hideDeployApi &&
!editingConfig?.hideDeployMcp &&
!editingConfig?.hideDeployA2a &&
!editingConfig?.hideDeployChatbot &&
!editingConfig?.hideDeployTemplate
? 'Deselect All'
: 'Select All'}
</Button>
<ModalTabsContent value='blocks'>
<div className='flex items-center gap-2 pb-3'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<BaseInput
placeholder='Search blocks...'
value={integrationSearchTerm}
onChange={(e) => setIntegrationSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='grid max-h-[340px] grid-cols-3 gap-x-6 gap-y-4 overflow-y-auto'>
{Object.entries(platformCategories).map(([category, features]) => (
<div key={category} className='flex flex-col gap-2'>
<span className='font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide'>
{category}
</span>
<div className='flex flex-col gap-2'>
{features.map((feature) => (
<div key={feature.id} className='flex items-center gap-2'>
<Button
variant='default'
className='h-8'
onClick={() => {
const allAllowed =
editingConfig?.allowedIntegrations === null ||
allBlocks.every((b) =>
editingConfig?.allowedIntegrations?.includes(b.type)
)
setEditingConfig((prev) =>
prev
? {
...prev,
allowedIntegrations: allAllowed ? ['start_trigger'] : null,
}
: prev
)
}}
>
{editingConfig?.allowedIntegrations === null ||
allBlocks.every((b) => editingConfig?.allowedIntegrations?.includes(b.type))
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className='flex flex-col gap-4'>
{filteredCoreBlocks.length > 0 && (
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide'>
Core Blocks
</span>
<div className='grid grid-cols-3 gap-x-2 gap-y-0.5'>
{filteredCoreBlocks.map((block) => {
const BlockIcon = block.icon
const checkboxId = `block-${block.type}`
return (
<label
key={block.type}
htmlFor={checkboxId}
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-2)]'
>
<Checkbox
id={feature.id}
checked={!editingConfig?.[feature.configKey]}
onCheckedChange={(checked) =>
setEditingConfig((prev) =>
prev
? { ...prev, [feature.configKey]: checked !== true }
: prev
)
}
id={checkboxId}
checked={isIntegrationAllowed(block.type)}
onCheckedChange={() => toggleIntegration(block.type)}
/>
<Label
htmlFor={feature.id}
className='cursor-pointer font-normal text-sm'
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: block.bgColor }}
>
{feature.label}
</Label>
</div>
))}
</div>
{BlockIcon && (
<BlockIcon className='!h-[10px] !w-[10px] text-white' />
)}
</div>
<span className='truncate font-medium text-sm'>{block.name}</span>
</label>
)
})}
</div>
))}
</div>
</div>
)}
{filteredToolBlocks.length > 0 && (
<div className='flex flex-col gap-1.5 border-[var(--border)] border-t pt-4'>
<span className='font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide'>
Tools
</span>
<div className='grid grid-cols-3 gap-x-2 gap-y-0.5'>
{filteredToolBlocks.map((block) => {
const BlockIcon = block.icon
const checkboxId = `block-${block.type}`
return (
<label
key={block.type}
htmlFor={checkboxId}
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-2)]'
>
<Checkbox
id={checkboxId}
checked={isIntegrationAllowed(block.type)}
onCheckedChange={() => toggleIntegration(block.type)}
/>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: block.bgColor }}
>
{BlockIcon && (
<BlockIcon className='!h-[10px] !w-[10px] text-white' />
)}
</div>
<span className='truncate font-medium text-sm'>{block.name}</span>
</label>
)
})}
</div>
</div>
)}
</div>
</ModalBody>
</ModalTabsContent>
</ModalTabsContent>
<ModalTabsContent value='platform'>
<div className='flex items-center gap-2 pb-3'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<BaseInput
placeholder='Search features...'
value={platformSearchTerm}
onChange={(e) => setPlatformSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base text-sm leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
variant='default'
className='h-8'
onClick={() => {
const allVisible = platformFeatures.every(
(f) => !editingConfig?.[f.configKey]
)
setEditingConfig((prev) =>
prev
? {
...prev,
...Object.fromEntries(
platformFeatures.map((f) => [f.configKey, allVisible])
),
}
: prev
)
}}
>
{platformFeatures.every((f) => !editingConfig?.[f.configKey])
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className='grid grid-cols-3 gap-x-6'>
{platformCategoryColumns.map((column, columnIndex) => (
<div key={columnIndex} className='flex flex-col gap-8'>
{column.map(({ category, features }) => (
<div key={category} className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide'>
{category}
</span>
<div className='flex flex-col gap-0.5'>
{features.map((feature) => (
<label
key={feature.id}
htmlFor={feature.id}
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-2)]'
>
<Checkbox
id={feature.id}
checked={!editingConfig?.[feature.configKey]}
onCheckedChange={(checked) =>
setEditingConfig((prev) =>
prev
? { ...prev, [feature.configKey]: checked !== true }
: prev
)
}
/>
<span className='font-normal text-sm'>{feature.label}</span>
</label>
))}
</div>
</div>
))}
</div>
))}
</div>
</ModalTabsContent>
</ModalBody>
</ModalTabs>
<ModalFooter>
<Button
@@ -17,7 +17,10 @@ const {
hideKnowledgeBaseTab: false,
hideTablesTab: false,
hideCopilot: false,
hideIntegrationsTab: false,
hideSecretsTab: false,
hideApiKeysTab: false,
hideInboxTab: false,
hideEnvironmentTab: false,
hideFilesTab: false,
disableMcpTools: false,
@@ -25,6 +28,7 @@ const {
disableSkills: false,
hideTemplates: false,
disableInvitations: false,
disablePublicApi: false,
hideDeployApi: false,
hideDeployMcp: false,
hideDeployA2a: false,
+203 -95
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { RefreshCw, Search } from 'lucide-react'
import { ChevronDown, RefreshCw, Search } from 'lucide-react'
import { Badge, Button, Combobox, type ComboboxOption, Skeleton } from '@/components/emcn'
import { Input } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
@@ -38,15 +38,109 @@ function formatAction(action: string): string {
return action.replace(/[._]/g, ' ')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function formatMetadataLabel(key: string): string {
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function formatPrimitiveValue(value: string | number | boolean | null): string {
if (value === null) return '-'
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
if (typeof value === 'number') return value.toLocaleString()
return value
}
function renderMetadataValue(value: unknown) {
if (value == null) return <span className='text-[var(--text-muted)]'>-</span>
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return <span className='text-[var(--text-primary)]'>{formatPrimitiveValue(value)}</span>
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className='text-[var(--text-muted)]'>None</span>
}
const hasComplexValues = value.some((item) => typeof item === 'object' && item !== null)
if (!hasComplexValues) {
return (
<span className='text-[var(--text-primary)]'>
{value
.map((item) => formatPrimitiveValue((item as string | number | boolean | null) ?? null))
.join(', ')}
</span>
)
}
return (
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(value, null, 2)}
</pre>
)
}
if (isRecord(value)) {
const entries = Object.entries(value).filter(([, nestedValue]) => nestedValue !== undefined)
if (entries.length === 0) {
return <span className='text-[var(--text-muted)]'>None</span>
}
const hasComplexValues = entries.some(([, nestedValue]) => {
return Array.isArray(nestedValue) || isRecord(nestedValue)
})
if (!hasComplexValues) {
return (
<span className='text-[var(--text-primary)]'>
{entries
.map(([nestedKey, nestedValue]) => {
return `${formatMetadataLabel(nestedKey)}: ${formatPrimitiveValue((nestedValue as string | number | boolean | null) ?? null)}`
})
.join(' · ')}
</span>
)
}
return (
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(value, null, 2)}
</pre>
)
}
return (
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(value, null, 2)}
</pre>
)
}
function getMetadataEntries(metadata: unknown) {
if (!isRecord(metadata)) return []
return Object.entries(metadata).filter(([key, value]) => {
if (value === undefined) return false
return !['name', 'description'].includes(key)
})
}
interface ActionBadgeProps {
action: string
}
function ActionBadge({ action }: ActionBadgeProps) {
const [, verb] = action.split('.')
const variant = verb === 'deleted' || verb === 'removed' || verb === 'revoked' ? 'red' : 'default'
const variant =
verb === 'deleted' || verb === 'removed' || verb === 'revoked' ? 'red' : 'gray-secondary'
return (
<Badge variant={variant} size='sm'>
<Badge variant={variant} size='sm' className='shrink-0'>
{formatAction(action)}
</Badge>
)
@@ -59,68 +153,86 @@ interface AuditLogRowProps {
function AuditLogRow({ entry }: AuditLogRowProps) {
const [expanded, setExpanded] = useState(false)
const timestamp = formatDateTime(new Date(entry.createdAt))
const metadataEntries = getMetadataEntries(entry.metadata)
return (
<div className='border-[var(--border)] border-b last:border-b-0'>
<div
className={cn(
'rounded-md transition-colors',
'hover-hover:bg-[var(--surface-2)]',
expanded && 'bg-[var(--surface-2)]'
)}
>
<button
type='button'
className='flex w-full items-center gap-4 px-0 py-2.5 text-left transition-colors hover-hover:bg-[var(--surface-4)]'
className='flex w-full items-center gap-3 px-3 py-2 text-left'
onClick={() => setExpanded(!expanded)}
>
<span className='w-[160px] flex-shrink-0 text-[var(--text-secondary)] text-sm'>
<span className='w-[160px] flex-shrink-0 text-[var(--text-secondary)] text-small'>
{timestamp}
</span>
<span className='w-[180px] flex-shrink-0'>
<ActionBadge action={entry.action} />
</span>
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)] text-sm'>
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)] text-small'>
{entry.description || entry.resourceName || entry.resourceId || '-'}
</span>
<span className='w-[160px] flex-shrink-0 truncate text-right text-[var(--text-secondary)] text-sm'>
{entry.actorEmail || entry.actorName || 'System'}
<span className='flex w-[160px] flex-shrink-0 items-center justify-end gap-1.5 text-[var(--text-secondary)] text-small'>
<span className='min-w-0 truncate'>
{entry.actorEmail || entry.actorName || 'System'}
</span>
<ChevronDown
className={cn(
'h-[14px] w-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
expanded && 'rotate-180'
)}
/>
</span>
</button>
{expanded && (
<div className='mb-2 ml-0 flex flex-col gap-1.5 rounded-md bg-[var(--surface-4)] p-3 text-sm'>
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Resource</span>
<span className='text-[var(--text-primary)]'>
{formatResourceType(entry.resourceType)}
{entry.resourceId && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.resourceId})</span>
)}
</span>
<div className='px-3 pb-2'>
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border-1)] bg-[var(--surface-3)] p-3 text-small'>
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Resource</span>
<span className='text-[var(--text-primary)]'>
{formatResourceType(entry.resourceType)}
{entry.resourceId && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.resourceId})</span>
)}
</span>
</div>
{entry.resourceName && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Name</span>
<span className='text-[var(--text-primary)]'>{entry.resourceName}</span>
</div>
)}
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Actor</span>
<span className='text-[var(--text-primary)]'>
{entry.actorName || 'Unknown'}
{entry.actorEmail && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.actorEmail})</span>
)}
</span>
</div>
{entry.description && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>
Description
</span>
<span className='text-[var(--text-primary)]'>{entry.description}</span>
</div>
)}
{metadataEntries.map(([key, value]) => (
<div key={key} className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>
{formatMetadataLabel(key)}
</span>
<div className='min-w-0 flex-1'>{renderMetadataValue(value)}</div>
</div>
))}
</div>
{entry.resourceName && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Name</span>
<span className='text-[var(--text-primary)]'>{entry.resourceName}</span>
</div>
)}
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Actor</span>
<span className='text-[var(--text-primary)]'>
{entry.actorName || 'Unknown'}
{entry.actorEmail && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.actorEmail})</span>
)}
</span>
</div>
{entry.description && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Description</span>
<span className='text-[var(--text-primary)]'>{entry.description}</span>
</div>
)}
{entry.metadata != null &&
Object.keys(entry.metadata as Record<string, unknown>).length > 0 ? (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Details</span>
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</div>
) : null}
</div>
)}
</div>
@@ -178,7 +290,7 @@ export function AuditLogs() {
return (
<div className='flex h-full flex-col gap-4.5'>
<div className='flex items-center gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-2 transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-1.5 transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
@@ -196,7 +308,7 @@ export function AuditLogs() {
value={resourceType}
onChange={setResourceType}
placeholder='Resource type'
size='md'
size='sm'
/>
</div>
<div className='w-[140px]'>
@@ -205,7 +317,7 @@ export function AuditLogs() {
value={dateRange}
onChange={setDateRange}
placeholder='Date range'
size='md'
size='sm'
/>
</div>
<Button variant='ghost' onClick={handleRefresh} disabled={isRefetching}>
@@ -216,51 +328,47 @@ export function AuditLogs() {
</Button>
</div>
<div className='flex items-center gap-4 border-[var(--border)] border-b pb-2'>
<span className='w-[160px] flex-shrink-0 font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Timestamp
</span>
<span className='w-[180px] flex-shrink-0 font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Event
</span>
<span className='min-w-0 flex-1 font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Description
</span>
<span className='w-[160px] flex-shrink-0 text-right font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Actor
</span>
</div>
<div className='flex min-h-0 flex-1 flex-col'>
<div className='flex items-center gap-3 px-3 pb-1 text-[var(--text-tertiary)] text-caption'>
<span className='w-[160px] flex-shrink-0'>Timestamp</span>
<span className='w-[180px] flex-shrink-0'>Event</span>
<span className='min-w-0 flex-1'>Description</span>
<span className='w-[160px] flex-shrink-0 text-right'>Actor</span>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
{isLoading ? (
<div className='flex flex-col gap-3'>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className='flex items-center gap-4 py-2.5'>
<Skeleton className='h-4 w-[140px]' />
<Skeleton className='h-5 w-[120px] rounded-full' />
<Skeleton className='h-4 flex-1' />
<Skeleton className='h-4 w-[140px]' />
</div>
))}
</div>
) : allEntries.length === 0 ? (
<div className='flex h-full items-center justify-center py-12 text-[var(--text-muted)] text-sm'>
{debouncedSearch ? `No results for "${debouncedSearch}"` : 'No audit logs found'}
</div>
) : (
<div className='flex flex-col'>
{allEntries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
))}
{hasNextPage && (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
)}
<div className='min-h-0 flex-1 overflow-y-auto'>
{isLoading ? (
<div className='flex flex-col gap-0.5'>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className='rounded-md px-3 py-2'>
<div className='flex items-center gap-3'>
<Skeleton className='h-4 w-[140px]' />
<Skeleton className='h-5 w-[120px] rounded-full' />
<Skeleton className='h-4 flex-1' />
<Skeleton className='h-4 w-[140px]' />
</div>
</div>
))}
</div>
) : allEntries.length === 0 ? (
<div className='flex h-full items-center justify-center py-12 text-[var(--text-muted)] text-small'>
{debouncedSearch ? `No results for "${debouncedSearch}"` : 'No audit logs found'}
</div>
) : (
<div className='flex flex-col gap-0.5'>
{allEntries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
))}
{hasNextPage && (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
+10 -5
View File
@@ -14,8 +14,8 @@ export const ssoKeys = {
/**
* Fetch SSO providers
*/
async function fetchSSOProviders() {
const response = await fetch('/api/auth/sso/providers')
async function fetchSSOProviders(signal: AbortSignal) {
const response = await fetch('/api/auth/sso/providers', { signal })
if (!response.ok) {
throw new Error('Failed to fetch SSO providers')
}
@@ -25,12 +25,17 @@ async function fetchSSOProviders() {
/**
* Hook to fetch SSO providers
*/
export function useSSOProviders() {
interface UseSSOProvidersOptions {
enabled?: boolean
}
export function useSSOProviders({ enabled = true }: UseSSOProvidersOptions = {}) {
return useQuery({
queryKey: ssoKeys.providers(),
queryFn: fetchSSOProviders,
staleTime: 5 * 60 * 1000, // 5 minutes
queryFn: ({ signal }) => fetchSSOProviders(signal),
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
enabled,
})
}
@@ -4,7 +4,7 @@ import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Loader2, X } from 'lucide-react'
import Image from 'next/image'
import { Button, Input, Label, Switch } from '@/components/emcn'
import { Button, Input, Label } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { HEX_COLOR_REGEX } from '@/lib/branding'
@@ -79,6 +79,22 @@ interface ColorInputProps {
function ColorInput({ label, value, onChange, placeholder = '#000000' }: ColorInputProps) {
const isValidHex = !value || HEX_COLOR_REGEX.test(value)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
let v = e.target.value.trim()
if (v && !v.startsWith('#')) {
v = `#${v}`
}
v = v.slice(0, 1) + v.slice(1).replace(/[^0-9a-fA-F]/g, '')
onChange(v.slice(0, 7))
},
[onChange]
)
const handleFocus = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
e.target.select()
}, [])
return (
<div className='flex flex-col gap-1.5'>
<Label className='text-[13px] text-[var(--text-primary)]'>{label}</Label>
@@ -92,7 +108,8 @@ function ColorInput({ label, value, onChange, placeholder = '#000000' }: ColorIn
</div>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={handleChange}
onFocus={handleFocus}
placeholder={placeholder}
className={cn(
'h-[36px] font-mono text-[13px]',
@@ -154,7 +171,6 @@ export function WhitelabelingSettings() {
const [documentationUrl, setDocumentationUrl] = useState('')
const [termsUrl, setTermsUrl] = useState('')
const [privacyUrl, setPrivacyUrl] = useState('')
const [hidePoweredBySim, setHidePoweredBySim] = useState(false)
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [wordmarkUrl, setWordmarkUrl] = useState<string | null>(null)
const [formInitialized, setFormInitialized] = useState(false)
@@ -172,7 +188,6 @@ export function WhitelabelingSettings() {
setDocumentationUrl(savedSettings.documentationUrl ?? '')
setTermsUrl(savedSettings.termsUrl ?? '')
setPrivacyUrl(savedSettings.privacyUrl ?? '')
setHidePoweredBySim(savedSettings.hidePoweredBySim ?? false)
setLogoUrl(savedSettings.logoUrl ?? null)
setWordmarkUrl(savedSettings.wordmarkUrl ?? null)
setFormInitialized(true)
@@ -222,7 +237,6 @@ export function WhitelabelingSettings() {
documentationUrl: documentationUrl || null,
termsUrl: termsUrl || null,
privacyUrl: privacyUrl || null,
hidePoweredBySim,
}
try {
@@ -246,7 +260,6 @@ export function WhitelabelingSettings() {
documentationUrl,
termsUrl,
privacyUrl,
hidePoweredBySim,
])
if (isBillingEnabled) {
@@ -496,21 +509,6 @@ export function WhitelabelingSettings() {
</div>
</section>
<section>
<SectionTitle>Advanced</SectionTitle>
<div className='flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3'>
<div className='flex flex-col gap-0.5'>
<span className='text-[13px] text-[var(--text-primary)]'>
Hide "Powered by Sim" branding
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
Removes the Sim logo from deployed chats and forms.
</span>
</div>
<Switch checked={hidePoweredBySim} onCheckedChange={setHidePoweredBySim} />
</div>
</section>
<div className='flex items-center gap-3'>
<Button
onClick={handleSave}
@@ -0,0 +1,35 @@
'use client'
import { useQuery } from '@tanstack/react-query'
/**
* Query key factory for allowed providers queries
*/
export const allowedProvidersKeys = {
all: ['allowedProviders'] as const,
blacklisted: () => [...allowedProvidersKeys.all, 'blacklisted'] as const,
}
interface BlacklistedProvidersResponse {
blacklistedProviders: string[]
}
async function fetchBlacklistedProviders(
signal: AbortSignal
): Promise<BlacklistedProvidersResponse> {
const res = await fetch('/api/settings/allowed-providers', { signal })
if (!res.ok) return { blacklistedProviders: [] }
return res.json()
}
/**
* Hook to fetch the list of blacklisted provider IDs from the server.
*/
export function useBlacklistedProviders({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({
queryKey: allowedProvidersKeys.blacklisted(),
queryFn: ({ signal }) => fetchBlacklistedProviders(signal),
staleTime: 5 * 60 * 1000,
enabled,
})
}
+6 -1
View File
@@ -303,6 +303,7 @@ export function useUpgradeSubscription() {
interface PurchaseCreditsParams {
amount: number
requestId: string
orgId?: string
}
export function usePurchaseCredits() {
@@ -324,9 +325,13 @@ export function usePurchaseCredits() {
return data
},
onSuccess: () => {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() })
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() })
if (variables.orgId) {
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
}
},
})
}
+8
View File
@@ -335,6 +335,10 @@ export const env = createEnv({
// Access Control (Permission Groups) - for self-hosted deployments
ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements)
// Enterprise Feature Overrides - for self-hosted deployments
WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
@@ -426,6 +430,8 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
@@ -460,6 +466,8 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED,
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED,
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
+23
View File
@@ -117,6 +117,18 @@ export const isOrganizationsEnabled =
*/
export const isInboxEnabled = isTruthy(env.INBOX_ENABLED)
/**
* Is whitelabeling enabled via env var override
* This bypasses hosted requirements for self-hosted deployments
*/
export const isWhitelabelingEnabled = isTruthy(env.WHITELABELING_ENABLED)
/**
* Is audit logs enabled via env var override
* This bypasses hosted requirements for self-hosted deployments
*/
export const isAuditLogsEnabled = isTruthy(env.AUDIT_LOGS_ENABLED)
/**
* Is E2B enabled for remote code execution
*/
@@ -186,6 +198,17 @@ export function getAllowedIntegrationsFromEnv(): string[] | null {
return parsed.length > 0 ? parsed : null
}
/**
* Returns the list of blacklisted provider IDs from the environment variable.
* If not set or empty, returns an empty array (meaning no providers are blacklisted).
*/
export function getBlacklistedProvidersFromEnv(): string[] {
if (!env.BLACKLISTED_PROVIDERS) return []
return env.BLACKLISTED_PROVIDERS.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean)
}
/**
* Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var.
* Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com").
+14
View File
@@ -6,7 +6,10 @@ export interface PermissionGroupConfig {
hideKnowledgeBaseTab: boolean
hideTablesTab: boolean
hideCopilot: boolean
hideIntegrationsTab: boolean
hideSecretsTab: boolean
hideApiKeysTab: boolean
hideInboxTab: boolean
hideEnvironmentTab: boolean
hideFilesTab: boolean
disableMcpTools: boolean
@@ -30,7 +33,10 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
hideKnowledgeBaseTab: false,
hideTablesTab: false,
hideCopilot: false,
hideIntegrationsTab: false,
hideSecretsTab: false,
hideApiKeysTab: false,
hideInboxTab: false,
hideEnvironmentTab: false,
hideFilesTab: false,
disableMcpTools: false,
@@ -61,7 +67,15 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
hideTablesTab: typeof c.hideTablesTab === 'boolean' ? c.hideTablesTab : false,
hideCopilot: typeof c.hideCopilot === 'boolean' ? c.hideCopilot : false,
hideIntegrationsTab: typeof c.hideIntegrationsTab === 'boolean' ? c.hideIntegrationsTab : false,
hideSecretsTab:
typeof c.hideSecretsTab === 'boolean'
? c.hideSecretsTab
: typeof c.hideEnvironmentTab === 'boolean'
? c.hideEnvironmentTab
: false,
hideApiKeysTab: typeof c.hideApiKeysTab === 'boolean' ? c.hideApiKeysTab : false,
hideInboxTab: typeof c.hideInboxTab === 'boolean' ? c.hideInboxTab : false,
hideEnvironmentTab: typeof c.hideEnvironmentTab === 'boolean' ? c.hideEnvironmentTab : false,
hideFilesTab: typeof c.hideFilesTab === 'boolean' ? c.hideFilesTab : false,
disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false,
+2 -8
View File
@@ -4,7 +4,7 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/feature-flags'
import {
buildCanonicalIndex,
type CanonicalGroup,
@@ -281,14 +281,8 @@ export function getProviderModels(providerId: ProviderId): string[] {
return getProviderModelsFromDefinitions(providerId)
}
function getBlacklistedProviders(): string[] {
if (!env.BLACKLISTED_PROVIDERS) return []
return env.BLACKLISTED_PROVIDERS.split(',').map((p) => p.trim().toLowerCase())
}
export function isProviderBlacklisted(providerId: string): boolean {
const blacklist = getBlacklistedProviders()
return blacklist.includes(providerId.toLowerCase())
return getBlacklistedProvidersFromEnv().includes(providerId.toLowerCase())
}
/**
+8 -8
View File
@@ -13,7 +13,7 @@
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.9.3",
"turbo": "2.9.5",
},
},
"apps/docs": {
@@ -1498,17 +1498,17 @@
"@trigger.dev/sdk": ["@trigger.dev/sdk@4.4.3", "", { "dependencies": { "@opentelemetry/api": "1.9.0", "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/core": "4.4.3", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "uuid": "^9.0.0", "ws": "^8.11.0" }, "peerDependencies": { "ai": "^4.2.0 || ^5.0.0 || ^6.0.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai"] }, "sha512-ghJkak+PTBJJ9HiHMcnahJmzjsgCzYiIHu5Qj5R7I9q5LS6i7mkx169rB/tOE9HLadd4HSu3yYA5DrH4wXhZuw=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SIzEkvtNdzdI50FJDaIQ6kQGqgSSdFPcdn0wqmmONN6iGKjy6hsT+EH99GP65FsfV7DLZTh2NmtTIRl2kdoz5Q=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vkF/9F/l3aWd4bHxTui5Hh0F5xrTZ4e3rbBsc57zA6O8gNbmHN3B6eZ5psAIP2CnJRZ8ZxRjV3WZHeNXMXkPBw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pLRwFmcHHNBvsCySLS6OFabr/07kDT2pxEt/k6eBf/3asiVQZKJ7Rk88AafQx2aYA641qek4RsXvYO3JYpiBug=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-z/Get5NUaUxm5HSGFqVMICDRjFNsCUhSc4wnFa/PP1QD0NXCjr7bu9a2EM6md/KMCBW0Qe393Ac+UM7/ryDDTw=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gy6ApUroC2Nzv+qjGtE/uPNkhHAFU4c8God+zd5Aiv9L9uBgHlxVJpHT3XWl5xwlJZ2KWuMrlHTaS5kmNB+q1Q=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-jyBifaNoI5/NheyswomiZXJvjdAdvT7hDRYzQ4meP0DKGvpXUjnqsD+4/J2YSDQ34OHxFkL30FnSCUIVOh2PHw=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-d0YelTX6hAsB7kIEtGB3PzIzSfAg3yDoUlHwuwJc3adBXUsyUIs0YLG+1NNtuhcDOUGnWQeKUoJ2pGWvbpRj7w=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ph24K5uPtvo7UfuyDXnBiB/8XvrO+RQWbbw5zkA/bVNoy9HDiNoIJJj3s62MxT9tjEb6DnPje5PXSz1UR7QAyg=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-/08CwpKJl3oRY8nOlh2YgilZVJDHsr60XTNxRhuDeuFXONpUZ5X+Nv65izbG/xBew9qxcJFbDX9/sAmAX+ITcQ=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-6c5RccT/+iR39SdT1G5HyZaD2n57W77o+l0TTfxG/cVlhV94Acyg2gTQW7zUOhW1BeQpBjHzu9x8yVBZwrHh7g=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
@@ -3644,7 +3644,7 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo": ["turbo@2.9.3", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.3", "@turbo/darwin-arm64": "2.9.3", "@turbo/linux-64": "2.9.3", "@turbo/linux-arm64": "2.9.3", "@turbo/windows-64": "2.9.3", "@turbo/windows-arm64": "2.9.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-J/VUvsGRykPb9R8Kh8dHVBOqioDexLk9BhLCU/ZybRR+HN9UR3cURdazFvNgMDt9zPP8TF6K73Z+tplfmi0PqQ=="],
"turbo": ["turbo@2.9.5", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.5", "@turbo/darwin-arm64": "2.9.5", "@turbo/linux-64": "2.9.5", "@turbo/linux-arm64": "2.9.5", "@turbo/windows-64": "2.9.5", "@turbo/windows-arm64": "2.9.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-JXNkRe6H6MjSlk5UQRTjyoKX5YN2zlc2632xcSlSFBao5yvbMWTpv9SNolOZlZmUlcDOHuszPLItbKrvcXnnZA=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
+10
View File
@@ -237,6 +237,16 @@ app:
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)
NEXT_PUBLIC_SSO_ENABLED: "" # Show SSO login button in UI ("true" to enable)
# Enterprise Feature Overrides (self-hosted)
CREDENTIAL_SETS_ENABLED: "" # Enable credential sets (email polling) on self-hosted ("true" to enable)
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: "" # Show credential sets settings page ("true" to enable)
INBOX_ENABLED: "" # Enable Sim Mailer on self-hosted ("true" to enable)
NEXT_PUBLIC_INBOX_ENABLED: "" # Show Sim Mailer settings page ("true" to enable)
WHITELABELING_ENABLED: "" # Enable whitelabeling on self-hosted ("true" to enable)
NEXT_PUBLIC_WHITELABELING_ENABLED: "" # Show whitelabeling settings page ("true" to enable)
AUDIT_LOGS_ENABLED: "" # Enable audit logs on self-hosted ("true" to enable)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: "" # Show audit logs settings page ("true" to enable)
# AWS Bedrock Credential Mode
# Set to "true" when the deployment uses AWS default credential chain (IAM roles, instance
# profiles, ECS task roles, IRSA, etc.) instead of explicit access key/secret per workflow.
+1 -1
View File
@@ -39,7 +39,7 @@
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.9.3"
"turbo": "2.9.5"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,scss}": [