diff --git a/frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx b/frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx index 473b91a00..03be5386a 100644 --- a/frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx +++ b/frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx @@ -36,6 +36,7 @@ import { import { useSecretReferenceAutocomplete } from '@/hooks/useSecretReferenceAutocomplete' import { ReferenceAutocompleteDropdown } from '@/components/secrets/ReferenceAutocompleteDropdown' import { SecretReferenceHighlight } from '@/components/secrets/SecretReferenceHighlight' +import { DuplicateSecretKeyMessage } from '@/components/environments/secrets/DuplicateSecretKeyMessage' const INPUT_BASE_STYLE = 'w-full flex-1 font-mono custom bg-transparent group-hover:bg-zinc-400/20 dark:group-hover:bg-zinc-400/10 transition ease ph-no-capture text-2xs 2xl:text-sm' @@ -395,6 +396,8 @@ interface AppSecretRowProps { deleteEnvValue: (appSecretId: string, environment: EnvironmentType) => void deleteKey: (id: string) => void revealOnHover?: boolean + keyIsDuplicate?: boolean + duplicateKeyNumber?: number } const AppSecretRowComponent = ({ @@ -412,6 +415,8 @@ const AppSecretRowComponent = ({ deleteEnvValue, deleteKey, revealOnHover, + keyIsDuplicate = false, + duplicateKeyNumber, }: AppSecretRowProps) => { const { activeOrganisation: organisation } = useContext(organisationContext) const routeParams = useParams<{ app: string }>() @@ -464,7 +469,6 @@ const AppSecretRowComponent = ({ env.env.envType?.toLowerCase() !== 'prod' const keyIsBlank = clientAppSecret.key === '' - const keyIsDuplicate = false // TODO implement const tooltipText = (env: { env: Partial; secret: SecretType | null }) => { if (env.secret === null) return `This secret is missing in ${env.env.name}` @@ -584,24 +588,33 @@ const AppSecretRowComponent = ({
- + {(descriptionId) => ( + e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + /> )} - value={clientAppSecret.key} - onChange={handleUpdateKey} - onClick={(e) => e.stopPropagation()} - onFocus={(e) => e.stopPropagation()} - /> +
e.stopPropagation()}> @@ -705,6 +718,8 @@ const areAppSecretRowEqual = (prev: AppSecretRowProps, next: AppSecretRowProps) if (prev.isExpanded !== next.isExpanded) return false if (prev.stagedForDelete !== next.stagedForDelete) return false if (prev.revealOnHover !== next.revealOnHover) return false + if (prev.keyIsDuplicate !== next.keyIsDuplicate) return false + if (prev.duplicateKeyNumber !== next.duplicateKeyNumber) return false if (prev.clientAppSecret.id !== next.clientAppSecret.id) return false if (prev.clientAppSecret.key !== next.clientAppSecret.key) return false diff --git a/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx b/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx index 43951cb20..c68f92098 100644 --- a/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx +++ b/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx @@ -50,7 +50,7 @@ import { formatTitle } from '@/utils/meta' import MultiEnvImportDialog from '@/components/environments/secrets/import/MultiEnvImportDialog' import { TbDownload } from 'react-icons/tb' import { - duplicateKeysExist, + getDuplicateSecretKeys, getSavedSort, normalizeKey, saveSort, @@ -67,6 +67,8 @@ import { dynamicSearchText, dynamicMatchesSearch, hasRegularOnlyFacet, + buildKeyPositionIndex, + getDuplicateKeyNumber, } from '@/utils/secrets' import { useWarnIfUnsavedChanges } from '@/hooks/warnUnsavedChanges' import { AppDynamicSecretGroup } from './AppDynamicSecretGroup' @@ -86,6 +88,7 @@ import { } from '@/utils/secretReferences' import { BrokenReferencesDialog } from '@/components/secrets/BrokenReferencesDialog' import { useOrgSecretKeys } from '@/hooks/useOrgSecretKeys' +import { DUPLICATE_SECRET_KEYS_MESSAGE } from '@/components/environments/secrets/DuplicateSecretKeyMessage' export const AppSecrets = ({ team, app }: { team: string; app: string }) => { const { activeOrganisation: organisation } = useContext(organisationContext) @@ -183,6 +186,34 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { unsavedChanges ? 0 : 10000 // Poll every 10 seconds ) + const appSecretsToDeleteSet = useMemo( + () => new Set(appSecretsToDelete), + [appSecretsToDelete] + ) + + const duplicateSecretKeys = useMemo(() => { + const activeSecrets = clientAppSecrets.filter( + (secret) => !appSecretsToDeleteSet.has(secret.id) + ) + const duplicates = getDuplicateSecretKeys(activeSecrets) + const activeSecretKeys = new Set(activeSecrets.map((secret) => secret.key.toUpperCase())) + const dynamicKeyNames = new Set() + + for (const appDynamicSecret of appDynamicSecrets) { + for (const { dynamicSecret } of appDynamicSecret.envs) { + for (const keyMapEntry of dynamicSecret?.keyMap ?? []) { + if (keyMapEntry?.keyName) dynamicKeyNames.add(keyMapEntry.keyName.toUpperCase()) + } + } + } + + for (const dynamicKeyName of dynamicKeyNames) { + if (activeSecretKeys.has(dynamicKeyName)) duplicates.add(dynamicKeyName) + } + + return duplicates + }, [appDynamicSecrets, appSecretsToDeleteSet, clientAppSecrets]) + // Fetch and decrypt all org secret keys for cross-app reference autocomplete/validation const { orgApps: allOrgApps } = useOrgSecretKeys() @@ -616,8 +647,8 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { return false } - if (duplicateKeysExist(clientAppSecrets)) { - toast.error('Secret keys cannot be repeated!') + if (duplicateSecretKeys.size > 0) { + toast.error(DUPLICATE_SECRET_KEYS_MESSAGE) setIsLoading(false) return false } @@ -1162,9 +1193,37 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { {(() => { const envs = appEnvironments ?? [] const colSpanForGroup = 1 + envs.length + const keyPositions = buildKeyPositionIndex( + secretListItems.flatMap((item) => { + if (item.kind === 'single') { + return [{ + id: item.secret.id, + key: item.secret.key, + include: !appSecretsToDeleteSet.has(item.secret.id), + }] + } + if (item.kind === 'group') { + return item.appSecrets.map((secret) => ({ + id: secret.id, + key: secret.key, + include: !appSecretsToDeleteSet.has(secret.id), + })) + } + return item.keyMap.map((key) => ({ + id: `${item.appDynamicSecret.id}-${key.id}`, + key: key.keyName, + })) + }) + ) + let runningIndex = 0 const renderRow = (appSecret: AppSecret) => { const idx = runningIndex++ + const duplicateKeyNumber = getDuplicateKeyNumber( + keyPositions, + appSecret.key, + appSecret.id + ) return ( { deleteEnvValue={stageEnvValueForDelete} updateValue={handleUpdateSecretValue} deleteKey={handleStageClientSecretForDelete} - stagedForDelete={appSecretsToDelete.includes(appSecret.id)} + stagedForDelete={appSecretsToDeleteSet.has(appSecret.id)} revealOnHover={revealOnHover} + keyIsDuplicate={ + !appSecretsToDeleteSet.has(appSecret.id) && + duplicateSecretKeys.has(appSecret.key.toUpperCase()) + } + duplicateKeyNumber={duplicateKeyNumber} /> ) } diff --git a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx index 8551f630a..df84fe15b 100644 --- a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx @@ -65,11 +65,10 @@ import { } from '@/utils/crypto' import { EmptyState } from '@/components/common/EmptyState' import { - duplicateKeysExist, + getDuplicateSecretKeys, exportToEnvFile, getSavedSort, normalizeKey, - processEnvFile, saveSort, SortOption, sortSecrets, @@ -84,6 +83,8 @@ import { dynamicSearchText, dynamicMatchesSearch, hasRegularOnlyFacet, + buildKeyPositionIndex, + getDuplicateKeyNumber, } from '@/utils/secrets' import SortMenu from '@/components/environments/secrets/SortMenu' import FilterMenu from '@/components/environments/secrets/FilterMenu' @@ -120,6 +121,7 @@ import { validateSecretReferences, } from '@/utils/secretReferences' import { BrokenReferencesDialog } from '@/components/secrets/BrokenReferencesDialog' +import { DUPLICATE_SECRET_KEYS_MESSAGE } from '@/components/environments/secrets/DuplicateSecretKeyMessage' import { useOrgSecretKeys } from '@/hooks/useOrgSecretKeys' export default function EnvironmentPath({ @@ -164,13 +166,28 @@ export default function EnvironmentPath({ const [folderMenuIsOpen, setFolderMenuIsOpen] = useState(false) const [globallyRevealed, setGloballyRevealed] = useState(false) - const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const importDialogRef = useRef<{ + openModal: () => void + closeModal: () => void + importSource: (source: string) => void + }>(null) const dynamicSecretDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const rotatingSecretDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const upsellDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const refWarningDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const [refWarnings, setRefWarnings] = useState([]) + const secretsToDeleteSet = useMemo(() => new Set(secretsToDelete), [secretsToDelete]) + + const duplicateSecretKeys = useMemo( + () => + getDuplicateSecretKeys( + clientSecrets.filter((secret) => !secretsToDeleteSet.has(secret.id)), + dynamicSecrets + ), + [clientSecrets, dynamicSecrets, secretsToDeleteSet] + ) + const [sort, _setSort] = useState(() => getSavedSort() ?? '-created') const setSort = useCallback((option: SortOption) => { _setSort(option) @@ -857,8 +874,8 @@ export default function EnvironmentPath({ return false } - if (duplicateKeysExist(clientSecrets, dynamicSecrets)) { - toast.error('Secret keys cannot be repeated!') + if (duplicateSecretKeys.size > 0) { + toast.error(DUPLICATE_SECRET_KEYS_MESSAGE) setIsloading(false) return false } @@ -904,11 +921,6 @@ export default function EnvironmentPath({ setSecretsToDelete([]) } - const secretNames = useMemo( - () => serverSecrets.map(({ id, key }) => ({ id, key })), - [serverSecrets] - ) - // Fast lookup for canonical secrets by id const serverSecretsById = useMemo( () => new Map(serverSecrets.map((s) => [s.id, s] as const)), @@ -1289,8 +1301,7 @@ export default function EnvironmentPath({ const EmptyStateFileImport = () => { const handleFileSelection = (fileString: string) => { - const secrets: SecretType[] = processEnvFile(fileString, environment, '/') - bulkAddSecrets(secrets) + importDialogRef.current?.importSource(fileString) } return handleFileSelection(content)} /> @@ -1531,9 +1542,37 @@ export default function EnvironmentPath({ {organisation && environment && (() => { + const dynamicKeyEntries = filteredDynamicSecrets.flatMap((dynamicSecret) => + (dynamicSecret.keyMap ?? []).flatMap((keyMapEntry) => + keyMapEntry?.keyName + ? [{ + id: `${dynamicSecret.id}-${keyMapEntry.id}`, + key: keyMapEntry.keyName, + }] + : [] + ) + ) + const secretKeyEntries = groupedSecretItems.flatMap((item) => { + const secrets = item.kind === 'single' ? [item.secret] : item.secrets + return secrets.map((secret) => ({ + id: secret.id, + key: secret.key, + include: !secretsToDeleteSet.has(secret.id), + })) + }) + const keyPositions = buildKeyPositionIndex([ + ...dynamicKeyEntries, + ...secretKeyEntries, + ]) + let runningIndex = 0 const renderSecretRow = (secret: SecretType) => { const index = runningIndex++ + const duplicateKeyNumber = getDuplicateKeyNumber( + keyPositions, + secret.key, + secret.id + ) return (
) diff --git a/frontend/components/environments/secrets/DuplicateSecretKeyMessage.tsx b/frontend/components/environments/secrets/DuplicateSecretKeyMessage.tsx new file mode 100644 index 000000000..d0b008c57 --- /dev/null +++ b/frontend/components/environments/secrets/DuplicateSecretKeyMessage.tsx @@ -0,0 +1,32 @@ +import { ReactNode, useId } from 'react' + +export const DUPLICATE_SECRET_KEYS_MESSAGE = + 'Duplicate secret keys found. Rename the highlighted keys.' + +export const DuplicateSecretKeyMessage = ({ + isDuplicate, + duplicateKeyNumber, + children, +}: { + isDuplicate: boolean + duplicateKeyNumber?: number + children: (descriptionId: string | undefined) => ReactNode +}) => { + const descriptionId = useId() + + return ( + <> + {children(isDuplicate ? descriptionId : undefined)} + {isDuplicate && ( +

+ {duplicateKeyNumber + ? `Check key #${duplicateKeyNumber} — it uses the same name. Rename one of these keys.` + : 'Another key uses this name. Clear filters to find it, then rename one of the keys.'} +

+ )} + + ) +} diff --git a/frontend/components/environments/secrets/SecretRow.tsx b/frontend/components/environments/secrets/SecretRow.tsx index 83bff2318..3c0707921 100644 --- a/frontend/components/environments/secrets/SecretRow.tsx +++ b/frontend/components/environments/secrets/SecretRow.tsx @@ -30,27 +30,30 @@ import { useSecretReferenceAutocomplete } from '@/hooks/useSecretReferenceAutoco import { ReferenceAutocompleteDropdown } from '@/components/secrets/ReferenceAutocompleteDropdown' import { SecretReferenceHighlight } from '@/components/secrets/SecretReferenceHighlight' import { FaCircle, FaHashtag } from 'react-icons/fa6' +import { DuplicateSecretKeyMessage } from './DuplicateSecretKeyMessage' function SecretRow(props: { orgId: string secret: SecretType & { isImported?: boolean } environment: EnvironmentType canonicalSecret: SecretType | undefined - secretNames: Array> handlePropertyChange: Function handleDelete: Function globallyRevealed: boolean stagedForDelete?: boolean + keyIsDuplicate?: boolean + duplicateKeyNumber?: number }) { const { orgId, secret, canonicalSecret, - secretNames, handlePropertyChange, handleDelete, globallyRevealed, stagedForDelete, + keyIsDuplicate = false, + duplicateKeyNumber, } = props const { activeOrganisation: organisation } = useContext(organisationContext) @@ -182,9 +185,6 @@ function SecretRow(props: { const keyIsBlank = secret.key.length === 0 - const keyIsDuplicate = - secretNames.findIndex((s) => s.key === secret.key && s.id !== secret.id) > -1 - const secretHasBeenModified = () => { if (canonicalSecret === undefined) return true return ( @@ -369,46 +369,55 @@ function SecretRow(props: { return (
- + {(descriptionId) => ( + { + if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault() + if (isSealedAndSaved) { + focusNextRowKey(e.currentTarget) + } else { + ;( + e.currentTarget.parentElement?.nextElementSibling?.querySelector( + 'textarea' + ) as HTMLElement + )?.focus() + } + } + }} + onChange={(e) => { + const { selectionStart } = e.target + handlePropertyChange(secret.id, 'key', e.target.value.replace(/ /g, '_').toUpperCase()) + requestAnimationFrame(() => { + if (keyInputRef.current) { + keyInputRef.current.selectionStart = selectionStart + keyInputRef.current.selectionEnd = selectionStart + } + }) + }} + /> )} - value={secret.key} - onKeyDown={(e) => { - if (e.key === 'Tab' && !e.shiftKey) { - e.preventDefault() - if (isSealedAndSaved) { - focusNextRowKey(e.currentTarget) - } else { - ;( - e.currentTarget.parentElement?.nextElementSibling?.querySelector( - 'textarea' - ) as HTMLElement - )?.focus() - } - } - }} - onChange={(e) => { - const { selectionStart } = e.target - handlePropertyChange(secret.id, 'key', e.target.value.replace(/ /g, '_').toUpperCase()) - requestAnimationFrame(() => { - if (keyInputRef.current) { - keyInputRef.current.selectionStart = selectionStart - keyInputRef.current.selectionEnd = selectionStart - } - }) - }} - /> + {keyActionMenu}
@@ -502,6 +511,8 @@ export default memo(SecretRow, (prev, next) => { prev.canonicalSecret === next.canonicalSecret && prev.globallyRevealed === next.globallyRevealed && prev.stagedForDelete === next.stagedForDelete && + prev.keyIsDuplicate === next.keyIsDuplicate && + prev.duplicateKeyNumber === next.duplicateKeyNumber && prev.orgId === next.orgId && prev.environment === next.environment ) diff --git a/frontend/components/environments/secrets/import/EnvConflictResolution.tsx b/frontend/components/environments/secrets/import/EnvConflictResolution.tsx new file mode 100644 index 000000000..a5ee5a52f --- /dev/null +++ b/frontend/components/environments/secrets/import/EnvConflictResolution.tsx @@ -0,0 +1,324 @@ +import { Fragment, useState } from 'react' +import { Menu, RadioGroup, Transition } from '@headlessui/react' +import { + FaArrowDown, + FaArrowUp, + FaChevronDown, + FaChevronRight, + FaCircle, + FaDotCircle, + FaEye, + FaEyeSlash, + FaUndo, +} from 'react-icons/fa' +import clsx from 'clsx' +import { Button } from '@/components/common/Button' +import { ConflictSelectionMap, EnvConflict } from '@/utils/secrets' + +interface EnvConflictResolutionProps { + conflicts: EnvConflict[] + selections: ConflictSelectionMap + onSelectionsChange: (selections: ConflictSelectionMap) => void + onBack: () => void + onContinue: () => void +} + +const EnvConflictResolution = ({ + conflicts, + selections, + onSelectionsChange, + onBack, + onContinue, +}: EnvConflictResolutionProps) => { + const [openConflictKey, setOpenConflictKey] = useState(conflicts[0]?.key ?? null) + const [revealedConflictKeys, setRevealedConflictKeys] = useState>(new Set()) + const resolvedCount = conflicts.filter((conflict) => Boolean(selections[conflict.key])).length + const remainingCount = conflicts.length - resolvedCount + const allResolved = remainingCount === 0 + + const setAllSelections = (position: 'first' | 'last') => { + onSelectionsChange( + Object.fromEntries( + conflicts.map((conflict) => { + const index = position === 'first' ? 0 : conflict.occurrences.length - 1 + return [conflict.key, conflict.occurrences[index].id] + }) + ) + ) + setOpenConflictKey(null) + } + + const resetSelections = () => { + onSelectionsChange({}) + setOpenConflictKey(conflicts[0]?.key ?? null) + } + + const selectOccurrence = (conflictKey: string, occurrenceId: string) => { + const nextSelections = { ...selections, [conflictKey]: occurrenceId } + onSelectionsChange(nextSelections) + + const currentIndex = conflicts.findIndex((conflict) => conflict.key === conflictKey) + const nextConflict = [ + ...conflicts.slice(currentIndex + 1), + ...conflicts.slice(0, currentIndex), + ].find((conflict) => !nextSelections[conflict.key]) + setOpenConflictKey(nextConflict?.key ?? null) + } + + const toggleRevealed = (conflictKey: string) => { + setRevealedConflictKeys((current) => { + const next = new Set(current) + next.has(conflictKey) ? next.delete(conflictKey) : next.add(conflictKey) + return next + }) + } + + return ( +
+

Review duplicated keys before importing.

+ +
+
+
+ {conflicts.length} {conflicts.length === 1 ? 'conflict' : 'conflicts'} require your + attention +
+
+
+
+ Resolved {resolvedCount} of {conflicts.length} +
+ +
+
+ +
+

+ Conflicts to resolve ({conflicts.length}) +

+
+ {conflicts.map((conflict) => { + const isOpen = openConflictKey === conflict.key + const isRevealed = revealedConflictKeys.has(conflict.key) + const selected = conflict.occurrences.find( + (occurrence) => occurrence.id === selections[conflict.key] + ) + + return ( +
+ + + +
+
+ +
+ selectOccurrence(conflict.key, id)} + > + + Select an occurrence of {conflict.key} + +
+ {conflict.occurrences.map((occurrence, index) => { + const isSelected = selections[conflict.key] === occurrence.id + + return ( +
+ + {({ active, checked }) => ( +
+ {checked ? ( + + ) : ( + + )} +
+ {isRevealed + ? occurrence.value || '(empty value)' + : '••••••••'} +
+
+ )} +
+
+ ) + })} +
+
+
+
+
+ ) + })} +
+
+ +
+ + {({ open }) => ( + <> + + + + + + + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + + + + )} + + +
+ + +
+
+
+ ) +} + +export default EnvConflictResolution diff --git a/frontend/components/environments/secrets/import/MultiEnvImportDialog.tsx b/frontend/components/environments/secrets/import/MultiEnvImportDialog.tsx index 3fdf437d9..b771f1b60 100644 --- a/frontend/components/environments/secrets/import/MultiEnvImportDialog.tsx +++ b/frontend/components/environments/secrets/import/MultiEnvImportDialog.tsx @@ -3,12 +3,19 @@ import { Button } from '@/components/common/Button' import GenericDialog from '@/components/common/GenericDialog' import { Textarea } from '@/components/common/TextArea' import { ToggleSwitch } from '@/components/common/ToggleSwitch' -import { duplicateKeysExist, envFilePlaceholder, processEnvFile } from '@/utils/secrets' +import { + ConflictSelectionMap, + EnvConflict, + envFilePlaceholder, + groupEnvConflicts, + parseEnvEntries, + processEnvFile, +} from '@/utils/secrets' import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import EnvFileDropZone from './EnvFileDropZone' import { AppSecret } from '@/app/[team]/apps/[app]/types' import clsx from 'clsx' -import { toast } from 'react-toastify' +import EnvConflictResolution from './EnvConflictResolution' interface MultiEnvImportDialogProps { environments: EnvironmentType[] @@ -29,6 +36,9 @@ const MultiEnvImportDialog = forwardRef( ) ) const [selectedEnvs, setSelectedEnvs] = useState(environments) + const [step, setStep] = useState<'input' | 'resolve-conflicts'>('input') + const [conflicts, setConflicts] = useState([]) + const [selections, setSelections] = useState({}) const dialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) @@ -58,50 +68,43 @@ const MultiEnvImportDialog = forwardRef( }) } - const processImport = () => { + const finishImport = (conflictSelections: ConflictSelectionMap) => { const secretsByKey = new Map() - try { - // First pass: Process only selected environments - environments.forEach((env) => { - if (!selectedEnvs.includes(env)) return - - const secrets = processEnvFile( - envFileString, - env, - path, - envConfigs[env.id].withValues, - envConfigs[env.id].withComments - ) - - if (duplicateKeysExist(secrets)) { - throw 'File contains duplicate keys!' - } + // First pass: Process only selected environments + environments.forEach((env) => { + if (!selectedEnvs.includes(env)) return - secrets.forEach((secret) => { - if (!secretsByKey.has(secret.key)) { - secretsByKey.set(secret.key, { - id: crypto.randomUUID(), - isImported: true, - key: secret.key, - envs: [], - }) - } - secretsByKey.get(secret.key)?.envs.push({ env, secret }) - }) + const secrets = processEnvFile( + envFileString, + env, + path, + envConfigs[env.id].withValues, + envConfigs[env.id].withComments, + conflictSelections + ) + + secrets.forEach((secret) => { + if (!secretsByKey.has(secret.key)) { + secretsByKey.set(secret.key, { + id: crypto.randomUUID(), + isImported: true, + key: secret.key, + envs: [], + }) + } + secretsByKey.get(secret.key)?.envs.push({ env, secret }) }) + }) - // Second pass: Ensure all environments exist (including unselected) - environments.forEach((env) => { - secretsByKey.forEach((appSecret) => { - if (!appSecret.envs.some((e) => e.env === env)) { - appSecret.envs.push({ env, secret: null }) - } - }) + // Second pass: Ensure all environments exist (including unselected) + environments.forEach((env) => { + secretsByKey.forEach((appSecret) => { + if (!appSecret.envs.some((e) => e.env === env)) { + appSecret.envs.push({ env, secret: null }) + } }) - } catch (error) { - toast.error(error as string) - } + }) const newSecrets = Array.from(secretsByKey.values()) @@ -112,10 +115,30 @@ const MultiEnvImportDialog = forwardRef( } } + const processImport = () => { + const parsedEnvEntries = parseEnvEntries(envFileString) + const allConflicts = groupEnvConflicts(parsedEnvEntries) + const differingConflicts = allConflicts.filter((conflict) => conflict.hasDifferentValues) + + if (!differingConflicts.length) { + finishImport({}) + return + } + + setConflicts(differingConflicts) + setSelections({}) + setStep('resolve-conflicts') + } + + const continueImport = () => finishImport(selections) + const handleFileSelection = (fileString: string) => setEnvFileString(fileString) const reset = () => { setEnvFileString('') + setStep('input') + setConflicts([]) + setSelections({}) setSelectedEnvs(environments) setEnvConfigs( environments.reduce( @@ -139,7 +162,25 @@ const MultiEnvImportDialog = forwardRef( const disabled = !envFileString || selectedEnvs.length === 0 return ( - + + {step === 'resolve-conflicts' ? 'Resolve duplicate secrets' : 'Import secrets'} + + } + ref={dialogRef} + onClose={reset} + > + {step === 'resolve-conflicts' ? ( + setStep('input')} + onContinue={continueImport} + /> + ) : (

Drop, select or paste your .env here to import secrets into your environment @@ -252,6 +293,7 @@ const MultiEnvImportDialog = forwardRef(

+ )} ) } diff --git a/frontend/components/environments/secrets/import/SingleEnvImportDialog.tsx b/frontend/components/environments/secrets/import/SingleEnvImportDialog.tsx index 7494ccdbe..aebb09cad 100644 --- a/frontend/components/environments/secrets/import/SingleEnvImportDialog.tsx +++ b/frontend/components/environments/secrets/import/SingleEnvImportDialog.tsx @@ -3,10 +3,17 @@ import { Button } from '@/components/common/Button' import GenericDialog from '@/components/common/GenericDialog' import { Textarea } from '@/components/common/TextArea' import { ToggleSwitch } from '@/components/common/ToggleSwitch' -import { duplicateKeysExist, envFilePlaceholder, processEnvFile } from '@/utils/secrets' +import { + ConflictSelectionMap, + EnvConflict, + envFilePlaceholder, + groupEnvConflicts, + parseEnvEntries, + processEnvFile, +} from '@/utils/secrets' import { forwardRef, useImperativeHandle, useRef, useState } from 'react' import EnvFileDropZone from './EnvFileDropZone' -import { toast } from 'react-toastify' +import EnvConflictResolution from './EnvConflictResolution' interface SingleEnvImportDialogProps { environment: EnvironmentType @@ -19,52 +26,94 @@ const SingleEnvImportDialog = forwardRef( const [envFileString, setEnvFileString] = useState('') const [withValues, setWithValues] = useState(true) const [withComments, setWithComments] = useState(true) + const [step, setStep] = useState<'input' | 'resolve-conflicts'>('input') + const [conflicts, setConflicts] = useState([]) + const [selections, setSelections] = useState({}) const dialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const openModal = () => dialogRef.current?.openModal() const closeModal = () => dialogRef.current?.closeModal() - useImperativeHandle(ref, () => ({ - openModal, - closeModal, - })) - const reset = () => { setEnvFileString('') setWithValues(true) setWithComments(true) + setStep('input') + setConflicts([]) + setSelections({}) } - const processImport = () => { + const finishImport = ( + conflictSelections: ConflictSelectionMap, + envFileStringToImport: string = envFileString + ) => { const newSecrets: SecretType[] = processEnvFile( - envFileString, + envFileStringToImport, environment, path, withValues, - withComments + withComments, + conflictSelections ) - if (duplicateKeysExist(newSecrets)) { - toast.error('File contains duplicate keys!') - return - } - if (newSecrets.length) { addSecrets(newSecrets) if (dialogRef.current) dialogRef.current.closeModal() } } + const prepareImport = (envFileStringToImport: string, openOnConflict = false) => { + const parsedEnvEntries = parseEnvEntries(envFileStringToImport) + const allConflicts = groupEnvConflicts(parsedEnvEntries) + const differingConflicts = allConflicts.filter((conflict) => conflict.hasDifferentValues) + + if (!differingConflicts.length) { + finishImport({}, envFileStringToImport) + return + } + + setEnvFileString(envFileStringToImport) + setConflicts(differingConflicts) + setSelections({}) + setStep('resolve-conflicts') + if (openOnConflict) openModal() + } + + useImperativeHandle(ref, () => ({ + openModal, + closeModal, + importSource: (envFileStringToImport: string) => + prepareImport(envFileStringToImport, true), + })) + + const processImport = () => prepareImport(envFileString) + + const continueImport = () => finishImport(selections) + const handleFileSelection = (fileString: string) => setEnvFileString(fileString) return ( + {step === 'resolve-conflicts' ? 'Resolve duplicate secrets' : 'Import secrets'} + + } buttonVariant="secondary" ref={dialogRef} onClose={reset} > + {step === 'resolve-conflicts' ? ( + setStep('input')} + onContinue={continueImport} + /> + ) : (

Drop, select or paste your .env here to import secrets into your environment @@ -96,7 +145,10 @@ const SingleEnvImportDialog = forwardRef( > Import values - setWithValues(!withValues)} /> + setWithValues(!withValues)} + />

+ )} ) } diff --git a/frontend/tests/components/DuplicateSecretKeyMessage.test.tsx b/frontend/tests/components/DuplicateSecretKeyMessage.test.tsx new file mode 100644 index 000000000..08adf4094 --- /dev/null +++ b/frontend/tests/components/DuplicateSecretKeyMessage.test.tsx @@ -0,0 +1,48 @@ +import { TextEncoder } from 'util' +import { DuplicateSecretKeyMessage } from '@/components/environments/secrets/DuplicateSecretKeyMessage' + +global.TextEncoder = TextEncoder + +describe('DuplicateSecretKeyMessage', () => { + test('connects an invalid input to a private, scope-specific message', async () => { + const { renderToStaticMarkup } = await import('react-dom/server') + const html = renderToStaticMarkup( + + {(descriptionId) => ( + + )} + + ) + + expect(html).toContain('aria-invalid="true"') + const descriptionId = html.match(/aria-describedby="([^"]+)"/)?.[1] + expect(descriptionId).toBeDefined() + expect(html).toContain(`id="${descriptionId}"`) + expect(html).toContain('ph-no-capture') + expect(html).toContain('Check key #4') + expect(html).not.toContain('PRIVATE_KEY') + }) + + test('omits the message and description when the key is valid', async () => { + const { renderToStaticMarkup } = await import('react-dom/server') + const html = renderToStaticMarkup( + + {(descriptionId) => } + + ) + + expect(html).not.toContain('aria-describedby') + expect(html).not.toContain('This key already exists') + }) + + test('explains when filters hide the conflicting key', async () => { + const { renderToStaticMarkup } = await import('react-dom/server') + const html = renderToStaticMarkup( + + {() => } + + ) + + expect(html).toContain('Clear filters to find it') + }) +}) diff --git a/frontend/tests/components/EnvConflictResolution.test.tsx b/frontend/tests/components/EnvConflictResolution.test.tsx new file mode 100644 index 000000000..4bed88e76 --- /dev/null +++ b/frontend/tests/components/EnvConflictResolution.test.tsx @@ -0,0 +1,75 @@ +import { act } from 'react-dom/test-utils' +import { createRoot } from 'react-dom/client' +import EnvConflictResolution from '@/components/environments/secrets/import/EnvConflictResolution' +import { EnvConflict } from '@/utils/secrets' + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true + +const conflicts: EnvConflict[] = [ + { + key: 'API_KEY', + hasDifferentValues: true, + occurrences: [ + { + id: 'api-key-1', + key: 'API_KEY', + value: 'first-value', + comment: '', + lineNumber: 2, + occurrenceIndex: 0, + }, + { + id: 'api-key-2', + key: 'API_KEY', + value: 'second-value', + comment: '', + lineNumber: 8, + occurrenceIndex: 1, + }, + ], + }, +] + +describe('EnvConflictResolution accordion', () => { + test('hides source lines and reveals all values in the conflict together', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + await act(async () => { + root.render( + + ) + }) + + expect(container.textContent).not.toMatch(/line\s+\d/i) + expect(container.textContent).toContain('A value has been selected') + expect(container.querySelector('[aria-label="Value option 1 for API_KEY"]')).not.toBeNull() + expect(container.querySelector('[aria-label="Value option 2 for API_KEY"]')).not.toBeNull() + expect(container.textContent).not.toContain('first-value') + expect(container.textContent).not.toContain('second-value') + + const revealAll = container.querySelector( + '[aria-label="Reveal all values for API_KEY"]' + ) as HTMLButtonElement + + await act(async () => { + revealAll.click() + }) + + expect(container.textContent).toContain('first-value') + expect(container.textContent).toContain('second-value') + expect(container.querySelector('[aria-label="Hide all values for API_KEY"]')).not.toBeNull() + expect(container.querySelectorAll('[aria-label^="Reveal value option"]')).toHaveLength(0) + + await act(async () => root.unmount()) + container.remove() + }) +}) diff --git a/frontend/tests/utils/secrets.test.ts b/frontend/tests/utils/secrets.test.ts index 3ad2f783b..360871201 100644 --- a/frontend/tests/utils/secrets.test.ts +++ b/frontend/tests/utils/secrets.test.ts @@ -2,10 +2,16 @@ import { formatEnvValue, exportToEnvFile, processEnvFile, + parseEnvEntries, + groupEnvConflicts, + selectFirstOccurrences, + selectLastOccurrences, + resolveEnvEntries, toggleBooleanKeepingCase, getSecretPermalink, sortSecrets, duplicateKeysExist, + getDuplicateSecretKeys, sortEnvs, normalizeKey, parseSecretSearch, @@ -22,8 +28,15 @@ import { hasRegularOnlyFacet, EMPTY_SECRET_FILTER, SecretFilter, + buildKeyPositionIndex, + getDuplicateKeyNumber, } from '@/utils/secrets' -import { ApiSecretTypeChoices, EnvironmentType, SecretType, DynamicSecretType } from '@/apollo/graphql' +import { + ApiSecretTypeChoices, + EnvironmentType, + SecretType, + DynamicSecretType, +} from '@/apollo/graphql' import { AppSecret } from '@/app/[team]/apps/[app]/types' // Polyfill APIs missing in jsdom — save originals so we can restore after @@ -520,6 +533,108 @@ describe('processEnvFile', () => { }) }) +describe('dotenv duplicate resolution', () => { + test('preserves ordered occurrences, normalized keys, and one-based source lines', () => { + const entries = parseEnvEntries('foo=one\r\n\r\n# comment\r\nFOO=two\r\nBAR=three') + + expect( + entries.map(({ key, value, lineNumber, occurrenceIndex }) => ({ + key, + value, + lineNumber, + occurrenceIndex, + })) + ).toEqual([ + { key: 'FOO', value: 'one', lineNumber: 1, occurrenceIndex: 0 }, + { key: 'FOO', value: 'two', lineNumber: 4, occurrenceIndex: 1 }, + { key: 'BAR', value: 'three', lineNumber: 5, occurrenceIndex: 0 }, + ]) + expect(new Set(entries.map(({ id }) => id)).size).toBe(entries.length) + }) + + test('groups differing and identical duplicate values separately', () => { + const conflicts = groupEnvConflicts(parseEnvEntries('A=one\nA=two\nB=same\nB=same')) + + expect(conflicts).toHaveLength(2) + expect(conflicts[0].hasDifferentValues).toBe(true) + expect(conflicts[1].hasDifferentValues).toBe(false) + }) + + test('selects the first or last occurrence for every conflict', () => { + const conflicts = groupEnvConflicts(parseEnvEntries('A=one\nA=two\nB=three\nB=four')) + + expect(selectFirstOccurrences(conflicts)).toEqual({ + A: conflicts[0].occurrences[0].id, + B: conflicts[1].occurrences[0].id, + }) + expect(selectLastOccurrences(conflicts)).toEqual({ + A: conflicts[0].occurrences[1].id, + B: conflicts[1].occurrences[1].id, + }) + }) + + test('resolves one value per key while retaining first-key order', () => { + const entries = parseEnvEntries('A=first\nB=only\nA=last\nC=value') + const conflict = groupEnvConflicts(entries)[0] + const resolved = resolveEnvEntries(entries, { A: conflict.occurrences[1].id }) + + expect(resolved.map(({ key, value }) => ({ key, value }))).toEqual([ + { key: 'A', value: 'last' }, + { key: 'B', value: 'only' }, + { key: 'C', value: 'value' }, + ]) + }) + + test('automatically keeps the first identical duplicate', () => { + const entries = parseEnvEntries('A=same\nA=same') + expect(resolveEnvEntries(entries, {})).toEqual([entries[0]]) + }) + + test('omits an unresolved differing conflict', () => { + const entries = parseEnvEntries('A=one\nA=two\nB=only') + expect(resolveEnvEntries(entries, {}).map(({ key }) => key)).toEqual(['B']) + }) + + test('preserves empty, equals, quoted, multiline, and comment semantics', () => { + const entries = parseEnvEntries( + '# first\nEMPTY=\nTOKEN=abc=def\nQUOTED="hello world"\nMULTI="line 1\nline 2"' + ) + + expect(entries.map(({ value }) => value)).toEqual([ + '', + 'abc=def', + 'hello world', + 'line 1\nline 2', + ]) + expect(entries[0].comment).toBe('first') + expect(entries[3].lineNumber).toBe(5) + }) + + test('processEnvFile applies conflict choices through the existing parser options', () => { + const envFileString = '# first\nA=one\n# second\nA=two' + const parsedEnvEntries = parseEnvEntries(envFileString) + const conflict = groupEnvConflicts(parsedEnvEntries)[0] + + const secrets = processEnvFile( + envFileString, + mockEnv, + '/test', + false, + false, + { A: conflict.occurrences[1].id } + ) + + expect(secrets).toHaveLength(1) + expect(secrets[0]).toMatchObject({ + key: 'A', + value: '', + comment: '', + path: '/test', + environment: mockEnv, + }) + }) +}) + describe('duplicateKeysExist', () => { test('returns false for unique keys', () => { const secrets = [ @@ -537,20 +652,70 @@ describe('duplicateKeysExist', () => { { key: 'A' }, ] as SecretType[] expect(duplicateKeysExist(secrets)).toBe(true) + expect(Array.from(getDuplicateSecretKeys(secrets))).toEqual(['A']) + }) + + test('identifies duplicates between existing and newly added secrets', () => { + const secrets = [ + { id: 'existing', key: 'API_KEY' }, + { id: 'new-1', key: 'API_KEY' }, + { id: 'new-2', key: 'OTHER_KEY' }, + ] as SecretType[] + + expect(Array.from(getDuplicateSecretKeys(secrets))).toEqual(['API_KEY']) + }) + + test('identifies duplicates between multiple newly added secrets', () => { + const secrets = [ + { id: 'new-1', key: 'API_KEY' }, + { id: 'new-2', key: 'API_KEY' }, + ] as SecretType[] + + expect(Array.from(getDuplicateSecretKeys(secrets))).toEqual(['API_KEY']) + }) + + test('canonicalizes duplicate keys to uppercase', () => { + const secrets = [{ key: 'api_key' }, { key: 'API_KEY' }] as SecretType[] + + expect(Array.from(getDuplicateSecretKeys(secrets))).toEqual(['API_KEY']) + }) + + test('returns every duplicated key once', () => { + const secrets = [ + { key: 'A' }, + { key: 'B' }, + { key: 'A' }, + { key: 'B' }, + { key: 'A' }, + ] as SecretType[] + + expect(Array.from(getDuplicateSecretKeys(secrets))).toEqual(['A', 'B']) + }) + + test('does not report a conflict after a staged secret is omitted', () => { + const secrets = [ + { id: 'existing', key: 'API_KEY' }, + { id: 'new-1', key: 'API_KEY' }, + ] as SecretType[] + const activeSecrets = secrets.filter((secret) => secret.id !== 'existing') + + expect(getDuplicateSecretKeys(activeSecrets).size).toBe(0) }) test('returns false for empty array', () => { expect(duplicateKeysExist([])).toBe(false) + expect(getDuplicateSecretKeys([]).size).toBe(0) }) test('detects duplicates between secrets and dynamic secret keyMap', () => { - const secrets = [{ key: 'DB_HOST' }] as SecretType[] + const secrets = [{ key: 'db_host' }] as SecretType[] const dynamicSecrets = [ { keyMap: [{ keyName: 'DB_HOST' }, { keyName: 'DB_PORT' }], }, ] as DynamicSecretType[] expect(duplicateKeysExist(secrets, dynamicSecrets)).toBe(true) + expect(Array.from(getDuplicateSecretKeys(secrets, dynamicSecrets))).toEqual(['DB_HOST']) }) test('returns false when dynamic secrets have no overlap', () => { @@ -587,6 +752,39 @@ describe('duplicateKeysExist', () => { }) }) +describe('key position index', () => { + const entries = [ + { id: 'dynamic', key: 'API_KEY' }, + { id: 'deleted', key: 'IGNORED', include: false }, + { id: 'secret', key: 'api_key' }, + { id: 'other', key: 'OTHER_KEY' }, + ] + + test('indexes keys case-insensitively using their displayed row numbers', () => { + const index = buildKeyPositionIndex(entries) + + expect(index.get('API_KEY')).toEqual([ + { id: 'dynamic', number: 1 }, + { id: 'secret', number: 3 }, + ]) + }) + + test('does not index excluded entries but preserves their row positions', () => { + const index = buildKeyPositionIndex(entries) + + expect(index.has('IGNORED')).toBe(false) + expect(index.get('OTHER_KEY')).toEqual([{ id: 'other', number: 4 }]) + }) + + test('returns the other occurrence number for a duplicate key', () => { + const index = buildKeyPositionIndex(entries) + + expect(getDuplicateKeyNumber(index, 'api_key', 'dynamic')).toBe(3) + expect(getDuplicateKeyNumber(index, 'API_KEY', 'secret')).toBe(1) + expect(getDuplicateKeyNumber(index, 'OTHER_KEY', 'other')).toBeUndefined() + }) +}) + describe('sortEnvs', () => { test('sorts environments by index ascending', () => { const envs = [ diff --git a/frontend/utils/secrets.ts b/frontend/utils/secrets.ts index f9ac52ae7..bf1e48153 100644 --- a/frontend/utils/secrets.ts +++ b/frontend/utils/secrets.ts @@ -394,25 +394,28 @@ export const dynamicMatchesSearch = (text: string, q: ParsedSearch): boolean => return true } -/** - * Processes a .env format string into a list of secrets. - * - * @param envFileString - the input string - * @param environment - * @param path - * @param withValues - whether to parse values from the file - * @param withComments - whether to parse comments from the file - * @returns {SecretType[]} - */ -export const processEnvFile = ( - envFileString: string, - environment: EnvironmentType, - path: string, - withValues: boolean = true, - withComments: boolean = true -): SecretType[] => { - const lines = envFileString.split('\n') - const newSecrets: SecretType[] = [] +export interface ParsedEnvEntry { + id: string + key: string + value: string + comment: string + lineNumber: number + occurrenceIndex: number +} + +export interface EnvConflict { + key: string + occurrences: ParsedEnvEntry[] + hasDifferentValues: boolean +} + +export type ConflictSelectionMap = Record + +/** Parse dotenv content without collapsing repeated keys. */ +export const parseEnvEntries = (envFileString: string): ParsedEnvEntry[] => { + const lines = envFileString.split(/\r?\n/) + const entries: ParsedEnvEntry[] = [] + const occurrenceCounts = new Map() let lastComment = '' let i = 0 @@ -425,6 +428,7 @@ export const processEnvFile = ( } while (i < lines.length) { + const lineNumber = i + 1 const rawLine = lines[i] const trimmed = rawLine.trim() @@ -504,56 +508,199 @@ export const processEnvFile = ( } } - newSecrets.push({ + const normalizedKey = key.toUpperCase() + const occurrenceIndex = occurrenceCounts.get(normalizedKey) ?? 0 + occurrenceCounts.set(normalizedKey, occurrenceIndex + 1) + entries.push({ + id: `${normalizedKey}-${lineNumber}-${occurrenceIndex}`, + key: normalizedKey, + value: valueStr, + comment: lastComment || inlineComment || '', + lineNumber, + occurrenceIndex, + }) + + lastComment = '' + i++ // advance to next line + } + + return entries +} + +export const groupEnvConflicts = (entries: ParsedEnvEntry[]): EnvConflict[] => { + const grouped = new Map() + entries.forEach((entry) => grouped.set(entry.key, [...(grouped.get(entry.key) ?? []), entry])) + + return Array.from(grouped.entries()) + .filter(([, occurrences]) => occurrences.length > 1) + .map(([key, occurrences]) => ({ + key, + occurrences, + hasDifferentValues: new Set(occurrences.map(({ value }) => value)).size > 1, + })) +} + +export const selectFirstOccurrences = (conflicts: EnvConflict[]): ConflictSelectionMap => + Object.fromEntries(conflicts.map((conflict) => [conflict.key, conflict.occurrences[0].id])) + +export const selectLastOccurrences = (conflicts: EnvConflict[]): ConflictSelectionMap => + Object.fromEntries( + conflicts.map((conflict) => [ + conflict.key, + conflict.occurrences[conflict.occurrences.length - 1].id, + ]) + ) + +export const resolveEnvEntries = ( + entries: ParsedEnvEntry[], + selections: ConflictSelectionMap +): ParsedEnvEntry[] => { + const conflicts = new Map(groupEnvConflicts(entries).map((conflict) => [conflict.key, conflict])) + const emitted = new Set() + + return entries.flatMap((entry) => { + if (emitted.has(entry.key)) return [] + emitted.add(entry.key) + + const conflict = conflicts.get(entry.key) + if (!conflict) return [entry] + + const selectedId = conflict.hasDifferentValues + ? selections[entry.key] + : conflict.occurrences[0].id + const selected = conflict.occurrences.find((occurrence) => occurrence.id === selectedId) + return selected ? [selected] : [] + }) +} + +export const envEntriesToSecrets = ( + entries: ParsedEnvEntry[], + environment: EnvironmentType, + path: string, + withValues: boolean = true, + withComments: boolean = true +): SecretType[] => + entries.map((entry) => ({ id: `new-${crypto.randomUUID()}`, updatedAt: null, version: 1, - key: key.toUpperCase(), - value: withValues ? valueStr : '', + key: entry.key, + value: withValues ? entry.value : '', tags: [], - comment: withComments ? lastComment || inlineComment || '' : '', + comment: withComments ? entry.comment : '', path, type: ApiSecretTypeChoices.Secret, environment, - }) + })) - lastComment = '' - i++ // advance to next line +/** + * Processes a .env format string into a list of secrets. + * + * @param envFileString - the input string + * @param environment + * @param path + * @param withValues - whether to parse values from the file + * @param withComments - whether to parse comments from the file + * @param conflictSelections - selected occurrences for duplicated keys + * @returns {SecretType[]} + */ +export const processEnvFile = ( + envFileString: string, + environment: EnvironmentType, + path: string, + withValues: boolean = true, + withComments: boolean = true, + conflictSelections?: ConflictSelectionMap +): SecretType[] => { + const parsedEnvEntries = parseEnvEntries(envFileString) + const envEntries = conflictSelections + ? resolveEnvEntries(parsedEnvEntries, conflictSelections) + : parsedEnvEntries + + return envEntriesToSecrets( + envEntries, + environment, + path, + withValues, + withComments + ) +} + +const findDuplicateKeys = (keys: Iterable): Set => { + const seenKeys = new Set() + const duplicateKeys = new Set() + + for (const key of keys) { + const canonicalKey = key.toUpperCase() + if (seenKeys.has(canonicalKey)) { + duplicateKeys.add(canonicalKey) + } + seenKeys.add(canonicalKey) } - return newSecrets + return duplicateKeys } -export const duplicateKeysExist = ( +export const getDuplicateSecretKeys = ( secrets: SecretType[] | AppSecret[], dynamicSecrets: DynamicSecretType[] = [] -): boolean => { - const keySet = new Set() +): Set => { + const keys = secrets.map((secret) => secret.key) - // Check regular secrets - for (const secret of secrets) { - if (keySet.has(secret.key)) { - return true // Duplicate found + for (const dynamicSecret of dynamicSecrets) { + for (const keyMapEntry of dynamicSecret.keyMap ?? []) { + if (keyMapEntry?.keyName) keys.push(keyMapEntry.keyName) } - keySet.add(secret.key) } - // Check dynamic secrets' keyMap - for (const ds of dynamicSecrets) { - if (!ds.keyMap) continue + return findDuplicateKeys(keys) +} - for (const km of ds.keyMap) { - if (!km?.keyName) continue - if (keySet.has(km.keyName)) { - return true // Duplicate found - } - keySet.add(km.keyName) - } - } +export const duplicateKeysExist = ( + secrets: SecretType[] | AppSecret[], + dynamicSecrets: DynamicSecretType[] = [] +): boolean => getDuplicateSecretKeys(secrets, dynamicSecrets).size > 0 + +export interface KeyPositionEntry { + id: string + key: string + include?: boolean +} - return false // No duplicates +export interface KeyPosition { + id: string + number: number } +export type KeyPositionIndex = Map + +/** + * Indexes secret keys by their canonical name while retaining their displayed + * row number. Excluded entries still occupy a row number but cannot be reported + * as duplicate matches. + */ +export const buildKeyPositionIndex = (entries: KeyPositionEntry[]): KeyPositionIndex => { + const index: KeyPositionIndex = new Map() + + entries.forEach(({ id, key, include = true }, position) => { + if (!include) return + + const canonicalKey = key.toUpperCase() + const keyPositions = index.get(canonicalKey) ?? [] + keyPositions.push({ id, number: position + 1 }) + index.set(canonicalKey, keyPositions) + }) + + return index +} + +export const getDuplicateKeyNumber = ( + index: KeyPositionIndex, + key: string, + id: string +): number | undefined => + index.get(key.toUpperCase())?.find((position) => position.id !== id)?.number + /** * Formats a secret value for safe inclusion in a .env file. * Wraps the value in quotes if it contains characters that would