Files
floci-ui/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

778 lines
31 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Check,
Copy,
Eye,
EyeOff,
Info,
KeyRound,
Loader2,
Plus,
RefreshCw,
Save,
Search,
Trash2,
X,
} from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { ServiceIcon } from '@/components/serviceIcons'
import type { SecretSummary } from '@/api/aws/secretsmanager.api'
import {
secretsManagerQueryKeys,
useSecretDetailQuery,
useSecretsQuery,
useSecretValueQuery,
} from '@/api/aws/secretsmanager.queries'
import {
useCreateSecretMutation,
useDeleteSecretMutation,
usePutSecretValueMutation,
} from '@/api/aws/secretsmanager.mutations'
import { timeAgo } from '@/lib/utils'
import { clientId } from '@/lib/clientId'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json'
type SecretKeyValueEntry = {
id: string
key: string
value: string
kind: JsonValueKind
}
type SerializedKeyValueEntries =
| { secretString: string }
| { error: string }
function valueKind(value: JsonValue): JsonValueKind {
if (value === null) return 'null'
if (Array.isArray(value) || typeof value === 'object') return 'json'
if (typeof value === 'number') return 'number'
if (typeof value === 'boolean') return 'boolean'
return 'string'
}
function valueText(value: JsonValue): string {
return typeof value === 'string' ? value : JSON.stringify(value)
}
function parseKeyValueEntries(secretString: string): SecretKeyValueEntry[] | null {
try {
const parsed: unknown = JSON.parse(secretString)
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') return null
return Object.entries(parsed as Record<string, JsonValue>).map(([key, value], index) => ({
id: `${index}-${key}`,
key,
value: valueText(value),
kind: valueKind(value),
}))
} catch {
return null
}
}
function parseEntryValue(entry: SecretKeyValueEntry): JsonValue | undefined {
if (entry.kind === 'string') return entry.value
if (entry.kind === 'number') {
const value = Number(entry.value)
return Number.isFinite(value) ? value : undefined
}
if (entry.kind === 'boolean') {
if (entry.value === 'true') return true
if (entry.value === 'false') return false
return undefined
}
try {
const value: unknown = JSON.parse(entry.value)
if (entry.kind === 'null') return value === null ? null : undefined
return value as JsonValue
} catch {
return undefined
}
}
function serializeKeyValueEntries(entries: SecretKeyValueEntry[]): SerializedKeyValueEntries {
const output: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>
for (const entry of entries) {
const key = entry.key
if (!key) return { error: 'Every entry needs a key.' }
if (Object.prototype.hasOwnProperty.call(output, key)) return { error: `Duplicate key: ${key}` }
const value = parseEntryValue(entry)
if (value === undefined) {
return { error: `Invalid ${entry.kind} value for ${key}.` }
}
output[key] = value
}
return { secretString: JSON.stringify(output, null, 2) }
}
// ─── Create secret form ─────────────────────────────────────────────────────────
function CreateSecretForm({ onClose }: { onClose: () => void }) {
const qc = useQueryClient()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [secretString, setSecretString] = useState('')
const [err, setErr] = useState('')
const createMut = useCreateSecretMutation({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['resources', 'secretsmanager'] })
onClose()
},
onError: (e) => setErr(e instanceof Error ? e.message : 'Create failed'),
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return setErr('Secret name is required')
if (!secretString) return setErr('Secret value is required')
setErr('')
createMut.mutate({ name: name.trim(), secretString, description: description.trim() || undefined })
}
return (
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14, background: 'var(--raised)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 12 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<KeyRound size={14} style={{ color: 'var(--accent)' }} />
<strong style={{ fontSize: 13 }}>Create secret</strong>
<button type="button" className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close" title="Close">
<X size={14} />
</button>
</div>
<input
className="input"
placeholder="Secret name (e.g. prod/db/password)"
value={name}
onChange={(e) => { setName(e.target.value); setErr('') }}
autoFocus
/>
<input
className="input"
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<textarea
className="json-editor"
style={{ minHeight: 100 }}
placeholder={'Secret value — plaintext or JSON, e.g.\n{\n "username": "admin",\n "password": "…"\n}'}
value={secretString}
onChange={(e) => { setSecretString(e.target.value); setErr('') }}
spellCheck={false}
/>
{err && <span style={{ fontSize: 12, color: 'var(--status-error)' }}>{err}</span>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="button" onClick={onClose}>Cancel</button>
<button type="submit" className="button primary" disabled={createMut.isPending}>
{createMut.isPending ? <Loader2 size={13} className="spin" /> : <Plus size={13} />}
Create
</button>
</div>
</form>
)
}
// ─── Secret detail drawer ─────────────────────────────────────────────────────────
function SecretDrawer({
secretId,
onClose,
onDeleted,
}: {
secretId: string | null
onClose: () => void
onDeleted: () => void
}) {
const qc = useQueryClient()
const [tab, setTab] = useState<'details' | 'value'>('details')
const [revealed, setRevealed] = useState(false)
const [editing, setEditing] = useState(false)
const [draftValue, setDraftValue] = useState('')
const [valueViewMode, setValueViewMode] = useState<'key-value' | 'raw'>('raw')
const [editorMode, setEditorMode] = useState<'key-value' | 'raw'>('raw')
const [keyValueEntries, setKeyValueEntries] = useState<SecretKeyValueEntry[]>([])
const [editorError, setEditorError] = useState('')
const [copied, setCopied] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [forceDelete, setForceDelete] = useState(false)
// Reset transient state when the selected secret changes (via key prop), and
// evict any fetched plaintext from the cache so it never outlives this view.
useEffect(() => {
setTab('details')
setRevealed(false)
setEditing(false)
setDraftValue('')
setValueViewMode('raw')
setEditorMode('raw')
setKeyValueEntries([])
setEditorError('')
setCopied(false)
setDeleteConfirm(false)
setForceDelete(false)
return () => {
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
}
}, [secretId, qc])
// Auto-hide when the user leaves the Value tab. Flipping `revealed` is not
// enough — the fetched plaintext stays in the React Query cache (readable via
// DevTools/other components) until its entry is evicted, so drop it here and
// discard any in-progress edit as well.
useEffect(() => {
if (tab === 'value') return
setRevealed(false)
setEditing(false)
setDraftValue('')
setValueViewMode('raw')
setEditorMode('raw')
setKeyValueEntries([])
setEditorError('')
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
}, [tab, secretId, qc])
const detailQuery = useSecretDetailQuery(secretId)
// The plaintext value is only fetched once the user explicitly reveals it.
const valueQuery = useSecretValueQuery(secretId, revealed && tab === 'value')
const putMut = usePutSecretValueMutation({
onSuccess: () => {
setEditing(false)
setDraftValue('')
},
onError: (e) => alert(`Update failed: ${e instanceof Error ? e.message : e}`),
})
const deleteMut = useDeleteSecretMutation({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['resources', 'secretsmanager'] })
onDeleted()
},
onError: (e) => alert(`Delete failed: ${e instanceof Error ? e.message : e}`),
})
const detail = detailQuery.data
const value = valueQuery.data
// A binary secret has no SecretString. The API only writes SecretString, so
// editing one here would overwrite the binary value with text — block it.
const isBinary = Boolean(value && value.secretString === undefined && value.secretBinary !== undefined)
const valueEntries = value?.secretString === undefined ? null : parseKeyValueEntries(value.secretString)
useEffect(() => {
if (!revealed || tab !== 'value' || editing) return
setValueViewMode(parseKeyValueEntries(value?.secretString ?? '') ? 'key-value' : 'raw')
}, [editing, revealed, tab, value?.secretString])
// Hiding must evict the fetched plaintext from the cache, not merely flip the
// reveal flag — otherwise the value lingers in memory (DevTools, extensions,
// other components) after the user hides it.
function hideValue() {
setRevealed(false)
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
}
function cancelEditing() {
setEditing(false)
setDraftValue('')
setEditorMode('raw')
setKeyValueEntries([])
setEditorError('')
}
function startEditing() {
if (isBinary) return
const secretString = value?.secretString ?? ''
const entries = parseKeyValueEntries(secretString)
setDraftValue(secretString)
setKeyValueEntries(entries ?? [])
setEditorMode(valueViewMode === 'key-value' && entries ? 'key-value' : 'raw')
setEditorError('')
setEditing(true)
}
function switchEditorMode(mode: 'key-value' | 'raw') {
if (mode === editorMode) return
if (mode === 'key-value') {
const entries = parseKeyValueEntries(draftValue)
if (!entries) {
setEditorError('Key-value editing is available only for a JSON object.')
return
}
setKeyValueEntries(entries)
setEditorError('')
setEditorMode('key-value')
return
}
const serialized = serializeKeyValueEntries(keyValueEntries)
if ('error' in serialized) {
setEditorError(serialized.error)
return
}
setDraftValue(serialized.secretString)
setEditorError('')
setEditorMode('raw')
}
function updateKeyValueEntry(id: string, field: 'key' | 'value', nextValue: string) {
setKeyValueEntries((entries) => entries.map((entry) => (
entry.id === id ? { ...entry, [field]: nextValue } : entry
)))
setEditorError('')
}
function addKeyValueEntry() {
setKeyValueEntries((entries) => [
...entries,
{ id: clientId(), key: '', value: '', kind: 'string' },
])
setEditorError('')
}
function removeKeyValueEntry(id: string) {
setKeyValueEntries((entries) => entries.filter((entry) => entry.id !== id))
setEditorError('')
}
function saveValue() {
let secretString = draftValue
if (editorMode === 'key-value') {
const serialized = serializeKeyValueEntries(keyValueEntries)
if ('error' in serialized) {
setEditorError(serialized.error)
return
}
secretString = serialized.secretString
}
if (!secretString) {
setEditorError('Secret value is required.')
return
}
putMut.mutate({ id: secretId!, secretString })
}
async function copyValue() {
const text = value?.secretString ?? value?.secretBinary
if (text === undefined || text === null) return
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// Clipboard access can be denied; silently ignore.
}
}
return (
<div className={`tag-drawer ${secretId ? 'open' : ''}`} style={{ width: 440 }}>
<div className="tag-drawer-header">
<KeyRound size={14} style={{ color: 'var(--accent)' }} />
<h3 title={detail?.name ?? secretId ?? ''}>{detail?.name ?? secretId}</h3>
<button className="icon-btn" onClick={onClose} aria-label="Close" title="Close"><X size={14} aria-hidden="true" /></button>
</div>
<div className="drawer-tabs">
<button className={`drawer-tab ${tab === 'details' ? 'active' : ''}`} onClick={() => setTab('details')}>
Details
</button>
<button className={`drawer-tab ${tab === 'value' ? 'active' : ''}`} onClick={() => setTab('value')}>
Value
</button>
</div>
<div className="tag-drawer-body">
{/* ── Details tab ── */}
{tab === 'details' && (
detailQuery.isLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-3)', fontSize: 13 }}>
<Loader2 size={14} className="spin" /> Loading details…
</div>
) : detailQuery.isError ? (
<p style={{ color: 'var(--status-error)', fontSize: 13 }}>Failed to load secret details.</p>
) : detail ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="badge" style={{ background: 'rgba(34,197,94,0.14)', color: 'var(--status-ok)' }}>
{detail.rotationEnabled ? 'Rotation enabled' : 'Active'}
</span>
{detail.versionIds.length > 0 && (
<span className="badge" style={{ background: 'rgba(107,114,128,0.14)', color: 'var(--text-3)' }}>
{detail.versionIds.length} version{detail.versionIds.length !== 1 ? 's' : ''}
</span>
)}
</div>
<div className="meta-grid">
{detail.arn && (
<div className="meta-row">
<span className="meta-label">ARN</span>
<span className="meta-value" style={{ fontSize: 11 }}>{detail.arn}</span>
</div>
)}
{detail.description && (
<div className="meta-row">
<span className="meta-label">Description</span>
<span className="meta-value" style={{ fontFamily: 'inherit', color: 'var(--text-3)' }}>{detail.description}</span>
</div>
)}
{detail.kmsKeyId && (
<div className="meta-row">
<span className="meta-label">KMS key</span>
<span className="meta-value" style={{ fontSize: 11 }}>{detail.kmsKeyId}</span>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{detail.createdDate && (
<div className="meta-row">
<span className="meta-label">Created</span>
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.createdDate)}</span>
</div>
)}
{detail.lastChangedDate && (
<div className="meta-row">
<span className="meta-label">Last changed</span>
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.lastChangedDate)}</span>
</div>
)}
{detail.lastAccessedDate && (
<div className="meta-row">
<span className="meta-label">Last accessed</span>
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.lastAccessedDate)}</span>
</div>
)}
</div>
</div>
{/* Tags */}
{detail.tags.length > 0 && (
<div>
<p style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>
Tags ({detail.tags.length})
</p>
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
{detail.tags.map((tag, i) => (
<div
key={tag.key}
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', borderBottom: i < detail.tags.length - 1 ? '1px solid var(--border)' : undefined }}
>
<div style={{ padding: '6px 8px', borderRight: '1px solid var(--border)', fontSize: 12, fontFamily: 'monospace', color: 'var(--status-warn)' }}>{tag.key}</div>
<div style={{ padding: '6px 8px', fontSize: 12, fontFamily: 'monospace', color: 'var(--text-2)' }} title={tag.value}>{tag.value}</div>
</div>
))}
</div>
</div>
)}
</div>
) : null
)}
{/* ── Value tab ── */}
{tab === 'value' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{!revealed && !editing ? (
<button className="button" onClick={() => setRevealed(true)} style={{ alignSelf: 'flex-start' }}>
<Eye size={13} />
Reveal secret value
</button>
) : editing ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<p style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: 0 }}>
New secret value
</p>
<div className="drawer-tabs" style={{ marginLeft: 'auto' }}>
<button
className={`drawer-tab ${editorMode === 'key-value' ? 'active' : ''}`}
onClick={() => switchEditorMode('key-value')}
>
Key-value
</button>
<button
className={`drawer-tab ${editorMode === 'raw' ? 'active' : ''}`}
onClick={() => switchEditorMode('raw')}
>
Plain text
</button>
</div>
</div>
{editorMode === 'key-value' ? (
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 32px', gap: 6, padding: '7px 8px', borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<span>Key</span>
<span>Value</span>
<span />
</div>
{keyValueEntries.map((entry) => (
<div key={entry.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 32px', gap: 6, padding: 8, borderBottom: '1px solid var(--border)' }}>
<input
className="input"
aria-label="Secret key"
value={entry.key}
onChange={(e) => updateKeyValueEntry(entry.id, 'key', e.target.value)}
placeholder="Key"
/>
<input
className="input"
aria-label={`Value for ${entry.key || 'new key'}`}
value={entry.value}
onChange={(e) => updateKeyValueEntry(entry.id, 'value', e.target.value)}
placeholder="Value"
/>
<button className="icon-btn" title="Delete entry" aria-label={`Delete ${entry.key || 'entry'}`} onClick={() => removeKeyValueEntry(entry.id)}>
<Trash2 size={13} />
</button>
</div>
))}
<div style={{ padding: 8 }}>
<button className="button" onClick={addKeyValueEntry}>
<Plus size={13} />
Add key-value pair
</button>
</div>
</div>
) : (
<textarea
className="json-editor"
style={{ minHeight: 180 }}
value={draftValue}
onChange={(e) => { setDraftValue(e.target.value); setEditorError('') }}
spellCheck={false}
/>
)}
{editorError && <span style={{ fontSize: 12, color: 'var(--status-error)' }}>{editorError}</span>}
<div style={{ display: 'flex', gap: 8 }}>
<button className="button primary" disabled={putMut.isPending} onClick={saveValue}>
{putMut.isPending ? <Loader2 size={13} className="spin" /> : <Save size={13} />}
Save new version
</button>
<button className="button" onClick={cancelEditing}>Cancel</button>
</div>
</>
) : valueQuery.isLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-3)', fontSize: 13 }}>
<Loader2 size={14} className="spin" /> Loading value…
</div>
) : valueQuery.isError ? (
<p style={{ color: 'var(--status-error)', fontSize: 13 }}>Failed to load secret value.</p>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{value?.secretBinary ? 'Binary value (base64)' : 'Secret value'}
</span>
<button className="icon-btn" style={{ marginLeft: 'auto' }} title="Hide" onClick={hideValue}>
<EyeOff size={13} />
</button>
<button className="icon-btn" title="Copy" onClick={copyValue}>
{copied ? <Check size={13} color="var(--status-ok)" /> : <Copy size={13} />}
</button>
</div>
{!isBinary && valueEntries && (
<div className="drawer-tabs" style={{ alignSelf: 'flex-start' }}>
<button className={`drawer-tab ${valueViewMode === 'key-value' ? 'active' : ''}`} onClick={() => setValueViewMode('key-value')}>
Key-value
</button>
<button className={`drawer-tab ${valueViewMode === 'raw' ? 'active' : ''}`} onClick={() => setValueViewMode('raw')}>
Plain text
</button>
</div>
)}
{valueViewMode === 'key-value' && valueEntries ? (
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: '7px 8px', borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<span>Key</span>
<span>Value</span>
</div>
{valueEntries.length === 0 ? (
<p style={{ margin: 0, padding: 10, color: 'var(--text-3)', fontSize: 12 }}>Empty JSON object</p>
) : valueEntries.map((entry, index) => (
<div key={entry.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: 8, borderBottom: index < valueEntries.length - 1 ? '1px solid var(--border)' : undefined }}>
<span className="mono" style={{ color: 'var(--status-warn)', fontSize: 12, overflowWrap: 'anywhere' }}>{entry.key}</span>
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 12, overflowWrap: 'anywhere' }}>{entry.value}</span>
</div>
))}
</div>
) : (
<pre className="json-editor" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0 }}>
{value?.secretString ?? value?.secretBinary ?? '(empty)'}
</pre>
)}
{value?.versionId && (
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>Version: {value.versionId}</span>
)}
{isBinary ? (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
Binary secrets are read-only here; editing would overwrite the binary value with text.
</span>
) : (
<button className="button" onClick={startEditing} style={{ alignSelf: 'flex-start' }}>
<Save size={13} />
Edit value
</button>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
<div className="tag-drawer-footer">
{deleteConfirm ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', padding: '8px', background: 'rgba(239,68,68,0.08)', borderRadius: 4, border: '1px solid rgba(239,68,68,0.3)' }}>
<span style={{ fontSize: 12, color: 'var(--status-error)' }}>Delete this secret?</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--text-2)' }}>
<input type="checkbox" checked={forceDelete} onChange={(e) => setForceDelete(e.target.checked)} />
Force delete (no 7-day recovery window)
</label>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="button danger" onClick={() => deleteMut.mutate({ id: secretId!, force: forceDelete })} disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Loader2 size={12} className="spin" /> : 'Yes, delete'}
</button>
<button className="button" onClick={() => setDeleteConfirm(false)}>Cancel</button>
</div>
</div>
) : (
<button className="button danger" style={{ marginLeft: 'auto' }} onClick={() => setDeleteConfirm(true)}>
<Trash2 size={13} />
Delete
</button>
)}
</div>
</div>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function SecretsManagerPage() {
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const query = useSecretsQuery()
const secrets = useMemo(() => {
const all = query.data ?? []
if (!search) return all
const q = search.toLowerCase()
return all.filter((s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q))
}, [query.data, search])
function selectionId(secret: SecretSummary): string {
return secret.arn ?? secret.name
}
return (
<>
<SecretDrawer
key={selected}
secretId={selected}
onClose={() => setSelected(null)}
onDeleted={() => setSelected(null)}
/>
<div className="page-header">
<div className="page-title">
<ServiceIcon cloud="aws" iconKey="secrets" size={32} />
<h1>Secrets Manager</h1>
<span className="info-link">
<Info size={11} />
{query.data ? `${query.data.length} secrets` : 'Encrypted secrets'}
</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="button" onClick={() => void query.refetch()}>
<RefreshCw size={13} />
Refresh
</button>
<button className="button primary" onClick={() => setCreating((v) => !v)}>
<Plus size={13} />
Create secret
</button>
</div>
</div>
<div className="input-row">
<Search size={14} color="var(--text-3)" />
<input
className="input"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or description…"
/>
</div>
<div className="content">
{creating && <CreateSecretForm onClose={() => setCreating(false)} />}
<div className="table-panel">
<div className="widget-header">
<h3>Secrets</h3>
</div>
{query.isError ? (
<EmptyState icon={KeyRound} title="Cannot load secrets" description="Secrets Manager did not respond from TheCloud's runtime." />
) : query.isLoading ? (
<div className="empty"><p>Loading secrets…</p></div>
) : secrets.length === 0 ? (
<EmptyState
icon={KeyRound}
title={search ? 'No secrets match your search' : 'No secrets'}
description={search ? 'Try a different name or description filter.' : 'Create a secret to get started.'}
/>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Last changed</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
{secrets.map((secret) => {
const id = selectionId(secret)
return (
<tr
key={id}
style={{ cursor: 'pointer', background: selected === id ? 'var(--raised)' : undefined }}
onClick={() => setSelected(selected === id ? null : id)}
>
<td className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ServiceIcon cloud="aws" iconKey="secrets" size={18} />
{secret.name}
</td>
<td style={{ color: 'var(--text-3)' }}>{secret.description ?? '—'}</td>
<td style={{ color: 'var(--text-3)' }}>{secret.lastChangedDate ? timeAgo(secret.lastChangedDate) : '—'}</td>
<td>{secret.tags.length || '—'}</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
</>
)
}