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 4c46981b3..446e0606f 100644 --- a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx @@ -40,6 +40,7 @@ import { FaLock, FaCog, FaLink, + FaUserEdit, } from 'react-icons/fa' import SecretRow from '@/components/environments/secrets/SecretRow' import clsx from 'clsx' @@ -74,6 +75,8 @@ import { saveSort, SortOption, sortSecrets, + countActiveOverrides, + secretHasActiveOverride, } from '@/utils/secrets' import SortMenu from '@/components/environments/secrets/SortMenu' @@ -152,6 +155,7 @@ export default function EnvironmentPath({ const [isLoading, setIsloading] = useState(false) const [folderMenuIsOpen, setFolderMenuIsOpen] = useState(false) const [globallyRevealed, setGloballyRevealed] = useState(false) + const [showOverriddenOnly, setShowOverriddenOnly] = useState(false) const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const dynamicSecretDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) @@ -904,17 +908,33 @@ export default function EnvironmentPath({ [serverSecretsById] ) + const activeOverrideCount = useMemo( + () => countActiveOverrides(clientSecrets), + [clientSecrets] + ) + + // Drop the filter if there is nothing left to filter (e.g. last override removed) + useEffect(() => { + if (showOverriddenOnly && activeOverrideCount === 0) setShowOverriddenOnly(false) + }, [showOverriddenOnly, activeOverrideCount]) + const filteredFolders = useMemo(() => { + // Folders cannot have personal overrides, so hide them when filtering to overrides + if (showOverriddenOnly) return [] if (searchQuery === '') return folders const re = new RegExp(escapeRegExp(searchQuery), 'i') return folders.filter((f) => re.test(f.name)) - }, [folders, searchQuery]) + }, [folders, searchQuery, showOverriddenOnly]) const filteredSecrets = useMemo(() => { - if (searchQuery === '') return clientSecrets - const re = new RegExp(escapeRegExp(searchQuery), 'i') - return clientSecrets.filter((s) => re.test(s.key) || re.test(s.value)) - }, [clientSecrets, searchQuery]) + let result = clientSecrets + if (showOverriddenOnly) result = result.filter(secretHasActiveOverride) + if (searchQuery !== '') { + const re = new RegExp(escapeRegExp(searchQuery), 'i') + result = result.filter((s) => re.test(s.key) || re.test(s.value)) + } + return result + }, [clientSecrets, searchQuery, showOverriddenOnly]) const filteredAndSortedSecrets = useMemo( () => sortSecrets(filteredSecrets, sort), @@ -948,12 +968,14 @@ export default function EnvironmentPath({ }, [filteredAndSortedSecrets]) const filteredDynamicSecrets = useMemo(() => { + // Dynamic secrets cannot have personal overrides, so hide them when filtering to overrides + if (showOverriddenOnly) return [] if (searchQuery === '') return dynamicSecrets const re = new RegExp(escapeRegExp(searchQuery), 'i') return dynamicSecrets.filter((s) => re.test(`${s.name}${(s.keyMap ?? []).map((k) => k?.keyName).join('')}`) ) - }, [dynamicSecrets, searchQuery]) + }, [dynamicSecrets, searchQuery, showOverriddenOnly]) // Add this (was missing -> ReferenceError: noSecrets is not defined) const noSecrets = @@ -1365,6 +1387,26 @@ export default function EnvironmentPath({
+ {activeOverrideCount > 0 && ( + + )}
diff --git a/frontend/components/environments/secrets/SecretRow.tsx b/frontend/components/environments/secrets/SecretRow.tsx index 15205489d..54604d943 100644 --- a/frontend/components/environments/secrets/SecretRow.tsx +++ b/frontend/components/environments/secrets/SecretRow.tsx @@ -19,7 +19,11 @@ import { HistoryDialog } from './HistoryDialog' import { OverrideDialog } from './OverrideDialog' import { TagsDialog } from './TagsDialog' import { ShareSecretDialog } from './ShareSecretDialog' -import { toggleBooleanKeepingCase } from '@/utils/secrets' +import { + toggleBooleanKeepingCase, + secretHasActiveOverride, + overrideValueDiffers, +} from '@/utils/secrets' import { Switch } from '@headlessui/react' import { organisationContext } from '@/contexts/organisationContext' import { useAppPermissions } from '@/hooks/useAppPermissions' @@ -30,6 +34,7 @@ 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 { FaUserEdit } from 'react-icons/fa' function SecretRow(props: { orgId: string @@ -87,6 +92,10 @@ function SecretRow(props: { const [isRevealed, setIsRevealed] = useState(getInitialRevealState()) const [expanded, setExpanded] = useState(false) + // True only while the value textarea itself holds focus (i.e. you are editing). + // Distinct from group focus-within, which also fires when a hover-toolbar button + // is clicked - we don't want the override chip to vanish in that case. + const [valueFocused, setValueFocused] = useState(false) const keyInputRef = useRef(null) const textareaRef = useRef(null) @@ -214,6 +223,11 @@ function SecretRow(props: { if (value.includes('\n') && !expanded) setExpanded(true) } + // Whether this secret has an active personal override, and whether that override's + // value actually differs from the team value shown in the row (drives the hint text). + const activeOverride = secretHasActiveOverride(secret) + const showOverrideValueHint = overrideValueDiffers(secret) + const keyActionMenu = ( <>
@@ -442,10 +456,14 @@ function SecretRow(props: { } }} onSelect={autocomplete.handleSelect} - onBlur={autocomplete.handleBlur} + onBlur={() => { + setValueFocused(false) + autocomplete.handleBlur() + }} isRevealed={isRevealed} expanded={expanded} onFocus={() => { + setValueFocused(true) setExpanded(true) // Move cursor to end for new secrets with prefilled values (e.g. reference shortcut) if (isNewSecret && secret.value && textareaRef.current) { @@ -470,6 +488,44 @@ function SecretRow(props: { visible={autocomplete.isOpen} />
+ {activeOverride && ( + <> + {/* Decorative gradient: dissolves the value text into the row background + before the chip so long values fade out instead of overlapping it + (mirrors the action-button overlay from PR #752). Visual only - kept a + separate layer so it never becomes a pointer-events-none ancestor of the + chip, which would suppress the chip's native title tooltip. z-30 sits + above the value field wrapper (z-20). */} +
diff --git a/frontend/tests/utils/secrets.test.ts b/frontend/tests/utils/secrets.test.ts index 959d29091..6ea1ef858 100644 --- a/frontend/tests/utils/secrets.test.ts +++ b/frontend/tests/utils/secrets.test.ts @@ -8,8 +8,16 @@ import { duplicateKeysExist, sortEnvs, normalizeKey, + secretHasActiveOverride, + countActiveOverrides, + overrideValueDiffers, } from '@/utils/secrets' -import { EnvironmentType, SecretType, DynamicSecretType } from '@/apollo/graphql' +import { + EnvironmentType, + SecretType, + DynamicSecretType, + PersonalSecretType, +} from '@/apollo/graphql' // Polyfill APIs missing in jsdom — save originals so we can restore after const originalCrypto = globalThis.crypto @@ -648,3 +656,82 @@ describe('normalizeKey', () => { expect(normalizeKey('!@#$%')).toBe('') }) }) + +describe('personal override helpers', () => { + const makeSecret = (overrides: Partial): SecretType => + ({ + id: 'id', + key: 'KEY', + value: 'team-value', + comment: '', + tags: [], + path: '/', + version: 1, + updatedAt: null, + createdAt: null, + override: null, + ...overrides, + }) as SecretType + + const activeOverride = { value: 'my-value', isActive: true } as PersonalSecretType + const inactiveOverride = { value: 'my-value', isActive: false } as PersonalSecretType + + describe('secretHasActiveOverride', () => { + test('is true when an override is active', () => { + expect(secretHasActiveOverride(makeSecret({ override: activeOverride }))).toBe(true) + }) + + test('is false when the override is inactive', () => { + expect(secretHasActiveOverride(makeSecret({ override: inactiveOverride }))).toBe(false) + }) + + test('is false when there is no override', () => { + expect(secretHasActiveOverride(makeSecret({ override: null }))).toBe(false) + }) + }) + + describe('countActiveOverrides', () => { + test('counts only secrets with an active override', () => { + const secrets = [ + makeSecret({ override: activeOverride }), + makeSecret({ override: inactiveOverride }), + makeSecret({ override: null }), + makeSecret({ override: activeOverride }), + ] + expect(countActiveOverrides(secrets)).toBe(2) + }) + + test('returns 0 for an empty list', () => { + expect(countActiveOverrides([])).toBe(0) + }) + }) + + describe('overrideValueDiffers', () => { + test('is true when the active override value differs from the displayed value', () => { + expect( + overrideValueDiffers(makeSecret({ value: 'team-value', override: activeOverride })) + ).toBe(true) + }) + + test('is false when the active override value matches the displayed value', () => { + expect( + overrideValueDiffers( + makeSecret({ + value: 'same', + override: { value: 'same', isActive: true } as PersonalSecretType, + }) + ) + ).toBe(false) + }) + + test('is false when the override is inactive even if the value differs', () => { + expect( + overrideValueDiffers(makeSecret({ value: 'team-value', override: inactiveOverride })) + ).toBe(false) + }) + + test('is false when there is no override', () => { + expect(overrideValueDiffers(makeSecret({ override: null }))).toBe(false) + }) + }) +}) diff --git a/frontend/utils/secrets.ts b/frontend/utils/secrets.ts index 8954fa24b..939e9adcc 100644 --- a/frontend/utils/secrets.ts +++ b/frontend/utils/secrets.ts @@ -434,3 +434,34 @@ export const normalizeKey = (key: string) => { .replace(/[\s-]/g, '_') .replace(/[^A-Z0-9_]/g, '') } + +/** + * Whether a secret carries an active personal override - i.e. the current user is + * running a different value than the team for this secret. + * + * @param {Pick} secret - The secret to inspect. + * @returns {boolean} - True when an override exists and is active. + */ +export const secretHasActiveOverride = (secret: Pick): boolean => + Boolean(secret.override?.isActive) + +/** + * Counts how many secrets in a list have an active personal override. + * + * @param {Pick[]} secrets - The secrets to count over. + * @returns {number} - The number of secrets with an active override. + */ +export const countActiveOverrides = (secrets: Pick[]): number => + secrets.filter(secretHasActiveOverride).length + +/** + * Whether an active personal override is running a different value than the + * (team) value shown for the secret. False when there is no active override, or + * when the override value matches the displayed value. + * + * @param {Pick} secret - The secret to inspect. + * @returns {boolean} - True when the displayed value differs from the active override. + */ +export const overrideValueDiffers = ( + secret: Pick +): boolean => secretHasActiveOverride(secret) && secret.override?.value !== secret.value