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
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
FaLock,
FaCog,
FaLink,
FaUserEdit,
} from 'react-icons/fa'
import SecretRow from '@/components/environments/secrets/SecretRow'
import clsx from 'clsx'
Expand Down Expand Up @@ -74,6 +75,8 @@ import {
saveSort,
SortOption,
sortSecrets,
countActiveOverrides,
secretHasActiveOverride,
} from '@/utils/secrets'
import SortMenu from '@/components/environments/secrets/SortMenu'

Expand Down Expand Up @@ -152,6 +155,7 @@ export default function EnvironmentPath({
const [isLoading, setIsloading] = useState(false)
const [folderMenuIsOpen, setFolderMenuIsOpen] = useState<boolean>(false)
const [globallyRevealed, setGloballyRevealed] = useState<boolean>(false)
const [showOverriddenOnly, setShowOverriddenOnly] = useState<boolean>(false)

const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null)
const dynamicSecretDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -1365,6 +1387,26 @@ export default function EnvironmentPath({
<div className="relative z-20">
<SortMenu sort={sort} setSort={setSort} />
</div>
{activeOverrideCount > 0 && (
<button
type="button"
onClick={() => setShowOverriddenOnly((prev) => !prev)}
title={
showOverriddenOnly
? 'Show all secrets'
: 'Show only secrets with an active personal override'
}
className={clsx(
'bg-zinc-100 dark:bg-zinc-800 transition ease px-2 py-1.5 text-2xs 2xl:text-sm rounded-md flex items-center gap-2',
showOverriddenOnly
? 'text-amber-500'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
)}
>
<FaUserEdit />
{activeOverrideCount} {activeOverrideCount === 1 ? 'Override' : 'Overrides'}
</button>
)}
Comment on lines +1390 to +1409

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than a dedicated button here to filter by override, I would rather we add a proper filter menu with options to filter secrets by:

  • Sealed type
  • Secret type
  • Config type
  • Booleans
  • Overrides
  • Dynamic
  • Rotating

For this PR, I'd suggest we remove this button entirely and add this filter in a separate PR with broader utility

</div>

<div className="flex gap-2 items-center">
Expand Down
60 changes: 58 additions & 2 deletions frontend/components/environments/secrets/SecretRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -87,6 +92,10 @@ function SecretRow(props: {

const [isRevealed, setIsRevealed] = useState<boolean>(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<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
Expand Down Expand Up @@ -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 = (
<>
<div className="flex items-center gap-1 absolute right-1 top-1/2 -translate-y-1/2 opacity-100 group-hover:opacity-0 group-focus-within:opacity-0 text-2xs">
Expand Down Expand Up @@ -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) {
Expand All @@ -470,6 +488,44 @@ function SecretRow(props: {
visible={autocomplete.isOpen}
/>
</div>
{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). */}
<div
aria-hidden="true"
className={clsx(
'absolute inset-y-0 right-0 w-40 z-30 pointer-events-none transition ease',
valueFocused ? 'opacity-0' : 'opacity-100',
'bg-gradient-to-r from-transparent via-zinc-100/90 to-zinc-100 dark:via-zinc-800/90 dark:to-zinc-800',
'group-hover:via-zinc-200/90 group-hover:to-zinc-200 dark:group-hover:via-zinc-700/90 dark:group-hover:to-zinc-700'
)}
/>
{/* Interactive chip: hoverable for the hint. Both layers fade out on focus,
when the field reclaims the full width for editing. */}
<div
title={
showOverrideValueHint
? 'You have an active personal override - you are running a different value than the one shown here.'
: 'You have an active personal override on this secret.'
}
className={clsx(
'absolute right-2 top-1/2 -translate-y-1/2 z-30 cursor-help transition ease',
'flex items-center gap-1 shrink-0 rounded-full px-2 py-0.5',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'flex items-center gap-1 shrink-0 rounded-full px-2 py-0.5',
'flex items-center gap-1 shrink-0 rounded-full px-2 py-1',

'bg-amber-400/10 text-amber-500 ring-1 ring-inset ring-amber-400/30',
'text-2xs font-medium uppercase tracking-wider',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'text-2xs font-medium uppercase tracking-wider',
'text-2xs',

valueFocused ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
>
<FaUserEdit className="shrink-0" />
<span>Overridden</span>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<span>Overridden</span>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The persistent indicator is a good idea, but the labelled chip in full CAPS is a bit too noisy IMO. Given the fact that its persistent and a distinct amber color, just an icon chip works fine here:

Image

</div>
</>
)}
{valueActionMenu}
</div>
</div>
Expand Down
89 changes: 88 additions & 1 deletion frontend/tests/utils/secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -648,3 +656,82 @@ describe('normalizeKey', () => {
expect(normalizeKey('!@#$%')).toBe('')
})
})

describe('personal override helpers', () => {
const makeSecret = (overrides: Partial<SecretType>): 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)
})
})
})
31 changes: 31 additions & 0 deletions frontend/utils/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SecretType, 'override'>} secret - The secret to inspect.
* @returns {boolean} - True when an override exists and is active.
*/
export const secretHasActiveOverride = (secret: Pick<SecretType, 'override'>): boolean =>
Boolean(secret.override?.isActive)

/**
* Counts how many secrets in a list have an active personal override.
*
* @param {Pick<SecretType, 'override'>[]} secrets - The secrets to count over.
* @returns {number} - The number of secrets with an active override.
*/
export const countActiveOverrides = (secrets: Pick<SecretType, 'override'>[]): 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<SecretType, 'override' | 'value'>} secret - The secret to inspect.
* @returns {boolean} - True when the displayed value differs from the active override.
*/
export const overrideValueDiffers = (
secret: Pick<SecretType, 'override' | 'value'>
): boolean => secretHasActiveOverride(secret) && secret.override?.value !== secret.value
Loading