778 lines
31 KiB
TypeScript
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 the Floci endpoint." />
|
|
) : 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>
|
|
</>
|
|
)
|
|
}
|