Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions frontend/app/[team]/apps/[app]/_components/AppSecretRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -395,6 +396,8 @@ interface AppSecretRowProps {
deleteEnvValue: (appSecretId: string, environment: EnvironmentType) => void
deleteKey: (id: string) => void
revealOnHover?: boolean
keyIsDuplicate?: boolean
duplicateKeyNumber?: number
}

const AppSecretRowComponent = ({
Expand All @@ -412,6 +415,8 @@ const AppSecretRowComponent = ({
deleteEnvValue,
deleteKey,
revealOnHover,
keyIsDuplicate = false,
duplicateKeyNumber,
}: AppSecretRowProps) => {
const { activeOrganisation: organisation } = useContext(organisationContext)
const routeParams = useParams<{ app: string }>()
Expand Down Expand Up @@ -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<EnvironmentType>; secret: SecretType | null }) => {
if (env.secret === null) return `This secret is missing in ${env.env.name}`
Expand Down Expand Up @@ -584,24 +588,33 @@ const AppSecretRowComponent = ({
</span>
</button>
<div className="relative group flex-1 min-w-60 md:min-w-80">
<input
ref={keyInputRef}
disabled={stagedForDelete || !userCanUpdateSecrets}
className={clsx(
INPUT_BASE_STYLE,
rowInputColor(),
'rounded-sm ',
keyIsBlank
? 'ring-1 ring-inset ring-red-500'
: keyIsDuplicate
? 'ring-1 ring-inset ring-amber-500'
: 'focus:ring-1 focus:ring-inset focus:ring-zinc-500'
<DuplicateSecretKeyMessage
isDuplicate={keyIsDuplicate}
duplicateKeyNumber={duplicateKeyNumber}
>
{(descriptionId) => (
<input
ref={keyInputRef}
disabled={stagedForDelete || !userCanUpdateSecrets}
aria-invalid={keyIsDuplicate}
aria-describedby={descriptionId}
className={clsx(
INPUT_BASE_STYLE,
rowInputColor(),
'rounded-sm ',
keyIsBlank
? 'ring-1 ring-inset ring-red-500'
: keyIsDuplicate
? 'ring-1 ring-inset ring-amber-500'
: 'focus:ring-1 focus:ring-inset focus:ring-zinc-500'
)}
value={clientAppSecret.key}
onChange={handleUpdateKey}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
/>
)}
value={clientAppSecret.key}
onChange={handleUpdateKey}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
/>
</DuplicateSecretKeyMessage>

<div className="absolute inset-y-0 right-2 flex gap-1 items-center">
<div className="hidden group-hover:flex gap-1 items-center" onClick={(e) => e.stopPropagation()}>
Expand Down Expand Up @@ -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

Expand Down
72 changes: 68 additions & 4 deletions frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -67,6 +67,8 @@ import {
dynamicSearchText,
dynamicMatchesSearch,
hasRegularOnlyFacet,
buildKeyPositionIndex,
getDuplicateKeyNumber,
} from '@/utils/secrets'
import { useWarnIfUnsavedChanges } from '@/hooks/warnUnsavedChanges'
import { AppDynamicSecretGroup } from './AppDynamicSecretGroup'
Expand All @@ -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)
Expand Down Expand Up @@ -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<string>()

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()

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 (
<AppSecretRow
index={idx}
Expand All @@ -1180,8 +1239,13 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => {
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}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,10 @@ import {
} from '@/utils/crypto'
import { EmptyState } from '@/components/common/EmptyState'
import {
duplicateKeysExist,
getDuplicateSecretKeys,
exportToEnvFile,
getSavedSort,
normalizeKey,
processEnvFile,
saveSort,
SortOption,
sortSecrets,
Expand All @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -164,13 +166,28 @@ export default function EnvironmentPath({
const [folderMenuIsOpen, setFolderMenuIsOpen] = useState<boolean>(false)
const [globallyRevealed, setGloballyRevealed] = useState<boolean>(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<ReferenceValidationError[]>([])

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<SortOption>(() => getSavedSort() ?? '-created')
const setSort = useCallback((option: SortOption) => {
_setSort(option)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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 <EnvFileDropZone onFileProcessed={(content) => handleFileSelection(content)} />
Expand Down Expand Up @@ -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 (
<div
ref={secretToHighlight === secret.id ? highlightedRef : null}
Expand All @@ -1552,11 +1591,15 @@ export default function EnvironmentPath({
secret={secret}
environment={environment}
canonicalSecret={canonicalSecret(secret.id)}
secretNames={secretNames}
handlePropertyChange={handleUpdateSecretProperty}
handleDelete={stageSecretForDelete}
globallyRevealed={globallyRevealed}
stagedForDelete={secretsToDelete.includes(secret.id)}
stagedForDelete={secretsToDeleteSet.has(secret.id)}
keyIsDuplicate={
!secretsToDeleteSet.has(secret.id) &&
duplicateSecretKeys.has(secret.key.toUpperCase())
}
duplicateKeyNumber={duplicateKeyNumber}
/>
</div>
)
Expand Down
Loading
Loading