mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-25 09:34:44 +08:00
Merge branch 'improvement/settings-perf' into feat/mothership-copilot
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query'
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
retryOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined
|
||||
|
||||
/**
|
||||
* Returns a QueryClient instance. On the server, creates a new instance per request.
|
||||
* On the client, reuses a singleton instance.
|
||||
*/
|
||||
export function getQueryClient() {
|
||||
if (isServer) {
|
||||
return makeQueryClient()
|
||||
}
|
||||
if (!browserQueryClient) {
|
||||
browserQueryClient = makeQueryClient()
|
||||
}
|
||||
return browserQueryClient
|
||||
}
|
||||
@@ -1,40 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
|
||||
/**
|
||||
* Singleton QueryClient instance for client-side use.
|
||||
* Can be imported directly for cache operations outside React components.
|
||||
*/
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
retryOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined
|
||||
|
||||
export function getQueryClient() {
|
||||
if (typeof window === 'undefined') {
|
||||
return makeQueryClient()
|
||||
}
|
||||
if (!browserQueryClient) {
|
||||
browserQueryClient = makeQueryClient()
|
||||
}
|
||||
return browserQueryClient
|
||||
}
|
||||
export { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { ErrorState } from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
export default function SettingsSectionError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<ErrorState
|
||||
error={error}
|
||||
reset={reset}
|
||||
title='Something went wrong'
|
||||
description='An unexpected error occurred. Please try again or refresh the page.'
|
||||
loggerName='SettingsSectionError'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import type { Metadata } from 'next'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { prefetchGeneralSettings, prefetchUserProfile } from './prefetch'
|
||||
import { SettingsPage } from './settings'
|
||||
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
@@ -36,5 +39,14 @@ export default async function SettingsSectionPage({
|
||||
params: Promise<{ workspaceId: string; section: string }>
|
||||
}) {
|
||||
const { section } = await params
|
||||
return <SettingsPage section={section as SettingsSection} />
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
void prefetchGeneralSettings(queryClient)
|
||||
void prefetchUserProfile(queryClient)
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<SettingsPage section={section as SettingsSection} />
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 { mapUserProfileResponse, userProfileKeys } from '@/hooks/queries/user-profile'
|
||||
|
||||
/**
|
||||
* Forwards incoming request cookies so server-side API fetches authenticate correctly.
|
||||
*/
|
||||
async function getForwardedHeaders(): Promise<Record<string, string>> {
|
||||
const h = await headers()
|
||||
const cookie = h.get('cookie')
|
||||
return cookie ? { cookie } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch general settings server-side via internal API fetch.
|
||||
* Uses the same query keys as the client `useGeneralSettings` hook
|
||||
* so data is shared via HydrationBoundary.
|
||||
*/
|
||||
export function prefetchGeneralSettings(queryClient: QueryClient) {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
queryFn: async () => {
|
||||
const fwdHeaders = await getForwardedHeaders()
|
||||
const baseUrl = getInternalApiBaseUrl()
|
||||
const response = await fetch(`${baseUrl}/api/users/me/settings`, {
|
||||
headers: fwdHeaders,
|
||||
})
|
||||
if (!response.ok) throw new Error(`Settings prefetch failed: ${response.status}`)
|
||||
const { data } = await response.json()
|
||||
return mapGeneralSettingsResponse(data)
|
||||
},
|
||||
staleTime: 60 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch user profile server-side via internal API fetch.
|
||||
* Uses the same query keys as the client `useUserProfile` hook
|
||||
* so data is shared via HydrationBoundary.
|
||||
*/
|
||||
export function prefetchUserProfile(queryClient: QueryClient) {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: userProfileKeys.profile(),
|
||||
queryFn: async () => {
|
||||
const fwdHeaders = await getForwardedHeaders()
|
||||
const baseUrl = getInternalApiBaseUrl()
|
||||
const response = await fetch(`${baseUrl}/api/users/me/profile`, {
|
||||
headers: fwdHeaders,
|
||||
})
|
||||
if (!response.ok) throw new Error(`Profile prefetch failed: ${response.status}`)
|
||||
const { user } = await response.json()
|
||||
return mapUserProfileResponse(user)
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
@@ -1,29 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
ApiKeys,
|
||||
BYOK,
|
||||
Copilot,
|
||||
CredentialSets,
|
||||
Credentials,
|
||||
CustomTools,
|
||||
Debug,
|
||||
General,
|
||||
MCP,
|
||||
Skills,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
TemplateProfile,
|
||||
WorkflowMcpServers,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
|
||||
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
|
||||
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
|
||||
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
|
||||
import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
|
||||
import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
|
||||
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
|
||||
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
|
||||
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
|
||||
import { SkillsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton'
|
||||
import { WorkflowMcpServersSkeleton } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton'
|
||||
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import {
|
||||
allNavigationItems,
|
||||
isBillingEnabled,
|
||||
} from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { AccessControl } from '@/ee/access-control/components/access-control'
|
||||
import { SSO } from '@/ee/sso/components/sso-settings'
|
||||
|
||||
/**
|
||||
* Generic skeleton fallback for sections without a dedicated skeleton.
|
||||
*/
|
||||
function SettingsSectionSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[20px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const General = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/general/general').then(
|
||||
(m) => m.General
|
||||
),
|
||||
{ loading: () => <GeneralSkeleton /> }
|
||||
)
|
||||
const Credentials = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/credentials/credentials').then(
|
||||
(m) => m.Credentials
|
||||
),
|
||||
{ loading: () => <CredentialsSkeleton /> }
|
||||
)
|
||||
const TemplateProfile = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/workspace/[workspaceId]/settings/components/template-profile/template-profile'
|
||||
).then((m) => m.TemplateProfile),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
const CredentialSets = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets'
|
||||
).then((m) => m.CredentialSets),
|
||||
{ loading: () => <CredentialSetsSkeleton /> }
|
||||
)
|
||||
const ApiKeys = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then(
|
||||
(m) => m.ApiKeys
|
||||
),
|
||||
{ loading: () => <ApiKeysSkeleton /> }
|
||||
)
|
||||
const Subscription = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription').then(
|
||||
(m) => m.Subscription
|
||||
),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
const TeamManagement = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/workspace/[workspaceId]/settings/components/team-management/team-management'
|
||||
).then((m) => m.TeamManagement),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
const BYOK = dynamic(
|
||||
() => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK),
|
||||
{ loading: () => <BYOKSkeleton /> }
|
||||
)
|
||||
const Copilot = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then(
|
||||
(m) => m.Copilot
|
||||
),
|
||||
{ loading: () => <CopilotSkeleton /> }
|
||||
)
|
||||
const MCP = dynamic(
|
||||
() => import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP),
|
||||
{ loading: () => <McpSkeleton /> }
|
||||
)
|
||||
const CustomTools = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then(
|
||||
(m) => m.CustomTools
|
||||
),
|
||||
{ loading: () => <CustomToolsSkeleton /> }
|
||||
)
|
||||
const Skills = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/skills/skills').then((m) => m.Skills),
|
||||
{ loading: () => <SkillsSkeleton /> }
|
||||
)
|
||||
const WorkflowMcpServers = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers'
|
||||
).then((m) => m.WorkflowMcpServers),
|
||||
{ loading: () => <WorkflowMcpServersSkeleton /> }
|
||||
)
|
||||
const Debug = dynamic(
|
||||
() =>
|
||||
import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug),
|
||||
{ loading: () => <DebugSkeleton /> }
|
||||
)
|
||||
const AccessControl = dynamic(
|
||||
() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
|
||||
loading: () => <SettingsSectionSkeleton />,
|
||||
})
|
||||
|
||||
interface SettingsPageProps {
|
||||
section: SettingsSection
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton component for API key list items.
|
||||
*/
|
||||
export function ApiKeySkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-5 w-[80px]' />
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-[100px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the API Keys section shown during dynamic import loading.
|
||||
*/
|
||||
export function ApiKeysSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<ApiKeySkeleton />
|
||||
<ApiKeySkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Input, Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { formatDate } from '@/lib/core/utils/formatting'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ApiKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
|
||||
import {
|
||||
type ApiKey,
|
||||
useApiKeys,
|
||||
@@ -408,18 +409,3 @@ export function ApiKeys() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeySkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-5 w-[80px]' />
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-[100px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton component for BYOK provider key items.
|
||||
*/
|
||||
export function BYOKKeySkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-[6px]' />
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[72px] rounded-[6px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the BYOK section shown during dynamic import loading.
|
||||
*/
|
||||
export function BYOKSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<BYOKKeySkeleton />
|
||||
<BYOKKeySkeleton />
|
||||
<BYOKKeySkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
PerplexityIcon,
|
||||
SerperIcon,
|
||||
} from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
|
||||
import {
|
||||
type BYOKKey,
|
||||
useBYOKKeys,
|
||||
@@ -155,21 +156,6 @@ const PROVIDERS: {
|
||||
},
|
||||
]
|
||||
|
||||
function BYOKKeySkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-[6px]' />
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[72px] rounded-[6px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BYOK() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
@@ -179,6 +165,7 @@ export function BYOK() {
|
||||
const upsertKey = useUpsertBYOKKey()
|
||||
const deleteKey = useDeleteBYOKKey()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [editingProvider, setEditingProvider] = useState<BYOKProviderId | null>(null)
|
||||
const [apiKeyInput, setApiKeyInput] = useState('')
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
@@ -186,6 +173,18 @@ export function BYOK() {
|
||||
|
||||
const [deleteConfirmProvider, setDeleteConfirmProvider] = useState<BYOKProviderId | null>(null)
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
if (!searchTerm.trim()) return PROVIDERS
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
return PROVIDERS.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(searchLower) ||
|
||||
p.description.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}, [searchTerm])
|
||||
|
||||
const showNoResults = searchTerm.trim() && filteredProviders.length === 0
|
||||
|
||||
const getKeyForProvider = (providerId: BYOKProviderId): BYOKKey | undefined => {
|
||||
return keys.find((k) => k.providerId === providerId)
|
||||
}
|
||||
@@ -234,6 +233,22 @@ export function BYOK() {
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[18px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search providers...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>
|
||||
Use your own API keys for hosted model providers.
|
||||
</p>
|
||||
@@ -247,7 +262,7 @@ export function BYOK() {
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{PROVIDERS.map((provider) => {
|
||||
{filteredProviders.map((provider) => {
|
||||
const existingKey = getKeyForProvider(provider.id)
|
||||
const Icon = provider.icon
|
||||
|
||||
@@ -289,6 +304,11 @@ export function BYOK() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{showNoResults && (
|
||||
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
|
||||
No providers found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
ApiKeySkeleton,
|
||||
ApiKeysSkeleton,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
|
||||
|
||||
/**
|
||||
* Re-export ApiKeySkeleton as CopilotKeySkeleton since both share identical markup.
|
||||
*/
|
||||
export const CopilotKeySkeleton = ApiKeySkeleton
|
||||
|
||||
/**
|
||||
* Skeleton for the Copilot section shown during dynamic import loading.
|
||||
*/
|
||||
export const CopilotSkeleton = ApiKeysSkeleton
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
ModalHeader,
|
||||
// Switch,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { formatDate } from '@/lib/core/utils/formatting'
|
||||
// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp'
|
||||
import { CopilotKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
|
||||
import {
|
||||
type CopilotKey,
|
||||
useCopilotKeys,
|
||||
@@ -23,28 +25,8 @@ import {
|
||||
useGenerateCopilotKey,
|
||||
} from '@/hooks/queries/copilot-keys'
|
||||
|
||||
// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp'
|
||||
|
||||
const logger = createLogger('CopilotSettings')
|
||||
|
||||
/**
|
||||
* Skeleton component for loading state of copilot key items
|
||||
*/
|
||||
function CopilotKeySkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-5 w-[80px]' />
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-[100px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot Keys management component for handling API keys used with the Copilot feature.
|
||||
* Provides functionality to create, view, and delete copilot API keys.
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton for the Credential Sets (Email Polling) section shown during dynamic import loading.
|
||||
*/
|
||||
export function CredentialSetsSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[18px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 rounded-[6px]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
<Skeleton className='h-[12px] w-[80px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[60px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[60px]' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 rounded-[6px]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[140px]' />
|
||||
<Skeleton className='h-[12px] w-[100px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-33
@@ -29,6 +29,7 @@ import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-s
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
|
||||
import {
|
||||
type CredentialSet,
|
||||
useAcceptCredentialSetInvitation,
|
||||
@@ -50,39 +51,6 @@ import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('EmailPolling')
|
||||
|
||||
function CredentialSetsSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[18px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 rounded-[6px]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
<Skeleton className='h-[12px] w-[80px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[60px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[60px]' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 rounded-[6px]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[140px]' />
|
||||
<Skeleton className='h-[12px] w-[100px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CredentialSets() {
|
||||
const { data: session } = useSession()
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton component for credential list items.
|
||||
*/
|
||||
export function CredentialSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
|
||||
<Skeleton className='h-[30px] w-[50px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the Credentials section shown during dynamic import loading.
|
||||
*/
|
||||
export function CredentialsSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<CredentialSkeleton />
|
||||
<CredentialSkeleton />
|
||||
<CredentialSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-15
@@ -33,6 +33,7 @@ import {
|
||||
getServiceConfigByProviderId,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { isValidEnvVarName } from '@/executor/constants'
|
||||
import {
|
||||
@@ -159,21 +160,6 @@ function normalizeEnvKeyInput(raw: string): string {
|
||||
return wrappedMatch ? wrappedMatch[1] : trimmed
|
||||
}
|
||||
|
||||
function CredentialSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
|
||||
<Skeleton className='h-[30px] w-[50px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CredentialsManager() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton component for custom tool list items.
|
||||
*/
|
||||
export function CustomToolSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the Custom Tools section shown during dynamic import loading.
|
||||
*/
|
||||
export function CustomToolsSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<CustomToolSkeleton />
|
||||
<CustomToolSkeleton />
|
||||
<CustomToolSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-16
@@ -5,27 +5,13 @@ import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { CustomToolSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
|
||||
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
|
||||
|
||||
const logger = createLogger('CustomToolsSettings')
|
||||
|
||||
function CustomToolSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomTools() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton for the Debug section shown during dynamic import loading.
|
||||
*/
|
||||
export function DebugSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-5 w-[200px]' />
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Input as EmcnInput } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
|
||||
import { useImportWorkflow } from '@/hooks/queries/workflows'
|
||||
|
||||
/**
|
||||
@@ -73,15 +73,3 @@ export function Debug() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton displayed during workflow import.
|
||||
*/
|
||||
function DebugSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-5 w-[200px]' />
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton component for general settings loading state.
|
||||
* Matches the exact layout structure of the General component.
|
||||
*/
|
||||
export function GeneralSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[18px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 rounded-full' />
|
||||
<div className='flex flex-1 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-5 w-24' />
|
||||
<Skeleton className='h-[10.5px] w-[10.5px]' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-40' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-b pb-[12px]'>
|
||||
<Skeleton className='h-4 w-12' />
|
||||
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-36' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-40' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-t pt-[16px]'>
|
||||
<Skeleton className='h-4 w-44' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
<div className='-mt-[8px] flex flex-col gap-1'>
|
||||
<Skeleton className='h-[12px] w-full' />
|
||||
<Skeleton className='h-[12px] w-4/5' />
|
||||
</div>
|
||||
|
||||
<div className='mt-auto flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-8 w-20 rounded-[4px]' />
|
||||
<Skeleton className='h-8 w-28 rounded-[4px]' />
|
||||
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -16,12 +17,12 @@ import {
|
||||
ModalHeader,
|
||||
Switch,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
@@ -49,77 +50,6 @@ function getInitials(name: string | undefined | null): string {
|
||||
return parts[0][0].toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for general settings loading state.
|
||||
* Matches the exact layout structure of the General component.
|
||||
*/
|
||||
function GeneralSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[18px]'>
|
||||
{/* User Info Section */}
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 rounded-full' />
|
||||
<div className='flex flex-1 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-5 w-24' />
|
||||
<Skeleton className='h-[10.5px] w-[10.5px]' />
|
||||
</div>
|
||||
<Skeleton className='h-5 w-40' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme selector row */}
|
||||
<div className='flex items-center justify-between border-b pb-[12px]'>
|
||||
<Skeleton className='h-4 w-12' />
|
||||
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
{/* Auto-connect row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-36' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* Error notifications row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-40' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* Snap to grid row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
{/* Show canvas controls row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* Telemetry row */}
|
||||
<div className='flex items-center justify-between border-t pt-[16px]'>
|
||||
<Skeleton className='h-4 w-44' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* Telemetry description */}
|
||||
<div className='-mt-[8px] flex flex-col gap-1'>
|
||||
<Skeleton className='h-[12px] w-full' />
|
||||
<Skeleton className='h-[12px] w-4/5' />
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='mt-auto flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-8 w-20 rounded-[4px]' />
|
||||
<Skeleton className='h-8 w-28 rounded-[4px]' />
|
||||
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function General() {
|
||||
const router = useRouter()
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export { ApiKeys } from './api-keys/api-keys'
|
||||
export { BYOK } from './byok/byok'
|
||||
export { Copilot } from './copilot/copilot'
|
||||
export { CredentialSets } from './credential-sets/credential-sets'
|
||||
export { Credentials } from './credentials/credentials'
|
||||
export { CustomTools } from './custom-tools/custom-tools'
|
||||
export { Debug } from './debug/debug'
|
||||
export { General } from './general/general'
|
||||
export { MCP } from './mcp/mcp'
|
||||
export { Skills } from './skills/skills'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
export { TeamManagement } from './team-management/team-management'
|
||||
export { TemplateProfile } from './template-profile/template-profile'
|
||||
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
|
||||
@@ -0,0 +1,14 @@
|
||||
import { McpServerSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-skeleton/mcp-server-skeleton'
|
||||
|
||||
/**
|
||||
* Skeleton for the MCP section shown during dynamic import loading.
|
||||
*/
|
||||
export function McpSkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<McpServerSkeleton />
|
||||
<McpServerSkeleton />
|
||||
<McpServerSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
CustomToolSkeleton,
|
||||
CustomToolsSkeleton,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
|
||||
|
||||
/**
|
||||
* Re-export CustomToolSkeleton as SkillSkeleton since both share identical markup.
|
||||
*/
|
||||
export const SkillSkeleton = CustomToolSkeleton
|
||||
|
||||
/**
|
||||
* Skeleton for the Skills section shown during dynamic import loading.
|
||||
*/
|
||||
export const SkillsSkeleton = CustomToolsSkeleton
|
||||
@@ -5,28 +5,14 @@ import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal'
|
||||
import { SkillSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
|
||||
|
||||
const logger = createLogger('SkillsSettings')
|
||||
|
||||
function SkillSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Skills() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
|
||||
|
||||
/**
|
||||
* Re-export McpSkeleton as WorkflowMcpServersSkeleton since both share identical markup.
|
||||
*/
|
||||
export const WorkflowMcpServersSkeleton = McpSkeleton
|
||||
+32
-4
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { ChevronDown, Skeleton } from '@/components/emcn'
|
||||
@@ -16,9 +17,10 @@ import {
|
||||
sectionConfig,
|
||||
} from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { useSSOProviders } from '@/ee/sso/hooks/sso'
|
||||
import { useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
|
||||
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
|
||||
@@ -28,10 +30,14 @@ export function SettingsSidebar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: session, isPending: sessionLoading } = useSession()
|
||||
const { data: organizationsData, isLoading: orgsLoading } = useOrganizations()
|
||||
const { data: generalSettings } = useGeneralSettings()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const { data: subscriptionData } = useSubscriptionData({
|
||||
enabled: isBillingEnabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
|
||||
|
||||
@@ -129,6 +135,26 @@ export function SettingsSidebar() {
|
||||
return 'general'
|
||||
}, [pathname])
|
||||
|
||||
const handlePrefetch = useCallback(
|
||||
(itemId: string) => {
|
||||
switch (itemId) {
|
||||
case 'general':
|
||||
prefetchGeneralSettings(queryClient)
|
||||
void import('@/app/workspace/[workspaceId]/settings/components/general/general')
|
||||
break
|
||||
case 'credentials':
|
||||
prefetchWorkspaceCredentials(queryClient, workspaceId)
|
||||
void import('@/app/workspace/[workspaceId]/settings/components/credentials/credentials')
|
||||
break
|
||||
case 'subscription':
|
||||
prefetchSubscriptionData(queryClient)
|
||||
void import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription')
|
||||
break
|
||||
}
|
||||
},
|
||||
[queryClient, workspaceId]
|
||||
)
|
||||
|
||||
const handleBack = () => {
|
||||
router.push(`/workspace/${workspaceId}/home`)
|
||||
}
|
||||
@@ -213,6 +239,8 @@ export function SettingsSidebar() {
|
||||
key={item.id}
|
||||
href={`/workspace/${workspaceId}/settings/${item.id}`}
|
||||
className={className}
|
||||
onMouseEnter={() => handlePrefetch(item.id)}
|
||||
onFocus={() => handlePrefetch(item.id)}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { environmentKeys } from '@/hooks/queries/environment'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
@@ -68,6 +69,33 @@ export const workspaceCredentialKeys = {
|
||||
[...workspaceCredentialKeys.detail(credentialId), 'members'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspace credential list from API.
|
||||
* Used by the prefetch function for hover-based cache warming.
|
||||
*/
|
||||
async function fetchWorkspaceCredentialList(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceCredential[]> {
|
||||
const data = await fetchJson<CredentialListResponse>('/api/credentials', {
|
||||
searchParams: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch workspace credentials into a QueryClient cache.
|
||||
* Use on hover to warm data before navigation.
|
||||
*/
|
||||
export function prefetchWorkspaceCredentials(queryClient: QueryClient, workspaceId: string) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: workspaceCredentialKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchWorkspaceCredentialList(workspaceId, signal),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspaceCredentials(params: {
|
||||
workspaceId?: string
|
||||
type?: WorkspaceCredentialType
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
|
||||
|
||||
@@ -27,6 +28,24 @@ export interface GeneralSettings {
|
||||
showActionBar: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw API response data to GeneralSettings with defaults.
|
||||
* Shared by both client fetch and server prefetch to prevent shape drift.
|
||||
*/
|
||||
export function mapGeneralSettingsResponse(data: Record<string, unknown>): GeneralSettings {
|
||||
return {
|
||||
autoConnect: (data.autoConnect as boolean) ?? true,
|
||||
showTrainingControls: (data.showTrainingControls as boolean) ?? false,
|
||||
superUserModeEnabled: (data.superUserModeEnabled as boolean) ?? true,
|
||||
theme: (data.theme as GeneralSettings['theme']) || 'system',
|
||||
telemetryEnabled: (data.telemetryEnabled as boolean) ?? true,
|
||||
billingUsageNotificationsEnabled: (data.billingUsageNotificationsEnabled as boolean) ?? true,
|
||||
errorNotificationsEnabled: (data.errorNotificationsEnabled as boolean) ?? true,
|
||||
snapToGridSize: (data.snapToGridSize as number) ?? 0,
|
||||
showActionBar: (data.showActionBar as boolean) ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch general settings from API
|
||||
*/
|
||||
@@ -38,18 +57,7 @@ async function fetchGeneralSettings(signal?: AbortSignal): Promise<GeneralSettin
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
return {
|
||||
autoConnect: data.autoConnect ?? true,
|
||||
showTrainingControls: data.showTrainingControls ?? false,
|
||||
superUserModeEnabled: data.superUserModeEnabled ?? true,
|
||||
theme: data.theme || 'system',
|
||||
telemetryEnabled: data.telemetryEnabled ?? true,
|
||||
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
|
||||
snapToGridSize: data.snapToGridSize ?? 0,
|
||||
showActionBar: data.showActionBar ?? true,
|
||||
}
|
||||
return mapGeneralSettingsResponse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +76,22 @@ export function useGeneralSettings() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch general settings into a QueryClient cache.
|
||||
* Use on hover to warm data before navigation.
|
||||
*/
|
||||
export function prefetchGeneralSettings(queryClient: QueryClient) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
queryFn: async () => {
|
||||
const settings = await fetchGeneralSettings()
|
||||
syncThemeToNextThemes(settings.theme)
|
||||
return settings
|
||||
},
|
||||
staleTime: 60 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience selector hooks for individual settings.
|
||||
* These provide a simple API for components that only need a single setting value.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
@@ -114,6 +115,8 @@ interface UseSubscriptionDataOptions {
|
||||
includeOrg?: boolean
|
||||
/** Whether to enable the query (defaults to true) */
|
||||
enabled?: boolean
|
||||
/** Override default staleTime (defaults to 30s) */
|
||||
staleTime?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,17 +124,29 @@ interface UseSubscriptionDataOptions {
|
||||
* @param options - Optional configuration
|
||||
*/
|
||||
export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) {
|
||||
const { includeOrg = false, enabled = true } = options
|
||||
const { includeOrg = false, enabled = true, staleTime = 30 * 1000 } = options
|
||||
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.user(includeOrg),
|
||||
queryFn: ({ signal }) => fetchSubscriptionData(includeOrg, signal),
|
||||
staleTime: 30 * 1000,
|
||||
staleTime,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch subscription data into a QueryClient cache.
|
||||
* Use on hover to warm data before navigation.
|
||||
*/
|
||||
export function prefetchSubscriptionData(queryClient: QueryClient) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: subscriptionKeys.user(false),
|
||||
queryFn: () => fetchSubscriptionData(false),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user usage limit metadata
|
||||
* Note: This endpoint returns limit information (currentLimit, minimumLimit, canEdit, etc.)
|
||||
|
||||
@@ -24,6 +24,21 @@ export interface UserProfile {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw API response user object to UserProfile.
|
||||
* Shared by both client fetch and server prefetch to prevent shape drift.
|
||||
*/
|
||||
export function mapUserProfileResponse(user: Record<string, unknown>): UserProfile {
|
||||
return {
|
||||
id: user.id as string,
|
||||
name: (user.name as string) || '',
|
||||
email: (user.email as string) || '',
|
||||
image: (user.image as string) || null,
|
||||
createdAt: user.createdAt as string,
|
||||
updatedAt: user.updatedAt as string,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile from API
|
||||
*/
|
||||
@@ -35,15 +50,7 @@ async function fetchUserProfile(signal?: AbortSignal): Promise<UserProfile> {
|
||||
}
|
||||
|
||||
const { user } = await response.json()
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
image: user.image || null,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
return mapUserProfileResponse(user)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user