@better-forge/ui 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +13 -3
- package/dist/api/client.d.ts +42 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +130 -0
- package/dist/api/client.js.map +1 -0
- package/dist/components/ConfirmIdentityModal.d.ts +18 -0
- package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
- package/dist/components/ConfirmIdentityModal.js +30 -0
- package/dist/components/ConfirmIdentityModal.js.map +1 -0
- package/dist/components/ConfirmModal.d.ts +18 -0
- package/dist/components/ConfirmModal.d.ts.map +1 -0
- package/dist/components/ConfirmModal.js +11 -0
- package/dist/components/ConfirmModal.js.map +1 -0
- package/dist/components/CopyButton.d.ts +11 -0
- package/dist/components/CopyButton.d.ts.map +1 -0
- package/dist/components/CopyButton.js +44 -0
- package/dist/components/CopyButton.js.map +1 -0
- package/dist/components/CredentialSection.d.ts +38 -0
- package/dist/components/CredentialSection.d.ts.map +1 -0
- package/dist/components/CredentialSection.js +69 -0
- package/dist/components/CredentialSection.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +25 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/ModalShell.d.ts +58 -0
- package/dist/components/ModalShell.d.ts.map +1 -0
- package/dist/components/ModalShell.js +92 -0
- package/dist/components/ModalShell.js.map +1 -0
- package/dist/components/ResetTwoFactorModal.d.ts +14 -0
- package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
- package/dist/components/ResetTwoFactorModal.js +16 -0
- package/dist/components/ResetTwoFactorModal.js.map +1 -0
- package/dist/components/SidePanel.d.ts +35 -0
- package/dist/components/SidePanel.d.ts.map +1 -0
- package/dist/components/SidePanel.js +19 -0
- package/dist/components/SidePanel.js.map +1 -0
- package/dist/components/SingleSignOnModal.d.ts +4 -0
- package/dist/components/SingleSignOnModal.d.ts.map +1 -0
- package/dist/components/SingleSignOnModal.js +64 -0
- package/dist/components/SingleSignOnModal.js.map +1 -0
- package/dist/components/SsoConfirmation.d.ts +22 -0
- package/dist/components/SsoConfirmation.d.ts.map +1 -0
- package/dist/components/SsoConfirmation.js +87 -0
- package/dist/components/SsoConfirmation.js.map +1 -0
- package/dist/components/VaultTypePicker.d.ts +13 -0
- package/dist/components/VaultTypePicker.d.ts.map +1 -0
- package/dist/components/VaultTypePicker.js +62 -0
- package/dist/components/VaultTypePicker.js.map +1 -0
- package/dist/components/WhatsNew.d.ts +18 -0
- package/dist/components/WhatsNew.d.ts.map +1 -0
- package/dist/components/WhatsNew.js +50 -0
- package/dist/components/WhatsNew.js.map +1 -0
- package/dist/components/formParts.d.ts +40 -0
- package/dist/components/formParts.d.ts.map +1 -0
- package/dist/components/formParts.js +31 -0
- package/dist/components/formParts.js.map +1 -0
- package/dist/components/ui/Alert.d.ts +15 -0
- package/dist/components/ui/Alert.d.ts.map +1 -0
- package/dist/components/ui/Alert.js +14 -0
- package/dist/components/ui/Alert.js.map +1 -0
- package/dist/components/ui/DataTable.d.ts +52 -0
- package/dist/components/ui/DataTable.d.ts.map +1 -0
- package/dist/components/ui/DataTable.js +112 -0
- package/dist/components/ui/DataTable.js.map +1 -0
- package/dist/components/ui/Field.d.ts +27 -0
- package/dist/components/ui/Field.d.ts.map +1 -0
- package/dist/components/ui/Field.js +25 -0
- package/dist/components/ui/Field.js.map +1 -0
- package/dist/components/ui/PageHeader.d.ts +18 -0
- package/dist/components/ui/PageHeader.d.ts.map +1 -0
- package/dist/components/ui/PageHeader.js +10 -0
- package/dist/components/ui/PageHeader.js.map +1 -0
- package/dist/components/ui/Pagination.d.ts +21 -0
- package/dist/components/ui/Pagination.d.ts.map +1 -0
- package/dist/components/ui/Pagination.js +10 -0
- package/dist/components/ui/Pagination.js.map +1 -0
- package/dist/components/ui/QuickStartPanel.d.ts +21 -0
- package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
- package/dist/components/ui/QuickStartPanel.js +11 -0
- package/dist/components/ui/QuickStartPanel.js.map +1 -0
- package/dist/components/ui/StateViews.d.ts +50 -0
- package/dist/components/ui/StateViews.d.ts.map +1 -0
- package/dist/components/ui/StateViews.js +33 -0
- package/dist/components/ui/StateViews.js.map +1 -0
- package/dist/components/ui/Switch.d.ts +16 -0
- package/dist/components/ui/Switch.d.ts.map +1 -0
- package/dist/components/ui/Switch.js +15 -0
- package/dist/components/ui/Switch.js.map +1 -0
- package/dist/components/ui/Toast.d.ts +20 -0
- package/dist/components/ui/Toast.d.ts.map +1 -0
- package/dist/components/ui/Toast.js +46 -0
- package/dist/components/ui/Toast.js.map +1 -0
- package/dist/components/ui/index.d.ts +10 -0
- package/dist/components/ui/index.d.ts.map +1 -0
- package/dist/components/ui/index.js +10 -0
- package/dist/components/ui/index.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/useDarkMode.d.ts +2 -0
- package/dist/hooks/useDarkMode.d.ts.map +1 -0
- package/dist/hooks/useDarkMode.js +37 -0
- package/dist/hooks/useDarkMode.js.map +1 -0
- package/dist/hooks/useDebouncedValue.d.ts +6 -0
- package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
- package/dist/hooks/useDebouncedValue.js +19 -0
- package/dist/hooks/useDebouncedValue.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changelog.d.ts +32 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +71 -0
- package/dist/lib/changelog.js.map +1 -0
- package/dist/lib/dateFormat.d.ts +24 -0
- package/dist/lib/dateFormat.d.ts.map +1 -0
- package/dist/lib/dateFormat.js +67 -0
- package/dist/lib/dateFormat.js.map +1 -0
- package/dist/navigation.d.ts +5 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +7 -0
- package/dist/navigation.js.map +1 -0
- package/dist/pages/ApiTokens.d.ts +9 -0
- package/dist/pages/ApiTokens.d.ts.map +1 -0
- package/dist/pages/ApiTokens.js +116 -0
- package/dist/pages/ApiTokens.js.map +1 -0
- package/dist/pages/AuditLog.d.ts +14 -0
- package/dist/pages/AuditLog.d.ts.map +1 -0
- package/dist/pages/AuditLog.js +104 -0
- package/dist/pages/AuditLog.js.map +1 -0
- package/dist/pages/Backups.d.ts +2 -0
- package/dist/pages/Backups.d.ts.map +1 -0
- package/dist/pages/Backups.js +118 -0
- package/dist/pages/Backups.js.map +1 -0
- package/dist/pages/ChangePassword.d.ts +2 -0
- package/dist/pages/ChangePassword.d.ts.map +1 -0
- package/dist/pages/ChangePassword.js +40 -0
- package/dist/pages/ChangePassword.js.map +1 -0
- package/dist/pages/Login.d.ts +12 -0
- package/dist/pages/Login.d.ts.map +1 -0
- package/dist/pages/Login.js +69 -0
- package/dist/pages/Login.js.map +1 -0
- package/dist/pages/Settings.d.ts +2 -0
- package/dist/pages/Settings.d.ts.map +1 -0
- package/dist/pages/Settings.js +149 -0
- package/dist/pages/Settings.js.map +1 -0
- package/dist/pages/Setup.d.ts +2 -0
- package/dist/pages/Setup.d.ts.map +1 -0
- package/dist/pages/Setup.js +113 -0
- package/dist/pages/Setup.js.map +1 -0
- package/dist/pages/SsoConfirm.d.ts +3 -0
- package/dist/pages/SsoConfirm.d.ts.map +1 -0
- package/dist/pages/SsoConfirm.js +20 -0
- package/dist/pages/SsoConfirm.js.map +1 -0
- package/dist/pages/SsoTestResult.d.ts +2 -0
- package/dist/pages/SsoTestResult.d.ts.map +1 -0
- package/dist/pages/SsoTestResult.js +49 -0
- package/dist/pages/SsoTestResult.js.map +1 -0
- package/dist/pages/Users.d.ts +22 -0
- package/dist/pages/Users.d.ts.map +1 -0
- package/dist/pages/Users.js +124 -0
- package/dist/pages/Users.js.map +1 -0
- package/dist/pages/Vault.d.ts +2 -0
- package/dist/pages/Vault.d.ts.map +1 -0
- package/dist/pages/Vault.js +372 -0
- package/dist/pages/Vault.js.map +1 -0
- package/dist/shell/AdminShell.d.ts +46 -0
- package/dist/shell/AdminShell.d.ts.map +1 -0
- package/dist/shell/AdminShell.js +76 -0
- package/dist/shell/AdminShell.js.map +1 -0
- package/dist/shell/guards.d.ts +28 -0
- package/dist/shell/guards.d.ts.map +1 -0
- package/dist/shell/guards.js +71 -0
- package/dist/shell/guards.js.map +1 -0
- package/icons.json +170 -0
- package/package.json +71 -6
- package/src/api/client.ts +139 -0
- package/src/components/ConfirmIdentityModal.tsx +82 -0
- package/src/components/ConfirmModal.tsx +83 -0
- package/src/components/CopyButton.tsx +71 -0
- package/src/components/CredentialSection.tsx +270 -0
- package/src/components/ErrorBoundary.tsx +47 -0
- package/src/components/ModalShell.tsx +160 -0
- package/src/components/ResetTwoFactorModal.tsx +74 -0
- package/src/components/SidePanel.tsx +96 -0
- package/src/components/SingleSignOnModal.tsx +161 -0
- package/src/components/SsoConfirmation.tsx +96 -0
- package/src/components/VaultTypePicker.tsx +131 -0
- package/src/components/WhatsNew.tsx +84 -0
- package/src/components/formParts.tsx +59 -0
- package/src/components/ui/Alert.tsx +45 -0
- package/src/components/ui/DataTable.tsx +225 -0
- package/src/components/ui/Field.tsx +63 -0
- package/src/components/ui/PageHeader.tsx +36 -0
- package/src/components/ui/Pagination.tsx +37 -0
- package/src/components/ui/QuickStartPanel.tsx +57 -0
- package/src/components/ui/StateViews.tsx +135 -0
- package/src/components/ui/Switch.tsx +65 -0
- package/src/components/ui/Toast.tsx +104 -0
- package/src/components/ui/index.ts +9 -0
- package/src/config.ts +46 -0
- package/src/hooks/useDarkMode.ts +38 -0
- package/src/hooks/useDebouncedValue.ts +22 -0
- package/src/index.ts +42 -0
- package/src/lib/changelog.ts +93 -0
- package/src/lib/dateFormat.ts +90 -0
- package/src/navigation.ts +6 -0
- package/src/pages/ApiTokens.tsx +361 -0
- package/src/pages/AuditLog.tsx +388 -0
- package/src/pages/Backups.tsx +170 -0
- package/src/pages/ChangePassword.tsx +84 -0
- package/src/pages/Login.tsx +275 -0
- package/src/pages/Settings.tsx +264 -0
- package/src/pages/Setup.tsx +438 -0
- package/src/pages/SsoConfirm.tsx +26 -0
- package/src/pages/SsoTestResult.tsx +104 -0
- package/src/pages/Users.tsx +426 -0
- package/src/pages/Vault.tsx +1036 -0
- package/src/shell/AdminShell.tsx +256 -0
- package/src/shell/guards.tsx +88 -0
- package/styles/base.css +521 -0
- package/styles/fonts/inter-latin-1.woff2 +0 -0
- package/styles/fonts/inter-latin-ext.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
- package/styles/fonts/manrope-latin-5.woff2 +0 -0
- package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
- package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
- package/styles/icons.css +4 -0
- 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
|
+
}
|