diff --git a/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx b/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx index 9e8341624..43951cb20 100644 --- a/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx +++ b/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx @@ -23,6 +23,7 @@ import { FaTimesCircle, FaUndo, } from 'react-icons/fa' +import { FaXmark } from 'react-icons/fa6' import Link from 'next/link' import { usePathname } from 'next/navigation' import { organisationContext } from '@/contexts/organisationContext' @@ -38,7 +39,6 @@ import { getUserKxPublicKey, arraysEqual, } from '@/utils/crypto' -import { escapeRegExp } from 'lodash' import { EmptyState } from '@/components/common/EmptyState' import { toast } from 'react-toastify' import { EnvSyncStatus } from '@/components/syncing/EnvSyncStatus' @@ -49,7 +49,25 @@ import { SecretInfoLegend } from './SecretInfoLegend' import { formatTitle } from '@/utils/meta' import MultiEnvImportDialog from '@/components/environments/secrets/import/MultiEnvImportDialog' import { TbDownload } from 'react-icons/tb' -import { duplicateKeysExist, getSavedSort, normalizeKey, saveSort, SortOption, sortAppSecrets } from '@/utils/secrets' +import { + duplicateKeysExist, + getSavedSort, + normalizeKey, + saveSort, + SortOption, + sortAppSecrets, + SecretFilter, + EMPTY_SECRET_FILTER, + filterIsActive, + appSecretMatchesFilter, + showDynamicUnderFilter, + collectAppSecretTags, + parseSecretSearch, + appSecretMatchesSearch, + dynamicSearchText, + dynamicMatchesSearch, + hasRegularOnlyFacet, +} from '@/utils/secrets' import { useWarnIfUnsavedChanges } from '@/hooks/warnUnsavedChanges' import { AppDynamicSecretGroup } from './AppDynamicSecretGroup' import { AppDynamicSecretKeyRow } from './AppDynamicSecretKeyRow' @@ -59,6 +77,7 @@ import { AppSecretRowSkeleton } from './AppSecretRowSkeleton' import { GetRotatingSecrets } from '@/graphql/queries/secrets/rotation/getRotatingSecrets.gql' import { RotatingSecretType } from '@/apollo/graphql' import SortMenu from '@/components/environments/secrets/SortMenu' +import FilterMenu from '@/components/environments/secrets/FilterMenu' import { SecretReferenceContext } from '@/contexts/secretReferenceContext' import { validateSecretReferences, @@ -111,6 +130,9 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { saveSort(option) }, []) + // Filter menu state — intentionally not persisted, so it resets each visit. + const [filter, setFilter] = useState(EMPTY_SECRET_FILTER) + const [bulkProcessSecrets, { loading: bulkUpdatePending }] = useMutation(BulkProcessSecrets) const [isLoading, setIsLoading] = useState(false) @@ -221,11 +243,21 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [secretKeysFingerprint, appEnvironments, appFolders, allOrgApps, data, appSecretsToDelete]) + const parsedSearch = useMemo(() => parseSecretSearch(searchQuery), [searchQuery]) + + const availableTags = useMemo(() => collectAppSecretTags(clientAppSecrets), [clientAppSecrets]) + const filteredFolders = useMemo(() => { - if (searchQuery === '') return appFolders - const re = new RegExp(escapeRegExp(searchQuery), 'i') - return appFolders.filter((folder) => re.test(folder.name)) - }, [appFolders, searchQuery]) + // Folders carry none of a secret's attributes, so any menu filter or search + // qualifier (type/rotating/dynamic/overridden/tag) hides them. Free text still filters by name. + if (filterIsActive(filter) || hasRegularOnlyFacet(parsedSearch) || parsedSearch.dynamic) + return [] + if (parsedSearch.text.length === 0) return appFolders + return appFolders.filter((folder) => { + const name = folder.name.toLowerCase() + return parsedSearch.text.every((token) => name.includes(token)) + }) + }, [appFolders, parsedSearch, filter]) const handleExpandRow = useCallback((secretId: string) => { setExpandedSecrets((prev) => (prev.includes(secretId) ? prev : [...prev, secretId])) @@ -288,29 +320,19 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { }, [appSecrets]) const filteredSecrets = useMemo(() => { - const filtered = - searchQuery === '' - ? clientAppSecrets - : clientAppSecrets.filter((secret) => { - const searchRegex = new RegExp(escapeRegExp(searchQuery), 'i') - const valueMatch = secret.envs.some( - (env) => env.secret && searchRegex.test(env.secret.value) - ) - return searchRegex.test(secret.key) || valueMatch - }) + const filtered = clientAppSecrets.filter( + (secret) => + appSecretMatchesSearch(secret, parsedSearch) && appSecretMatchesFilter(secret, filter) + ) return sortAppSecrets(filtered, sort) - }, [clientAppSecrets, searchQuery, sort]) - - const filteredDynamicSecrets = useMemo( - () => - searchQuery === '' - ? appDynamicSecrets - : appDynamicSecrets.filter((secret) => { - const searchRegex = new RegExp(escapeRegExp(searchQuery), 'i') - return searchRegex.test(secret.name) - }), - [appDynamicSecrets, searchQuery] - ) + }, [clientAppSecrets, parsedSearch, filter, sort]) + + const filteredDynamicSecrets = useMemo(() => { + if (!showDynamicUnderFilter(filter)) return [] + return appDynamicSecrets.filter((secret) => + dynamicMatchesSearch(dynamicSearchText(secret.name), parsedSearch) + ) + }, [appDynamicSecrets, parsedSearch, filter]) // Rotating-secret metadata across the app so we can render group headers. const { data: rotatingData } = useQuery(GetRotatingSecrets, { @@ -972,6 +994,9 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { onClick={() => setSearchQuery('')} /> +
+ +
@@ -1090,7 +1115,9 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { {clientAppSecrets.length > 0 || appFolders.length > 0 || searchQuery ? ( <> - {filteredSecrets.length > 0 || filteredFolders.length > 0 ? ( + {filteredSecrets.length > 0 || + filteredFolders.length > 0 || + filteredDynamicSecrets.length > 0 ? (
{
) : ( -
- - -
- } - > - {userCanCreateSecrets && ( -
- -
- )} - - + (() => { + const menuActive = filterIsActive(filter) + const hasQualifiers = hasRegularOnlyFacet(parsedSearch) || parsedSearch.dynamic + // A plain keyword search (no menu filter, no qualifiers) still offers "Create". + const pureTextSearch = searchQuery.trim() !== '' && !menuActive && !hasQualifiers + return ( +
+ + +
+ } + > + {pureTextSearch ? ( + userCanCreateSecrets && ( +
+ +
+ ) + ) : ( +
+ +
+ )} + + + ) + })() )} 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..8551f630a 100644 --- a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx @@ -63,7 +63,6 @@ import { envKeyring, EnvKeyring, } from '@/utils/crypto' -import { escapeRegExp } from 'lodash' import { EmptyState } from '@/components/common/EmptyState' import { duplicateKeysExist, @@ -74,8 +73,20 @@ import { saveSort, SortOption, sortSecrets, + SecretFilter, + EMPTY_SECRET_FILTER, + filterIsActive, + secretMatchesFilter, + showDynamicUnderFilter, + collectSecretTags, + parseSecretSearch, + secretMatchesSearch, + dynamicSearchText, + dynamicMatchesSearch, + hasRegularOnlyFacet, } from '@/utils/secrets' import SortMenu from '@/components/environments/secrets/SortMenu' +import FilterMenu from '@/components/environments/secrets/FilterMenu' import { DeployPreview } from '@/components/environments/secrets/DeployPreview' import { userHasPermission } from '@/utils/access/permissions' @@ -84,7 +95,7 @@ import { EnvironmentPageSkeleton } from './_components/EnvironmentPageSkeleton' import EnvFileDropZone from '@/components/environments/secrets/import/EnvFileDropZone' import SingleEnvImportDialog from '@/components/environments/secrets/import/SingleEnvImportDialog' import { useWarnIfUnsavedChanges } from '@/hooks/warnUnsavedChanges' -import { FaBolt } from 'react-icons/fa6' +import { FaBolt, FaXmark } from 'react-icons/fa6' import { CreateDynamicSecretDialog, CreateDynamicSecretInitialState, @@ -166,6 +177,11 @@ export default function EnvironmentPath({ saveSort(option) }, []) + // Filter menu state — intentionally not persisted, so it resets each visit. + const [filter, setFilter] = useState(EMPTY_SECRET_FILTER) + // When the filter menu is open, lift the sticky toolbar above the rows' hover menus. + const [filterMenuOpen, setFilterMenuOpen] = useState(false) + const { activeOrganisation: organisation } = useContext(organisationContext) const { hasPermission } = useAppPermissions(params.app) @@ -904,17 +920,29 @@ export default function EnvironmentPath({ [serverSecretsById] ) - const filteredFolders = useMemo(() => { - if (searchQuery === '') return folders - const re = new RegExp(escapeRegExp(searchQuery), 'i') - return folders.filter((f) => re.test(f.name)) - }, [folders, searchQuery]) + const parsedSearch = useMemo(() => parseSecretSearch(searchQuery), [searchQuery]) - 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]) + const availableTags = useMemo(() => collectSecretTags(clientSecrets), [clientSecrets]) + + const filteredFolders = useMemo(() => { + // Folders carry none of a secret's attributes, so any menu filter or search + // qualifier (type/rotating/dynamic/overridden/tag) hides them. Free text still filters by name. + if (filterIsActive(filter) || hasRegularOnlyFacet(parsedSearch) || parsedSearch.dynamic) + return [] + if (parsedSearch.text.length === 0) return folders + return folders.filter((f) => { + const name = f.name.toLowerCase() + return parsedSearch.text.every((token) => name.includes(token)) + }) + }, [folders, parsedSearch, filter]) + + const filteredSecrets = useMemo( + () => + clientSecrets.filter( + (s) => secretMatchesSearch(s, parsedSearch) && secretMatchesFilter(s, filter) + ), + [clientSecrets, parsedSearch, filter] + ) const filteredAndSortedSecrets = useMemo( () => sortSecrets(filteredSecrets, sort), @@ -948,12 +976,17 @@ export default function EnvironmentPath({ }, [filteredAndSortedSecrets]) const filteredDynamicSecrets = useMemo(() => { - if (searchQuery === '') return dynamicSecrets - const re = new RegExp(escapeRegExp(searchQuery), 'i') + if (!showDynamicUnderFilter(filter)) return [] return dynamicSecrets.filter((s) => - re.test(`${s.name}${(s.keyMap ?? []).map((k) => k?.keyName).join('')}`) + dynamicMatchesSearch( + dynamicSearchText( + s.name, + (s.keyMap ?? []).map((k) => k?.keyName) + ), + parsedSearch + ) ) - }, [dynamicSecrets, searchQuery]) + }, [dynamicSecrets, parsedSearch, filter]) // Add this (was missing -> ReferenceError: noSecrets is not defined) const noSecrets = @@ -1338,7 +1371,14 @@ export default function EnvironmentPath({ )} -
+
@@ -1362,6 +1402,14 @@ export default function EnvironmentPath({ onClick={() => setSearchQuery('')} />
+
+ +
@@ -1544,33 +1592,61 @@ export default function EnvironmentPath({ ) })()} - {noSecrets && ( - - {searchQuery ? : } -
- } - > - {searchQuery ? ( - userCanCreateSecrets && - normalizeKey(searchQuery) && ( - - ) - ) : ( - - )} - {!searchQuery && ( -
- -
- )} - - )} + {noSecrets && + (() => { + const menuActive = filterIsActive(filter) + const hasQualifiers = hasRegularOnlyFacet(parsedSearch) || parsedSearch.dynamic + const anyFilterActive = menuActive || searchQuery.trim() !== '' + // A plain keyword search (no menu filter, no qualifiers) still offers "Create". + const pureTextSearch = searchQuery.trim() !== '' && !menuActive && !hasQualifiers + return ( + + {anyFilterActive ? : } +
+ } + > + {pureTextSearch ? ( + userCanCreateSecrets && + normalizeKey(searchQuery) && ( + + ) + ) : anyFilterActive ? ( + + ) : ( + + )} + {!anyFilterActive && ( +
+ +
+ )} + + ) + })()}
)} diff --git a/frontend/app/[team]/apps/[app]/page.tsx b/frontend/app/[team]/apps/[app]/page.tsx index 97a7d9eda..08a530f58 100644 --- a/frontend/app/[team]/apps/[app]/page.tsx +++ b/frontend/app/[team]/apps/[app]/page.tsx @@ -19,7 +19,7 @@ export default function AppSecretsView({ params }: { params: { team: string; app const { team, app } = params return ( -
+
diff --git a/frontend/components/environments/secrets/FilterMenu.tsx b/frontend/components/environments/secrets/FilterMenu.tsx new file mode 100644 index 000000000..5bb7fd0d1 --- /dev/null +++ b/frontend/components/environments/secrets/FilterMenu.tsx @@ -0,0 +1,230 @@ +import { Popover, Transition } from '@headlessui/react' +import { Fragment, useEffect } from 'react' +import { FaCog, FaFilter, FaKey, FaLock, FaUserEdit } from 'react-icons/fa' +import { FaArrowsRotate, FaBolt, FaXmark } from 'react-icons/fa6' +import clsx from 'clsx' +import { ApiSecretTypeChoices, SecretTagType } from '@/apollo/graphql' +import { Checkbox } from '@/components/common/Checkbox' +import { + SecretFilter, + EMPTY_SECRET_FILTER, + activeFilterCount, + filterIsActive, +} from '@/utils/secrets' + +const SECTION_LABEL = + 'flex items-center justify-between gap-2 px-2 pt-1.5 pb-0.5 text-3xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-600' + +const SectionHeading = ({ + label, + hint, + divider, +}: { + label: string + hint: string + divider?: boolean +}) => ( +
+ {label} + + {hint} + +
+) + +const ToggleRow = ({ + active, + onToggle, + icon, + label, +}: { + active: boolean + onToggle: () => void + icon: React.ReactNode + label: React.ReactNode +}) => ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + }} + className={clsx( + 'flex items-center justify-between gap-2 w-full px-2 py-1 text-left rounded-md cursor-pointer transition ease hover:bg-zinc-100 dark:hover:bg-zinc-700', + active ? 'font-semibold text-neutral-900 dark:text-neutral-100' : 'text-neutral-500' + )} + > + + {icon} + {label} + + onToggle()} size="sm" /> +
+) + +// Reports the Popover's open state up to the parent (so the sticky toolbar can be +// lifted above the secret rows' hover menus while the menu is open). +const OpenObserver = ({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange?: (open: boolean) => void +}) => { + useEffect(() => { + onOpenChange?.(open) + }, [open, onOpenChange]) + return null +} + +const FilterMenu = ({ + filter, + setFilter, + availableTags, + onOpenChange, +}: { + filter: SecretFilter + setFilter: (filter: SecretFilter) => void + availableTags: SecretTagType[] + onOpenChange?: (open: boolean) => void +}) => { + const count = activeFilterCount(filter) + const active = filterIsActive(filter) + + const toggleType = (type: ApiSecretTypeChoices) => + setFilter({ + ...filter, + types: filter.types.includes(type) + ? filter.types.filter((t) => t !== type) + : [...filter.types, type], + }) + + const toggleBool = (key: 'rotating' | 'dynamic' | 'overridden') => + setFilter({ ...filter, [key]: !filter[key] }) + + const toggleTag = (id: string) => + setFilter({ + ...filter, + tagIds: filter.tagIds.includes(id) + ? filter.tagIds.filter((t) => t !== id) + : [...filter.tagIds, id], + }) + + return ( + + {({ open }) => ( + <> + + + + + + + + toggleType(ApiSecretTypeChoices.Config)} + icon={} + label="Config" + /> + toggleType(ApiSecretTypeChoices.Secret)} + icon={} + label="Secret" + /> + toggleType(ApiSecretTypeChoices.Sealed)} + icon={} + label="Sealed" + /> + + + toggleBool('rotating')} + icon={} + label="Rotating" + /> + toggleBool('dynamic')} + icon={} + label="Dynamic" + /> + toggleBool('overridden')} + icon={} + label="Overridden" + /> + + {availableTags.length > 0 && ( + <> + + {availableTags.map((tag) => ( + toggleTag(tag.id)} + icon={ + + } + label={tag.name} + /> + ))} + + )} + + {active && ( +
+ +
+ )} +
+
+ + )} +
+ ) +} + +export default FilterMenu diff --git a/frontend/tests/utils/secrets.test.ts b/frontend/tests/utils/secrets.test.ts index 959d29091..3ad2f783b 100644 --- a/frontend/tests/utils/secrets.test.ts +++ b/frontend/tests/utils/secrets.test.ts @@ -8,8 +8,23 @@ import { duplicateKeysExist, sortEnvs, normalizeKey, + parseSecretSearch, + secretMatchesFilter, + secretMatchesSearch, + appSecretMatchesFilter, + appSecretMatchesSearch, + dynamicSearchText, + dynamicMatchesSearch, + showDynamicUnderFilter, + filterIsActive, + activeFilterCount, + collectSecretTags, + hasRegularOnlyFacet, + EMPTY_SECRET_FILTER, + SecretFilter, } from '@/utils/secrets' -import { 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 const originalCrypto = globalThis.crypto @@ -648,3 +663,236 @@ describe('normalizeKey', () => { expect(normalizeKey('!@#$%')).toBe('') }) }) + +// ---- Filtering: menu (OR), search (AND), and their building blocks ---- + +const mockEnv = { id: 'env-1' } as EnvironmentType + +const makeSecret = (overrides: Partial): SecretType => + ({ + id: 'id', + key: '', + value: '', + comment: '', + tags: [], + path: '/', + version: 1, + updatedAt: null, + createdAt: null, + type: ApiSecretTypeChoices.Secret, + environment: mockEnv, + ...overrides, + }) as SecretType + +const filter = (overrides: Partial): SecretFilter => ({ + ...EMPTY_SECRET_FILTER, + ...overrides, +}) + +describe('parseSecretSearch', () => { + test('empty query yields an empty parse', () => { + const q = parseSecretSearch('') + expect(q).toEqual({ + text: [], + types: [], + rotating: false, + dynamic: false, + overridden: false, + tagNames: [], + }) + }) + + test('free text is lowercased and split into tokens', () => { + expect(parseSecretSearch('Redis Cache').text).toEqual(['redis', 'cache']) + }) + + test('keyword + type qualifier (redis type:config)', () => { + const q = parseSecretSearch('redis type:config') + expect(q.text).toEqual(['redis']) + expect(q.types).toEqual([ApiSecretTypeChoices.Config]) + }) + + test('repeated type qualifier ORs and dedupes', () => { + const q = parseSecretSearch('type:config type:sealed type:config') + expect(q.types).toEqual([ApiSecretTypeChoices.Config, ApiSecretTypeChoices.Sealed]) + }) + + test('is: flags are recognised (incl. overriden misspelling)', () => { + expect(parseSecretSearch('is:rotating').rotating).toBe(true) + expect(parseSecretSearch('is:dynamic').dynamic).toBe(true) + expect(parseSecretSearch('is:overridden').overridden).toBe(true) + expect(parseSecretSearch('is:overriden').overridden).toBe(true) + }) + + test('tag qualifier, including quoted values with spaces', () => { + expect(parseSecretSearch('tag:api').tagNames).toEqual(['api']) + const q = parseSecretSearch('tag:"db creds"') + expect(q.tagNames).toEqual(['db creds']) + expect(q.text).toEqual([]) + }) + + test('unknown qualifier key or value falls back to free text', () => { + expect(parseSecretSearch('type:foo').text).toEqual(['type:foo']) + expect(parseSecretSearch('type:foo').types).toEqual([]) + expect(parseSecretSearch('http://x').text).toEqual(['http://x']) + }) +}) + +describe('filterIsActive / activeFilterCount', () => { + test('empty filter is inactive with zero count', () => { + expect(filterIsActive(EMPTY_SECRET_FILTER)).toBe(false) + expect(activeFilterCount(EMPTY_SECRET_FILTER)).toBe(0) + }) + + test('counts each selected facet value', () => { + const f = filter({ + types: [ApiSecretTypeChoices.Config, ApiSecretTypeChoices.Sealed], + rotating: true, + tagIds: ['t1'], + }) + expect(filterIsActive(f)).toBe(true) + expect(activeFilterCount(f)).toBe(4) + }) +}) + +describe('secretMatchesFilter (menu, faceted AND)', () => { + test('inactive filter matches everything', () => { + expect(secretMatchesFilter(makeSecret({}), EMPTY_SECRET_FILTER)).toBe(true) + }) + + test('values within a facet OR (Config or Sealed)', () => { + const f = filter({ types: [ApiSecretTypeChoices.Config, ApiSecretTypeChoices.Sealed] }) + expect(secretMatchesFilter(makeSecret({ type: ApiSecretTypeChoices.Config }), f)).toBe(true) + expect(secretMatchesFilter(makeSecret({ type: ApiSecretTypeChoices.Sealed }), f)).toBe(true) + expect(secretMatchesFilter(makeSecret({ type: ApiSecretTypeChoices.Secret }), f)).toBe(false) + }) + + test('different facets AND (Sealed AND Rotating requires both)', () => { + const f = filter({ types: [ApiSecretTypeChoices.Sealed], rotating: true }) + const both = makeSecret({ type: ApiSecretTypeChoices.Sealed, rotatingSecretId: 'rs-1' }) + const sealedOnly = makeSecret({ type: ApiSecretTypeChoices.Sealed }) + const rotatingOnly = makeSecret({ rotatingSecretId: 'rs-1' }) + expect(secretMatchesFilter(both, f)).toBe(true) + expect(secretMatchesFilter(sealedOnly, f)).toBe(false) + expect(secretMatchesFilter(rotatingOnly, f)).toBe(false) + expect(secretMatchesFilter(makeSecret({}), f)).toBe(false) + }) + + test('overridden requires an active override', () => { + const active = makeSecret({ override: { isActive: true } as any }) + const inactive = makeSecret({ override: { isActive: false } as any }) + const f = filter({ overridden: true }) + expect(secretMatchesFilter(active, f)).toBe(true) + expect(secretMatchesFilter(inactive, f)).toBe(false) + }) + + test('tag filter matches by id', () => { + const tagged = makeSecret({ tags: [{ id: 't1', name: 'api', color: '#fff' }] as any }) + const f = filter({ tagIds: ['t1'] }) + expect(secretMatchesFilter(tagged, f)).toBe(true) + expect(secretMatchesFilter(makeSecret({}), f)).toBe(false) + }) + + test('dynamic-only filter excludes all regular secrets', () => { + const f = filter({ dynamic: true }) + expect(secretMatchesFilter(makeSecret({ rotatingSecretId: 'rs-1' }), f)).toBe(false) + }) +}) + +describe('secretMatchesSearch (search box, AND)', () => { + test('keyword AND type qualifier', () => { + const q = parseSecretSearch('redis type:config') + const redisConfig = makeSecret({ key: 'REDIS_URL', type: ApiSecretTypeChoices.Config }) + const redisSecret = makeSecret({ key: 'REDIS_URL', type: ApiSecretTypeChoices.Secret }) + const otherConfig = makeSecret({ key: 'PG_URL', type: ApiSecretTypeChoices.Config }) + expect(secretMatchesSearch(redisConfig, q)).toBe(true) + expect(secretMatchesSearch(redisSecret, q)).toBe(false) // right keyword, wrong type + expect(secretMatchesSearch(otherConfig, q)).toBe(false) // right type, wrong keyword + }) + + test('free text also matches value', () => { + const q = parseSecretSearch('localhost') + expect(secretMatchesSearch(makeSecret({ value: 'redis://localhost' }), q)).toBe(true) + }) + + test('OR within the type facet', () => { + const q = parseSecretSearch('type:config type:sealed') + expect(secretMatchesSearch(makeSecret({ type: ApiSecretTypeChoices.Sealed }), q)).toBe(true) + expect(secretMatchesSearch(makeSecret({ type: ApiSecretTypeChoices.Config }), q)).toBe(true) + expect(secretMatchesSearch(makeSecret({ type: ApiSecretTypeChoices.Secret }), q)).toBe(false) + }) + + test('tag name substring match', () => { + const q = parseSecretSearch('tag:api') + const tagged = makeSecret({ tags: [{ id: 't1', name: 'API-keys', color: '#fff' }] as any }) + expect(secretMatchesSearch(tagged, q)).toBe(true) + expect(secretMatchesSearch(makeSecret({}), q)).toBe(false) + }) + + test('is:dynamic excludes regular secrets', () => { + const q = parseSecretSearch('is:dynamic') + expect(secretMatchesSearch(makeSecret({ rotatingSecretId: 'rs-1' }), q)).toBe(false) + }) +}) + +describe('dynamic secret gating', () => { + test('dynamicSearchText combines name and key names, lowercased', () => { + expect(dynamicSearchText('MyDB', ['USER', 'PASS'])).toBe('mydbuserpass') + }) + + test('dynamic secret shows for free text with no regular-only facet', () => { + const q = parseSecretSearch('mydb') + expect(dynamicMatchesSearch(dynamicSearchText('MyDB', ['USER']), q)).toBe(true) + }) + + test('a regular-only facet hides dynamic secrets', () => { + expect(dynamicMatchesSearch('mydb', parseSecretSearch('type:config'))).toBe(false) + expect(dynamicMatchesSearch('mydb', parseSecretSearch('is:rotating'))).toBe(false) + expect(dynamicMatchesSearch('mydb', parseSecretSearch('tag:api'))).toBe(false) + }) + + test('is:dynamic alone still shows dynamic secrets', () => { + expect(hasRegularOnlyFacet(parseSecretSearch('is:dynamic'))).toBe(false) + expect(dynamicMatchesSearch('mydb', parseSecretSearch('is:dynamic'))).toBe(true) + }) + + test('showDynamicUnderFilter: shown when off or when dynamic is selected', () => { + expect(showDynamicUnderFilter(EMPTY_SECRET_FILTER)).toBe(true) + expect(showDynamicUnderFilter(filter({ types: [ApiSecretTypeChoices.Sealed] }))).toBe(false) + expect(showDynamicUnderFilter(filter({ dynamic: true }))).toBe(true) + }) +}) + +describe('AppSecret matchers (cross-env, match if ANY env matches)', () => { + const appSecret = (secrets: Array | null>): AppSecret => ({ + id: 'app-id', + key: secrets.find((s) => s)?.key ?? 'KEY', + envs: secrets.map((s) => ({ + env: mockEnv, + secret: s ? makeSecret(s) : null, + })), + }) + + test('appSecretMatchesFilter matches when one env satisfies the filter', () => { + const a = appSecret([{ type: ApiSecretTypeChoices.Secret }, { type: ApiSecretTypeChoices.Sealed }]) + expect(appSecretMatchesFilter(a, filter({ types: [ApiSecretTypeChoices.Sealed] }))).toBe(true) + expect(appSecretMatchesFilter(a, filter({ types: [ApiSecretTypeChoices.Config] }))).toBe(false) + }) + + test('appSecretMatchesSearch matches when one env satisfies the query', () => { + const a = appSecret([{ key: 'REDIS', value: 'a' }, null]) + expect(appSecretMatchesSearch(a, parseSecretSearch('redis'))).toBe(true) + expect(appSecretMatchesSearch(a, parseSecretSearch('postgres'))).toBe(false) + }) +}) + +describe('collectSecretTags', () => { + test('returns distinct tags sorted by name', () => { + const secrets = [ + makeSecret({ tags: [{ id: 't2', name: 'zeta', color: '#000' }] as any }), + makeSecret({ tags: [{ id: 't1', name: 'alpha', color: '#fff' }] as any }), + makeSecret({ tags: [{ id: 't1', name: 'alpha', color: '#fff' }] as any }), + ] + expect(collectSecretTags(secrets).map((t) => t.name)).toEqual(['alpha', 'zeta']) + }) +}) diff --git a/frontend/utils/secrets.ts b/frontend/utils/secrets.ts index 8954fa24b..f9ac52ae7 100644 --- a/frontend/utils/secrets.ts +++ b/frontend/utils/secrets.ts @@ -1,4 +1,10 @@ -import { ApiSecretTypeChoices, DynamicSecretType, EnvironmentType, SecretType } from '@/apollo/graphql' +import { + ApiSecretTypeChoices, + DynamicSecretType, + EnvironmentType, + SecretTagType, + SecretType, +} from '@/apollo/graphql' import { AppSecret } from '@/app/[team]/apps/[app]/types' export type SortOption = @@ -166,6 +172,228 @@ export const sortAppSecrets = (secrets: AppSecret[], sort: SortOption): AppSecre }) } +/* ------------------------------------------------------------------ * + * Secret filtering + * + * Two independent, complementary mechanisms narrow the visible list, + * both using the same faceted logic — OR among values of a single facet + * (a secret has one type, so Config + Sealed means "either"), AND across + * different facets (Config + Rotating means "both"): + * + * - The Filter menu (`SecretFilter`) is the multi-select toggles. + * - The search box (`ParsedSearch`) is the GitHub-style query, where + * free-text tokens are additionally AND'd in. + * + * The final list is `(menu match) AND (search match)`. + * + * Dynamic secrets and folders carry none of a regular secret's + * attributes (type/tags/override/rotation), so they are gated as whole + * "kinds" rather than matched per-attribute. + * ------------------------------------------------------------------ */ + +// ---- Filter menu (faceted AND) ---- + +export type SecretFilter = { + types: ApiSecretTypeChoices[] + rotating: boolean + dynamic: boolean + overridden: boolean + tagIds: string[] +} + +export const EMPTY_SECRET_FILTER: SecretFilter = { + types: [], + rotating: false, + dynamic: false, + overridden: false, + tagIds: [], +} + +export const filterIsActive = (f: SecretFilter): boolean => + f.types.length > 0 || f.rotating || f.dynamic || f.overridden || f.tagIds.length > 0 + +export const activeFilterCount = (f: SecretFilter): number => + f.types.length + + (f.rotating ? 1 : 0) + + (f.dynamic ? 1 : 0) + + (f.overridden ? 1 : 0) + + f.tagIds.length + +/** + * Faceted-AND match for a regular secret: every active facet must be satisfied + * (values within a facet OR). `dynamic` never applies to a regular secret. + */ +export const secretMatchesFilter = (secret: SecretType, f: SecretFilter): boolean => { + if (f.dynamic) return false // a regular secret is never dynamic + if (f.types.length > 0 && !f.types.includes(secret.type)) return false + if (f.rotating && !secret.rotatingSecretId) return false + if (f.overridden && !secret.override?.isActive) return false + if (f.tagIds.length > 0 && !secret.tags.some((t) => f.tagIds.includes(t.id))) return false + return true +} + +/** An AppSecret matches if ANY of its env secrets matches. */ +export const appSecretMatchesFilter = (appSecret: AppSecret, f: SecretFilter): boolean => { + if (!filterIsActive(f)) return true + return appSecret.envs.some((e) => e.secret != null && secretMatchesFilter(e.secret, f)) +} + +/** Facets that a dynamic secret can never satisfy. */ +export const filterHasRegularOnlyFacet = (f: SecretFilter): boolean => + f.types.length > 0 || f.rotating || f.overridden || f.tagIds.length > 0 + +/** Dynamic secrets show unless a regular-only facet is active (filter empty, or only `dynamic`). */ +export const showDynamicUnderFilter = (f: SecretFilter): boolean => !filterHasRegularOnlyFacet(f) + +/** Distinct tags applied to the given secrets, sorted by name (for the menu). */ +export const collectSecretTags = (secrets: SecretType[]): SecretTagType[] => { + const map = new Map() + for (const s of secrets) { + for (const t of s.tags) if (!map.has(t.id)) map.set(t.id, t) + } + return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)) +} + +export const collectAppSecretTags = (secrets: AppSecret[]): SecretTagType[] => { + const map = new Map() + for (const appSecret of secrets) { + for (const e of appSecret.envs) { + for (const t of e.secret?.tags ?? []) if (!map.has(t.id)) map.set(t.id, t) + } + } + return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)) +} + +// ---- Search box (AND / qualifiers) ---- + +export type ParsedSearch = { + text: string[] // free-text tokens, lowercased, all AND'd + types: ApiSecretTypeChoices[] + rotating: boolean + dynamic: boolean + overridden: boolean + tagNames: string[] // lowercased, substring-matched against tag names +} + +const SECRET_TYPE_BY_NAME: Record = { + secret: ApiSecretTypeChoices.Secret, + sealed: ApiSecretTypeChoices.Sealed, + config: ApiSecretTypeChoices.Config, +} + +const stripQuotes = (s: string): string => + s.length >= 2 && + ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) + ? s.slice(1, -1) + : s + +/** + * Parses a search string into free-text tokens and structured qualifiers. + * Supported qualifiers: `type:secret|sealed|config`, `is:rotating|dynamic|overridden`, + * `tag:` (quote for spaces, e.g. `tag:"db creds"`). Unrecognized keys or + * invalid values fall back to free text so a typo never silently empties the list. + */ +export const parseSecretSearch = (query: string): ParsedSearch => { + const result: ParsedSearch = { + text: [], + types: [], + rotating: false, + dynamic: false, + overridden: false, + tagNames: [], + } + // Tokens are whitespace-separated; a `key:` prefix may be followed by a quoted value. + const rawTokens = query.match(/(\w+:)?("[^"]*"|'[^']*'|\S+)/g) ?? [] + + for (const token of rawTokens) { + const qualifier = token.match(/^([a-zA-Z]+):([\s\S]*)$/) + if (qualifier) { + const key = qualifier[1].toLowerCase() + const value = stripQuotes(qualifier[2]).trim() + const lower = value.toLowerCase() + if (key === 'type') { + const type = SECRET_TYPE_BY_NAME[lower] + if (type) { + if (!result.types.includes(type)) result.types.push(type) + continue + } + } else if (key === 'is') { + if (lower === 'rotating') { + result.rotating = true + continue + } + if (lower === 'dynamic') { + result.dynamic = true + continue + } + if (lower === 'overridden' || lower === 'overriden' || lower === 'override') { + result.overridden = true + continue + } + } else if (key === 'tag') { + if (lower) { + if (!result.tagNames.includes(lower)) result.tagNames.push(lower) + continue + } + } + // Unknown key or invalid value → fall through to free text below. + } + const free = stripQuotes(token).trim().toLowerCase() + if (free) result.text.push(free) + } + return result +} + +export const searchIsActive = (q: ParsedSearch): boolean => + q.text.length > 0 || + q.types.length > 0 || + q.rotating || + q.dynamic || + q.overridden || + q.tagNames.length > 0 + +/** Facets that a dynamic secret or folder can never satisfy. */ +export const hasRegularOnlyFacet = (q: ParsedSearch): boolean => + q.types.length > 0 || q.rotating || q.overridden || q.tagNames.length > 0 + +/** AND match for a regular secret against a parsed search query. */ +export const secretMatchesSearch = (secret: SecretType, q: ParsedSearch): boolean => { + const key = secret.key.toLowerCase() + const value = secret.value.toLowerCase() + for (const token of q.text) { + if (!key.includes(token) && !value.includes(token)) return false + } + if (q.dynamic) return false // a regular secret is never dynamic + if (q.types.length > 0 && !q.types.includes(secret.type)) return false + if (q.rotating && !secret.rotatingSecretId) return false + if (q.overridden && !secret.override?.isActive) return false + if (q.tagNames.length > 0) { + const names = secret.tags.map((t) => t.name.toLowerCase()) + if (!q.tagNames.some((n) => names.some((name) => name.includes(n)))) return false + } + return true +} + +/** An AppSecret matches the search if ANY of its env secrets matches. */ +export const appSecretMatchesSearch = (appSecret: AppSecret, q: ParsedSearch): boolean => + appSecret.envs.some((e) => e.secret != null && secretMatchesSearch(e.secret, q)) + +/** Builds the searchable text for a dynamic secret (name + its key names). */ +export const dynamicSearchText = ( + name: string, + keyNames: (string | null | undefined)[] = [] +): string => `${name}${keyNames.filter(Boolean).join('')}`.toLowerCase() + +/** Dynamic secrets match only free-text tokens, and never a regular-only facet. */ +export const dynamicMatchesSearch = (text: string, q: ParsedSearch): boolean => { + if (hasRegularOnlyFacet(q)) return false + const hay = text.toLowerCase() + for (const token of q.text) { + if (!hay.includes(token)) return false + } + return true +} + /** * Processes a .env format string into a list of secrets. *