mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 11:36:37 +08:00
improvement(secrets): parallelize save mutations and add admin visibility for workspace secrets (#4032)
* improvement(secrets): parallelize save mutations and add admin visibility for workspace secrets * fix(secrets): sequence workspace upsert/delete to avoid read-modify-write race * fix(secrets): use Promise.allSettled to ensure credential invalidation after all mutations settle
This commit is contained in:
+62
-19
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Clipboard, Key, Search } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
useWorkspaceCredentials,
|
||||
type WorkspaceCredential,
|
||||
type WorkspaceCredentialRole,
|
||||
workspaceCredentialKeys,
|
||||
} from '@/hooks/queries/credentials'
|
||||
import {
|
||||
usePersonalEnvironment,
|
||||
@@ -125,6 +127,7 @@ interface WorkspaceVariableRowProps {
|
||||
renamingKey: string | null
|
||||
pendingKeyValue: string
|
||||
hasCredential: boolean
|
||||
isAdmin: boolean
|
||||
onRenameStart: (key: string) => void
|
||||
onPendingKeyChange: (value: string) => void
|
||||
onRenameEnd: (key: string, value: string) => void
|
||||
@@ -138,12 +141,18 @@ function WorkspaceVariableRow({
|
||||
renamingKey,
|
||||
pendingKeyValue,
|
||||
hasCredential,
|
||||
isAdmin,
|
||||
onRenameStart,
|
||||
onPendingKeyChange,
|
||||
onRenameEnd,
|
||||
onDelete,
|
||||
onViewDetails,
|
||||
}: WorkspaceVariableRowProps) {
|
||||
const [valueFocused, setValueFocused] = useState(false)
|
||||
|
||||
const maskedValueStyle =
|
||||
isAdmin && !valueFocused ? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties) : undefined
|
||||
|
||||
return (
|
||||
<div className='contents'>
|
||||
<EmcnInput
|
||||
@@ -163,12 +172,19 @@ function WorkspaceVariableRow({
|
||||
/>
|
||||
<div />
|
||||
<EmcnInput
|
||||
value={value ? '\u2022'.repeat(value.length) : ''}
|
||||
value={isAdmin ? value : value ? '\u2022'.repeat(value.length) : ''}
|
||||
readOnly
|
||||
onFocus={() => {
|
||||
if (isAdmin) setValueFocused(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isAdmin) setValueFocused(false)
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
style={maskedValueStyle}
|
||||
className='h-9'
|
||||
/>
|
||||
<Button
|
||||
@@ -298,6 +314,14 @@ export function CredentialsManager() {
|
||||
)
|
||||
|
||||
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const isAdmin = useMemo(() => {
|
||||
const userId = session?.user?.id
|
||||
if (!userId || !workspacePermissions?.users) return false
|
||||
const currentUser = workspacePermissions.users.find((user) => user.userId === userId)
|
||||
return currentUser?.permissionType === 'admin'
|
||||
}, [session?.user?.id, workspacePermissions?.users])
|
||||
|
||||
const isLoading = isPersonalLoading || isWorkspaceLoading
|
||||
const variables = useMemo(() => personalEnvData || {}, [personalEnvData])
|
||||
@@ -923,6 +947,7 @@ export function CredentialsManager() {
|
||||
|
||||
const prevInitialVars = [...initialVarsRef.current]
|
||||
const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current }
|
||||
const mutations: Promise<unknown>[] = []
|
||||
|
||||
try {
|
||||
setShowUnsavedChanges(false)
|
||||
@@ -944,8 +969,6 @@ export function CredentialsManager() {
|
||||
.filter((v) => v.key && v.value)
|
||||
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
|
||||
|
||||
await savePersonalMutation.mutateAsync({ variables: validVariables })
|
||||
|
||||
const before = prevInitialWorkspaceVars
|
||||
const after = mergedWorkspaceVars
|
||||
const toUpsert: Record<string, string> = {}
|
||||
@@ -961,14 +984,37 @@ export function CredentialsManager() {
|
||||
if (!(k in after)) toDelete.push(k)
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
if (Object.keys(toUpsert).length) {
|
||||
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
|
||||
}
|
||||
if (toDelete.length) {
|
||||
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
|
||||
const personalChanged = (() => {
|
||||
const initialMap = new Map(
|
||||
prevInitialVars.filter((v) => v.key && v.value).map((v) => [v.key, v.value])
|
||||
)
|
||||
const currentKeys = Object.keys(validVariables)
|
||||
if (initialMap.size !== currentKeys.length) return true
|
||||
for (const [key, value] of Object.entries(validVariables)) {
|
||||
if (initialMap.get(key) !== value) return true
|
||||
}
|
||||
return false
|
||||
})()
|
||||
|
||||
if (personalChanged) {
|
||||
mutations.push(savePersonalMutation.mutateAsync({ variables: validVariables }))
|
||||
}
|
||||
if (workspaceId && (Object.keys(toUpsert).length || toDelete.length)) {
|
||||
mutations.push(
|
||||
(async () => {
|
||||
if (Object.keys(toUpsert).length) {
|
||||
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
|
||||
}
|
||||
if (toDelete.length) {
|
||||
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(mutations)
|
||||
const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
||||
if (firstFailure) throw firstFailure.reason
|
||||
|
||||
setWorkspaceVars(mergedWorkspaceVars)
|
||||
setNewWorkspaceRows([createEmptyEnvVar()])
|
||||
@@ -977,17 +1023,13 @@ export function CredentialsManager() {
|
||||
initialVarsRef.current = prevInitialVars
|
||||
initialWorkspaceVarsRef.current = prevInitialWorkspaceVars
|
||||
logger.error('Failed to save environment variables:', error)
|
||||
} finally {
|
||||
if (mutations.length > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isListSaving,
|
||||
envVars,
|
||||
workspaceVars,
|
||||
newWorkspaceRows,
|
||||
workspaceId,
|
||||
savePersonalMutation,
|
||||
upsertWorkspaceMutation,
|
||||
removeWorkspaceMutation,
|
||||
])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects and queryClient are stable (TanStack Query v5)
|
||||
}, [isListSaving, envVars, workspaceVars, newWorkspaceRows, workspaceId])
|
||||
|
||||
const handleDiscardAndNavigate = useCallback(() => {
|
||||
shouldBlockNavRef.current = false
|
||||
@@ -1494,6 +1536,7 @@ export function CredentialsManager() {
|
||||
renamingKey={renamingKey}
|
||||
pendingKeyValue={pendingKeyValue}
|
||||
hasCredential={envKeyToCredential.has(key)}
|
||||
isAdmin={isAdmin}
|
||||
onRenameStart={setRenamingKey}
|
||||
onPendingKeyChange={setPendingKeyValue}
|
||||
onRenameEnd={handleWorkspaceKeyRename}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { EnvironmentVariable, WorkspaceEnvironmentData } from '@/lib/environment/api'
|
||||
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
|
||||
import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api'
|
||||
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
|
||||
import { API_ENDPOINTS } from '@/stores/constants'
|
||||
|
||||
const logger = createLogger('EnvironmentQueries')
|
||||
@@ -56,28 +55,10 @@ export function useSavePersonalEnvironment() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ variables }: SavePersonalEnvironmentParams) => {
|
||||
const transformedVariables = Object.entries(variables).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: { key, value },
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.ENVIRONMENT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
variables: Object.entries(transformedVariables).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: (value as EnvironmentVariable).value,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ variables }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -85,11 +66,9 @@ export function useSavePersonalEnvironment() {
|
||||
}
|
||||
|
||||
logger.info('Saved personal environment variables')
|
||||
return transformedVariables
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -124,8 +103,6 @@ export function useUpsertWorkspaceEnvironment() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: environmentKeys.workspace(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -160,8 +137,6 @@ export function useRemoveWorkspaceEnvironment() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: environmentKeys.workspace(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user