@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -3
  3. package/dist/api/client.d.ts +42 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +130 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/components/ConfirmIdentityModal.d.ts +18 -0
  8. package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
  9. package/dist/components/ConfirmIdentityModal.js +30 -0
  10. package/dist/components/ConfirmIdentityModal.js.map +1 -0
  11. package/dist/components/ConfirmModal.d.ts +18 -0
  12. package/dist/components/ConfirmModal.d.ts.map +1 -0
  13. package/dist/components/ConfirmModal.js +11 -0
  14. package/dist/components/ConfirmModal.js.map +1 -0
  15. package/dist/components/CopyButton.d.ts +11 -0
  16. package/dist/components/CopyButton.d.ts.map +1 -0
  17. package/dist/components/CopyButton.js +44 -0
  18. package/dist/components/CopyButton.js.map +1 -0
  19. package/dist/components/CredentialSection.d.ts +38 -0
  20. package/dist/components/CredentialSection.d.ts.map +1 -0
  21. package/dist/components/CredentialSection.js +69 -0
  22. package/dist/components/CredentialSection.js.map +1 -0
  23. package/dist/components/ErrorBoundary.d.ts +18 -0
  24. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  25. package/dist/components/ErrorBoundary.js +25 -0
  26. package/dist/components/ErrorBoundary.js.map +1 -0
  27. package/dist/components/ModalShell.d.ts +58 -0
  28. package/dist/components/ModalShell.d.ts.map +1 -0
  29. package/dist/components/ModalShell.js +92 -0
  30. package/dist/components/ModalShell.js.map +1 -0
  31. package/dist/components/ResetTwoFactorModal.d.ts +14 -0
  32. package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
  33. package/dist/components/ResetTwoFactorModal.js +16 -0
  34. package/dist/components/ResetTwoFactorModal.js.map +1 -0
  35. package/dist/components/SidePanel.d.ts +35 -0
  36. package/dist/components/SidePanel.d.ts.map +1 -0
  37. package/dist/components/SidePanel.js +19 -0
  38. package/dist/components/SidePanel.js.map +1 -0
  39. package/dist/components/SingleSignOnModal.d.ts +4 -0
  40. package/dist/components/SingleSignOnModal.d.ts.map +1 -0
  41. package/dist/components/SingleSignOnModal.js +64 -0
  42. package/dist/components/SingleSignOnModal.js.map +1 -0
  43. package/dist/components/SsoConfirmation.d.ts +22 -0
  44. package/dist/components/SsoConfirmation.d.ts.map +1 -0
  45. package/dist/components/SsoConfirmation.js +87 -0
  46. package/dist/components/SsoConfirmation.js.map +1 -0
  47. package/dist/components/VaultTypePicker.d.ts +13 -0
  48. package/dist/components/VaultTypePicker.d.ts.map +1 -0
  49. package/dist/components/VaultTypePicker.js +62 -0
  50. package/dist/components/VaultTypePicker.js.map +1 -0
  51. package/dist/components/WhatsNew.d.ts +18 -0
  52. package/dist/components/WhatsNew.d.ts.map +1 -0
  53. package/dist/components/WhatsNew.js +50 -0
  54. package/dist/components/WhatsNew.js.map +1 -0
  55. package/dist/components/formParts.d.ts +40 -0
  56. package/dist/components/formParts.d.ts.map +1 -0
  57. package/dist/components/formParts.js +31 -0
  58. package/dist/components/formParts.js.map +1 -0
  59. package/dist/components/ui/Alert.d.ts +15 -0
  60. package/dist/components/ui/Alert.d.ts.map +1 -0
  61. package/dist/components/ui/Alert.js +14 -0
  62. package/dist/components/ui/Alert.js.map +1 -0
  63. package/dist/components/ui/DataTable.d.ts +52 -0
  64. package/dist/components/ui/DataTable.d.ts.map +1 -0
  65. package/dist/components/ui/DataTable.js +112 -0
  66. package/dist/components/ui/DataTable.js.map +1 -0
  67. package/dist/components/ui/Field.d.ts +27 -0
  68. package/dist/components/ui/Field.d.ts.map +1 -0
  69. package/dist/components/ui/Field.js +25 -0
  70. package/dist/components/ui/Field.js.map +1 -0
  71. package/dist/components/ui/PageHeader.d.ts +18 -0
  72. package/dist/components/ui/PageHeader.d.ts.map +1 -0
  73. package/dist/components/ui/PageHeader.js +10 -0
  74. package/dist/components/ui/PageHeader.js.map +1 -0
  75. package/dist/components/ui/Pagination.d.ts +21 -0
  76. package/dist/components/ui/Pagination.d.ts.map +1 -0
  77. package/dist/components/ui/Pagination.js +10 -0
  78. package/dist/components/ui/Pagination.js.map +1 -0
  79. package/dist/components/ui/QuickStartPanel.d.ts +21 -0
  80. package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
  81. package/dist/components/ui/QuickStartPanel.js +11 -0
  82. package/dist/components/ui/QuickStartPanel.js.map +1 -0
  83. package/dist/components/ui/StateViews.d.ts +50 -0
  84. package/dist/components/ui/StateViews.d.ts.map +1 -0
  85. package/dist/components/ui/StateViews.js +33 -0
  86. package/dist/components/ui/StateViews.js.map +1 -0
  87. package/dist/components/ui/Switch.d.ts +16 -0
  88. package/dist/components/ui/Switch.d.ts.map +1 -0
  89. package/dist/components/ui/Switch.js +15 -0
  90. package/dist/components/ui/Switch.js.map +1 -0
  91. package/dist/components/ui/Toast.d.ts +20 -0
  92. package/dist/components/ui/Toast.d.ts.map +1 -0
  93. package/dist/components/ui/Toast.js +46 -0
  94. package/dist/components/ui/Toast.js.map +1 -0
  95. package/dist/components/ui/index.d.ts +10 -0
  96. package/dist/components/ui/index.d.ts.map +1 -0
  97. package/dist/components/ui/index.js +10 -0
  98. package/dist/components/ui/index.js.map +1 -0
  99. package/dist/config.d.ts +46 -0
  100. package/dist/config.d.ts.map +1 -0
  101. package/dist/config.js +12 -0
  102. package/dist/config.js.map +1 -0
  103. package/dist/hooks/useDarkMode.d.ts +2 -0
  104. package/dist/hooks/useDarkMode.d.ts.map +1 -0
  105. package/dist/hooks/useDarkMode.js +37 -0
  106. package/dist/hooks/useDarkMode.js.map +1 -0
  107. package/dist/hooks/useDebouncedValue.d.ts +6 -0
  108. package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
  109. package/dist/hooks/useDebouncedValue.js +19 -0
  110. package/dist/hooks/useDebouncedValue.js.map +1 -0
  111. package/dist/index.d.ts +36 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +36 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/lib/changelog.d.ts +32 -0
  116. package/dist/lib/changelog.d.ts.map +1 -0
  117. package/dist/lib/changelog.js +71 -0
  118. package/dist/lib/changelog.js.map +1 -0
  119. package/dist/lib/dateFormat.d.ts +24 -0
  120. package/dist/lib/dateFormat.d.ts.map +1 -0
  121. package/dist/lib/dateFormat.js +67 -0
  122. package/dist/lib/dateFormat.js.map +1 -0
  123. package/dist/navigation.d.ts +5 -0
  124. package/dist/navigation.d.ts.map +1 -0
  125. package/dist/navigation.js +7 -0
  126. package/dist/navigation.js.map +1 -0
  127. package/dist/pages/ApiTokens.d.ts +9 -0
  128. package/dist/pages/ApiTokens.d.ts.map +1 -0
  129. package/dist/pages/ApiTokens.js +116 -0
  130. package/dist/pages/ApiTokens.js.map +1 -0
  131. package/dist/pages/AuditLog.d.ts +14 -0
  132. package/dist/pages/AuditLog.d.ts.map +1 -0
  133. package/dist/pages/AuditLog.js +104 -0
  134. package/dist/pages/AuditLog.js.map +1 -0
  135. package/dist/pages/Backups.d.ts +2 -0
  136. package/dist/pages/Backups.d.ts.map +1 -0
  137. package/dist/pages/Backups.js +118 -0
  138. package/dist/pages/Backups.js.map +1 -0
  139. package/dist/pages/ChangePassword.d.ts +2 -0
  140. package/dist/pages/ChangePassword.d.ts.map +1 -0
  141. package/dist/pages/ChangePassword.js +40 -0
  142. package/dist/pages/ChangePassword.js.map +1 -0
  143. package/dist/pages/Login.d.ts +12 -0
  144. package/dist/pages/Login.d.ts.map +1 -0
  145. package/dist/pages/Login.js +69 -0
  146. package/dist/pages/Login.js.map +1 -0
  147. package/dist/pages/Settings.d.ts +2 -0
  148. package/dist/pages/Settings.d.ts.map +1 -0
  149. package/dist/pages/Settings.js +149 -0
  150. package/dist/pages/Settings.js.map +1 -0
  151. package/dist/pages/Setup.d.ts +2 -0
  152. package/dist/pages/Setup.d.ts.map +1 -0
  153. package/dist/pages/Setup.js +113 -0
  154. package/dist/pages/Setup.js.map +1 -0
  155. package/dist/pages/SsoConfirm.d.ts +3 -0
  156. package/dist/pages/SsoConfirm.d.ts.map +1 -0
  157. package/dist/pages/SsoConfirm.js +20 -0
  158. package/dist/pages/SsoConfirm.js.map +1 -0
  159. package/dist/pages/SsoTestResult.d.ts +2 -0
  160. package/dist/pages/SsoTestResult.d.ts.map +1 -0
  161. package/dist/pages/SsoTestResult.js +49 -0
  162. package/dist/pages/SsoTestResult.js.map +1 -0
  163. package/dist/pages/Users.d.ts +22 -0
  164. package/dist/pages/Users.d.ts.map +1 -0
  165. package/dist/pages/Users.js +124 -0
  166. package/dist/pages/Users.js.map +1 -0
  167. package/dist/pages/Vault.d.ts +2 -0
  168. package/dist/pages/Vault.d.ts.map +1 -0
  169. package/dist/pages/Vault.js +372 -0
  170. package/dist/pages/Vault.js.map +1 -0
  171. package/dist/shell/AdminShell.d.ts +46 -0
  172. package/dist/shell/AdminShell.d.ts.map +1 -0
  173. package/dist/shell/AdminShell.js +76 -0
  174. package/dist/shell/AdminShell.js.map +1 -0
  175. package/dist/shell/guards.d.ts +28 -0
  176. package/dist/shell/guards.d.ts.map +1 -0
  177. package/dist/shell/guards.js +71 -0
  178. package/dist/shell/guards.js.map +1 -0
  179. package/icons.json +170 -0
  180. package/package.json +71 -6
  181. package/src/api/client.ts +139 -0
  182. package/src/components/ConfirmIdentityModal.tsx +82 -0
  183. package/src/components/ConfirmModal.tsx +83 -0
  184. package/src/components/CopyButton.tsx +71 -0
  185. package/src/components/CredentialSection.tsx +270 -0
  186. package/src/components/ErrorBoundary.tsx +47 -0
  187. package/src/components/ModalShell.tsx +160 -0
  188. package/src/components/ResetTwoFactorModal.tsx +74 -0
  189. package/src/components/SidePanel.tsx +96 -0
  190. package/src/components/SingleSignOnModal.tsx +161 -0
  191. package/src/components/SsoConfirmation.tsx +96 -0
  192. package/src/components/VaultTypePicker.tsx +131 -0
  193. package/src/components/WhatsNew.tsx +84 -0
  194. package/src/components/formParts.tsx +59 -0
  195. package/src/components/ui/Alert.tsx +45 -0
  196. package/src/components/ui/DataTable.tsx +225 -0
  197. package/src/components/ui/Field.tsx +63 -0
  198. package/src/components/ui/PageHeader.tsx +36 -0
  199. package/src/components/ui/Pagination.tsx +37 -0
  200. package/src/components/ui/QuickStartPanel.tsx +57 -0
  201. package/src/components/ui/StateViews.tsx +135 -0
  202. package/src/components/ui/Switch.tsx +65 -0
  203. package/src/components/ui/Toast.tsx +104 -0
  204. package/src/components/ui/index.ts +9 -0
  205. package/src/config.ts +46 -0
  206. package/src/hooks/useDarkMode.ts +38 -0
  207. package/src/hooks/useDebouncedValue.ts +22 -0
  208. package/src/index.ts +42 -0
  209. package/src/lib/changelog.ts +93 -0
  210. package/src/lib/dateFormat.ts +90 -0
  211. package/src/navigation.ts +6 -0
  212. package/src/pages/ApiTokens.tsx +361 -0
  213. package/src/pages/AuditLog.tsx +388 -0
  214. package/src/pages/Backups.tsx +170 -0
  215. package/src/pages/ChangePassword.tsx +84 -0
  216. package/src/pages/Login.tsx +275 -0
  217. package/src/pages/Settings.tsx +264 -0
  218. package/src/pages/Setup.tsx +438 -0
  219. package/src/pages/SsoConfirm.tsx +26 -0
  220. package/src/pages/SsoTestResult.tsx +104 -0
  221. package/src/pages/Users.tsx +426 -0
  222. package/src/pages/Vault.tsx +1036 -0
  223. package/src/shell/AdminShell.tsx +256 -0
  224. package/src/shell/guards.tsx +88 -0
  225. package/styles/base.css +521 -0
  226. package/styles/fonts/inter-latin-1.woff2 +0 -0
  227. package/styles/fonts/inter-latin-ext.woff2 +0 -0
  228. package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
  229. package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
  230. package/styles/fonts/manrope-latin-5.woff2 +0 -0
  231. package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
  232. package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
  233. package/styles/icons.css +4 -0
  234. package/tailwind-preset.js +79 -0
