@better-forge/ui 0.1.0 → 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.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
|
@@ -1,270 +1,270 @@
|
|
|
1
|
-
import type { ReactNode } from 'react'
|
|
2
|
-
import type { VaultRef } from '@better-forge/contracts'
|
|
3
|
-
import { Field, Note, type VaultPickerProps } from './formParts'
|
|
4
|
-
|
|
5
|
-
interface CredentialSectionProps extends VaultPickerProps {
|
|
6
|
-
vault: VaultRef | undefined
|
|
7
|
-
onVaultChange: (v: VaultRef | undefined) => void
|
|
8
|
-
mappingFields: { key: string; label: string }[]
|
|
9
|
-
/** Optional note shown at the top of the vault/direct section */
|
|
10
|
-
note?: string
|
|
11
|
-
/** What a `value` secret is used as; defaults to the password / client secret. */
|
|
12
|
-
valueLabel?: string
|
|
13
|
-
/** Replaces the note shown for a User / Password secret when its username and password are not a username and password. */
|
|
14
|
-
userpassNote?: string
|
|
15
|
-
/** Restricts the secret dropdown to these types; every type is offered when omitted. */
|
|
16
|
-
secretTypes?: string[]
|
|
17
|
-
/** Direct-input fields — rendered only when not using vault */
|
|
18
|
-
children: ReactNode
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Wraps a set of credential fields with a Direct / Vault source toggle.
|
|
23
|
-
* In direct mode: shows a warning banner + the credential inputs (children).
|
|
24
|
-
* In vault mode: shows vault → secret dropdowns + JSON field mapping if needed.
|
|
25
|
-
*/
|
|
26
|
-
export function CredentialSection({
|
|
27
|
-
vault, onVaultChange,
|
|
28
|
-
vaults, secretsByVault, onLoadSecrets,
|
|
29
|
-
mappingFields, note, valueLabel = 'password / client secret', userpassNote, secretTypes, children,
|
|
30
|
-
}: CredentialSectionProps) {
|
|
31
|
-
const isVault = !!vault
|
|
32
|
-
|
|
33
|
-
const selectedVaultId = vault?.vaultId
|
|
34
|
-
const selectedSecretId = vault?.secretId
|
|
35
|
-
const fieldMapping = vault?.fieldMapping ?? {}
|
|
36
|
-
|
|
37
|
-
const secrets = selectedVaultId ? (secretsByVault[selectedVaultId]?.filter((s) => !secretTypes || secretTypes.includes(s.type)) ?? null) : null
|
|
38
|
-
const selectedSecret = secrets?.find((s) => s.id === selectedSecretId)
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<div className="space-y-3">
|
|
42
|
-
{note && (
|
|
43
|
-
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{note}</p>
|
|
44
|
-
)}
|
|
45
|
-
|
|
46
|
-
{/* Source toggle */}
|
|
47
|
-
<div className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)', width: 'fit-content' }}>
|
|
48
|
-
{(['direct', 'vault'] as const).map((src) => {
|
|
49
|
-
const active = src === 'vault' ? isVault : !isVault
|
|
50
|
-
return (
|
|
51
|
-
<button
|
|
52
|
-
key={src}
|
|
53
|
-
type="button"
|
|
54
|
-
onClick={() => {
|
|
55
|
-
if (src === 'direct') {
|
|
56
|
-
onVaultChange(undefined)
|
|
57
|
-
} else {
|
|
58
|
-
const first = vaults[0]
|
|
59
|
-
if (first) onLoadSecrets(first.id)
|
|
60
|
-
onVaultChange({ vaultId: first?.id ?? 0, secretId: 0, fieldMapping: {} })
|
|
61
|
-
}
|
|
62
|
-
}}
|
|
63
|
-
className={`px-4 py-1.5 text-xs font-medium transition-all ${active ? 'selection-active' : ''}`}
|
|
64
|
-
style={{
|
|
65
|
-
background: active ? 'var(--m3-primary-fixed)' : 'transparent',
|
|
66
|
-
color: active ? 'var(--m3-primary)' : 'var(--m3-secondary)',
|
|
67
|
-
}}
|
|
68
|
-
>
|
|
69
|
-
{src === 'direct' ? 'Direct input' : 'From Vault'}
|
|
70
|
-
</button>
|
|
71
|
-
)
|
|
72
|
-
})}
|
|
73
|
-
</div>
|
|
74
|
-
|
|
75
|
-
{/* Direct input */}
|
|
76
|
-
{!isVault && (
|
|
77
|
-
<>
|
|
78
|
-
<Note tone="warning">For security, store credentials in Vault rather than entering them directly here.</Note>
|
|
79
|
-
{children}
|
|
80
|
-
</>
|
|
81
|
-
)}
|
|
82
|
-
|
|
83
|
-
{/* Vault picker */}
|
|
84
|
-
{isVault && (
|
|
85
|
-
<div className="space-y-3">
|
|
86
|
-
<div className="grid grid-cols-2 gap-3">
|
|
87
|
-
<Field label="Vault">
|
|
88
|
-
<select
|
|
89
|
-
className="input-sig"
|
|
90
|
-
value={selectedVaultId || ''}
|
|
91
|
-
onChange={(e) => {
|
|
92
|
-
const id = Number(e.target.value)
|
|
93
|
-
onLoadSecrets(id)
|
|
94
|
-
onVaultChange({ vaultId: id, secretId: 0, fieldMapping: {} })
|
|
95
|
-
}}
|
|
96
|
-
>
|
|
97
|
-
<option value="">— select vault —</option>
|
|
98
|
-
{vaults.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
99
|
-
</select>
|
|
100
|
-
</Field>
|
|
101
|
-
<Field label="Secret">
|
|
102
|
-
<select
|
|
103
|
-
className="input-sig"
|
|
104
|
-
value={selectedSecretId || ''}
|
|
105
|
-
disabled={!selectedVaultId}
|
|
106
|
-
onChange={(e) => {
|
|
107
|
-
onVaultChange({ vaultId: selectedVaultId!, secretId: Number(e.target.value), fieldMapping: {} })
|
|
108
|
-
}}
|
|
109
|
-
>
|
|
110
|
-
<option value="">— select secret —</option>
|
|
111
|
-
{(secrets ?? []).map((s) => (
|
|
112
|
-
<option key={s.id} value={s.id}>{s.name} ({s.type})</option>
|
|
113
|
-
))}
|
|
114
|
-
</select>
|
|
115
|
-
</Field>
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
|
-
{/* userpass — auto-mapped, no input needed */}
|
|
119
|
-
{selectedSecret?.type === 'userpass' && (
|
|
120
|
-
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
121
|
-
{userpassNote ?? <>Auto-mapped: <strong>username</strong> → username field, <strong>password</strong> → password field.</>}
|
|
122
|
-
</p>
|
|
123
|
-
)}
|
|
124
|
-
|
|
125
|
-
{/* value — single value used as password / client secret */}
|
|
126
|
-
{selectedSecret?.type === 'value' && (
|
|
127
|
-
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
128
|
-
The secret value will be used as the <strong>{valueLabel}</strong>.
|
|
129
|
-
</p>
|
|
130
|
-
)}
|
|
131
|
-
|
|
132
|
-
{/* json — field mapping UI */}
|
|
133
|
-
{selectedSecret?.type === 'json' && selectedSecretId && (
|
|
134
|
-
<div>
|
|
135
|
-
<p className="font-mono text-xs uppercase tracking-wider mb-1" style={{ color: 'var(--m3-secondary)' }}>
|
|
136
|
-
JSON Field Mapping
|
|
137
|
-
</p>
|
|
138
|
-
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
|
|
139
|
-
For each credential, enter the key name from the JSON secret.
|
|
140
|
-
</p>
|
|
141
|
-
<div className="space-y-2">
|
|
142
|
-
{mappingFields.map(({ key, label }) => (
|
|
143
|
-
<div key={key} className="grid items-center gap-3" style={{ gridTemplateColumns: '110px 1fr' }}>
|
|
144
|
-
<span className="text-xs font-medium truncate" style={{ color: 'var(--m3-on-surface-variant)' }}>{label}</span>
|
|
145
|
-
<input
|
|
146
|
-
className="input-sig text-xs"
|
|
147
|
-
placeholder={`JSON key (e.g. "${key}")`}
|
|
148
|
-
value={fieldMapping[key] ?? ''}
|
|
149
|
-
onChange={(e) => {
|
|
150
|
-
onVaultChange({
|
|
151
|
-
vaultId: selectedVaultId!,
|
|
152
|
-
secretId: selectedSecretId,
|
|
153
|
-
fieldMapping: { ...fieldMapping, [key]: e.target.value },
|
|
154
|
-
})
|
|
155
|
-
}}
|
|
156
|
-
/>
|
|
157
|
-
</div>
|
|
158
|
-
))}
|
|
159
|
-
</div>
|
|
160
|
-
</div>
|
|
161
|
-
)}
|
|
162
|
-
|
|
163
|
-
{vaults.length === 0 && (
|
|
164
|
-
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
165
|
-
No vaults found. Create one in the Vault section first.
|
|
166
|
-
</p>
|
|
167
|
-
)}
|
|
168
|
-
</div>
|
|
169
|
-
)}
|
|
170
|
-
</div>
|
|
171
|
-
)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
interface ConnectionStringSectionProps extends VaultPickerProps {
|
|
175
|
-
vault: VaultRef | undefined
|
|
176
|
-
onVaultChange: (v: VaultRef | undefined) => void
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Vault-only picker for SQL database connection strings.
|
|
181
|
-
* Direct input is intentionally not available — a connection string contains credentials.
|
|
182
|
-
*/
|
|
183
|
-
export function ConnectionStringSection({
|
|
184
|
-
vault, onVaultChange, vaults, secretsByVault, onLoadSecrets,
|
|
185
|
-
}: ConnectionStringSectionProps) {
|
|
186
|
-
const selectedVaultId = vault?.vaultId
|
|
187
|
-
const selectedSecretId = vault?.secretId
|
|
188
|
-
const fieldMapping = vault?.fieldMapping ?? {}
|
|
189
|
-
|
|
190
|
-
const secrets = selectedVaultId ? (secretsByVault[selectedVaultId] ?? null) : null
|
|
191
|
-
const selectedSecret = secrets?.find((s) => s.id === selectedSecretId)
|
|
192
|
-
|
|
193
|
-
return (
|
|
194
|
-
<div className="space-y-3">
|
|
195
|
-
<Note tone="info">Connection strings contain credentials and must always be stored in Vault.</Note>
|
|
196
|
-
|
|
197
|
-
<div className="grid grid-cols-2 gap-3">
|
|
198
|
-
<Field label="Vault">
|
|
199
|
-
<select
|
|
200
|
-
className="input-sig"
|
|
201
|
-
value={selectedVaultId || ''}
|
|
202
|
-
onChange={(e) => {
|
|
203
|
-
const id = Number(e.target.value)
|
|
204
|
-
onLoadSecrets(id)
|
|
205
|
-
onVaultChange({ vaultId: id, secretId: 0, fieldMapping: {} })
|
|
206
|
-
}}
|
|
207
|
-
>
|
|
208
|
-
<option value="">— select vault —</option>
|
|
209
|
-
{vaults.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
210
|
-
</select>
|
|
211
|
-
</Field>
|
|
212
|
-
<Field label="Secret">
|
|
213
|
-
<select
|
|
214
|
-
className="input-sig"
|
|
215
|
-
value={selectedSecretId || ''}
|
|
216
|
-
disabled={!selectedVaultId}
|
|
217
|
-
onChange={(e) => {
|
|
218
|
-
onVaultChange({ vaultId: selectedVaultId!, secretId: Number(e.target.value), fieldMapping: {} })
|
|
219
|
-
}}
|
|
220
|
-
>
|
|
221
|
-
<option value="">— select secret —</option>
|
|
222
|
-
{(secrets ?? []).map((s) => (
|
|
223
|
-
<option key={s.id} value={s.id}>{s.name} ({s.type})</option>
|
|
224
|
-
))}
|
|
225
|
-
</select>
|
|
226
|
-
</Field>
|
|
227
|
-
</div>
|
|
228
|
-
|
|
229
|
-
{selectedSecret?.type === 'value' && (
|
|
230
|
-
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
231
|
-
The secret value will be used as the full connection string.
|
|
232
|
-
</p>
|
|
233
|
-
)}
|
|
234
|
-
|
|
235
|
-
{selectedSecret?.type === 'userpass' && (
|
|
236
|
-
<Note tone="warning">
|
|
237
|
-
A <strong>userpass</strong> secret cannot be used as a connection string. Use a <strong>value</strong> or <strong>json</strong> secret instead.
|
|
238
|
-
</Note>
|
|
239
|
-
)}
|
|
240
|
-
|
|
241
|
-
{selectedSecret?.type === 'json' && selectedSecretId && (
|
|
242
|
-
<div>
|
|
243
|
-
<p className="font-mono text-xs uppercase tracking-wider mb-1" style={{ color: 'var(--m3-secondary)' }}>JSON Field Mapping</p>
|
|
244
|
-
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>Enter the JSON key that holds the connection string.</p>
|
|
245
|
-
<div className="grid items-center gap-3" style={{ gridTemplateColumns: '130px 1fr' }}>
|
|
246
|
-
<span className="text-xs font-medium" style={{ color: 'var(--m3-on-surface-variant)' }}>Connection String</span>
|
|
247
|
-
<input
|
|
248
|
-
className="input-sig text-xs"
|
|
249
|
-
placeholder='JSON key (e.g. "connectionString")'
|
|
250
|
-
value={fieldMapping['connectionString'] ?? ''}
|
|
251
|
-
onChange={(e) => {
|
|
252
|
-
onVaultChange({
|
|
253
|
-
vaultId: selectedVaultId!,
|
|
254
|
-
secretId: selectedSecretId,
|
|
255
|
-
fieldMapping: { connectionString: e.target.value },
|
|
256
|
-
})
|
|
257
|
-
}}
|
|
258
|
-
/>
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
)}
|
|
262
|
-
|
|
263
|
-
{vaults.length === 0 && (
|
|
264
|
-
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
265
|
-
No vaults found. Create one in the Vault section first.
|
|
266
|
-
</p>
|
|
267
|
-
)}
|
|
268
|
-
</div>
|
|
269
|
-
)
|
|
270
|
-
}
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { VaultRef } from '@better-forge/contracts'
|
|
3
|
+
import { Field, Note, type VaultPickerProps } from './formParts'
|
|
4
|
+
|
|
5
|
+
interface CredentialSectionProps extends VaultPickerProps {
|
|
6
|
+
vault: VaultRef | undefined
|
|
7
|
+
onVaultChange: (v: VaultRef | undefined) => void
|
|
8
|
+
mappingFields: { key: string; label: string }[]
|
|
9
|
+
/** Optional note shown at the top of the vault/direct section */
|
|
10
|
+
note?: string
|
|
11
|
+
/** What a `value` secret is used as; defaults to the password / client secret. */
|
|
12
|
+
valueLabel?: string
|
|
13
|
+
/** Replaces the note shown for a User / Password secret when its username and password are not a username and password. */
|
|
14
|
+
userpassNote?: string
|
|
15
|
+
/** Restricts the secret dropdown to these types; every type is offered when omitted. */
|
|
16
|
+
secretTypes?: string[]
|
|
17
|
+
/** Direct-input fields — rendered only when not using vault */
|
|
18
|
+
children: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wraps a set of credential fields with a Direct / Vault source toggle.
|
|
23
|
+
* In direct mode: shows a warning banner + the credential inputs (children).
|
|
24
|
+
* In vault mode: shows vault → secret dropdowns + JSON field mapping if needed.
|
|
25
|
+
*/
|
|
26
|
+
export function CredentialSection({
|
|
27
|
+
vault, onVaultChange,
|
|
28
|
+
vaults, secretsByVault, onLoadSecrets,
|
|
29
|
+
mappingFields, note, valueLabel = 'password / client secret', userpassNote, secretTypes, children,
|
|
30
|
+
}: CredentialSectionProps) {
|
|
31
|
+
const isVault = !!vault
|
|
32
|
+
|
|
33
|
+
const selectedVaultId = vault?.vaultId
|
|
34
|
+
const selectedSecretId = vault?.secretId
|
|
35
|
+
const fieldMapping = vault?.fieldMapping ?? {}
|
|
36
|
+
|
|
37
|
+
const secrets = selectedVaultId ? (secretsByVault[selectedVaultId]?.filter((s) => !secretTypes || secretTypes.includes(s.type)) ?? null) : null
|
|
38
|
+
const selectedSecret = secrets?.find((s) => s.id === selectedSecretId)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="space-y-3">
|
|
42
|
+
{note && (
|
|
43
|
+
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{note}</p>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{/* Source toggle */}
|
|
47
|
+
<div className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)', width: 'fit-content' }}>
|
|
48
|
+
{(['direct', 'vault'] as const).map((src) => {
|
|
49
|
+
const active = src === 'vault' ? isVault : !isVault
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
key={src}
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => {
|
|
55
|
+
if (src === 'direct') {
|
|
56
|
+
onVaultChange(undefined)
|
|
57
|
+
} else {
|
|
58
|
+
const first = vaults[0]
|
|
59
|
+
if (first) onLoadSecrets(first.id)
|
|
60
|
+
onVaultChange({ vaultId: first?.id ?? 0, secretId: 0, fieldMapping: {} })
|
|
61
|
+
}
|
|
62
|
+
}}
|
|
63
|
+
className={`px-4 py-1.5 text-xs font-medium transition-all ${active ? 'selection-active' : ''}`}
|
|
64
|
+
style={{
|
|
65
|
+
background: active ? 'var(--m3-primary-fixed)' : 'transparent',
|
|
66
|
+
color: active ? 'var(--m3-primary)' : 'var(--m3-secondary)',
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{src === 'direct' ? 'Direct input' : 'From Vault'}
|
|
70
|
+
</button>
|
|
71
|
+
)
|
|
72
|
+
})}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Direct input */}
|
|
76
|
+
{!isVault && (
|
|
77
|
+
<>
|
|
78
|
+
<Note tone="warning">For security, store credentials in Vault rather than entering them directly here.</Note>
|
|
79
|
+
{children}
|
|
80
|
+
</>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{/* Vault picker */}
|
|
84
|
+
{isVault && (
|
|
85
|
+
<div className="space-y-3">
|
|
86
|
+
<div className="grid grid-cols-2 gap-3">
|
|
87
|
+
<Field label="Vault">
|
|
88
|
+
<select
|
|
89
|
+
className="input-sig"
|
|
90
|
+
value={selectedVaultId || ''}
|
|
91
|
+
onChange={(e) => {
|
|
92
|
+
const id = Number(e.target.value)
|
|
93
|
+
onLoadSecrets(id)
|
|
94
|
+
onVaultChange({ vaultId: id, secretId: 0, fieldMapping: {} })
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<option value="">— select vault —</option>
|
|
98
|
+
{vaults.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
99
|
+
</select>
|
|
100
|
+
</Field>
|
|
101
|
+
<Field label="Secret">
|
|
102
|
+
<select
|
|
103
|
+
className="input-sig"
|
|
104
|
+
value={selectedSecretId || ''}
|
|
105
|
+
disabled={!selectedVaultId}
|
|
106
|
+
onChange={(e) => {
|
|
107
|
+
onVaultChange({ vaultId: selectedVaultId!, secretId: Number(e.target.value), fieldMapping: {} })
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<option value="">— select secret —</option>
|
|
111
|
+
{(secrets ?? []).map((s) => (
|
|
112
|
+
<option key={s.id} value={s.id}>{s.name} ({s.type})</option>
|
|
113
|
+
))}
|
|
114
|
+
</select>
|
|
115
|
+
</Field>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* userpass — auto-mapped, no input needed */}
|
|
119
|
+
{selectedSecret?.type === 'userpass' && (
|
|
120
|
+
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
121
|
+
{userpassNote ?? <>Auto-mapped: <strong>username</strong> → username field, <strong>password</strong> → password field.</>}
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{/* value — single value used as password / client secret */}
|
|
126
|
+
{selectedSecret?.type === 'value' && (
|
|
127
|
+
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
128
|
+
The secret value will be used as the <strong>{valueLabel}</strong>.
|
|
129
|
+
</p>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* json — field mapping UI */}
|
|
133
|
+
{selectedSecret?.type === 'json' && selectedSecretId && (
|
|
134
|
+
<div>
|
|
135
|
+
<p className="font-mono text-xs uppercase tracking-wider mb-1" style={{ color: 'var(--m3-secondary)' }}>
|
|
136
|
+
JSON Field Mapping
|
|
137
|
+
</p>
|
|
138
|
+
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
|
|
139
|
+
For each credential, enter the key name from the JSON secret.
|
|
140
|
+
</p>
|
|
141
|
+
<div className="space-y-2">
|
|
142
|
+
{mappingFields.map(({ key, label }) => (
|
|
143
|
+
<div key={key} className="grid items-center gap-3" style={{ gridTemplateColumns: '110px 1fr' }}>
|
|
144
|
+
<span className="text-xs font-medium truncate" style={{ color: 'var(--m3-on-surface-variant)' }}>{label}</span>
|
|
145
|
+
<input
|
|
146
|
+
className="input-sig text-xs"
|
|
147
|
+
placeholder={`JSON key (e.g. "${key}")`}
|
|
148
|
+
value={fieldMapping[key] ?? ''}
|
|
149
|
+
onChange={(e) => {
|
|
150
|
+
onVaultChange({
|
|
151
|
+
vaultId: selectedVaultId!,
|
|
152
|
+
secretId: selectedSecretId,
|
|
153
|
+
fieldMapping: { ...fieldMapping, [key]: e.target.value },
|
|
154
|
+
})
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{vaults.length === 0 && (
|
|
164
|
+
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
165
|
+
No vaults found. Create one in the Vault section first.
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ConnectionStringSectionProps extends VaultPickerProps {
|
|
175
|
+
vault: VaultRef | undefined
|
|
176
|
+
onVaultChange: (v: VaultRef | undefined) => void
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Vault-only picker for SQL database connection strings.
|
|
181
|
+
* Direct input is intentionally not available — a connection string contains credentials.
|
|
182
|
+
*/
|
|
183
|
+
export function ConnectionStringSection({
|
|
184
|
+
vault, onVaultChange, vaults, secretsByVault, onLoadSecrets,
|
|
185
|
+
}: ConnectionStringSectionProps) {
|
|
186
|
+
const selectedVaultId = vault?.vaultId
|
|
187
|
+
const selectedSecretId = vault?.secretId
|
|
188
|
+
const fieldMapping = vault?.fieldMapping ?? {}
|
|
189
|
+
|
|
190
|
+
const secrets = selectedVaultId ? (secretsByVault[selectedVaultId] ?? null) : null
|
|
191
|
+
const selectedSecret = secrets?.find((s) => s.id === selectedSecretId)
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="space-y-3">
|
|
195
|
+
<Note tone="info">Connection strings contain credentials and must always be stored in Vault.</Note>
|
|
196
|
+
|
|
197
|
+
<div className="grid grid-cols-2 gap-3">
|
|
198
|
+
<Field label="Vault">
|
|
199
|
+
<select
|
|
200
|
+
className="input-sig"
|
|
201
|
+
value={selectedVaultId || ''}
|
|
202
|
+
onChange={(e) => {
|
|
203
|
+
const id = Number(e.target.value)
|
|
204
|
+
onLoadSecrets(id)
|
|
205
|
+
onVaultChange({ vaultId: id, secretId: 0, fieldMapping: {} })
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<option value="">— select vault —</option>
|
|
209
|
+
{vaults.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
210
|
+
</select>
|
|
211
|
+
</Field>
|
|
212
|
+
<Field label="Secret">
|
|
213
|
+
<select
|
|
214
|
+
className="input-sig"
|
|
215
|
+
value={selectedSecretId || ''}
|
|
216
|
+
disabled={!selectedVaultId}
|
|
217
|
+
onChange={(e) => {
|
|
218
|
+
onVaultChange({ vaultId: selectedVaultId!, secretId: Number(e.target.value), fieldMapping: {} })
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<option value="">— select secret —</option>
|
|
222
|
+
{(secrets ?? []).map((s) => (
|
|
223
|
+
<option key={s.id} value={s.id}>{s.name} ({s.type})</option>
|
|
224
|
+
))}
|
|
225
|
+
</select>
|
|
226
|
+
</Field>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{selectedSecret?.type === 'value' && (
|
|
230
|
+
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
231
|
+
The secret value will be used as the full connection string.
|
|
232
|
+
</p>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{selectedSecret?.type === 'userpass' && (
|
|
236
|
+
<Note tone="warning">
|
|
237
|
+
A <strong>userpass</strong> secret cannot be used as a connection string. Use a <strong>value</strong> or <strong>json</strong> secret instead.
|
|
238
|
+
</Note>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{selectedSecret?.type === 'json' && selectedSecretId && (
|
|
242
|
+
<div>
|
|
243
|
+
<p className="font-mono text-xs uppercase tracking-wider mb-1" style={{ color: 'var(--m3-secondary)' }}>JSON Field Mapping</p>
|
|
244
|
+
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>Enter the JSON key that holds the connection string.</p>
|
|
245
|
+
<div className="grid items-center gap-3" style={{ gridTemplateColumns: '130px 1fr' }}>
|
|
246
|
+
<span className="text-xs font-medium" style={{ color: 'var(--m3-on-surface-variant)' }}>Connection String</span>
|
|
247
|
+
<input
|
|
248
|
+
className="input-sig text-xs"
|
|
249
|
+
placeholder='JSON key (e.g. "connectionString")'
|
|
250
|
+
value={fieldMapping['connectionString'] ?? ''}
|
|
251
|
+
onChange={(e) => {
|
|
252
|
+
onVaultChange({
|
|
253
|
+
vaultId: selectedVaultId!,
|
|
254
|
+
secretId: selectedSecretId,
|
|
255
|
+
fieldMapping: { connectionString: e.target.value },
|
|
256
|
+
})
|
|
257
|
+
}}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{vaults.length === 0 && (
|
|
264
|
+
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
265
|
+
No vaults found. Create one in the Vault section first.
|
|
266
|
+
</p>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|
2
|
-
|
|
3
|
-
/** A lazy chunk that failed to load: after a deploy the old file names are gone, so the page needs a reload. */
|
|
4
|
-
export function isChunkLoadError(error: unknown): boolean {
|
|
5
|
-
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error)
|
|
6
|
-
return /ChunkLoadError|Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i.test(message)
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface Props {
|
|
10
|
-
children: ReactNode
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface State {
|
|
14
|
-
error: unknown
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Catches errors thrown while rendering (and by lazy imports) so the screen is not left blank; offers a reload. */
|
|
18
|
-
export class ErrorBoundary extends Component<Props, State> {
|
|
19
|
-
override state: State = { error: null }
|
|
20
|
-
|
|
21
|
-
static getDerivedStateFromError(error: unknown): State {
|
|
22
|
-
return { error: error ?? new Error('Unknown error') }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
26
|
-
console.error(error, info.componentStack)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
override render(): ReactNode {
|
|
30
|
-
const { error } = this.state
|
|
31
|
-
if (!error) return this.props.children
|
|
32
|
-
const stale = isChunkLoadError(error)
|
|
33
|
-
return (
|
|
34
|
-
<div role="alert" className="min-h-screen flex flex-col items-center justify-center gap-3 px-6 text-center" style={{ background: 'var(--m3-surface)', color: 'var(--m3-on-surface)' }}>
|
|
35
|
-
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '32px', color: 'var(--m3-down)' }}>error</span>
|
|
36
|
-
<h1 className="font-headline text-lg font-bold">{stale ? 'A new version is available' : 'Something went wrong'}</h1>
|
|
37
|
-
<p className="text-sm max-w-md" style={{ color: 'var(--m3-secondary)' }}>
|
|
38
|
-
{stale ? 'This page was updated while it was open. Reload to continue.' : 'The page could not be displayed. Reloading usually fixes it.'}
|
|
39
|
-
</p>
|
|
40
|
-
<button type="button" onClick={() => window.location.reload()} className="btn btn-secondary btn-sm">
|
|
41
|
-
<span className="material-symbols-outlined" aria-hidden="true">refresh</span>
|
|
42
|
-
Reload
|
|
43
|
-
</button>
|
|
44
|
-
</div>
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** A lazy chunk that failed to load: after a deploy the old file names are gone, so the page needs a reload. */
|
|
4
|
+
export function isChunkLoadError(error: unknown): boolean {
|
|
5
|
+
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error)
|
|
6
|
+
return /ChunkLoadError|Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i.test(message)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface State {
|
|
14
|
+
error: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Catches errors thrown while rendering (and by lazy imports) so the screen is not left blank; offers a reload. */
|
|
18
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
19
|
+
override state: State = { error: null }
|
|
20
|
+
|
|
21
|
+
static getDerivedStateFromError(error: unknown): State {
|
|
22
|
+
return { error: error ?? new Error('Unknown error') }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
26
|
+
console.error(error, info.componentStack)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override render(): ReactNode {
|
|
30
|
+
const { error } = this.state
|
|
31
|
+
if (!error) return this.props.children
|
|
32
|
+
const stale = isChunkLoadError(error)
|
|
33
|
+
return (
|
|
34
|
+
<div role="alert" className="min-h-screen flex flex-col items-center justify-center gap-3 px-6 text-center" style={{ background: 'var(--m3-surface)', color: 'var(--m3-on-surface)' }}>
|
|
35
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '32px', color: 'var(--m3-down)' }}>error</span>
|
|
36
|
+
<h1 className="font-headline text-lg font-bold">{stale ? 'A new version is available' : 'Something went wrong'}</h1>
|
|
37
|
+
<p className="text-sm max-w-md" style={{ color: 'var(--m3-secondary)' }}>
|
|
38
|
+
{stale ? 'This page was updated while it was open. Reload to continue.' : 'The page could not be displayed. Reloading usually fixes it.'}
|
|
39
|
+
</p>
|
|
40
|
+
<button type="button" onClick={() => window.location.reload()} className="btn btn-secondary btn-sm">
|
|
41
|
+
<span className="material-symbols-outlined" aria-hidden="true">refresh</span>
|
|
42
|
+
Reload
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|