mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(settings): restore paste-to-destructure for workspace secrets, cleanup hooks and design tokens (#4231)
* fix(settings): restore paste-to-destructure for workspace secrets, cleanup hooks and design tokens Restores the env-var paste feature (KEY=VALUE → split rows, multi-line → multi rows) for workspace secrets that was lost when the unified Credentials tab was split into Secrets and Integrations. Adds `parseEnvVarLine`, `parseValidEnvVars`, and `handleWorkspacePaste` with full support for export prefix, quoted values, inline comments, and base64 false-positive guards. Also adds consistent value masking (show on focus / mask on blur) to new workspace input rows. Cleans up ~20 unnecessary `useCallback` wrappers, fixes a direct state mutation in `handleSingleValuePaste`, moves `e.preventDefault()` inside the `parsedVars.length > 0` guard, replaces all hardcoded hex colors with CSS variable tokens, converts template-literal classNames to `cn()`, and replaces raw `<button>` with emcn `Button`. * fix(settings): fix handlePaste silent swallow, quote-strip bug, and credential sync efficiency - Move e.preventDefault() inside parsedVars guard in handlePaste so KEY= lines don't silently discard input (mirrors handleWorkspacePaste fix from same PR) - Add value.length >= 2 guard before quote-stripping to prevent single-char values like KEY=\" from being stripped to empty and silently dropped - Introduce createWorkspaceEnvCredentials and deleteWorkspaceEnvCredentials for delta-aware credential sync (O(k) instead of O(n*m) for env var mutations) - Fix createWorkspaceEnvCredentials early-return bug that skipped credential record creation when workspace had zero members - Update credentials/[id] DELETE to use deleteWorkspaceEnvCredentials instead of full syncWorkspaceEnvCredentials - Optimize syncWorkspaceEnvCredentials to fetch workspace+member IDs in parallel once instead of once per credential * fix(settings): normalize Windows line endings in paste handlers * fix(settings): eliminate double-parse in handlePaste by inlining handleKeyValuePaste
This commit is contained in:
@@ -10,8 +10,8 @@ import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import {
|
||||
deleteWorkspaceEnvCredentials,
|
||||
syncPersonalEnvCredentialsForUser,
|
||||
syncWorkspaceEnvCredentials,
|
||||
} from '@/lib/credentials/environment'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
@@ -317,10 +317,9 @@ export async function DELETE(
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncWorkspaceEnvCredentials({
|
||||
await deleteWorkspaceEnvCredentials({
|
||||
workspaceId: access.credential.workspaceId,
|
||||
envKeys: Object.keys(current),
|
||||
actingUserId: session.user.id,
|
||||
removedKeys: [access.credential.envKey],
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
|
||||
@@ -9,7 +9,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import {
|
||||
createWorkspaceEnvCredentials,
|
||||
deleteWorkspaceEnvCredentials,
|
||||
} from '@/lib/credentials/environment'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -126,11 +129,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
set: { variables: merged, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: Object.keys(merged),
|
||||
actingUserId: userId,
|
||||
})
|
||||
const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted))
|
||||
await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId })
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
@@ -215,11 +215,7 @@ export async function DELETE(
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: Object.keys(current),
|
||||
actingUserId: userId,
|
||||
})
|
||||
await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys })
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
|
||||
+354
-329
@@ -25,6 +25,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
|
||||
@@ -59,7 +60,7 @@ const logger = createLogger('SecretsManager')
|
||||
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto_auto] items-center'
|
||||
const COL_SPAN_ALL = 'col-span-5'
|
||||
const CONFLICT_CLASS = 'border-[var(--text-error)] bg-[#F6D2D2] dark:bg-[#442929]'
|
||||
const CONFLICT_CLASS = 'border-[var(--text-error)] bg-[var(--error-muted)]'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'member', label: 'Member' },
|
||||
@@ -121,6 +122,60 @@ function validateEnvVarKey(key: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single `.env`-style line into a key/value pair.
|
||||
* Handles `export KEY=VALUE`, quoted values, inline comments, and base64 false positives.
|
||||
* Returns null for blank lines, comments, and invalid entries.
|
||||
*/
|
||||
function parseEnvVarLine(line: string): UIEnvironmentVariable | null {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) return null
|
||||
|
||||
const withoutExport = trimmed.replace(/^export\s+/, '')
|
||||
const equalIndex = withoutExport.indexOf('=')
|
||||
if (equalIndex === -1 || equalIndex === 0) return null
|
||||
|
||||
const potentialKey = withoutExport.substring(0, equalIndex).trim()
|
||||
if (!isValidEnvVarName(potentialKey)) return null
|
||||
|
||||
let value = withoutExport.substring(equalIndex + 1)
|
||||
|
||||
const looksLikeBase64Key = /^[A-Za-z0-9+/]+$/.test(potentialKey) && !potentialKey.includes('_')
|
||||
const valueIsJustPadding = /^=+$/.test(value.trim())
|
||||
if (looksLikeBase64Key && valueIsJustPadding && potentialKey.length > 20) return null
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
if (
|
||||
!trimmedValue.startsWith('"') &&
|
||||
!trimmedValue.startsWith("'") &&
|
||||
!trimmedValue.startsWith('`')
|
||||
) {
|
||||
const commentIndex = value.search(/\s#/)
|
||||
if (commentIndex !== -1) value = value.substring(0, commentIndex)
|
||||
}
|
||||
|
||||
value = value.trim()
|
||||
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")) ||
|
||||
(value.startsWith('`') && value.endsWith('`')))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
return { key: potentialKey, value, id: generateRowId() }
|
||||
}
|
||||
|
||||
/** Parses an array of raw text lines, returning only valid non-empty KEY=VALUE entries. */
|
||||
function parseValidEnvVars(lines: string[]): UIEnvironmentVariable[] {
|
||||
return lines
|
||||
.map(parseEnvVarLine)
|
||||
.filter((parsed): parsed is UIEnvironmentVariable => parsed !== null)
|
||||
.filter(({ key, value }) => key && value)
|
||||
}
|
||||
|
||||
interface WorkspaceVariableRowProps {
|
||||
envKey: string
|
||||
value: string
|
||||
@@ -197,7 +252,7 @@ function WorkspaceVariableRow({
|
||||
variant='default'
|
||||
onClick={() => onViewDetails(envKey)}
|
||||
disabled={!hasCredential}
|
||||
className={`ml-2 h-9 ${!hasCredential ? 'opacity-40' : ''}`}
|
||||
className={cn('ml-2 h-9', !hasCredential && 'opacity-40')}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
@@ -221,17 +276,26 @@ interface NewWorkspaceVariableRowProps {
|
||||
envVar: UIEnvironmentVariable
|
||||
index: number
|
||||
onUpdate: (index: number, field: 'key' | 'value', value: string) => void
|
||||
onPaste?: (e: React.ClipboardEvent<HTMLInputElement>, index: number) => void
|
||||
}
|
||||
|
||||
function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariableRowProps) {
|
||||
function NewWorkspaceVariableRow({
|
||||
envVar,
|
||||
index,
|
||||
onUpdate,
|
||||
onPaste,
|
||||
}: NewWorkspaceVariableRowProps) {
|
||||
const [valueFocused, setValueFocused] = useState(false)
|
||||
const keyError = validateEnvVarKey(envVar.key)
|
||||
const hasContent = Boolean(envVar.key || envVar.value)
|
||||
|
||||
return (
|
||||
<div className='contents'>
|
||||
<EmcnInput
|
||||
data-input-type='key'
|
||||
value={envVar.key}
|
||||
onChange={(e) => onUpdate(index, 'key', e.target.value)}
|
||||
onPaste={onPaste ? (e) => onPaste(e, index) : undefined}
|
||||
placeholder='API_KEY'
|
||||
name={`new_workspace_key_${envVar.id || index}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
@@ -239,20 +303,26 @@ function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariab
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
className={`h-9 ${keyError ? 'border-[var(--text-error)]' : ''}`}
|
||||
className={cn('h-9', keyError && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
<div />
|
||||
<EmcnInput
|
||||
data-input-type='value'
|
||||
value={envVar.value}
|
||||
onChange={(e) => onUpdate(index, 'value', e.target.value)}
|
||||
onPaste={onPaste ? (e) => onPaste(e, index) : undefined}
|
||||
placeholder='Enter value'
|
||||
type='text'
|
||||
type={valueFocused ? 'text' : 'password'}
|
||||
name={`new_workspace_value_${envVar.id || index}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
onFocus={(e) => {
|
||||
setValueFocused(true)
|
||||
e.target.removeAttribute('readOnly')
|
||||
}}
|
||||
onBlur={() => setValueFocused(false)}
|
||||
className='col-span-2 ml-0 h-9'
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
@@ -264,7 +334,7 @@ function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariab
|
||||
onUpdate(index, 'value', '')
|
||||
}}
|
||||
disabled={!hasContent}
|
||||
className={`h-9 w-9 ${!hasContent ? 'opacity-30' : ''}`}
|
||||
className={cn('h-9 w-9', !hasContent && 'opacity-30')}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
@@ -273,7 +343,10 @@ function NewWorkspaceVariableRow({ envVar, index, onUpdate }: NewWorkspaceVariab
|
||||
</Tooltip.Root>
|
||||
{keyError && (
|
||||
<div
|
||||
className={`${COL_SPAN_ALL} mt-[-4px] text-[var(--text-error)] text-caption leading-tight`}
|
||||
className={cn(
|
||||
COL_SPAN_ALL,
|
||||
'mt-[-4px] text-[var(--text-error)] text-caption leading-tight'
|
||||
)}
|
||||
>
|
||||
{keyError}
|
||||
</div>
|
||||
@@ -336,7 +409,6 @@ export function CredentialsManager() {
|
||||
const isLoading = isPersonalLoading || isWorkspaceLoading
|
||||
const variables = useMemo(() => personalEnvData || {}, [personalEnvData])
|
||||
|
||||
// --- List view state ---
|
||||
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
|
||||
const [newWorkspaceRows, setNewWorkspaceRows] = useState<UIEnvironmentVariable[]>([
|
||||
createEmptyEnvVar(),
|
||||
@@ -349,7 +421,6 @@ export function CredentialsManager() {
|
||||
const [pendingKeyValue, setPendingKeyValue] = useState<string>('')
|
||||
const [changeToken, setChangeToken] = useState(0)
|
||||
|
||||
// --- Detail view state ---
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
|
||||
const [prevSelectedCredentialId, setPrevSelectedCredentialId] = useState<
|
||||
string | null | undefined
|
||||
@@ -370,7 +441,6 @@ export function CredentialsManager() {
|
||||
const shouldBlockNavRef = useRef(false)
|
||||
const pendingNavigationUrlRef = useRef<string | null>(null)
|
||||
|
||||
// --- Credential lookups ---
|
||||
const envKeyToCredential = useMemo(() => {
|
||||
const map = new Map<string, WorkspaceCredential>()
|
||||
for (const cred of envCredentials) {
|
||||
@@ -398,7 +468,6 @@ export function CredentialsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detail view hooks ---
|
||||
const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers(
|
||||
selectedCredential?.id
|
||||
)
|
||||
@@ -407,7 +476,6 @@ export function CredentialsManager() {
|
||||
const upsertMember = useUpsertWorkspaceCredentialMember()
|
||||
const removeMember = useRemoveWorkspaceCredentialMember()
|
||||
|
||||
// --- Detail view computed ---
|
||||
const activeMembers = useMemo(
|
||||
() => members.filter((member) => member.status === 'active'),
|
||||
[members]
|
||||
@@ -438,7 +506,6 @@ export function CredentialsManager() {
|
||||
: false
|
||||
const isDetailsDirty = isDescriptionDirty || isDisplayNameDirty
|
||||
|
||||
// --- List view computed ---
|
||||
const filteredEnvVars = useMemo(() => {
|
||||
const mapped = envVars.map((envVar, index) => ({ envVar, originalIndex: index }))
|
||||
if (!searchTerm.trim()) return mapped
|
||||
@@ -526,7 +593,6 @@ export function CredentialsManager() {
|
||||
|
||||
useEffect(() => () => resetNavGuard(), [resetNavGuard])
|
||||
|
||||
// --- Effects ---
|
||||
useEffect(() => {
|
||||
if (hasSavedRef.current) return
|
||||
|
||||
@@ -611,7 +677,6 @@ export function CredentialsManager() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// --- Pending credential create request ---
|
||||
const applyPendingCredentialCreateRequest = useCallback(
|
||||
(request: PendingCredentialCreateRequest) => {
|
||||
if (request.workspaceId !== workspaceId) return
|
||||
@@ -666,56 +731,52 @@ export function CredentialsManager() {
|
||||
}
|
||||
}, [workspaceId, applyPendingCredentialCreateRequest])
|
||||
|
||||
// --- Detail view handlers ---
|
||||
const handleSelectCredential = useCallback((credentialId: string) => {
|
||||
const handleSelectCredential = (credentialId: string) => {
|
||||
setSelectedCredentialId(credentialId)
|
||||
setDetailsError(null)
|
||||
setMemberUserId('')
|
||||
setMemberRole('member')
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleViewDetails = useCallback(
|
||||
async (envKey: string, type: 'env_workspace' | 'env_personal') => {
|
||||
const existing = envKeyToCredential.get(envKey)
|
||||
if (existing) {
|
||||
handleSelectCredential(existing.id)
|
||||
return
|
||||
const handleViewDetails = async (envKey: string, type: 'env_workspace' | 'env_personal') => {
|
||||
const existing = envKeyToCredential.get(envKey)
|
||||
if (existing) {
|
||||
handleSelectCredential(existing.id)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createCredential.mutateAsync({
|
||||
workspaceId,
|
||||
type,
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
...(type === 'env_personal' ? { envOwnerUserId: session?.user?.id } : {}),
|
||||
})
|
||||
if (result.credential?.id) {
|
||||
handleSelectCredential(result.credential.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create credential record', error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createCredential.mutateAsync({
|
||||
workspaceId,
|
||||
type,
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
...(type === 'env_personal' ? { envOwnerUserId: session?.user?.id } : {}),
|
||||
})
|
||||
if (result.credential?.id) {
|
||||
handleSelectCredential(result.credential.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create credential record', error)
|
||||
}
|
||||
},
|
||||
[envKeyToCredential, handleSelectCredential, createCredential, workspaceId, session?.user?.id]
|
||||
)
|
||||
|
||||
const handleBackAttempt = useCallback(() => {
|
||||
const handleBackAttempt = () => {
|
||||
if (isDetailsDirty && !updateCredential.isPending) {
|
||||
setShowDetailUnsavedChanges(true)
|
||||
} else {
|
||||
setSelectedCredentialId(null)
|
||||
}
|
||||
}, [isDetailsDirty, updateCredential.isPending])
|
||||
}
|
||||
|
||||
const handleDiscardDetailChanges = useCallback(() => {
|
||||
const handleDiscardDetailChanges = () => {
|
||||
setShowDetailUnsavedChanges(false)
|
||||
setSelectedDescriptionDraft(selectedCredential?.description || '')
|
||||
setSelectedDisplayNameDraft(selectedCredential?.displayName || '')
|
||||
setSelectedCredentialId(null)
|
||||
}, [selectedCredential])
|
||||
}
|
||||
|
||||
const handleSaveDetails = useCallback(async () => {
|
||||
const handleSaveDetails = async () => {
|
||||
if (!selectedCredential || !isSelectedAdmin || !isDetailsDirty || updateCredential.isPending)
|
||||
return
|
||||
setDetailsError(null)
|
||||
@@ -733,18 +794,9 @@ export function CredentialsManager() {
|
||||
setDetailsError(message)
|
||||
logger.error('Failed to save secret details', error)
|
||||
}
|
||||
}, [
|
||||
selectedCredential,
|
||||
isSelectedAdmin,
|
||||
isDetailsDirty,
|
||||
isDisplayNameDirty,
|
||||
isDescriptionDirty,
|
||||
selectedDisplayNameDraft,
|
||||
selectedDescriptionDraft,
|
||||
updateCredential,
|
||||
])
|
||||
}
|
||||
|
||||
const handleAddMember = useCallback(async () => {
|
||||
const handleAddMember = async () => {
|
||||
if (!memberUserId || !selectedCredential) return
|
||||
try {
|
||||
await upsertMember.mutateAsync({
|
||||
@@ -757,74 +809,61 @@ export function CredentialsManager() {
|
||||
} catch (error) {
|
||||
logger.error('Failed to add member', error)
|
||||
}
|
||||
}, [selectedCredential, memberUserId, memberRole])
|
||||
}
|
||||
|
||||
const handleRemoveMember = useCallback(
|
||||
async (userId: string) => {
|
||||
if (!selectedCredential) return
|
||||
try {
|
||||
await removeMember.mutateAsync({ credentialId: selectedCredential.id, userId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove member', error)
|
||||
}
|
||||
},
|
||||
[selectedCredential]
|
||||
)
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!selectedCredential) return
|
||||
try {
|
||||
await removeMember.mutateAsync({ credentialId: selectedCredential.id, userId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove member', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeMemberRole = useCallback(
|
||||
async (userId: string, role: WorkspaceCredentialRole) => {
|
||||
if (!selectedCredential) return
|
||||
try {
|
||||
await upsertMember.mutateAsync({ credentialId: selectedCredential.id, userId, role })
|
||||
} catch (error) {
|
||||
logger.error('Failed to change member role', error)
|
||||
}
|
||||
},
|
||||
[selectedCredential]
|
||||
)
|
||||
const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => {
|
||||
if (!selectedCredential) return
|
||||
try {
|
||||
await upsertMember.mutateAsync({ credentialId: selectedCredential.id, userId, role })
|
||||
} catch (error) {
|
||||
logger.error('Failed to change member role', error)
|
||||
}
|
||||
}
|
||||
|
||||
// --- List view handlers ---
|
||||
const handleWorkspaceKeyRename = useCallback(
|
||||
(currentKey: string, currentValue: string) => {
|
||||
const newKey = pendingKeyValue.trim()
|
||||
if (!renamingKey || renamingKey !== currentKey) return
|
||||
setRenamingKey(null)
|
||||
if (!newKey || newKey === currentKey) return
|
||||
const handleWorkspaceKeyRename = (currentKey: string, currentValue: string) => {
|
||||
const newKey = pendingKeyValue.trim()
|
||||
if (!renamingKey || renamingKey !== currentKey) return
|
||||
setRenamingKey(null)
|
||||
if (!newKey || newKey === currentKey) return
|
||||
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[currentKey]
|
||||
next[newKey] = currentValue
|
||||
return next
|
||||
})
|
||||
},
|
||||
[pendingKeyValue, renamingKey]
|
||||
)
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[currentKey]
|
||||
next[newKey] = currentValue
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleWorkspaceValueChange = useCallback((key: string, value: string) => {
|
||||
const handleWorkspaceValueChange = (key: string, value: string) => {
|
||||
setWorkspaceVars((prev) => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleDeleteWorkspaceVar = useCallback((key: string) => {
|
||||
const handleDeleteWorkspaceVar = (key: string) => {
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
const updateNewWorkspaceRow = useCallback(
|
||||
(index: number, field: 'key' | 'value', value: string) => {
|
||||
setNewWorkspaceRows((prev) => updateEnvVarArray(prev, index, field, value))
|
||||
},
|
||||
[]
|
||||
)
|
||||
const updateNewWorkspaceRow = (index: number, field: 'key' | 'value', value: string) => {
|
||||
setNewWorkspaceRows((prev) => updateEnvVarArray(prev, index, field, value))
|
||||
}
|
||||
|
||||
const updateEnvVar = useCallback((index: number, field: 'key' | 'value', value: string) => {
|
||||
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
|
||||
setEnvVars((prev) => updateEnvVarArray(prev, index, field, value))
|
||||
}, [])
|
||||
}
|
||||
|
||||
const removeEnvVar = useCallback((index: number) => {
|
||||
const removeEnvVar = (index: number) => {
|
||||
setEnvVars((prev) => {
|
||||
const filtered = prev.filter((_, i) => i !== index)
|
||||
const hasTrailingEmpty =
|
||||
@@ -833,130 +872,105 @@ export function CredentialsManager() {
|
||||
!filtered[filtered.length - 1].value
|
||||
return hasTrailingEmpty ? filtered : [...filtered, createEmptyEnvVar()]
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleValueFocus = useCallback((index: number, e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const handleValueFocus = (index: number, e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocusedValueIndex(index)
|
||||
e.target.scrollLeft = 0
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleValueClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
|
||||
const handleValueClick = (e: React.MouseEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.scrollLeft = 0
|
||||
}, [])
|
||||
}
|
||||
|
||||
const parseEnvVarLine = useCallback((line: string): UIEnvironmentVariable | null => {
|
||||
const trimmed = line.trim()
|
||||
const handleSingleValuePaste = (text: string, index: number, inputType: 'key' | 'value') => {
|
||||
setEnvVars((prev) => {
|
||||
const newEnvVars = [...prev]
|
||||
newEnvVars[index] = { ...newEnvVars[index], [inputType]: text }
|
||||
return newEnvVars
|
||||
})
|
||||
}
|
||||
|
||||
if (!trimmed || trimmed.startsWith('#')) return null
|
||||
/**
|
||||
* Paste handler for personal env var rows.
|
||||
* Only prevents default when it actually handles the paste: KV patterns destructure into new rows,
|
||||
* plain values overwrite the field. Falls through to native paste if pattern is detected but all
|
||||
* values are empty (e.g. KEY=), avoiding silently swallowed input.
|
||||
*/
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
if (!text) return
|
||||
|
||||
const withoutExport = trimmed.replace(/^export\s+/, '')
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim())
|
||||
if (lines.length === 0) return
|
||||
|
||||
const equalIndex = withoutExport.indexOf('=')
|
||||
if (equalIndex === -1 || equalIndex === 0) return null
|
||||
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
|
||||
| 'key'
|
||||
| 'value'
|
||||
|
||||
const potentialKey = withoutExport.substring(0, equalIndex).trim()
|
||||
if (!isValidEnvVarName(potentialKey)) return null
|
||||
|
||||
let value = withoutExport.substring(equalIndex + 1)
|
||||
|
||||
const looksLikeBase64Key = /^[A-Za-z0-9+/]+$/.test(potentialKey) && !potentialKey.includes('_')
|
||||
const valueIsJustPadding = /^=+$/.test(value.trim())
|
||||
if (looksLikeBase64Key && valueIsJustPadding && potentialKey.length > 20) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
if (
|
||||
!trimmedValue.startsWith('"') &&
|
||||
!trimmedValue.startsWith("'") &&
|
||||
!trimmedValue.startsWith('`')
|
||||
) {
|
||||
const commentIndex = value.search(/\s#/)
|
||||
if (commentIndex !== -1) {
|
||||
value = value.substring(0, commentIndex)
|
||||
if (inputType) {
|
||||
const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null)
|
||||
if (!hasValidEnvVarPattern) {
|
||||
e.preventDefault()
|
||||
handleSingleValuePaste(text, index, inputType)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
value = value.trim()
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")) ||
|
||||
(value.startsWith('`') && value.endsWith('`'))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
return { key: potentialKey, value, id: generateRowId() }
|
||||
}, [])
|
||||
|
||||
const handleSingleValuePaste = useCallback(
|
||||
(text: string, index: number, inputType: 'key' | 'value') => {
|
||||
setEnvVars((prev) => {
|
||||
const newEnvVars = [...prev]
|
||||
newEnvVars[index][inputType] = text
|
||||
return newEnvVars
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleKeyValuePaste = useCallback(
|
||||
(lines: string[]) => {
|
||||
const parsedVars = lines
|
||||
.map(parseEnvVarLine)
|
||||
.filter((parsed): parsed is UIEnvironmentVariable => parsed !== null)
|
||||
.filter(({ key, value }) => key && value)
|
||||
|
||||
if (parsedVars.length > 0) {
|
||||
setEnvVars((prev) => {
|
||||
const existingVars = prev.filter((v) => v.key || v.value)
|
||||
return [...existingVars, ...parsedVars, createEmptyEnvVar()]
|
||||
})
|
||||
scrollToBottom()
|
||||
}
|
||||
},
|
||||
[parseEnvVarLine, scrollToBottom]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
if (!text) return
|
||||
|
||||
const lines = text.split('\n').filter((line) => line.trim())
|
||||
if (lines.length === 0) return
|
||||
|
||||
const parsedVars = parseValidEnvVars(lines)
|
||||
if (parsedVars.length > 0) {
|
||||
e.preventDefault()
|
||||
setEnvVars((prev) => {
|
||||
const existingVars = prev.filter((v) => v.key || v.value)
|
||||
return [...existingVars, ...parsedVars, createEmptyEnvVar()]
|
||||
})
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
|
||||
| 'key'
|
||||
| 'value'
|
||||
/**
|
||||
* Paste handler for workspace new-row inputs.
|
||||
* Only prevents default when pasted text contains KEY=VALUE patterns; otherwise defers to
|
||||
* native browser paste so cursor/selection semantics are preserved for plain values.
|
||||
*/
|
||||
const handleWorkspacePaste = (e: React.ClipboardEvent<HTMLInputElement>, _index: number) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
if (!text) return
|
||||
|
||||
if (inputType) {
|
||||
const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null)
|
||||
if (!hasValidEnvVarPattern) {
|
||||
handleSingleValuePaste(text, index, inputType)
|
||||
return
|
||||
}
|
||||
}
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim())
|
||||
if (lines.length === 0) return
|
||||
|
||||
handleKeyValuePaste(lines)
|
||||
},
|
||||
[parseEnvVarLine, handleSingleValuePaste, handleKeyValuePaste]
|
||||
)
|
||||
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
|
||||
| 'key'
|
||||
| 'value'
|
||||
|
||||
const resetToSaved = useCallback(() => {
|
||||
if (inputType) {
|
||||
const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null)
|
||||
if (!hasValidEnvVarPattern) return
|
||||
}
|
||||
|
||||
const parsedVars = parseValidEnvVars(lines)
|
||||
if (parsedVars.length > 0) {
|
||||
e.preventDefault()
|
||||
setNewWorkspaceRows((prev) => {
|
||||
const existing = prev.filter((v) => v.key || v.value)
|
||||
return [...existing, ...parsedVars, createEmptyEnvVar()]
|
||||
})
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
const resetToSaved = () => {
|
||||
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
|
||||
setWorkspaceVars({ ...initialWorkspaceVarsRef.current })
|
||||
setNewWorkspaceRows([createEmptyEnvVar()])
|
||||
setShowUnsavedChanges(false)
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleCancel = resetToSaved
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const handleSave = async () => {
|
||||
if (isListSaving) return
|
||||
|
||||
const prevInitialVars = [...initialVarsRef.current]
|
||||
@@ -1042,10 +1056,9 @@ export function CredentialsManager() {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
}
|
||||
}
|
||||
// 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(() => {
|
||||
const handleDiscardAndNavigate = () => {
|
||||
shouldBlockNavRef.current = false
|
||||
resetNavGuard()
|
||||
resetToSaved()
|
||||
@@ -1056,117 +1069,119 @@ export function CredentialsManager() {
|
||||
pendingNavigationUrlRef.current = null
|
||||
router.push(url)
|
||||
}
|
||||
}, [router, resetToSaved, resetNavGuard])
|
||||
}
|
||||
|
||||
const renderEnvVarRow = useCallback(
|
||||
(envVar: UIEnvironmentVariable, originalIndex: number) => {
|
||||
const isConflict = !!envVar.key && allWorkspaceKeys.has(envVar.key)
|
||||
const keyError = validateEnvVarKey(envVar.key)
|
||||
const maskedValueStyle =
|
||||
focusedValueIndex !== originalIndex && !isConflict
|
||||
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
|
||||
: undefined
|
||||
const renderEnvVarRow = (envVar: UIEnvironmentVariable, originalIndex: number) => {
|
||||
const isConflict = !!envVar.key && allWorkspaceKeys.has(envVar.key)
|
||||
const keyError = validateEnvVarKey(envVar.key)
|
||||
const maskedValueStyle =
|
||||
focusedValueIndex !== originalIndex && !isConflict
|
||||
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
|
||||
: undefined
|
||||
|
||||
const isComplete = Boolean(envVar.key && envVar.value)
|
||||
const hasCredential = isComplete && envKeyToCredential.has(envVar.key)
|
||||
const isComplete = Boolean(envVar.key && envVar.value)
|
||||
const hasCredential = isComplete && envKeyToCredential.has(envVar.key)
|
||||
|
||||
const hasContent = Boolean(envVar.key || envVar.value)
|
||||
const hasContent = Boolean(envVar.key || envVar.value)
|
||||
|
||||
return (
|
||||
<div className='contents'>
|
||||
<EmcnInput
|
||||
data-input-type='key'
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder='API_KEY'
|
||||
name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
className={`h-9 ${isConflict ? CONFLICT_CLASS : ''} ${keyError ? 'border-[var(--text-error)]' : ''}`}
|
||||
/>
|
||||
<div />
|
||||
<EmcnInput
|
||||
data-input-type='value'
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
|
||||
type='text'
|
||||
onFocus={(e) => {
|
||||
if (!isConflict) {
|
||||
e.target.removeAttribute('readOnly')
|
||||
handleValueFocus(originalIndex, e)
|
||||
}
|
||||
}}
|
||||
onClick={handleValueClick}
|
||||
onBlur={() => setFocusedValueIndex(null)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder={isConflict ? 'Workspace override active' : 'Enter value'}
|
||||
disabled={isConflict}
|
||||
aria-disabled={isConflict}
|
||||
name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly={isConflict}
|
||||
style={maskedValueStyle}
|
||||
className={`h-9 ${isComplete ? '' : 'col-span-2'} ${isConflict ? `cursor-not-allowed ${CONFLICT_CLASS}` : ''}`}
|
||||
/>
|
||||
{isComplete && (
|
||||
return (
|
||||
<div className='contents'>
|
||||
<EmcnInput
|
||||
data-input-type='key'
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder='API_KEY'
|
||||
name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
className={cn(
|
||||
'h-9',
|
||||
isConflict && CONFLICT_CLASS,
|
||||
keyError && 'border-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
<div />
|
||||
<EmcnInput
|
||||
data-input-type='value'
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
|
||||
type='text'
|
||||
onFocus={(e) => {
|
||||
if (!isConflict) {
|
||||
e.target.removeAttribute('readOnly')
|
||||
handleValueFocus(originalIndex, e)
|
||||
}
|
||||
}}
|
||||
onClick={handleValueClick}
|
||||
onBlur={() => setFocusedValueIndex(null)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder={isConflict ? 'Workspace override active' : 'Enter value'}
|
||||
disabled={isConflict}
|
||||
aria-disabled={isConflict}
|
||||
name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly={isConflict}
|
||||
style={maskedValueStyle}
|
||||
className={cn(
|
||||
'h-9',
|
||||
!isComplete && 'col-span-2',
|
||||
isConflict && 'cursor-not-allowed',
|
||||
isConflict && CONFLICT_CLASS
|
||||
)}
|
||||
/>
|
||||
{isComplete && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => handleViewDetails(envVar.key, 'env_personal')}
|
||||
disabled={!hasCredential}
|
||||
className={cn('ml-2 h-9', !hasCredential && 'opacity-40')}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => handleViewDetails(envVar.key, 'env_personal')}
|
||||
disabled={!hasCredential}
|
||||
className={`ml-2 h-9 ${!hasCredential ? 'opacity-40' : ''}`}
|
||||
variant='ghost'
|
||||
onClick={() => removeEnvVar(originalIndex)}
|
||||
disabled={!hasContent}
|
||||
className={cn('h-9 w-9', !hasContent && 'opacity-30')}
|
||||
>
|
||||
Details
|
||||
<Trash />
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeEnvVar(originalIndex)}
|
||||
disabled={!hasContent}
|
||||
className={`h-9 w-9 ${!hasContent ? 'opacity-30' : ''}`}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{hasContent && <Tooltip.Content>Delete secret</Tooltip.Content>}
|
||||
</Tooltip.Root>
|
||||
{keyError && (
|
||||
<div
|
||||
className={`${COL_SPAN_ALL} mt-[-4px] text-[var(--text-error)] text-caption leading-tight`}
|
||||
>
|
||||
{keyError}
|
||||
</div>
|
||||
)}
|
||||
{isConflict && !keyError && (
|
||||
<div
|
||||
className={`${COL_SPAN_ALL} mt-[-4px] text-[var(--text-error)] text-caption leading-tight`}
|
||||
>
|
||||
Workspace variable with the same name overrides this. Rename your personal key to use
|
||||
it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
allWorkspaceKeys,
|
||||
focusedValueIndex,
|
||||
updateEnvVar,
|
||||
handlePaste,
|
||||
handleValueFocus,
|
||||
handleValueClick,
|
||||
removeEnvVar,
|
||||
handleViewDetails,
|
||||
envKeyToCredential,
|
||||
]
|
||||
)
|
||||
</Tooltip.Trigger>
|
||||
{hasContent && <Tooltip.Content>Delete secret</Tooltip.Content>}
|
||||
</Tooltip.Root>
|
||||
{keyError && (
|
||||
<div
|
||||
className={cn(
|
||||
COL_SPAN_ALL,
|
||||
'mt-[-4px] text-[var(--text-error)] text-caption leading-tight'
|
||||
)}
|
||||
>
|
||||
{keyError}
|
||||
</div>
|
||||
)}
|
||||
{isConflict && !keyError && (
|
||||
<div
|
||||
className={cn(
|
||||
COL_SPAN_ALL,
|
||||
'mt-[-4px] text-[var(--text-error)] text-caption leading-tight'
|
||||
)}
|
||||
>
|
||||
Workspace variable with the same name overrides this. Rename your personal key to use
|
||||
it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isPendingNavigation = pendingNavigationUrlRef.current !== null
|
||||
|
||||
@@ -1208,9 +1223,10 @@ export function CredentialsManager() {
|
||||
Display Name
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant='ghost'
|
||||
type='button'
|
||||
className='-my-1 flex h-5 w-5 items-center justify-center'
|
||||
className='-my-1 h-5 w-5 p-0'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(selectedCredential.id)
|
||||
setCopyIdSuccess(true)
|
||||
@@ -1219,11 +1235,11 @@ export function CredentialsManager() {
|
||||
aria-label='Copy value'
|
||||
>
|
||||
{copyIdSuccess ? (
|
||||
<Check className='h-3 w-3 text-green-500' />
|
||||
<Check className='h-3 w-3 text-[var(--success)]' />
|
||||
) : (
|
||||
<Clipboard className='h-3 w-3 text-[var(--text-icon)]' />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{copyIdSuccess ? 'Copied!' : 'Copy credential ID'}
|
||||
@@ -1439,7 +1455,6 @@ export function CredentialsManager() {
|
||||
)
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-4'>
|
||||
@@ -1493,7 +1508,7 @@ export function CredentialsManager() {
|
||||
isLoading || !hasChanges || hasConflicts || hasInvalidKeys || isListSaving
|
||||
}
|
||||
variant='primary'
|
||||
className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
className={cn((hasConflicts || hasInvalidKeys) && 'cursor-not-allowed opacity-50')}
|
||||
>
|
||||
{isListSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
@@ -1515,8 +1530,8 @@ export function CredentialsManager() {
|
||||
<Skeleton className='h-5 w-[160px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${GRID_COLS} gap-y-2`}>
|
||||
<Skeleton className={`${COL_SPAN_ALL} h-5 w-[55px]`} />
|
||||
<div className={cn(GRID_COLS, 'gap-y-2')}>
|
||||
<Skeleton className={cn(COL_SPAN_ALL, 'h-5 w-[55px]')} />
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<div key={`personal-${i}`} className='contents'>
|
||||
<Skeleton className='h-9 rounded-md' />
|
||||
@@ -1529,13 +1544,16 @@ export function CredentialsManager() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={`${GRID_COLS} gap-y-2`}>
|
||||
<div className={cn(GRID_COLS, 'gap-y-2')}>
|
||||
{(!searchTerm.trim() ||
|
||||
filteredWorkspaceEntries.length > 0 ||
|
||||
filteredNewWorkspaceRows.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
className={`${COL_SPAN_ALL} font-medium text-[var(--text-secondary)] text-small`}
|
||||
className={cn(
|
||||
COL_SPAN_ALL,
|
||||
'font-medium text-[var(--text-secondary)] text-small'
|
||||
)}
|
||||
>
|
||||
Workspace
|
||||
</div>
|
||||
@@ -1569,16 +1587,20 @@ export function CredentialsManager() {
|
||||
envVar={row}
|
||||
index={originalIndex}
|
||||
onUpdate={updateNewWorkspaceRow}
|
||||
onPaste={handleWorkspacePaste}
|
||||
/>
|
||||
))}
|
||||
<div className={`${COL_SPAN_ALL} h-[8px]`} />
|
||||
<div className={cn(COL_SPAN_ALL, 'h-[8px]')} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!searchTerm.trim() || filteredEnvVars.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
className={`${COL_SPAN_ALL} font-medium text-[var(--text-secondary)] text-small`}
|
||||
className={cn(
|
||||
COL_SPAN_ALL,
|
||||
'font-medium text-[var(--text-secondary)] text-small'
|
||||
)}
|
||||
>
|
||||
Personal
|
||||
</div>
|
||||
@@ -1597,7 +1619,10 @@ export function CredentialsManager() {
|
||||
Object.keys(workspaceVars).length > 0 ||
|
||||
newWorkspaceRows.length > 0) && (
|
||||
<div
|
||||
className={`${COL_SPAN_ALL} py-4 text-center text-[var(--text-muted)] text-small`}
|
||||
className={cn(
|
||||
COL_SPAN_ALL,
|
||||
'py-4 text-center text-[var(--text-muted)] text-small'
|
||||
)}
|
||||
>
|
||||
No secrets found matching “{searchTerm}”
|
||||
</div>
|
||||
|
||||
@@ -100,11 +100,10 @@ async function upsertCredentialAdminMember(credentialId: string, adminUserId: st
|
||||
|
||||
async function ensureWorkspaceCredentialMemberships(
|
||||
credentialId: string,
|
||||
workspaceId: string,
|
||||
memberUserIds: string[],
|
||||
ownerUserId: string
|
||||
) {
|
||||
const workspaceMemberUserIds = await getWorkspaceMemberUserIds(workspaceId)
|
||||
if (!workspaceMemberUserIds.length) return
|
||||
if (!memberUserIds.length) return
|
||||
|
||||
const existingMemberships = await db
|
||||
.select({
|
||||
@@ -117,14 +116,14 @@ async function ensureWorkspaceCredentialMemberships(
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, credentialId),
|
||||
inArray(credentialMember.userId, workspaceMemberUserIds)
|
||||
inArray(credentialMember.userId, memberUserIds)
|
||||
)
|
||||
)
|
||||
|
||||
const byUserId = new Map(existingMemberships.map((row) => [row.userId, row]))
|
||||
const now = new Date()
|
||||
|
||||
for (const memberUserId of workspaceMemberUserIds) {
|
||||
for (const memberUserId of memberUserIds) {
|
||||
const targetRole = memberUserId === ownerUserId ? 'admin' : 'member'
|
||||
const existing = byUserId.get(memberUserId)
|
||||
if (existing) {
|
||||
@@ -164,11 +163,14 @@ export async function syncWorkspaceEnvCredentials(params: {
|
||||
actingUserId: string
|
||||
}) {
|
||||
const { workspaceId, envKeys, actingUserId } = params
|
||||
const [workspaceRow] = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
const [[workspaceRow], memberUserIds] = await Promise.all([
|
||||
db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1),
|
||||
getWorkspaceMemberUserIds(workspaceId),
|
||||
])
|
||||
|
||||
if (!workspaceRow) return
|
||||
|
||||
@@ -217,7 +219,7 @@ export async function syncWorkspaceEnvCredentials(params: {
|
||||
}
|
||||
|
||||
for (const credentialId of credentialIdsToEnsureMembership) {
|
||||
await ensureWorkspaceCredentialMemberships(credentialId, workspaceId, workspaceRow.ownerId)
|
||||
await ensureWorkspaceCredentialMemberships(credentialId, memberUserIds, workspaceRow.ownerId)
|
||||
}
|
||||
|
||||
if (normalizedKeys.length > 0) {
|
||||
@@ -238,6 +240,97 @@ export async function syncWorkspaceEnvCredentials(params: {
|
||||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'env_workspace')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates credential records and bulk-inserts memberships for newly added workspace env keys.
|
||||
* Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys are new.
|
||||
*/
|
||||
export async function createWorkspaceEnvCredentials(params: {
|
||||
workspaceId: string
|
||||
newKeys: string[]
|
||||
actingUserId: string
|
||||
}): Promise<void> {
|
||||
const { workspaceId, newKeys, actingUserId } = params
|
||||
const keys = Array.from(new Set(newKeys.filter(Boolean)))
|
||||
if (keys.length === 0) return
|
||||
|
||||
const [[workspaceRow], memberUserIds] = await Promise.all([
|
||||
db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1),
|
||||
getWorkspaceMemberUserIds(workspaceId),
|
||||
])
|
||||
|
||||
if (!workspaceRow) return
|
||||
|
||||
const ownerUserId = workspaceRow.ownerId
|
||||
const now = new Date()
|
||||
const createdIds: string[] = []
|
||||
|
||||
for (const envKey of keys) {
|
||||
const createdId = generateId()
|
||||
try {
|
||||
await db.insert(credential).values({
|
||||
id: createdId,
|
||||
workspaceId,
|
||||
type: 'env_workspace',
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
createdBy: actingUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
createdIds.push(createdId)
|
||||
} catch (error: unknown) {
|
||||
const code = getPostgresErrorCode(error)
|
||||
if (code !== '23505') throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (createdIds.length === 0 || memberUserIds.length === 0) return
|
||||
|
||||
// Bulk-insert memberships for all new credentials × all workspace members in one query
|
||||
const membershipValues = createdIds.flatMap((credentialId) =>
|
||||
memberUserIds.map((memberUserId) => ({
|
||||
id: generateId(),
|
||||
credentialId,
|
||||
userId: memberUserId,
|
||||
role: (memberUserId === ownerUserId ? 'admin' : 'member') as 'admin' | 'member',
|
||||
status: 'active' as const,
|
||||
joinedAt: now,
|
||||
invitedBy: ownerUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
|
||||
await db.insert(credentialMember).values(membershipValues).onConflictDoNothing()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes credential records (and their memberships via cascade) for removed workspace env keys.
|
||||
* Use this instead of `syncWorkspaceEnvCredentials` when the caller knows exactly which keys were deleted.
|
||||
*/
|
||||
export async function deleteWorkspaceEnvCredentials(params: {
|
||||
workspaceId: string
|
||||
removedKeys: string[]
|
||||
}): Promise<void> {
|
||||
const { workspaceId, removedKeys } = params
|
||||
const keys = removedKeys.filter(Boolean)
|
||||
if (keys.length === 0) return
|
||||
|
||||
await db
|
||||
.delete(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'env_workspace'),
|
||||
inArray(credential.envKey, keys)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function syncPersonalEnvCredentialsForUser(params: {
|
||||
userId: string
|
||||
envKeys: string[]
|
||||
|
||||
@@ -5,9 +5,9 @@ import { generateId } from '@sim/utils/id'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import {
|
||||
createWorkspaceEnvCredentials,
|
||||
getAccessibleEnvCredentials,
|
||||
syncPersonalEnvCredentialsForUser,
|
||||
syncWorkspaceEnvCredentials,
|
||||
} from '@/lib/credentials/environment'
|
||||
|
||||
const logger = createLogger('EnvironmentUtils')
|
||||
@@ -305,7 +305,8 @@ export async function upsertWorkspaceEnvVars(
|
||||
set: { variables: merged, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncWorkspaceEnvCredentials({ workspaceId, envKeys: Object.keys(merged), actingUserId })
|
||||
const newKeys = Object.keys(newVars).filter((k) => !(k in existingWsEncrypted))
|
||||
await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId })
|
||||
|
||||
return updatedKeys
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user