@better-forge/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -2
  3. package/dist/api/client.d.ts +42 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +130 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/components/ConfirmIdentityModal.d.ts +18 -0
  8. package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
  9. package/dist/components/ConfirmIdentityModal.js +30 -0
  10. package/dist/components/ConfirmIdentityModal.js.map +1 -0
  11. package/dist/components/ConfirmModal.d.ts +18 -0
  12. package/dist/components/ConfirmModal.d.ts.map +1 -0
  13. package/dist/components/ConfirmModal.js +11 -0
  14. package/dist/components/ConfirmModal.js.map +1 -0
  15. package/dist/components/CopyButton.d.ts +11 -0
  16. package/dist/components/CopyButton.d.ts.map +1 -0
  17. package/dist/components/CopyButton.js +44 -0
  18. package/dist/components/CopyButton.js.map +1 -0
  19. package/dist/components/CredentialSection.d.ts +38 -0
  20. package/dist/components/CredentialSection.d.ts.map +1 -0
  21. package/dist/components/CredentialSection.js +69 -0
  22. package/dist/components/CredentialSection.js.map +1 -0
  23. package/dist/components/ErrorBoundary.d.ts +18 -0
  24. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  25. package/dist/components/ErrorBoundary.js +25 -0
  26. package/dist/components/ErrorBoundary.js.map +1 -0
  27. package/dist/components/ModalShell.d.ts +58 -0
  28. package/dist/components/ModalShell.d.ts.map +1 -0
  29. package/dist/components/ModalShell.js +92 -0
  30. package/dist/components/ModalShell.js.map +1 -0
  31. package/dist/components/ResetTwoFactorModal.d.ts +14 -0
  32. package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
  33. package/dist/components/ResetTwoFactorModal.js +16 -0
  34. package/dist/components/ResetTwoFactorModal.js.map +1 -0
  35. package/dist/components/SidePanel.d.ts +35 -0
  36. package/dist/components/SidePanel.d.ts.map +1 -0
  37. package/dist/components/SidePanel.js +19 -0
  38. package/dist/components/SidePanel.js.map +1 -0
  39. package/dist/components/SingleSignOnModal.d.ts +4 -0
  40. package/dist/components/SingleSignOnModal.d.ts.map +1 -0
  41. package/dist/components/SingleSignOnModal.js +64 -0
  42. package/dist/components/SingleSignOnModal.js.map +1 -0
  43. package/dist/components/SsoConfirmation.d.ts +22 -0
  44. package/dist/components/SsoConfirmation.d.ts.map +1 -0
  45. package/dist/components/SsoConfirmation.js +87 -0
  46. package/dist/components/SsoConfirmation.js.map +1 -0
  47. package/dist/components/VaultTypePicker.d.ts +13 -0
  48. package/dist/components/VaultTypePicker.d.ts.map +1 -0
  49. package/dist/components/VaultTypePicker.js +62 -0
  50. package/dist/components/VaultTypePicker.js.map +1 -0
  51. package/dist/components/WhatsNew.d.ts +18 -0
  52. package/dist/components/WhatsNew.d.ts.map +1 -0
  53. package/dist/components/WhatsNew.js +50 -0
  54. package/dist/components/WhatsNew.js.map +1 -0
  55. package/dist/components/formParts.d.ts +40 -0
  56. package/dist/components/formParts.d.ts.map +1 -0
  57. package/dist/components/formParts.js +31 -0
  58. package/dist/components/formParts.js.map +1 -0
  59. package/dist/components/ui/Alert.d.ts +15 -0
  60. package/dist/components/ui/Alert.d.ts.map +1 -0
  61. package/dist/components/ui/Alert.js +14 -0
  62. package/dist/components/ui/Alert.js.map +1 -0
  63. package/dist/components/ui/DataTable.d.ts +52 -0
  64. package/dist/components/ui/DataTable.d.ts.map +1 -0
  65. package/dist/components/ui/DataTable.js +112 -0
  66. package/dist/components/ui/DataTable.js.map +1 -0
  67. package/dist/components/ui/Field.d.ts +27 -0
  68. package/dist/components/ui/Field.d.ts.map +1 -0
  69. package/dist/components/ui/Field.js +25 -0
  70. package/dist/components/ui/Field.js.map +1 -0
  71. package/dist/components/ui/PageHeader.d.ts +18 -0
  72. package/dist/components/ui/PageHeader.d.ts.map +1 -0
  73. package/dist/components/ui/PageHeader.js +10 -0
  74. package/dist/components/ui/PageHeader.js.map +1 -0
  75. package/dist/components/ui/Pagination.d.ts +21 -0
  76. package/dist/components/ui/Pagination.d.ts.map +1 -0
  77. package/dist/components/ui/Pagination.js +10 -0
  78. package/dist/components/ui/Pagination.js.map +1 -0
  79. package/dist/components/ui/QuickStartPanel.d.ts +21 -0
  80. package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
  81. package/dist/components/ui/QuickStartPanel.js +11 -0
  82. package/dist/components/ui/QuickStartPanel.js.map +1 -0
  83. package/dist/components/ui/StateViews.d.ts +50 -0
  84. package/dist/components/ui/StateViews.d.ts.map +1 -0
  85. package/dist/components/ui/StateViews.js +33 -0
  86. package/dist/components/ui/StateViews.js.map +1 -0
  87. package/dist/components/ui/Switch.d.ts +16 -0
  88. package/dist/components/ui/Switch.d.ts.map +1 -0
  89. package/dist/components/ui/Switch.js +15 -0
  90. package/dist/components/ui/Switch.js.map +1 -0
  91. package/dist/components/ui/Toast.d.ts +20 -0
  92. package/dist/components/ui/Toast.d.ts.map +1 -0
  93. package/dist/components/ui/Toast.js +46 -0
  94. package/dist/components/ui/Toast.js.map +1 -0
  95. package/dist/components/ui/index.d.ts +10 -0
  96. package/dist/components/ui/index.d.ts.map +1 -0
  97. package/dist/components/ui/index.js +10 -0
  98. package/dist/components/ui/index.js.map +1 -0
  99. package/dist/config.d.ts +46 -0
  100. package/dist/config.d.ts.map +1 -0
  101. package/dist/config.js +12 -0
  102. package/dist/config.js.map +1 -0
  103. package/dist/hooks/useDarkMode.d.ts +2 -0
  104. package/dist/hooks/useDarkMode.d.ts.map +1 -0
  105. package/dist/hooks/useDarkMode.js +37 -0
  106. package/dist/hooks/useDarkMode.js.map +1 -0
  107. package/dist/hooks/useDebouncedValue.d.ts +6 -0
  108. package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
  109. package/dist/hooks/useDebouncedValue.js +19 -0
  110. package/dist/hooks/useDebouncedValue.js.map +1 -0
  111. package/dist/index.d.ts +36 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +36 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/lib/changelog.d.ts +32 -0
  116. package/dist/lib/changelog.d.ts.map +1 -0
  117. package/dist/lib/changelog.js +71 -0
  118. package/dist/lib/changelog.js.map +1 -0
  119. package/dist/lib/dateFormat.d.ts +24 -0
  120. package/dist/lib/dateFormat.d.ts.map +1 -0
  121. package/dist/lib/dateFormat.js +67 -0
  122. package/dist/lib/dateFormat.js.map +1 -0
  123. package/dist/navigation.d.ts +5 -0
  124. package/dist/navigation.d.ts.map +1 -0
  125. package/dist/navigation.js +7 -0
  126. package/dist/navigation.js.map +1 -0
  127. package/dist/pages/ApiTokens.d.ts +9 -0
  128. package/dist/pages/ApiTokens.d.ts.map +1 -0
  129. package/dist/pages/ApiTokens.js +116 -0
  130. package/dist/pages/ApiTokens.js.map +1 -0
  131. package/dist/pages/AuditLog.d.ts +14 -0
  132. package/dist/pages/AuditLog.d.ts.map +1 -0
  133. package/dist/pages/AuditLog.js +104 -0
  134. package/dist/pages/AuditLog.js.map +1 -0
  135. package/dist/pages/Backups.d.ts +2 -0
  136. package/dist/pages/Backups.d.ts.map +1 -0
  137. package/dist/pages/Backups.js +118 -0
  138. package/dist/pages/Backups.js.map +1 -0
  139. package/dist/pages/ChangePassword.d.ts +2 -0
  140. package/dist/pages/ChangePassword.d.ts.map +1 -0
  141. package/dist/pages/ChangePassword.js +40 -0
  142. package/dist/pages/ChangePassword.js.map +1 -0
  143. package/dist/pages/Login.d.ts +12 -0
  144. package/dist/pages/Login.d.ts.map +1 -0
  145. package/dist/pages/Login.js +69 -0
  146. package/dist/pages/Login.js.map +1 -0
  147. package/dist/pages/Settings.d.ts +2 -0
  148. package/dist/pages/Settings.d.ts.map +1 -0
  149. package/dist/pages/Settings.js +149 -0
  150. package/dist/pages/Settings.js.map +1 -0
  151. package/dist/pages/Setup.d.ts +2 -0
  152. package/dist/pages/Setup.d.ts.map +1 -0
  153. package/dist/pages/Setup.js +113 -0
  154. package/dist/pages/Setup.js.map +1 -0
  155. package/dist/pages/SsoConfirm.d.ts +3 -0
  156. package/dist/pages/SsoConfirm.d.ts.map +1 -0
  157. package/dist/pages/SsoConfirm.js +20 -0
  158. package/dist/pages/SsoConfirm.js.map +1 -0
  159. package/dist/pages/SsoTestResult.d.ts +2 -0
  160. package/dist/pages/SsoTestResult.d.ts.map +1 -0
  161. package/dist/pages/SsoTestResult.js +49 -0
  162. package/dist/pages/SsoTestResult.js.map +1 -0
  163. package/dist/pages/Users.d.ts +22 -0
  164. package/dist/pages/Users.d.ts.map +1 -0
  165. package/dist/pages/Users.js +124 -0
  166. package/dist/pages/Users.js.map +1 -0
  167. package/dist/pages/Vault.d.ts +2 -0
  168. package/dist/pages/Vault.d.ts.map +1 -0
  169. package/dist/pages/Vault.js +372 -0
  170. package/dist/pages/Vault.js.map +1 -0
  171. package/dist/shell/AdminShell.d.ts +46 -0
  172. package/dist/shell/AdminShell.d.ts.map +1 -0
  173. package/dist/shell/AdminShell.js +76 -0
  174. package/dist/shell/AdminShell.js.map +1 -0
  175. package/dist/shell/guards.d.ts +28 -0
  176. package/dist/shell/guards.d.ts.map +1 -0
  177. package/dist/shell/guards.js +71 -0
  178. package/dist/shell/guards.js.map +1 -0
  179. package/icons.json +170 -0
  180. package/package.json +69 -4
  181. package/src/api/client.ts +139 -0
  182. package/src/components/ConfirmIdentityModal.tsx +82 -0
  183. package/src/components/ConfirmModal.tsx +83 -0
  184. package/src/components/CopyButton.tsx +71 -0
  185. package/src/components/CredentialSection.tsx +270 -0
  186. package/src/components/ErrorBoundary.tsx +47 -0
  187. package/src/components/ModalShell.tsx +160 -0
  188. package/src/components/ResetTwoFactorModal.tsx +74 -0
  189. package/src/components/SidePanel.tsx +96 -0
  190. package/src/components/SingleSignOnModal.tsx +161 -0
  191. package/src/components/SsoConfirmation.tsx +96 -0
  192. package/src/components/VaultTypePicker.tsx +131 -0
  193. package/src/components/WhatsNew.tsx +84 -0
  194. package/src/components/formParts.tsx +59 -0
  195. package/src/components/ui/Alert.tsx +45 -0
  196. package/src/components/ui/DataTable.tsx +225 -0
  197. package/src/components/ui/Field.tsx +63 -0
  198. package/src/components/ui/PageHeader.tsx +36 -0
  199. package/src/components/ui/Pagination.tsx +37 -0
  200. package/src/components/ui/QuickStartPanel.tsx +57 -0
  201. package/src/components/ui/StateViews.tsx +135 -0
  202. package/src/components/ui/Switch.tsx +65 -0
  203. package/src/components/ui/Toast.tsx +104 -0
  204. package/src/components/ui/index.ts +9 -0
  205. package/src/config.ts +46 -0
  206. package/src/hooks/useDarkMode.ts +38 -0
  207. package/src/hooks/useDebouncedValue.ts +22 -0
  208. package/src/index.ts +42 -0
  209. package/src/lib/changelog.ts +93 -0
  210. package/src/lib/dateFormat.ts +90 -0
  211. package/src/navigation.ts +6 -0
  212. package/src/pages/ApiTokens.tsx +361 -0
  213. package/src/pages/AuditLog.tsx +388 -0
  214. package/src/pages/Backups.tsx +170 -0
  215. package/src/pages/ChangePassword.tsx +84 -0
  216. package/src/pages/Login.tsx +275 -0
  217. package/src/pages/Settings.tsx +264 -0
  218. package/src/pages/Setup.tsx +438 -0
  219. package/src/pages/SsoConfirm.tsx +26 -0
  220. package/src/pages/SsoTestResult.tsx +104 -0
  221. package/src/pages/Users.tsx +426 -0
  222. package/src/pages/Vault.tsx +1036 -0
  223. package/src/shell/AdminShell.tsx +256 -0
  224. package/src/shell/guards.tsx +88 -0
  225. package/styles/base.css +521 -0
  226. package/styles/fonts/inter-latin-1.woff2 +0 -0
  227. package/styles/fonts/inter-latin-ext.woff2 +0 -0
  228. package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
  229. package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
  230. package/styles/fonts/manrope-latin-5.woff2 +0 -0
  231. package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
  232. package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
  233. package/styles/icons.css +4 -0
  234. package/tailwind-preset.js +79 -0
