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:
Waleed
2026-04-07 18:30:26 -07:00
committed by GitHub
parent e0f5cf880a
commit 98be968b54
2 changed files with 65 additions and 47 deletions
@@ -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}
+3 -28
View File
@@ -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() })
},
})
}