mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 })
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+114
-98
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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
@@ -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}": [
|
||||
|
||||
Reference in New Issue
Block a user