@@ -0,0 +1,82 @@
1
+ import { useId, useState } from 'react'
2
+ import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
3
+ import { SsoConfirmationNote, isSsoSession, useSsoConfirmation } from './SsoConfirmation'
4
+ import { Alert } from './ui'
5
+
6
+ interface ConfirmIdentityModalProps {
7
+ title: string
8
+ message: string
9
+ confirmLabel: string
10
+ pendingLabel?: string
11
+ danger?: boolean
12
+ /**
13
+ * Runs the action. `currentPassword` is undefined in an SSO session, which is confirmed at the identity
14
+ * provider instead. A rejection is shown in the dialog, which stays open for another try.
15
+ */
16
+ action: (currentPassword: string | undefined) => Promise<unknown>
17
+ /** Called when the action succeeded or the user gave up. */
18
+ onClose: () => void
19
+ }
20
+
21
+ /** Asks the signed-in user to prove it is them (password, or SSO) before a sensitive action goes through. */
22
+ export function ConfirmIdentityModal({ title, message, confirmLabel, pendingLabel = 'Working…', danger = false, action, onClose }: ConfirmIdentityModalProps) {
23
+ const sso = isSsoSession()
24
+ const ssoConfirmation = useSsoConfirmation()
25
+ const [currentPassword, setCurrentPassword] = useState('')
26
+ const [pending, setPending] = useState(false)
27
+ const [error, setError] = useState('')
28
+ const titleId = useId()
29
+ const canConfirm = !pending && (sso || currentPassword.length > 0)
30
+
31
+ async function confirm() {
32
+ setPending(true)
33
+ setError('')
34
+ try {
35
+ await ssoConfirmation.run(() => action(sso ? undefined : currentPassword))
36
+ onClose()
37
+ } catch (err) {
38
+ setError(err instanceof Error && err.message ? err.message : 'Something went wrong. Try again.')
39
+ setPending(false)
40
+ }
41
+ }
42
+
43
+ return (
44
+ <ModalShell onClose={pending ? undefined : onClose}>
45
+ <form
46
+ role="dialog"
47
+ aria-modal="true"
48
+ aria-labelledby={titleId}
49
+ aria-busy={pending || undefined}
50
+ className="rounded-2xl w-full max-w-md"
51
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
52
+ onSubmit={(event) => { event.preventDefault(); if (canConfirm) void confirm() }}
53
+ >
54
+ <ModalHeader icon="shield_lock" titleId={titleId} title={title} onClose={pending ? () => {} : onClose} />
55
+ <div className="p-6 space-y-4">
56
+ <p className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>{message}</p>
57
+ {error && <Alert tone="error">{error}</Alert>}
58
+ {sso ? <SsoConfirmationNote confirmation={ssoConfirmation} /> : (
59
+ <label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
60
+ <span>Your current password</span>
61
+ <input
62
+ type="password"
63
+ autoComplete="current-password"
64
+ autoFocus
65
+ value={currentPassword}
66
+ disabled={pending}
67
+ onChange={(event) => setCurrentPassword(event.target.value)}
68
+ className="input-sig w-full"
69
+ />
70
+ </label>
71
+ )}
72
+ </div>
73
+ <ModalFooter>
74
+ <button type="button" onClick={onClose} disabled={pending} className="btn btn-secondary">Cancel</button>
75
+ <button type="submit" disabled={!canConfirm} className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}>
76
+ {pending ? pendingLabel : confirmLabel}
77
+ </button>
78
+ </ModalFooter>
79
+ </form>
80
+ </ModalShell>
81
+ )
82
+ }
@@ -0,0 +1,83 @@
1
+ import { useId, useState } from 'react'
2
+ import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
3
+
4
+ interface ConfirmModalProps {
5
+ title: string
6
+ message: string
7
+ confirmLabel?: string | undefined
8
+ danger?: boolean | undefined
9
+ confirmationText?: string | undefined
10
+ /**
11
+ * While true the modal stays open with both buttons disabled and the confirm button reading
12
+ * `pendingLabel`, so callers can keep it up until their mutation settles and close it in onSuccess.
13
+ */
14
+ pending?: boolean | undefined
15
+ pendingLabel?: string | undefined
16
+ onConfirm: () => void
17
+ onCancel: () => void
18
+ }
19
+
20
+ export function ConfirmModal({
21
+ title,
22
+ message,
23
+ confirmLabel = 'Delete',
24
+ danger = true,
25
+ confirmationText,
26
+ pending = false,
27
+ pendingLabel = 'Working…',
28
+ onConfirm,
29
+ onCancel,
30
+ }: ConfirmModalProps) {
31
+ const [typedConfirmation, setTypedConfirmation] = useState('')
32
+ const titleId = useId()
33
+ const messageId = useId()
34
+ const canConfirm = (!confirmationText || typedConfirmation === confirmationText) && !pending
35
+
36
+ return (
37
+ <ModalShell onClose={pending ? undefined : onCancel}>
38
+ <div
39
+ role="dialog"
40
+ aria-modal="true"
41
+ aria-labelledby={titleId}
42
+ aria-describedby={messageId}
43
+ aria-busy={pending || undefined}
44
+ className="rounded-2xl w-full max-w-sm"
45
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
46
+ >
47
+ <ModalHeader icon={danger ? 'warning' : 'help'} titleId={titleId} title={title} onClose={pending ? () => {} : onCancel} />
48
+ <div className="p-6 space-y-4">
49
+ <p id={messageId} className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
50
+ {message}
51
+ </p>
52
+ {confirmationText && (
53
+ <label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
54
+ <span>Type <strong className="font-mono">{confirmationText}</strong> to confirm:</span>
55
+ <input
56
+ autoFocus
57
+ value={typedConfirmation}
58
+ disabled={pending}
59
+ autoComplete="off"
60
+ spellCheck={false}
61
+ onChange={(event) => setTypedConfirmation(event.target.value)}
62
+ className="input-m3"
63
+ />
64
+ </label>
65
+ )}
66
+ </div>
67
+ <ModalFooter>
68
+ <button type="button" onClick={onCancel} disabled={pending} className="btn btn-secondary">
69
+ Cancel
70
+ </button>
71
+ <button
72
+ type="button"
73
+ onClick={onConfirm}
74
+ disabled={!canConfirm}
75
+ className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
76
+ >
77
+ {pending ? pendingLabel : confirmLabel}
78
+ </button>
79
+ </ModalFooter>
80
+ </div>
81
+ </ModalShell>
82
+ )
83
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ interface CopyButtonProps {
4
+ value: string
5
+ label?: string | undefined
6
+ copiedLabel?: string | undefined
7
+ failedLabel?: string | undefined
8
+ /** Show only the icon; the label still names the button for assistive technology. */
9
+ iconOnly?: boolean | undefined
10
+ }
11
+
12
+ type CopyState = 'idle' | 'copied' | 'failed'
13
+
14
+ /** Clipboard API first; outside a secure context (plain-HTTP deployments) fall back to a hidden textarea. */
15
+ async function writeClipboard(value: string) {
16
+ if (navigator.clipboard?.writeText) {
17
+ await navigator.clipboard.writeText(value)
18
+ return
19
+ }
20
+ const textarea = document.createElement('textarea')
21
+ textarea.value = value
22
+ textarea.setAttribute('readonly', '')
23
+ textarea.style.position = 'fixed'
24
+ textarea.style.opacity = '0'
25
+ document.body.appendChild(textarea)
26
+ textarea.select()
27
+ try {
28
+ if (!document.execCommand('copy')) throw new Error('Copy command was rejected')
29
+ } finally {
30
+ textarea.remove()
31
+ }
32
+ }
33
+
34
+ export function CopyButton({ value, label = 'Copy', copiedLabel = 'Copied!', failedLabel = 'Copy failed', iconOnly = false }: CopyButtonProps) {
35
+ const [state, setState] = useState<CopyState>('idle')
36
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
37
+
38
+ useEffect(() => () => clearTimeout(timer.current), [])
39
+
40
+ async function copy() {
41
+ let next: CopyState
42
+ try {
43
+ await writeClipboard(value)
44
+ next = 'copied'
45
+ } catch {
46
+ next = 'failed'
47
+ }
48
+ setState(next)
49
+ clearTimeout(timer.current)
50
+ timer.current = setTimeout(() => setState('idle'), 2000)
51
+ }
52
+
53
+ const text = state === 'copied' ? copiedLabel : state === 'failed' ? failedLabel : label
54
+
55
+ return (
56
+ <button
57
+ type="button"
58
+ onClick={() => void copy()}
59
+ aria-label={text}
60
+ title={iconOnly ? text : undefined}
61
+ className="copy-feedback-button flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg font-semibold"
62
+ data-copied={state === 'copied' || undefined}
63
+ data-failed={state === 'failed' || undefined}
64
+ >
65
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '14px' }}>
66
+ {state === 'copied' ? 'check' : state === 'failed' ? 'error' : 'content_copy'}
67
+ </span>
68
+ {!iconOnly && <span aria-live="polite">{text}</span>}
69
+ </button>
70
+ )
71
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }