+
+
key
@@ -1018,11 +1021,15 @@ export default function EnvironmentPath({
className={clsx(
'flex items-center gap-2 py-1 px-3 rounded-md',
secretToHighlight === secret.id &&
- 'ring-1 ring-inset ring-emerald-100 dark:ring-emerald-900 bg-emerald-400/10'
+ 'ring-1 ring-inset ring-emerald-100 dark:ring-emerald-900 bg-emerald-400/20'
)}
key={secret.id}
>
-
{index + 1}
+
+ {index + 1}
+
void }) {
useEffect(() => {
- // Log the error to an error reporting service
console.error(error)
}, [error])
return (
-
+
- Something went wrong!
- reset()
- }
- >
- Try again
-
-
+
+
+
+
+ Something went wrong!
+
+
+ {error.name}: {error.message || 'An unexpected error occurred'}
+
+
+
reset()}>
+ Try again
+
+
+
)
}
diff --git a/frontend/app/lockbox/[boxId]/page.tsx b/frontend/app/lockbox/[boxId]/page.tsx
index dc91e960b..18dc65c47 100644
--- a/frontend/app/lockbox/[boxId]/page.tsx
+++ b/frontend/app/lockbox/[boxId]/page.tsx
@@ -12,7 +12,7 @@ export default async function Lockbox({ params }: { params: { boxId: string } })
Phase Lockbox
- You've recieved a secret via Phase Lockbox, secured with Zero-Trust encryption.
+ You've received a secret via Phase Lockbox, secured with Zero-Trust encryption.
Click the View button to decrypt and view this secret.
diff --git a/frontend/components/common/CommandPalette.tsx b/frontend/components/common/CommandPalette.tsx
index 8d18616f2..fb1869959 100644
--- a/frontend/components/common/CommandPalette.tsx
+++ b/frontend/components/common/CommandPalette.tsx
@@ -29,6 +29,11 @@ import { ThemeContext } from '@/contexts/themeContext'
import { BsListColumnsReverse } from 'react-icons/bs'
import { FaArrowsRotate, FaCodeMerge, FaListCheck } from 'react-icons/fa6'
import { userHasPermission } from '@/utils/access/permissions'
+import { KeyringContext } from '@/contexts/keyringContext'
+import { useSecretSearch } from '@/hooks/useSecretSearch'
+import debounce from 'lodash/debounce'
+import Spinner from './Spinner'
+import clsx from 'clsx'
type CommandItem = {
id: string
@@ -51,6 +56,7 @@ const CommandPalette: React.FC = () => {
const router = useRouter()
const { activeOrganisation, organisations } = useContext(organisationContext)
const { theme, setTheme } = useContext(ThemeContext)
+ const { keyring } = useContext(KeyringContext)
// Permission checks
const userCanReadApps = userHasPermission(activeOrganisation?.role?.permissions, 'Apps', 'read')
@@ -251,6 +257,32 @@ const CommandPalette: React.FC = () => {
],
})) || []
+ // Debounce helper for query input
+ const debouncedSetQuery = React.useMemo(() => debounce((val: string) => setQuery(val), 250), [])
+ React.useEffect(() => {
+ return () => {
+ debouncedSetQuery.cancel()
+ }
+ }, [debouncedSetQuery])
+
+ // Secret search hook (fetches/decrypts keys only when query is set)
+ const { results: secretResults, loading: secretLoading } = useSecretSearch(
+ query,
+ activeOrganisation?.id,
+ keyring
+ )
+
+ const secretCommands: CommandItem[] = secretResults.map((secret) => ({
+ id: secret.id,
+ name: secret.key,
+ description: `${secret.appName} • ${secret.envName} • ${secret.path}`,
+ icon: ,
+ action: () =>
+ handleNavigation(
+ `/${activeOrganisation?.name}/apps/${secret.appId}/environments/${secret.envId}${secret.path === '/' ? '' : secret.path}?secret=${secret.id}`
+ ),
+ }))
+
const allCommands: CommandGroup[] = [
{
name: 'Actions',
@@ -262,6 +294,15 @@ const CommandPalette: React.FC = () => {
icon: ,
items: navigationCommands,
},
+ ...(secretCommands.length > 0
+ ? [
+ {
+ name: 'Secrets',
+ icon: ,
+ items: secretCommands,
+ },
+ ]
+ : []),
...(appCommands.length > 0 ? appCommands : []),
{
name: 'Resources',
@@ -325,6 +366,27 @@ const CommandPalette: React.FC = () => {
}, 100)
}
+ function highlightMatch(text: string, query: string) {
+ if (!query) return [text]
+
+ const lowerText = text.toLowerCase()
+ const lowerQuery = query.toLowerCase()
+ const matchIndex = lowerText.indexOf(lowerQuery)
+
+ if (matchIndex === -1) return [text]
+
+ return [
+ text.slice(0, matchIndex),
+
+ {text.slice(matchIndex, matchIndex + query.length)}
+ ,
+ text.slice(matchIndex + query.length),
+ ]
+ }
+
return (
<>
{
setQuery(event.target.value)}
+ onChange={(event) => debouncedSetQuery(event.target.value)}
/>
+ {secretLoading && (
+
+
+
+ )}
{filteredCommands.length > 0 && (
@@ -416,10 +483,18 @@ const CommandPalette: React.FC = () => {
{item.icon}
diff --git a/frontend/graphql/queries/organisation/getOrganisationMembers.gql b/frontend/graphql/queries/organisation/getOrganisationMembers.gql
index 6a48853ba..98dc029c6 100644
--- a/frontend/graphql/queries/organisation/getOrganisationMembers.gql
+++ b/frontend/graphql/queries/organisation/getOrganisationMembers.gql
@@ -15,26 +15,5 @@ query GetOrganisationMembers($organisationId: ID!, $role: [String]) {
createdAt
lastLogin
self
- appMemberships {
- id
- name
- sseEnabled
- environments {
- id
- name
- }
- }
- tokens {
- id
- name
- createdAt
- expiresAt
- }
- networkPolicies {
- id
- name
- allowedIps
- isGlobal
- }
}
}
diff --git a/frontend/graphql/queries/secrets/getAppSecrets.gql b/frontend/graphql/queries/secrets/getAppSecrets.gql
index 839858339..015a57fdc 100644
--- a/frontend/graphql/queries/secrets/getAppSecrets.gql
+++ b/frontend/graphql/queries/secrets/getAppSecrets.gql
@@ -1,4 +1,4 @@
-query GetAppSecrets($appId: ID!, $memberId: ID, $memberType: MemberType) {
+query GetAppSecrets($appId: ID!, $memberId: ID, $memberType: MemberType, $path: String) {
appEnvironments(
appId: $appId
environmentId: null
@@ -29,7 +29,7 @@ query GetAppSecrets($appId: ID!, $memberId: ID, $memberType: MemberType) {
name
path
}
- secrets {
+ secrets(path: $path) {
id
key
value
diff --git a/frontend/graphql/queries/secrets/getOrgSecretKeys.gql b/frontend/graphql/queries/secrets/getOrgSecretKeys.gql
new file mode 100644
index 000000000..011d8b830
--- /dev/null
+++ b/frontend/graphql/queries/secrets/getOrgSecretKeys.gql
@@ -0,0 +1,17 @@
+query GetOrgSecretKeys($organisationId: ID!) {
+ apps(organisationId: $organisationId) {
+ id
+ name
+ environments {
+ id
+ name
+ wrappedSeed
+ wrappedSalt
+ secrets {
+ id
+ key
+ path
+ }
+ }
+ }
+}
diff --git a/frontend/graphql/queries/users/getOrganisationMemberDetail.gql b/frontend/graphql/queries/users/getOrganisationMemberDetail.gql
new file mode 100644
index 000000000..b128f9895
--- /dev/null
+++ b/frontend/graphql/queries/users/getOrganisationMemberDetail.gql
@@ -0,0 +1,40 @@
+query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {
+ organisationMembers(organisationId: $organisationId, memberId: $id) {
+ id
+ role {
+ id
+ name
+ description
+ permissions
+ color
+ }
+ identityKey
+ email
+ fullName
+ avatarUrl
+ createdAt
+ lastLogin
+ self
+ appMemberships {
+ id
+ name
+ sseEnabled
+ environments {
+ id
+ name
+ }
+ }
+ tokens {
+ id
+ name
+ createdAt
+ expiresAt
+ }
+ networkPolicies {
+ id
+ name
+ allowedIps
+ isGlobal
+ }
+ }
+}
diff --git a/frontend/hooks/useSecretSearch.ts b/frontend/hooks/useSecretSearch.ts
new file mode 100644
index 000000000..eded77014
--- /dev/null
+++ b/frontend/hooks/useSecretSearch.ts
@@ -0,0 +1,108 @@
+import { useApolloClient } from '@apollo/client'
+import { useEffect, useRef, useState } from 'react'
+import { unwrapEnvSecretsForUser } from '@/utils/crypto'
+import { decryptAsymmetric } from '@/utils/crypto/general'
+import GetOrgSecretKeys from '@/graphql/queries/secrets/getOrgSecretKeys.gql'
+import { OrganisationKeyring } from '@/utils/crypto'
+
+interface SecretMeta {
+ id: string
+ key: string
+ appId: string
+ envId: string
+ path: string
+ appName: string
+ envName: string
+}
+
+interface UseSecretSearchReturn {
+ results: SecretMeta[]
+ loading: boolean
+}
+
+// Fetches and decrypts secret keys once per-organisaton on first query, then caches.
+export const useSecretSearch = (
+ query: string,
+ organisationId: string | undefined | null,
+ keyring: OrganisationKeyring | null
+): UseSecretSearchReturn => {
+ const client = useApolloClient()
+ const cacheRef = useRef
(null)
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+
+ const executeSearch = async () => {
+ // Simple normalisation: remove spaces/underscores, lowercase.
+ const normalize = (str: string) => str.replace(/[\\s_]/g, '').toLowerCase()
+ const normalizedQuery = normalize(query)
+
+ if (!normalizedQuery) {
+ setResults([])
+ return
+ }
+ if (!organisationId || !keyring) return
+
+ if (!cacheRef.current) {
+ setLoading(true)
+ const collected: SecretMeta[] = []
+
+ try {
+ const { data } = await client.query({
+ query: GetOrgSecretKeys,
+ variables: { organisationId },
+ fetchPolicy: 'network-only',
+ })
+
+ for (const app of data.apps) {
+ for (const env of app.environments) {
+ const { publicKey, privateKey } = await unwrapEnvSecretsForUser(
+ env.wrappedSeed,
+ env.wrappedSalt,
+ keyring
+ )
+
+ for (const secret of env.secrets) {
+ const decryptedKey = await decryptAsymmetric(
+ secret.key,
+ privateKey,
+ publicKey
+ )
+ collected.push({
+ id: secret.id,
+ key: decryptedKey,
+ appId: app.id,
+ envId: env.id,
+ path: secret.path,
+ appName: app.name,
+ envName: env.name,
+ })
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Secret key fetch failed', error)
+ }
+
+ if (cancelled) return
+ cacheRef.current = collected
+ setLoading(false)
+ }
+
+ const filtered = cacheRef.current!.filter((s) =>
+ normalize(s.key).includes(normalizedQuery)
+ )
+ setResults(filtered)
+ }
+
+ executeSearch()
+
+ return () => {
+ cancelled = true
+ }
+ }, [query, organisationId, keyring, client])
+
+ return { results, loading }
+}
diff --git a/img/aws-eks.svg b/img/aws-eks.svg
new file mode 100644
index 000000000..071b640ff
--- /dev/null
+++ b/img/aws-eks.svg
@@ -0,0 +1 @@
+Amazon EKS
\ No newline at end of file
diff --git a/img/console-ui.mp4 b/img/console-ui.mp4
deleted file mode 100644
index 8cfa30526..000000000
Binary files a/img/console-ui.mp4 and /dev/null differ
diff --git a/img/terraform.svg b/img/terraform.svg
new file mode 100644
index 000000000..f4f3dbe0e
--- /dev/null
+++ b/img/terraform.svg
@@ -0,0 +1 @@
+Terraform
\ No newline at end of file