diff --git a/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx b/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx index e43e12101..6f2da2959 100644 --- a/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx +++ b/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx @@ -33,7 +33,7 @@ const AppFolderRowBase = ({ appFolder, pathname }: AppFolderRowProps) => { open ? 'font-bold' : 'font-medium' )} > - + {fullPath} void addEnvValue: (appSecretId: string, environment: EnvironmentType) => void deleteEnvValue: (appSecretId: string, environment: EnvironmentType) => void + currentSecretKey?: string revealOnHover?: boolean }) => { const pathname = usePathname() @@ -82,6 +87,27 @@ const EnvSecretComponent = ({ valueIsNew || !serverEnvSecret || isEmptyValue || false ) + // Secret reference autocomplete + const textareaRef = useRef(null) + const handleValueChange = useCallback( + (v: string) => updateEnvValue(appSecretId, clientEnvSecret.env.id!, v), + [updateEnvValue, appSecretId, clientEnvSecret.env.id] + ) + + const autocomplete = useSecretReferenceAutocomplete({ + value: clientEnvSecret.secret?.value ?? '', + isRevealed: showValue, + textareaRef, + onChange: handleValueChange, + currentSecretKey, + }) + + const secretValue = clientEnvSecret.secret?.value ?? '' + const highlightContent = + showValue && secretValue.includes('${') ? ( + + ) : undefined + const isBoolean = clientEnvSecret?.secret ? ['true', 'false'].includes(clientEnvSecret.secret.value.toLowerCase()) : false @@ -231,17 +257,33 @@ const EnvSecretComponent = ({ )} updateEnvValue(appSecretId, clientEnvSecret.env.id!, v)} + onChange={(v) => { + handleValueChange(v) + autocomplete.handleChange() + }} + onKeyDown={autocomplete.handleKeyDown} + onSelect={autocomplete.handleSelect} + onBlur={autocomplete.handleBlur} + onFocus={autocomplete.handleFocus} isRevealed={showValue} expanded={true} + highlightContent={highlightContent} /> + {clientEnvSecret.secret !== null && (
+ )} + + {syncsData?.syncs && userCanReadSyncs && ( +
+ +
+ )} + + - )} - - {syncsData?.syncs && userCanReadSyncs && ( -
- -
- )} - - +
- - {appEnvironments && ( - + )} + + - )} - - {(filteredSecrets.length > 0 || filteredDynamicSecrets.length > 0) && ( -
-
- - - -
- )} - - {clientAppSecrets.length > 0 || appFolders.length > 0 || searchQuery ? ( - <> - {filteredSecrets.length > 0 || filteredFolders.length > 0 ? ( -
- - - - - {appEnvironments?.map((env: EnvironmentType) => ( -
- key - - - - + )} + + {clientAppSecrets.length > 0 || appFolders.length > 0 || searchQuery ? ( + <> + {filteredSecrets.length > 0 || filteredFolders.length > 0 ? ( +
+ + + + + {appEnvironments?.map((env: EnvironmentType) => ( + + ))} + + + + {filteredFolders.map((appFolder) => ( + ))} - - - - {filteredFolders.map((appFolder) => ( - - ))} - {filteredDynamicSecrets.map((appDynamicSecret) => ( - - ))} + {filteredDynamicSecrets.map((appDynamicSecret) => ( + + ))} - {filteredSecrets.map((appSecret, index) => ( - - ))} - -
+ key + + + +
-
- ) : ( -
- - -
- } + {filteredSecrets.map((appSecret, index) => ( + + ))} + +
+
+ ) : ( +
+ + +
+ } + > + {userCanCreateSecrets && ( +
+ +
+ )} + +
+ )} + + + ) : isLoading || fetching || (appSecrets?.length > 0 && clientAppSecrets.length === 0) ? ( +
+ + - {userCanCreateSecrets && ( -
- -
- )} - - - )} - - - ) : isLoading || fetching || (appSecrets?.length > 0 && clientAppSecrets.length === 0) ? ( -
-
- - - - {['Development', 'Staging', 'Production'].map((envName) => ( - + + {['Development', 'Staging', 'Production'].map((envName) => ( + + ))} + + + + {[...Array(13)].map((_, index) => ( + ))} - - - - {[...Array(13)].map((_, index) => ( - - ))} - -
- key - - +
+ key + +
-
- ) : userCanReadEnvironments && userCanReadSecrets ? ( -
- + + +
+ } + > +
+ + +
+ + + ) : ( + - + } > -
- - -
+ <>
- - ) : ( - - - - } - > - <> - - )} - + )} + + ) } diff --git a/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx b/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx index efd87d17d..18f924d88 100644 --- a/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx +++ b/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx @@ -27,7 +27,7 @@ const EnvFolderBase = ({ envFolder, pathname }: EnvFolderProp) => { >
{envFolder.env.name}
-
+
{fullPath}
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 c52551c41..fb6e2f0aa 100644 --- a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx @@ -82,6 +82,15 @@ import { CreateDynamicSecretDialog } from '@/ee/components/secrets/dynamic/Creat import { DynamicSecretRow } from '@/ee/components/secrets/dynamic/DynamicSecretRow' import { PlanLabel } from '@/components/settings/organisation/PlanLabel' import { UpsellDialog } from '@/components/settings/organisation/UpsellDialog' +import { SecretReferenceContext } from '@/contexts/secretReferenceContext' +import { + ReferenceContext, + ReferenceValidationError, + secretIdKey, + validateSecretReferences, +} from '@/utils/secretReferences' +import { BrokenReferencesDialog } from '@/components/secrets/BrokenReferencesDialog' +import { useOrgSecretKeys } from '@/hooks/useOrgSecretKeys' export default function EnvironmentPath({ params, @@ -114,6 +123,8 @@ export default function EnvironmentPath({ const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const dynamicSecretDialogRef = 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 [sort, setSort] = useState('-created') @@ -199,6 +210,8 @@ export default function EnvironmentPath({ useWarnIfUnsavedChanges(unsavedChanges) + const { orgApps: allOrgApps } = useOrgSecretKeys() + const { data: appEnvsData } = useQuery(GetAppEnvironments, { variables: { appId: params.app, @@ -220,6 +233,79 @@ export default function EnvironmentPath({ return data?.folders ?? [] }, [data?.folders]) + const environment = data?.appEnvironments[0] as EnvironmentType + + const referenceContext: ReferenceContext = useMemo(() => { + const secretKeys = clientSecrets.map((s) => s.key).filter((k) => k !== '') + + const appName = environment?.app?.name + const currentAppData = allOrgApps.find((a) => a.name.toLowerCase() === appName?.toLowerCase()) + + const envSecretKeys: Record = { + ...(currentAppData?.envSecretKeys ?? {}), + } + if (environment?.name) { + envSecretKeys[environment.name.toLowerCase()] = secretKeys + } + + const envNames = (appEnvsData?.appEnvironments ?? []).map((e: EnvironmentType) => e.name) + + const localFolderPaths = folders.map((f: SecretFolderType) => + `${f.path}/${f.name}`.replace(/^\/+/, '') + ) + const orgFolderPaths = Object.keys(currentAppData?.folderKeys ?? {}) + const folderPaths = [...new Set([...localFolderPaths, ...orgFolderPaths])] + + const folderSecretKeys = currentAppData?.folderKeys ?? {} + const envRootKeys: Record = { + ...(currentAppData?.envRootKeys ?? {}), + } + // Override current env with client-side root keys when viewing root + if (environment?.name && secretPath === '/') { + envRootKeys[environment.name.toLowerCase()] = secretKeys + } + + const orgApps = allOrgApps.filter((a) => a.name.toLowerCase() !== appName?.toLowerCase()) + + const deletedKeys = clientSecrets + .filter((s) => secretsToDelete.includes(s.id)) + .map((s) => s.key) + + // Build env ID mapping for navigation + const envIds: Record = {} + for (const env of appEnvsData?.appEnvironments ?? []) { + envIds[(env as EnvironmentType).name.toLowerCase()] = (env as EnvironmentType).id + } + + const secretIdLookup: Record = { + ...(currentAppData?.secretIdLookup ?? {}), + } + // Override with client-side secret IDs for current env (includes newly decrypted secrets) + if (environment?.name) { + for (const s of clientSecrets) { + if (s.id && !s.id.startsWith('new-')) { + secretIdLookup[secretIdKey(environment.name, s.path || '/', s.key)] = s.id + } + } + } + + return { + teamSlug: params.team, + appId: params.app, + envId: params.environment, + envIds, + secretIdLookup, + secretKeys, + envSecretKeys, + envRootKeys, + envNames, + folderPaths, + folderSecretKeys, + orgApps, + deletedKeys, + } + }, [clientSecrets, environment, appEnvsData, allOrgApps, folders, secretsToDelete, params]) + const savingAndFetching = isLoading || loading const [bulkProcessSecrets] = useMutation(BulkProcessSecrets) @@ -231,7 +317,6 @@ export default function EnvironmentPath({ return `/${params.team}/apps/${params.app}/environments/${env.id}${secretPath}` } - const environment = data?.appEnvironments[0] as EnvironmentType //const dynamicSecrets: DynamicSecretType[] = data?.dynamicSecrets ?? [] const envLinks = @@ -575,6 +660,26 @@ export default function EnvironmentPath({ return false } + // Validate secret references + const activeSecrets = clientSecrets + .filter((s) => !secretsToDelete.includes(s.id)) + .map((s) => ({ + key: s.key, + envs: [ + { + env: { id: environment.id, name: environment.name }, + secret: { value: s.value }, + }, + ], + })) + const refErrors = validateSecretReferences(activeSecrets, referenceContext) + if (refErrors.length > 0) { + setRefWarnings(refErrors) + refWarningDialogRef.current?.openModal() + setIsloading(false) + return false + } + await handleBulkUpdateSecrets() setTimeout(() => setIsloading(false), 500) @@ -582,6 +687,15 @@ export default function EnvironmentPath({ toast.success('Changes successfully deployed.') } + const handleSaveWithBrokenRefs = async () => { + refWarningDialogRef.current?.closeModal() + setRefWarnings([]) + setIsloading(true) + await handleBulkUpdateSecrets() + setTimeout(() => setIsloading(false), 500) + toast.success('Changes successfully deployed.') + } + const handleDiscardChanges = () => { setClientSecrets(serverSecrets) setSecretsToDelete([]) @@ -918,261 +1032,273 @@ export default function EnvironmentPath({ } return ( -
- {keyring !== null && !loading && ( -
-
-
- {envLinks.length > 1 ? ( - - {({ open }) => ( - <> - -
-

{environment.name}

- -
-
- - -
- {envLinks.map((link: { label: string; href: string }) => ( - - {({ active }) => ( - -
{link.label}
- - - )} -
- ))} + +
+ {keyring !== null && !loading && ( +
+
+
+ {envLinks.length > 1 ? ( + + {({ open }) => ( + <> + +
+

{environment.name}

+
- - - - )} -
- ) : ( -

{environment.name}

- )} -
- -
-
-
- {unsavedChanges && ( - - )} -
-
-
-
-
-
-
- -
- setSearchQuery(e.target.value)} - /> - + + +
+ {envLinks.map((link: { label: string; href: string }) => ( + + {({ active }) => ( + +
{link.label}
+ + + )} +
+ ))} +
+
+
+ )} - role="button" - onClick={() => setSearchQuery('')} - /> -
-
- +
+ ) : ( +

{environment.name}

+ )} +
+
- -
+
{unsavedChanges && ( - - )} - - {data.envSyncs && userCanReadSyncs && ( -
- -
+ )} - -
- - - - {!noSecrets && ( -
-
- key +
+
+
+
+
+ +
+ setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + /> +
+
+ +
-
- value -
- + +
+ {unsavedChanges && ( - -
+ )} + + {data.envSyncs && userCanReadSyncs && ( +
+ +
+ )} + +
- )} -
-
- - - - - {organisation && - filteredFolders.map((folder: SecretFolderType) => ( - - ))} - - {environment && - filteredDynamicSecrets.map((secret) => ( - - ))} - - {organisation && - filteredAndSortedSecrets.map((secret, index: number) => ( -
-
- {index + 1} + + + + + {!noSecrets && ( +
+
+ key
- -
- ))} - - {noSecrets && ( - - {searchQuery ? : } +
+ value +
+ + + +
- } - > - {searchQuery ? ( - userCanCreateSecrets && - normalizeKey(searchQuery) && ( - - ) - ) : ( - - )} - {!searchQuery && ( -
- +
+ )} +
+ +
+ + + + + {organisation && + filteredFolders.map((folder: SecretFolderType) => ( + + ))} + + {environment && + filteredDynamicSecrets.map((secret) => ( + + ))} + + {organisation && + filteredAndSortedSecrets.map((secret, index: number) => ( +
+
+ {index + 1} +
+
- )} - - )} + ))} + + {noSecrets && ( + + {searchQuery ? : } +
+ } + > + {searchQuery ? ( + userCanCreateSecrets && + normalizeKey(searchQuery) && ( + + ) + ) : ( + + )} + {!searchQuery && ( +
+ +
+ )} + + )} +
-
- )} -
+ )} +
+ ) } diff --git a/frontend/components/common/MaskedTextarea.tsx b/frontend/components/common/MaskedTextarea.tsx index b7c8160fe..0fb2da531 100644 --- a/frontend/components/common/MaskedTextarea.tsx +++ b/frontend/components/common/MaskedTextarea.tsx @@ -1,55 +1,108 @@ -import React, { useRef, useEffect } from 'react' +import React, { useCallback, useRef, forwardRef } from 'react' import clsx from 'clsx' interface MaskedTextareaProps { value?: string onChange?: (value: string) => void onFocus?: () => void + onBlur?: () => void + onKeyDown?: (e: React.KeyboardEvent) => void + onSelect?: (e: React.SyntheticEvent) => void isRevealed: boolean expanded: boolean placeholder?: string className?: string disabled?: boolean rowsAutoGrow?: boolean + highlightContent?: React.ReactNode } -export const MaskedTextarea: React.FC = ({ - value = '', - onChange, - onFocus, - isRevealed, - expanded, - placeholder, - className, - disabled, - rowsAutoGrow = true, -}) => { - const textareaRef = useRef(null) - const lineCount = value.split('\n').length - const rows = expanded ? (rowsAutoGrow ? Math.min(Math.max(lineCount, 1), 40) : 1) : 1 - - const handleChange = (e: React.ChangeEvent) => { - onChange?.(e.target.value) +export const MaskedTextarea = forwardRef( + ( + { + value = '', + onChange, + onFocus, + onBlur, + onKeyDown, + onSelect, + isRevealed, + expanded, + placeholder, + className, + disabled, + rowsAutoGrow = true, + highlightContent, + }, + ref + ) => { + const highlightRef = useRef(null) + const lineCount = value.split('\n').length + const rows = expanded ? (rowsAutoGrow ? Math.min(Math.max(lineCount, 1), 40) : 1) : 1 + const hasHighlight = isRevealed && highlightContent != null + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange?.(e.target.value) + }, + [onChange] + ) + + const handleScroll = useCallback((e: React.UIEvent) => { + if (highlightRef.current) { + highlightRef.current.scrollTop = e.currentTarget.scrollTop + highlightRef.current.scrollLeft = e.currentTarget.scrollLeft + } + }, []) + + const sharedClasses = clsx( + className, + 'resize-none', + rows === 1 ? 'whitespace-nowrap' : 'whitespace-pre-wrap break-all' + ) + + const textarea = ( +