@@ -0,0 +1,1036 @@
1
+ import { useCallback, useEffect, useId, useState } from 'react'
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { api } from '../api/client'
4
+ import { ConfirmModal } from '../components/ConfirmModal'
5
+ import { CopyButton } from '../components/CopyButton'
6
+ import { Modal, ModalFooter, ModalHeader, ModalShell } from '../components/ModalShell'
7
+ import { SidePanelFrame, SideTabStrip, type SidePanelMeta } from '../components/SidePanel'
8
+ import { VAULT_TYPE_ICONS, VaultTypePicker, isReferenceVault, type VaultTypeValue } from '../components/VaultTypePicker'
9
+ import { Alert, EmptyState, EmptyStateLink, ErrorState, Field, LoadingState, useToast } from '../components/ui'
10
+ import { CredentialSection } from '../components/CredentialSection'
11
+ import type { VaultPickerProps } from '../components/formParts'
12
+ import { uiConfig } from '../config'
13
+ import { formatDate } from '../lib/dateFormat'
14
+ import type { VaultRef } from '@better-forge/contracts'
15
+
16
+ // ── Types ──────────────────────────────────────────────────────────────────────
17
+
18
+ interface Vault {
19
+ id: number
20
+ name: string
21
+ type: VaultTypeValue
22
+ description: string | null
23
+ createdAt: number
24
+ updatedAt: number
25
+ }
26
+
27
+ interface VaultSecret {
28
+ id: number
29
+ vaultId: number
30
+ name: string
31
+ type: 'userpass' | 'value' | 'json'
32
+ createdAt: number
33
+ updatedAt: number
34
+ }
35
+
36
+ interface RevealedSecret {
37
+ id: number
38
+ name: string
39
+ type: 'userpass' | 'value' | 'json'
40
+ value: { username?: string; password?: string; value?: string }
41
+ /** HashiCorp vaults only: where in Vault the value was read from. */
42
+ source?: { path: string; key?: string }
43
+ }
44
+
45
+ interface HashicorpConnection {
46
+ address: string
47
+ namespace: string
48
+ mount: string
49
+ authMethod: 'token' | 'approle'
50
+ approleMount: string
51
+ roleId: string
52
+ caCert: string
53
+ hasToken: boolean
54
+ hasSecretId: boolean
55
+ /** Local vault secret the credentials are read from, instead of being stored in the connection. */
56
+ credentialsRef: { vaultId: number; secretId: number; fieldMapping?: Record<string, string> } | null
57
+ }
58
+
59
+ /** What the connection form edits; credentials stay blank when unchanged. */
60
+ interface ConnectionForm {
61
+ address: string
62
+ namespace: string
63
+ mount: string
64
+ authMethod: 'token' | 'approle'
65
+ token: string
66
+ roleId: string
67
+ secretId: string
68
+ approleMount: string
69
+ caCert: string
70
+ credentialsRef: VaultRef | undefined
71
+ }
72
+
73
+ const EMPTY_CONNECTION: ConnectionForm = {
74
+ address: '', namespace: '', mount: 'secret', authMethod: 'token', token: '', roleId: '', secretId: '', approleMount: 'approle', caCert: '', credentialsRef: undefined,
75
+ }
76
+
77
+ /** The request body for a connection: a chosen secret replaces the typed credentials, direct input clears it. */
78
+ function connectionPayload(form: ConnectionForm) {
79
+ return { ...form, credentialsRef: form.credentialsRef ? { vaultId: form.credentialsRef.vaultId, secretId: form.credentialsRef.secretId, ...(Object.keys(form.credentialsRef.fieldMapping ?? {}).length > 0 && { fieldMapping: form.credentialsRef.fieldMapping }) } : null }
80
+ }
81
+
82
+ const VAULT_TYPE_LABELS: Record<Vault['type'], string> = { local: 'LOCAL', hashicorp: 'HASHICORP VAULT' }
83
+
84
+ /**
85
+ * Local-vault secrets a HashiCorp connection can take its credentials from. Only local vaults are offered,
86
+ * so a connection never depends on another external vault.
87
+ */
88
+ function useLocalSecretPicker(vaults: Vault[]): VaultPickerProps {
89
+ const [secretsByVault, setSecretsByVault] = useState<VaultPickerProps['secretsByVault']>({})
90
+ const loadSecrets = useCallback(async (vaultId: number) => {
91
+ try {
92
+ const secrets = await api.get<VaultPickerProps['secretsByVault'][number]>(`/admin/vaults/${vaultId}/secrets`)
93
+ setSecretsByVault((prev) => ({ ...prev, [vaultId]: secrets }))
94
+ } catch { /* the picker simply stays empty */ }
95
+ }, [])
96
+ return { vaults: vaults.filter((v) => v.type === 'local'), secretsByVault, onLoadSecrets: loadSecrets }
97
+ }
98
+
99
+ function errorMessage(err: unknown, fallback: string) {
100
+ return err instanceof Error && err.message ? err.message : fallback
101
+ }
102
+
103
+ // ── Main page ──────────────────────────────────────────────────────────────────
104
+
105
+ export default function VaultPage() {
106
+ const qc = useQueryClient()
107
+ const toast = useToast()
108
+ const [selectedVaultId, setSelectedVaultId] = useState<number | null>(null)
109
+ const [showCreateVault, setShowCreateVault] = useState(false)
110
+ const [showCreateSecret, setShowCreateSecret] = useState(false)
111
+ const [revealedSecret, setRevealedSecret] = useState<RevealedSecret | null>(null)
112
+ const [revealing, setRevealing] = useState<number | null>(null)
113
+ const [deleteVaultTarget, setDeleteVaultTarget] = useState<Vault | null>(null)
114
+ const [deleteSecretTarget, setDeleteSecretTarget] = useState<VaultSecret | null>(null)
115
+ const [showConnection, setShowConnection] = useState(false)
116
+
117
+ const vaultsQuery = useQuery<Vault[]>({
118
+ queryKey: ['vaults'],
119
+ queryFn: () => api.get('/admin/vaults'),
120
+ })
121
+ const vaults = vaultsQuery.data ?? []
122
+
123
+ const secretsQuery = useQuery<VaultSecret[]>({
124
+ queryKey: ['vault-secrets', selectedVaultId],
125
+ queryFn: () => api.get(`/admin/vaults/${selectedVaultId}/secrets`),
126
+ enabled: selectedVaultId !== null,
127
+ })
128
+ const secrets = secretsQuery.data ?? []
129
+
130
+ const deleteVault = useMutation({
131
+ mutationFn: (id: number) => api.delete(`/admin/vaults/${id}`),
132
+ onSuccess: (_, id) => {
133
+ const vault = vaults.find((v) => v.id === id)
134
+ toast.success(vault ? `Deleted vault "${vault.name}".` : 'Vault deleted.')
135
+ setDeleteVaultTarget(null)
136
+ qc.invalidateQueries({ queryKey: ['vaults'] })
137
+ if (selectedVaultId === id) setSelectedVaultId(null)
138
+ },
139
+ onError: (err) => {
140
+ setDeleteVaultTarget(null)
141
+ toast.error(errorMessage(err, 'Failed to delete vault'))
142
+ },
143
+ })
144
+
145
+ const deleteSecret = useMutation({
146
+ mutationFn: ({ vaultId, secretId }: { vaultId: number; secretId: number }) =>
147
+ api.delete(`/admin/vaults/${vaultId}/secrets/${secretId}`),
148
+ onSuccess: () => {
149
+ toast.success('Secret deleted.')
150
+ setDeleteSecretTarget(null)
151
+ qc.invalidateQueries({ queryKey: ['vault-secrets', selectedVaultId] })
152
+ },
153
+ onError: (err) => {
154
+ setDeleteSecretTarget(null)
155
+ toast.error(errorMessage(err, 'Failed to delete secret'))
156
+ },
157
+ })
158
+
159
+ const testConnection = useMutation({
160
+ mutationFn: (id: number) => api.post<{ ok: boolean; ttlSeconds: number | null }>(`/admin/vaults/${id}/test`),
161
+ onSuccess: (res) => toast.success(res.ttlSeconds ? `Connected to HashiCorp Vault. Token valid for ${res.ttlSeconds} s.` : 'Connected to HashiCorp Vault.'),
162
+ onError: (err) => toast.error(`Connection failed: ${errorMessage(err, 'unknown error')}`),
163
+ })
164
+
165
+ async function handleReveal(secret: VaultSecret) {
166
+ setRevealing(secret.id)
167
+ try {
168
+ const data = await api.get<RevealedSecret>(`/admin/vaults/${secret.vaultId}/secrets/${secret.id}/reveal`)
169
+ setRevealedSecret(data)
170
+ } catch (err) {
171
+ toast.error(`Could not reveal "${secret.name}": ${errorMessage(err, 'unknown error')}`)
172
+ } finally {
173
+ setRevealing(null)
174
+ }
175
+ }
176
+
177
+ const selectedVault = vaults.find((v) => v.id === selectedVaultId)
178
+
179
+ return (
180
+ <div className="flex flex-col md:flex-row h-full md:overflow-hidden fade-up">
181
+ {/* ── Left sidebar: vault list ── */}
182
+ <aside
183
+ aria-label="Vaults"
184
+ className="w-full md:w-64 flex flex-col shrink-0 md:overflow-y-auto border-b md:border-b-0 md:border-r"
185
+ style={{ borderColor: 'var(--m3-outline-variant)', background: 'var(--m3-surface-container-low)' }}
186
+ >
187
+ <div className="flex items-center justify-between px-4 py-4" style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
188
+ <div>
189
+ <h1 className="font-headline font-bold text-base" style={{ color: 'var(--m3-on-surface)' }}>Vaults</h1>
190
+ {vaultsQuery.data && (
191
+ <p className="text-xs mt-0.5" style={{ color: 'var(--m3-secondary)' }}>{vaults.length} vault{vaults.length !== 1 ? 's' : ''}</p>
192
+ )}
193
+ </div>
194
+ <button
195
+ type="button"
196
+ onClick={() => setShowCreateVault(true)}
197
+ className="btn btn-primary btn-sm px-2"
198
+ aria-label="Create vault"
199
+ title="Create vault"
200
+ >
201
+ <span className="material-symbols-outlined" aria-hidden="true">add</span>
202
+ </button>
203
+ </div>
204
+
205
+ <div className="flex-1 p-2 space-y-1">
206
+ {/* Local vaults */}
207
+ {vaultsQuery.isLoading ? (
208
+ <LoadingState label="Loading vaults…" className="py-6" />
209
+ ) : vaultsQuery.isError ? (
210
+ <ErrorState message="Could not load vaults." onRetry={() => void vaultsQuery.refetch()} className="px-3 py-6" />
211
+ ) : (
212
+ <>
213
+ {vaults.map((vault) => (
214
+ <VaultRow
215
+ key={vault.id}
216
+ vault={vault}
217
+ isSelected={selectedVaultId === vault.id}
218
+ onClick={() => setSelectedVaultId(vault.id)}
219
+ onDelete={() => setDeleteVaultTarget(vault)}
220
+ />
221
+ ))}
222
+ {vaults.length === 0 && (
223
+ <p className="text-xs text-center py-6" style={{ color: 'var(--m3-secondary)' }}>No vaults yet</p>
224
+ )}
225
+ </>
226
+ )}
227
+ </div>
228
+ </aside>
229
+
230
+ {/* ── Right panel: secrets ── */}
231
+ <main className="flex-1 flex flex-col md:overflow-hidden min-w-0">
232
+ {selectedVault ? (
233
+ <>
234
+ <div className="flex flex-wrap items-center justify-between gap-3 px-4 md:px-6 py-4 shrink-0" style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
235
+ <div className="min-w-0">
236
+ <div className="flex items-center gap-2">
237
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '20px', color: 'var(--m3-primary)' }}>{VAULT_TYPE_ICONS[selectedVault.type]}</span>
238
+ <h2 className="font-headline font-bold text-lg break-all" style={{ color: 'var(--m3-on-surface)' }}>{selectedVault.name}</h2>
239
+ <span className="text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wide" style={{ background: 'var(--m3-primary-fixed)', color: 'var(--m3-primary)' }}>{VAULT_TYPE_LABELS[selectedVault.type]}</span>
240
+ </div>
241
+ {selectedVault.description && (
242
+ <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>{selectedVault.description}</p>
243
+ )}
244
+ </div>
245
+ <div className="flex flex-wrap items-center gap-2">
246
+ {selectedVault.type === 'hashicorp' && (
247
+ <>
248
+ <button type="button" onClick={() => testConnection.mutate(selectedVault.id)} disabled={testConnection.isPending} className="btn btn-secondary">
249
+ <span className="material-symbols-outlined" aria-hidden="true">network_check</span>
250
+ {testConnection.isPending ? 'Testing…' : 'Test connection'}
251
+ </button>
252
+ <button type="button" onClick={() => setShowConnection(true)} className="btn btn-secondary">
253
+ <span className="material-symbols-outlined" aria-hidden="true">settings</span>
254
+ Connection settings
255
+ </button>
256
+ </>
257
+ )}
258
+ <button type="button" onClick={() => setShowCreateSecret(true)} className="btn btn-primary">
259
+ <span className="material-symbols-outlined" aria-hidden="true">{isReferenceVault(selectedVault.type) ? 'add_link' : 'add'}</span>
260
+ {isReferenceVault(selectedVault.type) ? 'Add Reference' : 'New Secret'}
261
+ </button>
262
+ </div>
263
+ </div>
264
+
265
+ <div className="flex-1 overflow-y-auto p-4 md:p-6">
266
+ {secretsQuery.isLoading ? (
267
+ <LoadingState label="Loading secrets…" />
268
+ ) : secretsQuery.isError ? (
269
+ <ErrorState message="Could not load the secrets in this vault." onRetry={() => void secretsQuery.refetch()} />
270
+ ) : secrets.length === 0 ? (
271
+ <EmptyState
272
+ icon="key_off"
273
+ title={isReferenceVault(selectedVault.type) ? 'No references in this vault' : 'No secrets in this vault'}
274
+ description={isReferenceVault(selectedVault.type)
275
+ ? <><EmptyStateLink onClick={() => setShowCreateSecret(true)}>Add a reference</EmptyStateLink> to a path in HashiCorp Vault that {uiConfig().texts.secretConsumers} can use. {uiConfig().texts.appName} only reads from HashiCorp Vault; it never creates or changes secrets there.</>
276
+ : <><EmptyStateLink onClick={() => setShowCreateSecret(true)}>Add a secret</EmptyStateLink> to store credentials, tokens or JSON configuration that {uiConfig().texts.secretConsumers} can reference.</>}
277
+ />
278
+ ) : (
279
+ <div className="space-y-2">
280
+ {secrets.map((secret) => (
281
+ <SecretRow
282
+ key={secret.id}
283
+ secret={secret}
284
+ isRevealing={revealing === secret.id}
285
+ onReveal={() => handleReveal(secret)}
286
+ onDelete={() => setDeleteSecretTarget(secret)}
287
+ />
288
+ ))}
289
+ </div>
290
+ )}
291
+ </div>
292
+ </>
293
+ ) : (
294
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 py-16" style={{ color: 'var(--m3-secondary)' }}>
295
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '56px', opacity: 0.3 }}>shield_lock</span>
296
+ <p className="text-sm">Select a vault to view its secrets</p>
297
+ </div>
298
+ )}
299
+ </main>
300
+
301
+ {/* ── Modals ── */}
302
+ {showCreateVault && (
303
+ <CreateVaultModal
304
+ vaults={vaults}
305
+ onClose={() => setShowCreateVault(false)}
306
+ onCreated={(v) => { qc.invalidateQueries({ queryKey: ['vaults'] }); setSelectedVaultId(v.id); setShowCreateVault(false); toast.success(`Created vault "${v.name}".`) }}
307
+ />
308
+ )}
309
+ {showCreateSecret && selectedVaultId !== null && (
310
+ <CreateSecretModal
311
+ vaultId={selectedVaultId}
312
+ vaultType={selectedVault?.type ?? 'local'}
313
+ onClose={() => setShowCreateSecret(false)}
314
+ onCreated={() => { qc.invalidateQueries({ queryKey: ['vault-secrets', selectedVaultId] }); setShowCreateSecret(false); toast.success('Secret saved.') }}
315
+ />
316
+ )}
317
+ {showConnection && selectedVault?.type === 'hashicorp' && (
318
+ <ConnectionSettingsModal
319
+ vaultId={selectedVault.id}
320
+ vaults={vaults}
321
+ onClose={() => setShowConnection(false)}
322
+ onSaved={() => { qc.invalidateQueries({ queryKey: ['vaults'] }); setShowConnection(false); toast.success('Connection settings saved.') }}
323
+ />
324
+ )}
325
+ {revealedSecret && (
326
+ <RevealModal secret={revealedSecret} onClose={() => setRevealedSecret(null)} />
327
+ )}
328
+ {deleteVaultTarget && (
329
+ <DeleteVaultModal
330
+ vault={deleteVaultTarget}
331
+ secretCount={deleteVaultTarget.id === selectedVaultId && secretsQuery.data ? secrets.length : null}
332
+ pending={deleteVault.isPending}
333
+ onClose={() => setDeleteVaultTarget(null)}
334
+ onConfirm={() => deleteVault.mutate(deleteVaultTarget.id)}
335
+ />
336
+ )}
337
+ {deleteSecretTarget && (
338
+ <ConfirmModal
339
+ title="Delete secret"
340
+ message={`Delete secret "${deleteSecretTarget.name}"? This action cannot be undone.`}
341
+ confirmLabel="Delete"
342
+ pending={deleteSecret.isPending}
343
+ pendingLabel="Deleting…"
344
+ onConfirm={() => deleteSecret.mutate({ vaultId: deleteSecretTarget.vaultId, secretId: deleteSecretTarget.id })}
345
+ onCancel={() => setDeleteSecretTarget(null)}
346
+ />
347
+ )}
348
+ </div>
349
+ )
350
+ }
351
+
352
+ // ── Vault row ──────────────────────────────────────────────────────────────────
353
+
354
+ function VaultRow({ vault, isSelected, onClick, onDelete }: {
355
+ vault: Vault
356
+ isSelected: boolean
357
+ onClick: () => void
358
+ onDelete: () => void
359
+ }) {
360
+ return (
361
+ <div
362
+ className={`group flex items-center gap-1 rounded-xl transition-colors ${isSelected ? '' : 'hover:bg-[var(--m3-surface-container)]'}`}
363
+ style={{
364
+ background: isSelected ? 'var(--m3-surface-container-lowest)' : undefined,
365
+ boxShadow: isSelected ? '0 1px 4px rgba(19,27,46,0.08)' : 'none',
366
+ }}
367
+ >
368
+ <button
369
+ type="button"
370
+ onClick={onClick}
371
+ aria-current={isSelected ? 'true' : undefined}
372
+ className="flex-1 min-w-0 flex items-center gap-3 px-3 py-2.5 rounded-xl text-left focus-ring"
373
+ >
374
+ <span className="material-symbols-outlined shrink-0" aria-hidden="true" style={{ fontSize: '18px', color: isSelected ? 'var(--m3-primary)' : 'var(--m3-secondary)', fontVariationSettings: isSelected ? "'FILL' 1" : "'FILL' 0" }}>{VAULT_TYPE_ICONS[vault.type]}</span>
375
+ <span className="flex-1 min-w-0">
376
+ <span className="block text-sm font-medium truncate" style={{ color: 'var(--m3-on-surface)' }}>{vault.name}</span>
377
+ {vault.description && (
378
+ <span className="block text-[10px] truncate" style={{ color: 'var(--m3-secondary)' }}>{vault.description}</span>
379
+ )}
380
+ </span>
381
+ </button>
382
+ <button
383
+ type="button"
384
+ onClick={onDelete}
385
+ aria-label={`Delete vault ${vault.name}`}
386
+ title="Delete vault"
387
+ className="btn-icon w-7 h-7 mr-1.5 shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100 hover:!text-[color:var(--m3-down)]"
388
+ >
389
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>close</span>
390
+ </button>
391
+ </div>
392
+ )
393
+ }
394
+
395
+ // ── Secret row ─────────────────────────────────────────────────────────────────
396
+
397
+ const TYPE_LABELS: Record<string, string> = { userpass: 'User/Pass', value: 'Value', json: 'JSON' }
398
+ const TYPE_ICONS: Record<string, string> = { userpass: 'person', value: 'key', json: 'data_object' }
399
+
400
+ function SecretRow({ secret, isRevealing, onReveal, onDelete }: {
401
+ secret: VaultSecret
402
+ isRevealing: boolean
403
+ onReveal: () => void
404
+ onDelete: () => void
405
+ }) {
406
+ return (
407
+ <div
408
+ className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 rounded-xl"
409
+ style={{ background: 'var(--m3-surface-container)', border: '1px solid var(--m3-outline-variant)' }}
410
+ >
411
+ <span className="material-symbols-outlined shrink-0" aria-hidden="true" style={{ fontSize: '20px', color: 'var(--m3-secondary)' }}>{TYPE_ICONS[secret.type] ?? 'key'}</span>
412
+ <div className="flex-1 min-w-0">
413
+ <p className="text-sm font-semibold break-all" style={{ color: 'var(--m3-on-surface)' }}>{secret.name}</p>
414
+ <p className="text-xs mt-0.5" style={{ color: 'var(--m3-secondary)' }}>
415
+ {TYPE_LABELS[secret.type]} · Updated {formatDate(secret.updatedAt)}
416
+ </p>
417
+ </div>
418
+ <div className="flex items-center gap-2 shrink-0">
419
+ <button type="button" onClick={onReveal} disabled={isRevealing} className="btn btn-secondary btn-sm">
420
+ <span className={`material-symbols-outlined ${isRevealing ? 'animate-spin' : ''}`} aria-hidden="true">{isRevealing ? 'progress_activity' : 'visibility'}</span>
421
+ {isRevealing ? 'Loading…' : 'Reveal'}
422
+ </button>
423
+ <button
424
+ type="button"
425
+ onClick={onDelete}
426
+ aria-label={`Delete secret ${secret.name}`}
427
+ title="Delete secret"
428
+ className="btn-icon hover:!text-[color:var(--m3-down)]"
429
+ >
430
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>delete</span>
431
+ </button>
432
+ </div>
433
+ </div>
434
+ )
435
+ }
436
+
437
+ // ── Delete Vault modal (with checkbox when secrets exist) ─────────────────────
438
+
439
+ function DeleteVaultModal({ vault, secretCount, pending, onClose, onConfirm }: {
440
+ vault: Vault
441
+ secretCount: number | null // null = unknown (vault not currently selected/loaded)
442
+ pending: boolean
443
+ onClose: () => void
444
+ onConfirm: () => void
445
+ }) {
446
+ const hasSecrets = secretCount === null || secretCount > 0
447
+ const isReference = isReferenceVault(vault.type)
448
+ const [confirmed, setConfirmed] = useState(false)
449
+
450
+ return (
451
+ <ModalShell onClose={pending ? undefined : onClose}>
452
+ <div
453
+ role="dialog"
454
+ aria-modal="true"
455
+ aria-label="Delete vault"
456
+ aria-busy={pending || undefined}
457
+ className="rounded-2xl w-full max-w-sm"
458
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
459
+ >
460
+ <ModalHeader icon="warning" title="Delete vault" onClose={pending ? () => {} : onClose} />
461
+ <div className="p-6 space-y-4">
462
+ <p className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
463
+ You are about to delete <span className="font-semibold" style={{ color: 'var(--m3-on-surface)' }}>{vault.name}</span>.
464
+ </p>
465
+ {hasSecrets && (
466
+ <div className="rounded-xl px-4 py-3 space-y-3" style={{ background: 'var(--m3-error-container)' }}>
467
+ <p className="text-sm font-medium" style={{ color: 'var(--m3-on-error-container)' }}>
468
+ {isReference
469
+ ? (secretCount !== null && secretCount > 0
470
+ ? `This vault contains ${secretCount} reference${secretCount !== 1 ? 's' : ''}. They will be removed from ${uiConfig().texts.appName}. Nothing is deleted in HashiCorp Vault.`
471
+ : `This vault may contain references. They will be removed from ${uiConfig().texts.appName}. Nothing is deleted in HashiCorp Vault.`)
472
+ : (secretCount !== null && secretCount > 0
473
+ ? `This vault contains ${secretCount} secret${secretCount !== 1 ? 's' : ''}. All secrets will be permanently deleted.`
474
+ : 'This vault may contain secrets. All secrets will be permanently deleted.')}
475
+ </p>
476
+ <label className="flex items-center gap-2.5 cursor-pointer select-none">
477
+ <input
478
+ type="checkbox"
479
+ checked={confirmed}
480
+ disabled={pending}
481
+ onChange={(e) => setConfirmed(e.target.checked)}
482
+ className="w-4 h-4 rounded"
483
+ />
484
+ <span className="text-sm font-medium" style={{ color: 'var(--m3-on-error-container)' }}>
485
+ {isReference ? 'I understand the references will be removed' : 'I understand all secrets will be permanently deleted'}
486
+ </span>
487
+ </label>
488
+ </div>
489
+ )}
490
+
491
+ </div>
492
+ <ModalFooter>
493
+ <button type="button" onClick={onClose} disabled={pending} className="btn btn-secondary">
494
+ Cancel
495
+ </button>
496
+ <button
497
+ type="button"
498
+ onClick={onConfirm}
499
+ disabled={pending || (hasSecrets && !confirmed)}
500
+ className="btn btn-danger"
501
+ >
502
+ {pending ? 'Deleting…' : 'Delete vault'}
503
+ </button>
504
+ </ModalFooter>
505
+ </div>
506
+ </ModalShell>
507
+ )
508
+ }
509
+
510
+ // ── Create Vault modal ─────────────────────────────────────────────────────────
511
+
512
+ function CreateVaultModal({ vaults, onClose, onCreated }: { vaults: Vault[]; onClose: () => void; onCreated: (v: Vault) => void }) {
513
+ const [name, setName] = useState('')
514
+ const [description, setDescription] = useState('')
515
+ const [type, setType] = useState<Vault['type']>('local')
516
+ const [connection, setConnection] = useState<ConnectionForm>(EMPTY_CONNECTION)
517
+ const [error, setError] = useState('')
518
+ const [loading, setLoading] = useState(false)
519
+ const test = useConnectionTest(connection)
520
+ const pickers = useLocalSecretPicker(vaults)
521
+
522
+ async function handleSubmit(e: React.FormEvent) {
523
+ e.preventDefault()
524
+ setError('')
525
+ setLoading(true)
526
+ try {
527
+ const vault = await api.post<Vault>('/admin/vaults', {
528
+ name,
529
+ description: description || undefined,
530
+ ...(type === 'hashicorp' ? { type, connection: connectionPayload(connection) } : {}),
531
+ })
532
+ onCreated(vault)
533
+ } catch (err) {
534
+ setError(errorMessage(err, 'Failed to create vault'))
535
+ } finally {
536
+ setLoading(false)
537
+ }
538
+ }
539
+
540
+ const hashicorp = type === 'hashicorp'
541
+ return (
542
+ <ConnectionDialog
543
+ title="Create Vault"
544
+ icon="shield_lock"
545
+ onClose={onClose}
546
+ onSubmit={handleSubmit}
547
+ advanced={hashicorp ? { connection, onChange: setConnection } : null}
548
+ footer={<ModalActions onClose={onClose} loading={loading} submitLabel="Create Vault" test={hashicorp ? test : undefined} />}
549
+ >
550
+ {error && <Alert tone="error">{error}</Alert>}
551
+ <Field label="Vault name" required>
552
+ <input value={name} onChange={(e) => setName(e.target.value)} required className="input-sig" placeholder="My Credentials" autoFocus />
553
+ </Field>
554
+ <Field label="Description (optional)">
555
+ <input value={description} onChange={(e) => setDescription(e.target.value)} className="input-sig" placeholder="What this vault stores…" />
556
+ </Field>
557
+ <VaultTypePicker value={type} onChange={setType} />
558
+ {hashicorp && <ConnectionFields value={connection} onChange={setConnection} pickers={pickers} />}
559
+ {hashicorp && test.result && <Alert tone={test.result.ok ? 'success' : 'error'}>{test.result.text}</Alert>}
560
+ </ConnectionDialog>
561
+ )
562
+ }
563
+
564
+ // ── Create Secret modal ────────────────────────────────────────────────────────
565
+
566
+ function CreateSecretModal({ vaultId, vaultType, onClose, onCreated }: { vaultId: number; vaultType: Vault['type']; onClose: () => void; onCreated: () => void }) {
567
+ const [name, setName] = useState('')
568
+ const [path, setPath] = useState('')
569
+ const [secretKey, setSecretKey] = useState('')
570
+ const isReference = isReferenceVault(vaultType)
571
+ const [type, setType] = useState<'userpass' | 'value' | 'json'>('userpass')
572
+ const [username, setUsername] = useState('')
573
+ const [password, setPassword] = useState('')
574
+ const [value, setValue] = useState('')
575
+ const [json, setJson] = useState('')
576
+ const [jsonError, setJsonError] = useState('')
577
+ const [error, setError] = useState('')
578
+ const [loading, setLoading] = useState(false)
579
+ const typeLabelId = useId()
580
+
581
+ function validateJson(v: string) {
582
+ try { JSON.parse(v); setJsonError('') } catch { setJsonError('Invalid JSON') }
583
+ }
584
+
585
+ async function handleSubmit(e: React.FormEvent) {
586
+ e.preventDefault()
587
+ if (type === 'json' && jsonError) return
588
+ setError('')
589
+ setLoading(true)
590
+ try {
591
+ const body: Record<string, unknown> = { name, type }
592
+ if (isReference) {
593
+ body['path'] = path
594
+ if (type === 'value') body['key'] = secretKey
595
+ } else {
596
+ if (type === 'userpass') body['userpass'] = { username, password }
597
+ if (type === 'value') body['value'] = value
598
+ if (type === 'json') body['json'] = json
599
+ }
600
+ await api.post(`/admin/vaults/${vaultId}/secrets`, body)
601
+ onCreated()
602
+ } catch (err) {
603
+ setError(errorMessage(err, 'Failed to create secret'))
604
+ } finally {
605
+ setLoading(false)
606
+ }
607
+ }
608
+
609
+ const types: { value: 'userpass' | 'value' | 'json'; label: string; desc: string }[] = [
610
+ { value: 'userpass', label: 'User / Password', desc: 'Username + password pair' },
611
+ { value: 'value', label: 'Secure Value', desc: 'Token, connection string…' },
612
+ { value: 'json', label: 'JSON', desc: 'Arbitrary JSON object' },
613
+ ]
614
+
615
+ return (
616
+ <Modal title={isReference ? 'Add Secret Reference' : 'New Secret'} icon={isReference ? 'link' : 'key'} onClose={onClose} flush>
617
+ <form onSubmit={handleSubmit}>
618
+ <div className="p-6 space-y-4">
619
+ {error && <Alert tone="error">{error}</Alert>}
620
+ {isReference && <Alert tone="info">This only points at a secret that already exists in HashiCorp Vault. {uiConfig().texts.appName} reads it when needed and never creates, changes or deletes anything there.</Alert>}
621
+ <Field label={isReference ? 'Reference name' : 'Secret name'} required>
622
+ <input value={name} onChange={(e) => setName(e.target.value)} required className="input-sig" placeholder="my-api-key" autoFocus />
623
+ </Field>
624
+
625
+ <div role="group" aria-labelledby={typeLabelId}>
626
+ <p id={typeLabelId} className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Type</p>
627
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
628
+ {types.map((t) => (
629
+ <button
630
+ key={t.value}
631
+ type="button"
632
+ aria-pressed={type === t.value}
633
+ onClick={() => setType(t.value)}
634
+ className={`px-3 py-2.5 rounded-xl text-left transition-all focus-ring ${type === t.value ? 'selection-active' : ''}`}
635
+ style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)', border: '1px solid var(--m3-outline-variant)' }}
636
+ >
637
+ <span className="block text-xs font-bold">{t.label}</span>
638
+ <span className="block text-[10px] mt-0.5 opacity-80">{t.desc}</span>
639
+ </button>
640
+ ))}
641
+ </div>
642
+ </div>
643
+
644
+ {isReference && (
645
+ <>
646
+ <Field label="Path" required hint={type === 'userpass' ? 'Reads the keys "username" and "password" at this path.' : type === 'json' ? 'Reads every key at this path.' : 'Path of the KV v2 secret, relative to the mount.'}>
647
+ <input value={path} onChange={(e) => setPath(e.target.value)} required className="input-sig font-mono" placeholder={uiConfig().texts.vaultPathExample} autoComplete="off" />
648
+ </Field>
649
+ {type === 'value' && (
650
+ <Field label="Key" required>
651
+ <input value={secretKey} onChange={(e) => setSecretKey(e.target.value)} required className="input-sig font-mono" placeholder="api_key" autoComplete="off" />
652
+ </Field>
653
+ )}
654
+ </>
655
+ )}
656
+ {!isReference && type === 'userpass' && (
657
+ <>
658
+ <Field label="Username" required>
659
+ <input value={username} onChange={(e) => setUsername(e.target.value)} required className="input-sig" placeholder="admin" autoComplete="off" />
660
+ </Field>
661
+ <Field label="Password" required>
662
+ <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required className="input-sig" placeholder="••••••••" autoComplete="new-password" />
663
+ </Field>
664
+ </>
665
+ )}
666
+ {!isReference && type === 'value' && (
667
+ <Field label="Value" required>
668
+ <input value={value} onChange={(e) => setValue(e.target.value)} required className="input-sig" placeholder="Bearer eyJ…" autoComplete="off" />
669
+ </Field>
670
+ )}
671
+ {!isReference && type === 'json' && (
672
+ <Field label="JSON" required error={jsonError || undefined}>
673
+ <textarea
674
+ value={json}
675
+ onChange={(e) => { setJson(e.target.value); validateJson(e.target.value) }}
676
+ required
677
+ rows={5}
678
+ className="input-sig font-mono text-xs resize-none"
679
+ placeholder={'{\n "key": "value"\n}'}
680
+ style={{ borderColor: jsonError ? 'var(--m3-error)' : undefined }}
681
+ />
682
+ </Field>
683
+ )}
684
+
685
+ </div>
686
+ <ModalActions onClose={onClose} loading={loading} submitLabel={isReference ? 'Save Reference' : 'Save Secret'} />
687
+ </form>
688
+ </Modal>
689
+ )
690
+ }
691
+
692
+ // ── HashiCorp connection ───────────────────────────────────────────────────────
693
+
694
+ /** Fields that decide where credentials are sent. */
695
+ const TARGET_FIELDS = ['address', 'namespace', 'mount', 'authMethod', 'approleMount', 'caCert'] as const
696
+
697
+ const DEFAULT_MOUNT = 'secret'
698
+ const DEFAULT_APPROLE_MOUNT = 'approle'
699
+
700
+ /** The fields every connection needs; the rarely changed options live in the side panel (`ConnectionAdvanced`). */
701
+ function ConnectionFields({ value, onChange, pickers, keepHint }: { value: ConnectionForm; onChange: (v: ConnectionForm) => void; pickers: VaultPickerProps; keepHint?: boolean }) {
702
+ const set = (patch: Partial<ConnectionForm>) => onChange({ ...value, ...patch })
703
+ const authLabelId = useId()
704
+ const credentialHint = keepHint ? 'Leave blank to keep the saved value. Re-enter it when you change the address, namespace, mount, CA certificate or auth method.' : undefined
705
+ const insecure = value.address.trim().toLowerCase().startsWith('http://')
706
+ return (
707
+ <div className="space-y-4">
708
+ <Field label="Address" required>
709
+ <input type="url" value={value.address} onChange={(e) => set({ address: e.target.value })} required className="input-sig font-mono" placeholder="https://vault.example.com:8200" autoComplete="off" />
710
+ </Field>
711
+ {insecure && <Alert tone="warning">The token is sent unencrypted over http://. Use https:// unless Vault is on a trusted private network.</Alert>}
712
+ <div role="group" aria-labelledby={authLabelId}>
713
+ <p id={authLabelId} className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Auth method</p>
714
+ <div className="flex gap-2">
715
+ {(['token', 'approle'] as const).map((method) => (
716
+ <button
717
+ key={method}
718
+ type="button"
719
+ aria-pressed={value.authMethod === method}
720
+ onClick={() => set({ authMethod: method, credentialsRef: undefined })}
721
+ className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium focus-ring ${value.authMethod === method ? 'selection-active' : ''}`}
722
+ style={{ border: '1px solid var(--m3-outline-variant)' }}
723
+ >
724
+ {method === 'token' ? 'Token' : 'AppRole'}
725
+ </button>
726
+ ))}
727
+ </div>
728
+ </div>
729
+ <CredentialSection
730
+ {...pickers}
731
+ vault={value.credentialsRef}
732
+ onVaultChange={(credentialsRef) => set({ credentialsRef })}
733
+ mappingFields={value.authMethod === 'token' ? [{ key: 'token', label: 'Token' }] : [{ key: 'roleId', label: 'Role ID' }, { key: 'secretId', label: 'Secret ID' }]}
734
+ secretTypes={value.authMethod === 'token' ? ['value', 'json'] : ['userpass', 'json']}
735
+ valueLabel="token"
736
+ userpassNote="The username is used as the Role ID and the password as the Secret ID."
737
+ note={value.authMethod === 'token'
738
+ ? 'Read the token from a local vault secret.'
739
+ : 'Read the Role ID and Secret ID from a local vault secret.'}
740
+ >
741
+ {value.authMethod === 'token' ? (
742
+ <Field label="Token" required={!keepHint} hint={credentialHint}>
743
+ <input type="password" value={value.token} onChange={(e) => set({ token: e.target.value })} required={!keepHint} className="input-sig font-mono" autoComplete="new-password" />
744
+ </Field>
745
+ ) : (
746
+ <>
747
+ <Field label="Role ID" required>
748
+ <input value={value.roleId} onChange={(e) => set({ roleId: e.target.value })} required className="input-sig font-mono" autoComplete="off" />
749
+ </Field>
750
+ <Field label="Secret ID" required={!keepHint} hint={credentialHint}>
751
+ <input type="password" value={value.secretId} onChange={(e) => set({ secretId: e.target.value })} required={!keepHint} className="input-sig font-mono" autoComplete="new-password" />
752
+ </Field>
753
+ </>
754
+ )}
755
+ </CredentialSection>
756
+ </div>
757
+ )
758
+ }
759
+
760
+ /** Options that rarely change from their defaults: namespace, mounts and the CA certificate. */
761
+ function ConnectionAdvanced({ value, onChange }: { value: ConnectionForm; onChange: (v: ConnectionForm) => void }) {
762
+ const set = (patch: Partial<ConnectionForm>) => onChange({ ...value, ...patch })
763
+ return (
764
+ <div className="space-y-4">
765
+ <Field label="Namespace (optional)">
766
+ <input value={value.namespace} onChange={(e) => set({ namespace: e.target.value })} className="input-sig font-mono" placeholder="team-a" autoComplete="off" />
767
+ </Field>
768
+ <Field label="KV v2 mount" required>
769
+ <input value={value.mount} onChange={(e) => set({ mount: e.target.value })} required className="input-sig font-mono" placeholder={DEFAULT_MOUNT} autoComplete="off" />
770
+ </Field>
771
+ {value.authMethod === 'approle' && (
772
+ <Field label="AppRole mount">
773
+ <input value={value.approleMount} onChange={(e) => set({ approleMount: e.target.value })} className="input-sig font-mono" placeholder={DEFAULT_APPROLE_MOUNT} autoComplete="off" />
774
+ </Field>
775
+ )}
776
+ <Field label="CA certificate (optional)" hint="PEM. Only for a private CA; include the full chain.">
777
+ <textarea value={value.caCert} onChange={(e) => set({ caCert: e.target.value })} rows={5} className="input-sig font-mono text-xs resize-none" placeholder="-----BEGIN CERTIFICATE-----" />
778
+ </Field>
779
+ </div>
780
+ )
781
+ }
782
+
783
+ const ADVANCED_META: SidePanelMeta = { icon: 'tune', label: 'Connection options' }
784
+
785
+ /** Number of options in the side panel that differ from their defaults, shown as the tab badge. */
786
+ function advancedCount(c: ConnectionForm): number {
787
+ return [
788
+ c.namespace.trim() !== '',
789
+ c.mount.trim() !== DEFAULT_MOUNT,
790
+ c.authMethod === 'approle' && c.approleMount.trim() !== DEFAULT_APPROLE_MOUNT,
791
+ c.caCert.trim() !== '',
792
+ ].filter(Boolean).length
793
+ }
794
+
795
+ /**
796
+ * A modal form with the connection options in a collapsible side panel.
797
+ * Without `advanced` (a local vault) it is a plain narrow dialog with no tab strip.
798
+ */
799
+ function ConnectionDialog({ title, icon, onClose, onSubmit, advanced, footer, children }: {
800
+ title: string
801
+ icon: string
802
+ onClose: () => void
803
+ onSubmit: (e: React.FormEvent) => void
804
+ advanced: { connection: ConnectionForm; onChange: (v: ConnectionForm) => void } | null
805
+ /** The `ModalActions` row; it sits under the scrolling body so it stays in view. */
806
+ footer: React.ReactNode
807
+ children: React.ReactNode
808
+ }) {
809
+ const hasAdvanced = advanced !== null
810
+ // Open whenever the dialog has connection options, since they include a required field.
811
+ const [open, setOpen] = useState(hasAdvanced)
812
+ useEffect(() => setOpen(hasAdvanced), [hasAdvanced])
813
+ const showPanel = open && advanced !== null
814
+ const count = advanced ? advancedCount(advanced.connection) : 0
815
+ return (
816
+ <ModalShell align="top" onClose={onClose} label={title}>
817
+ <div
818
+ className="rounded-2xl my-8 flex flex-col lg:flex-row"
819
+ style={{
820
+ width: showPanel ? 'min(900px, calc(100vw - 32px))' : advanced ? 'min(492px, calc(100vw - 32px))' : 'min(448px, calc(100vw - 32px))',
821
+ background: 'var(--m3-surface-container-low)',
822
+ border: '1px solid var(--m3-outline-variant)',
823
+ transition: 'width 0.2s ease',
824
+ }}
825
+ >
826
+ <div className={`flex-none min-w-0 w-full ${showPanel ? 'lg:w-[460px]' : advanced ? 'lg:w-[calc(100%-44px)]' : 'lg:w-full'}`}>
827
+ <ModalHeader icon={icon} title={title} onClose={onClose} />
828
+ <form onSubmit={onSubmit}>
829
+ <div className="p-6 space-y-4">{children}</div>
830
+ {footer}
831
+ </form>
832
+ </div>
833
+ {showPanel && (
834
+ <SidePanelFrame meta={ADVANCED_META} layout="responsive">
835
+ <ConnectionAdvanced value={advanced.connection} onChange={advanced.onChange} />
836
+ </SidePanelFrame>
837
+ )}
838
+ {advanced && (
839
+ <SideTabStrip
840
+ tabs={[{ key: 'advanced', badge: count > 0 ? String(count) : null }]}
841
+ meta={{ advanced: ADVANCED_META }}
842
+ active={open ? 'advanced' : null}
843
+ onToggle={(key) => setOpen(key !== null)}
844
+ layout="responsive"
845
+ />
846
+ )}
847
+ </div>
848
+ </ModalShell>
849
+ )
850
+ }
851
+
852
+ function ConnectionSettingsModal({ vaultId, vaults, onClose, onSaved }: { vaultId: number; vaults: Vault[]; onClose: () => void; onSaved: () => void }) {
853
+ const [form, setForm] = useState<ConnectionForm | null>(null)
854
+ const [error, setError] = useState('')
855
+ const [loading, setLoading] = useState(false)
856
+ const query = useQuery<{ connection: HashicorpConnection }>({
857
+ queryKey: ['vault-connection', vaultId],
858
+ queryFn: () => api.get(`/admin/vaults/${vaultId}`),
859
+ gcTime: 0,
860
+ })
861
+
862
+ const saved = query.data?.connection
863
+ const current = form ?? (saved ? { ...EMPTY_CONNECTION, ...saved, credentialsRef: saved.credentialsRef ? { fieldMapping: {}, ...saved.credentialsRef } : undefined, token: '', secretId: '' } : null)
864
+ const pickers = useLocalSecretPicker(vaults)
865
+ const savedVaultId = saved?.credentialsRef?.vaultId
866
+ const { onLoadSecrets } = pickers
867
+ useEffect(() => { if (savedVaultId) void onLoadSecrets(savedVaultId) }, [savedVaultId, onLoadSecrets])
868
+ // The saved secret must not follow a changed target to a new server: the admin picks it again, as typed credentials are.
869
+ const change = (next: ConnectionForm) => {
870
+ const moved = current !== null && TARGET_FIELDS.some((f) => next[f] !== current[f])
871
+ setForm(moved && next.credentialsRef === current?.credentialsRef ? { ...next, credentialsRef: undefined } : next)
872
+ }
873
+ const hasSavedDirect = !!current && (current.authMethod === 'token' ? saved?.hasToken : saved?.hasSecretId) === true
874
+ const test = useConnectionTest(current ?? EMPTY_CONNECTION, vaultId)
875
+
876
+ async function handleSubmit(e: React.FormEvent) {
877
+ e.preventDefault()
878
+ if (!current) return
879
+ setError('')
880
+ setLoading(true)
881
+ try {
882
+ await api.patch(`/admin/vaults/${vaultId}`, { connection: connectionPayload(current) })
883
+ onSaved()
884
+ } catch (err) {
885
+ setError(errorMessage(err, 'Failed to save connection settings'))
886
+ } finally {
887
+ setLoading(false)
888
+ }
889
+ }
890
+
891
+ if (!current) {
892
+ return (
893
+ <Modal title="Connection settings" icon="settings" onClose={onClose}>
894
+ {query.isError
895
+ ? <ErrorState message="Could not load the connection settings." onRetry={() => void query.refetch()} />
896
+ : <LoadingState label="Loading…" />}
897
+ </Modal>
898
+ )
899
+ }
900
+ return (
901
+ <ConnectionDialog title="Connection settings" icon="settings" onClose={onClose} onSubmit={handleSubmit} advanced={{ connection: current, onChange: change }} footer={<ModalActions onClose={onClose} loading={loading} submitLabel="Save" test={test} />}>
902
+ {error && <Alert tone="error">{error}</Alert>}
903
+ <ConnectionFields value={current} onChange={change} pickers={pickers} keepHint={hasSavedDirect} />
904
+ {test.result && <Alert tone={test.result.ok ? 'success' : 'error'}>{test.result.text}</Alert>}
905
+ </ConnectionDialog>
906
+ )
907
+ }
908
+
909
+ // ── Reveal modal ───────────────────────────────────────────────────────────────
910
+
911
+ /** Pretty-prints stored JSON; falls back to the raw text when it cannot be parsed so the modal never crashes. */
912
+ function formatJson(raw: string): { text: string; valid: boolean } {
913
+ try {
914
+ return { text: JSON.stringify(JSON.parse(raw), null, 2), valid: true }
915
+ } catch {
916
+ return { text: raw, valid: false }
917
+ }
918
+ }
919
+
920
+ function RevealModal({ secret, onClose }: { secret: RevealedSecret; onClose: () => void }) {
921
+ const [showPassword, setShowPassword] = useState(false)
922
+ const json = secret.type === 'json' ? formatJson(secret.value.value ?? '{}') : null
923
+
924
+ return (
925
+ <Modal title={secret.name} icon={TYPE_ICONS[secret.type] ?? 'key'} onClose={onClose} flush>
926
+ <div className="p-6 space-y-3">
927
+ <div className="flex items-center gap-2">
928
+ <span className="text-[10px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-secondary)' }}>
929
+ {TYPE_LABELS[secret.type]}
930
+ </span>
931
+ </div>
932
+
933
+ {secret.source && (
934
+ <p className="text-xs font-mono break-all" style={{ color: 'var(--m3-secondary)' }}>
935
+ Source: {secret.source.path}{secret.source.key ? ` (${secret.source.key})` : ''}
936
+ </p>
937
+ )}
938
+
939
+ {secret.type === 'userpass' && (
940
+ <>
941
+ <RevealField label="Username" value={secret.value.username ?? ''} mono />
942
+ <RevealField label="Password" value={secret.value.password ?? ''} hidden={!showPassword} mono
943
+ action={
944
+ <button type="button" onClick={() => setShowPassword((p) => !p)} aria-pressed={showPassword} className="btn btn-ghost btn-sm py-1 text-xs">
945
+ {showPassword ? 'Hide' : 'Show'}
946
+ </button>
947
+ }
948
+ />
949
+ </>
950
+ )}
951
+ {(secret.type === 'value') && (
952
+ <RevealField label="Value" value={secret.value.value ?? ''} mono />
953
+ )}
954
+ {json && (
955
+ <div>
956
+ <div className="flex items-center justify-between mb-1.5">
957
+ <p className="text-[10px] uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>JSON</p>
958
+ <CopyButton value={json.text} iconOnly />
959
+ </div>
960
+ {!json.valid && (
961
+ <Alert tone="warning" className="mb-2">The stored value is not valid JSON; showing it as plain text.</Alert>
962
+ )}
963
+ <pre className="text-xs p-3 rounded-xl overflow-auto max-h-64" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)', fontFamily: 'monospace' }}>
964
+ {json.text}
965
+ </pre>
966
+ </div>
967
+ )}
968
+
969
+ </div>
970
+ <ModalFooter>
971
+ <button type="button" onClick={onClose} className="btn btn-secondary">
972
+ Close
973
+ </button>
974
+ </ModalFooter>
975
+ </Modal>
976
+ )
977
+ }
978
+
979
+ function RevealField({ label, value, mono, hidden, action }: { label: string; value: string; mono?: boolean; hidden?: boolean; action?: React.ReactNode }) {
980
+ return (
981
+ <div>
982
+ <div className="flex items-center justify-between mb-1">
983
+ <p className="text-[10px] uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>{label}</p>
984
+ {action}
985
+ </div>
986
+ <div className="px-3 py-2 rounded-xl flex items-center justify-between gap-2" style={{ background: 'var(--m3-surface-container-high)' }}>
987
+ <span className={`text-sm flex-1 break-all ${mono ? 'font-mono' : ''}`} style={{ color: 'var(--m3-on-surface)', filter: hidden ? 'blur(6px)' : 'none', userSelect: hidden ? 'none' : undefined }}>
988
+ {value || '(empty)'}
989
+ </span>
990
+ {!hidden && <CopyButton value={value} label={`Copy ${label.toLowerCase()}`} iconOnly />}
991
+ </div>
992
+ </div>
993
+ )
994
+ }
995
+
996
+ // ── Shared primitives ──────────────────────────────────────────────────────────
997
+
998
+ /** Tests the connection form as it is filled in, before it is saved. */
999
+ function useConnectionTest(connection: ConnectionForm, vaultId?: number) {
1000
+ const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null)
1001
+ const [pending, setPending] = useState(false)
1002
+
1003
+ async function run() {
1004
+ setPending(true)
1005
+ setResult(null)
1006
+ try {
1007
+ const res = await api.post<{ ttlSeconds: number | null }>('/admin/vaults/test-connection', { vaultId, connection: connectionPayload(connection) })
1008
+ setResult({ ok: true, text: res.ttlSeconds ? `Connected. Token valid for ${res.ttlSeconds} s.` : 'Connected.' })
1009
+ } catch (err) {
1010
+ setResult({ ok: false, text: `Connection failed: ${errorMessage(err, 'unknown error')}` })
1011
+ } finally {
1012
+ setPending(false)
1013
+ }
1014
+ }
1015
+
1016
+ return { result, pending, run }
1017
+ }
1018
+
1019
+ function ModalActions({ onClose, loading, submitLabel, test }: { onClose: () => void; loading: boolean; submitLabel: string; test?: ReturnType<typeof useConnectionTest> | undefined }) {
1020
+ return (
1021
+ <ModalFooter
1022
+ start={test ? (
1023
+ <button type="button" onClick={() => void test.run()} disabled={test.pending || loading} className="btn btn-outline">
1024
+ {test.pending
1025
+ ? <><span className="material-symbols-outlined animate-spin" aria-hidden="true">progress_activity</span> Testing…</>
1026
+ : <><span className="material-symbols-outlined" aria-hidden="true">network_check</span> Test connection</>}
1027
+ </button>
1028
+ ) : undefined}
1029
+ >
1030
+ <button type="button" onClick={onClose} className="btn btn-secondary">Cancel</button>
1031
+ <button type="submit" disabled={loading} className="btn btn-primary">
1032
+ {loading ? 'Saving…' : submitLabel}
1033
+ </button>
1034
+ </ModalFooter>
1035
+ )
1036
+ }