@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,131 @@
1
+ import { useEffect, useId, useRef, useState } from 'react'
2
+ import { uiConfig } from '../config'
3
+
4
+ export type VaultTypeValue = 'local' | 'hashicorp'
5
+
6
+ /** Only a local vault stores values; every external vault holds references to secrets kept in the provider. */
7
+ export const isReferenceVault = (type: VaultTypeValue): boolean => type !== 'local'
8
+
9
+ /** Icon per vault type, shared by the picker and the vault list so they always match. */
10
+ export const VAULT_TYPE_ICONS: Record<VaultTypeValue, string> = { local: 'storage', hashicorp: 'vpn_key' }
11
+
12
+ interface VaultTypeOption {
13
+ value: VaultTypeValue | null
14
+ label: string
15
+ hint: string
16
+ icon: string
17
+ }
18
+
19
+ /** Vault types in picker order; entries with a null value are announced but cannot be selected yet. */
20
+ const vaultTypeOptions = (): VaultTypeOption[] => [
21
+ { value: 'local', label: 'Local', hint: `Encrypted in the ${uiConfig().texts.appName} database`, icon: VAULT_TYPE_ICONS.local },
22
+ { value: 'hashicorp', label: 'HashiCorp Vault', hint: 'Read secrets from your own HashiCorp Vault', icon: VAULT_TYPE_ICONS.hashicorp },
23
+ { value: null, label: 'Azure Key Vault', hint: 'Coming soon', icon: 'cloud' },
24
+ { value: null, label: 'GCP Secret Manager', hint: 'Coming soon', icon: 'cloud' },
25
+ { value: null, label: 'AWS Secrets Manager', hint: 'Coming soon', icon: 'cloud' },
26
+ ]
27
+
28
+ interface Props {
29
+ value: VaultTypeValue
30
+ onChange: (type: VaultTypeValue) => void
31
+ }
32
+
33
+ function TypeIcon({ icon }: { icon: string }) {
34
+ return (
35
+ <span className="flex-none w-8 h-8 rounded-lg grid place-items-center" style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}>
36
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 20 }}>{icon}</span>
37
+ </span>
38
+ )
39
+ }
40
+
41
+ /** Vault type list in the same dropdown style as the other type pickers. */
42
+ export function VaultTypePicker({ value, onChange }: Props) {
43
+ const [open, setOpen] = useState(false)
44
+ const rootRef = useRef<HTMLDivElement>(null)
45
+ const buttonRef = useRef<HTMLButtonElement>(null)
46
+ const labelId = useId()
47
+ const buttonId = useId()
48
+ const selected = vaultTypeOptions().find((t) => t.value === value)!
49
+
50
+ useEffect(() => {
51
+ if (!open) return
52
+ function onPointerDown(e: MouseEvent) {
53
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
54
+ }
55
+ function onKeyDown(e: KeyboardEvent) {
56
+ if (e.key !== 'Escape') return
57
+ e.stopPropagation()
58
+ setOpen(false)
59
+ buttonRef.current?.focus()
60
+ }
61
+ document.addEventListener('mousedown', onPointerDown)
62
+ document.addEventListener('keydown', onKeyDown, true)
63
+ return () => {
64
+ document.removeEventListener('mousedown', onPointerDown)
65
+ document.removeEventListener('keydown', onKeyDown, true)
66
+ }
67
+ }, [open])
68
+
69
+ function pick(option: VaultTypeOption) {
70
+ if (!option.value) return
71
+ setOpen(false)
72
+ buttonRef.current?.focus()
73
+ if (option.value !== value) onChange(option.value)
74
+ }
75
+
76
+ return (
77
+ <div ref={rootRef} className="relative">
78
+ <span id={labelId} className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Type</span>
79
+ <button
80
+ ref={buttonRef}
81
+ id={buttonId}
82
+ type="button"
83
+ aria-haspopup="listbox"
84
+ aria-expanded={open}
85
+ aria-labelledby={`${labelId} ${buttonId}`}
86
+ onClick={() => setOpen((o) => !o)}
87
+ className="w-full flex items-center gap-3 text-left rounded-xl px-3 py-2 focus-ring"
88
+ style={{ background: 'var(--m3-surface-container-lowest)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }}
89
+ >
90
+ <TypeIcon icon={selected.icon} />
91
+ <span className="min-w-0 flex-1">
92
+ <span className="block text-sm font-semibold">{selected.label}</span>
93
+ <span className="block text-xs truncate" style={{ color: 'var(--m3-secondary)' }}>{selected.hint}</span>
94
+ </span>
95
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ color: 'var(--m3-secondary)' }}>unfold_more</span>
96
+ </button>
97
+
98
+ {open && (
99
+ <div
100
+ className="absolute z-10 left-0 right-0 mt-1.5 rounded-xl overflow-hidden shadow-xl"
101
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
102
+ >
103
+ <div role="listbox" aria-labelledby={labelId} className="max-h-72 overflow-auto p-1.5">
104
+ {vaultTypeOptions().map((t) => {
105
+ const disabled = t.value === null
106
+ const isSelected = t.value === value
107
+ return (
108
+ <button
109
+ key={t.label}
110
+ type="button"
111
+ role="option"
112
+ aria-selected={isSelected}
113
+ aria-disabled={disabled}
114
+ onClick={() => pick(t)}
115
+ className={`w-full flex items-center gap-3 text-left rounded-lg px-2 py-1.5 focus-ring ${disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--m3-surface-container)]'} ${isSelected ? 'selection-active' : ''}`}
116
+ style={{ border: '1px solid transparent' }}
117
+ >
118
+ <TypeIcon icon={t.icon} />
119
+ <span className="min-w-0 flex-1">
120
+ <span className="block text-sm font-semibold">{t.label}</span>
121
+ <span className="block text-xs truncate opacity-75">{t.hint}</span>
122
+ </span>
123
+ </button>
124
+ )
125
+ })}
126
+ </div>
127
+ </div>
128
+ )}
129
+ </div>
130
+ )
131
+ }
@@ -0,0 +1,84 @@
1
+ import { safeLocalStorage } from '@better-forge/contracts'
2
+ import { storageKey } from '../config'
3
+ import { parseChangelog, releasesBetween, summarizeRelease, type ReleaseItem, type ReleaseNotes } from '../lib/changelog'
4
+ import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
5
+
6
+ const seenKey = (userId: number) => storageKey(`whatsnew-seen-${userId}`)
7
+
8
+ /** Remembers that this user has seen the notes up to `version`. */
9
+ export function markWhatsNewSeen(userId: number, version: string): void {
10
+ safeLocalStorage.set(seenKey(userId), version) // blocked storage: the notes may show again next visit
11
+ }
12
+
13
+ /**
14
+ * Releases this user has not seen yet. A user with no record (new, or from before this existed) is only
15
+ * marked as up to date, so the first run shows nothing.
16
+ */
17
+ export function pendingWhatsNew(userId: number, changelog: string, current: string): ReleaseNotes[] {
18
+ try {
19
+ const seen = safeLocalStorage.get(seenKey(userId))
20
+ if (seen === null) {
21
+ markWhatsNewSeen(userId, current)
22
+ return []
23
+ }
24
+ return releasesBetween(parseChangelog(changelog), seen, current).filter((release) => release.groups.length > 0)
25
+ } catch {
26
+ return []
27
+ }
28
+ }
29
+
30
+ const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
31
+
32
+ /** Opening one section closes the others (shared `name`), so the dialog keeps its size. */
33
+ function Section({ title, items, defaultOpen }: { title: string; items: ReleaseItem[]; defaultOpen?: boolean }) {
34
+ return (
35
+ <details name="whats-new-sections" open={defaultOpen} className="group rounded-xl" style={{ border: '1px solid var(--m3-outline-variant)' }}>
36
+ <summary className="flex items-center justify-between gap-2 px-4 py-3 cursor-pointer list-none [&::-webkit-details-marker]:hidden font-headline font-semibold text-sm" style={{ color: 'var(--m3-on-surface)' }}>
37
+ <span>{title} <span className="font-mono text-xs" style={{ color: 'var(--m3-outline)' }}>({items.length})</span></span>
38
+ <span aria-hidden="true" className="material-symbols-outlined transition-transform group-open:rotate-180" style={{ fontSize: '20px' }}>expand_more</span>
39
+ </summary>
40
+ <ul className="space-y-2 px-4 pb-3 overflow-y-auto" style={{ maxHeight: '40vh' }}>
41
+ {items.map((item) => (
42
+ <li key={item.title + item.summary} className="font-sans text-sm" style={{ color: 'var(--m3-on-surface)' }}>
43
+ {item.title && <span className="font-bold">{item.title}. </span>}{item.summary}
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ </details>
48
+ )
49
+ }
50
+
51
+ /**
52
+ * The newest release as collapsible sections (the first one with entries open, empty ones left out), earlier ones in one line each.
53
+ * Only one section is open at a time, so the dialog stays small however many entries a release has.
54
+ */
55
+ export function WhatsNew({ releases, onClose, docsUrl }: { releases: ReleaseNotes[]; onClose: () => void; docsUrl: string }) {
56
+ const [latest, ...earlier] = releases.map(summarizeRelease)
57
+ const sections = [
58
+ { title: 'New', items: latest!.added },
59
+ { title: 'Changed', items: latest!.changed },
60
+ { title: 'Fixes', items: latest!.fixed },
61
+ ].filter((section) => section.items.length > 0)
62
+ return (
63
+ <ModalShell onClose={onClose} label="What's new">
64
+ <div className="w-full max-w-lg rounded-2xl" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
65
+ <ModalHeader icon="celebration" title={`What's new in ${latest!.version}`} onClose={onClose} />
66
+ <div className="p-6 space-y-3">
67
+ {sections.map((section, index) => <Section key={section.title} title={section.title} items={section.items} defaultOpen={index === 0} />)}
68
+ {earlier.length > 0 && (
69
+ <p className="font-sans text-sm pt-1" style={{ color: 'var(--m3-secondary)' }}>
70
+ Earlier: {earlier.map((release) => `${release.version} (${[
71
+ release.added.length > 0 && `${release.added.length} new`,
72
+ release.changed.length > 0 && plural(release.changed.length, 'change', 'changes'),
73
+ release.fixed.length > 0 && plural(release.fixed.length, 'fix', 'fixes'),
74
+ ].filter(Boolean).join(', ')})`).join(' · ')}
75
+ </p>
76
+ )}
77
+ </div>
78
+ <ModalFooter start={<a href={`${docsUrl}/changelog/`} target="_blank" rel="noopener" className="btn btn-secondary">Full changelog</a>}>
79
+ <button type="button" onClick={onClose} className="btn btn-primary">Got it</button>
80
+ </ModalFooter>
81
+ </div>
82
+ </ModalShell>
83
+ )
84
+ }
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ // Building blocks shared by the forms of the interface and the sections extracted from them.
4
+
5
+ export interface VaultSummary { id: number; name: string }
6
+ export interface SecretSummary { id: number; name: string; type: 'userpass' | 'value' | 'json' }
7
+
8
+ /** What every vault-aware credential picker needs to list vaults and lazily load their secrets. */
9
+ export interface VaultPickerProps {
10
+ vaults: { id: number; name: string }[]
11
+ secretsByVault: Record<number, { id: number; name: string; type: string }[]>
12
+ onLoadSecrets: (vaultId: number) => Promise<void>
13
+ }
14
+
15
+ // Fields that can be sourced from a json vault secret, per context
16
+ export const JSON_MAPPING_FIELDS: Record<'basic' | 'oauth2' | 'cas' | 'database', { key: string; label: string }[]> = {
17
+ basic: [{ key: 'username', label: 'Username' }, { key: 'password', label: 'Password' }],
18
+ oauth2: [{ key: 'clientId', label: 'Client ID' }, { key: 'clientSecret', label: 'Client Secret' }],
19
+ cas: [{ key: 'username', label: 'Username' }, { key: 'password', label: 'Password' }],
20
+ database: [{ key: 'username', label: 'Username' }, { key: 'password', label: 'Password' }],
21
+ }
22
+
23
+ /** The shared caps-style form field: its label is programmatically tied to the control it wraps. */
24
+ export { Field } from './ui'
25
+
26
+ export function SectionDivider({ label }: { label: string }) {
27
+ return (
28
+ <div className="flex items-center gap-3 pt-1">
29
+ <div style={{ flex: 1, borderTop: '1px solid var(--m3-outline-variant)' }} />
30
+ <span className="font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>{label}</span>
31
+ <div style={{ flex: 1, borderTop: '1px solid var(--m3-outline-variant)' }} />
32
+ </div>
33
+ )
34
+ }
35
+
36
+ /** Tinted callout: `info` uses the accent blue, `warning` the degraded amber (text colour, not the bar colour). */
37
+ export function Note({ tone, children }: { tone: 'info' | 'warning'; children: ReactNode }) {
38
+ const color = tone === 'info' ? 'var(--m3-on-primary-container)' : 'var(--m3-degraded)'
39
+ const tint = tone === 'info' ? color : 'var(--m3-degraded-bar)'
40
+ return (
41
+ <div
42
+ className="flex items-start gap-2 rounded-lg px-3 py-2"
43
+ style={{ background: `color-mix(in srgb, ${tint} 8%, transparent)`, border: `1px solid color-mix(in srgb, ${tint} 20%, transparent)` }}
44
+ >
45
+ {tone === 'info'
46
+ ? <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px', color, lineHeight: '20px' }}>info</span>
47
+ : <span aria-hidden="true" style={{ color, lineHeight: '20px' }}>⚠</span>}
48
+ <p className="text-xs" style={{ color: 'var(--m3-on-surface-variant)' }}>{children}</p>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ /** Adds or removes one id from a selection set without mutating it. */
54
+ export function toggleInSet(set: Set<number>, id: number, on: boolean): Set<number> {
55
+ const next = new Set(set)
56
+ if (on) next.add(id)
57
+ else next.delete(id)
58
+ return next
59
+ }
@@ -0,0 +1,45 @@
1
+ import clsx from 'clsx'
2
+ import type { CSSProperties, ReactNode } from 'react'
3
+
4
+ export type AlertTone = 'error' | 'success' | 'info' | 'warning'
5
+
6
+ const TONES: Record<AlertTone, { icon: string; style: CSSProperties }> = {
7
+ error: { icon: 'error', style: { background: 'var(--m3-down-bg)', color: 'var(--m3-down)', borderColor: 'color-mix(in srgb, var(--m3-down) 30%, transparent)' } },
8
+ success: { icon: 'check_circle', style: { background: 'var(--m3-up-bg)', color: 'var(--m3-up)', borderColor: 'color-mix(in srgb, var(--m3-up) 30%, transparent)' } },
9
+ warning: { icon: 'warning', style: { background: 'var(--m3-degraded-bg)', color: 'var(--m3-degraded)', borderColor: 'color-mix(in srgb, var(--m3-degraded) 30%, transparent)' } },
10
+ info: { icon: 'info', style: { background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)', borderColor: 'var(--m3-outline-variant)' } },
11
+ }
12
+
13
+ interface AlertProps {
14
+ tone: AlertTone
15
+ children: ReactNode
16
+ /** Optional bold first line. */
17
+ title?: ReactNode | undefined
18
+ /** Renders a × button (aria-label "Dismiss") that calls this. */
19
+ onDismiss?: (() => void) | undefined
20
+ className?: string | undefined
21
+ }
22
+
23
+ /** Inline, token-coloured message box. Errors are announced assertively (role="alert"); other tones politely. */
24
+ export function Alert({ tone, children, title, onDismiss, className }: AlertProps) {
25
+ const { icon, style } = TONES[tone]
26
+ return (
27
+ <div
28
+ role={tone === 'error' ? 'alert' : 'status'}
29
+ data-tone={tone}
30
+ className={clsx('flex items-start gap-3 rounded-xl border px-4 py-3 text-sm', className)}
31
+ style={style}
32
+ >
33
+ <span className="material-symbols-outlined flex-shrink-0 mt-px" aria-hidden="true" style={{ fontSize: '18px' }}>{icon}</span>
34
+ <div className="flex-1 min-w-0 break-words">
35
+ {title && <p className="font-semibold">{title}</p>}
36
+ <div className={title ? 'mt-0.5' : undefined}>{children}</div>
37
+ </div>
38
+ {onDismiss && (
39
+ <button type="button" onClick={onDismiss} aria-label="Dismiss" className="btn-icon -my-1 -mr-2" style={{ color: 'inherit' }}>
40
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '18px' }}>close</span>
41
+ </button>
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,225 @@
1
+ import {
2
+ createContext, useCallback, useContext, useMemo, useRef, useState,
3
+ type KeyboardEvent, type PointerEvent, type ReactNode, type TableHTMLAttributes, type ThHTMLAttributes,
4
+ } from 'react'
5
+
6
+ export const MIN_COLUMN_WIDTH = 60
7
+ export const MAX_COLUMN_WIDTH = 600
8
+ const KEY_STEP = 16
9
+
10
+ interface ResizeHandleProps {
11
+ /** Column name, used for the accessible description. */
12
+ label: string
13
+ /** Current width in px; shown to assistive technology. */
14
+ width: number | undefined
15
+ /** Reads the width the column has right now; called when a drag starts and for key presses. */
16
+ measure: () => number
17
+ onResize: (width: number) => void
18
+ /** Restores the default width. */
19
+ onReset: () => void
20
+ }
21
+
22
+ /** Grip on the right edge of a column header: drag, arrow keys, double-click or Home to reset. Place it in a `position: relative` header cell. */
23
+ export function ResizeHandle({ label, width, measure, onResize, onReset }: ResizeHandleProps) {
24
+ const drag = useRef<{ startX: number; startWidth: number } | null>(null)
25
+ const clamp = (w: number) => Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, Math.round(w)))
26
+
27
+ function onPointerDown(e: PointerEvent<HTMLSpanElement>) {
28
+ e.preventDefault()
29
+ e.currentTarget.setPointerCapture(e.pointerId)
30
+ drag.current = { startX: e.clientX, startWidth: measure() }
31
+ }
32
+
33
+ function onPointerMove(e: PointerEvent<HTMLSpanElement>) {
34
+ if (drag.current) onResize(clamp(drag.current.startWidth + e.clientX - drag.current.startX))
35
+ }
36
+
37
+ function onKeyDown(e: KeyboardEvent<HTMLSpanElement>) {
38
+ if (e.key === 'ArrowLeft') onResize(clamp(measure() - KEY_STEP))
39
+ else if (e.key === 'ArrowRight') onResize(clamp(measure() + KEY_STEP))
40
+ else if (e.key === 'Home') onReset()
41
+ else return
42
+ e.preventDefault()
43
+ }
44
+
45
+ return (
46
+ <span
47
+ role="separator"
48
+ aria-orientation="vertical"
49
+ aria-description={`Resize ${label} column`}
50
+ aria-valuenow={width}
51
+ aria-valuemin={MIN_COLUMN_WIDTH}
52
+ aria-valuemax={MAX_COLUMN_WIDTH}
53
+ tabIndex={0}
54
+ onPointerDown={onPointerDown}
55
+ onPointerMove={onPointerMove}
56
+ onPointerUp={() => { drag.current = null }}
57
+ onPointerCancel={() => { drag.current = null }}
58
+ onKeyDown={onKeyDown}
59
+ onDoubleClick={onReset}
60
+ className="group focus-ring absolute top-0 right-0 z-10 flex h-full w-3 cursor-col-resize touch-none select-none justify-center"
61
+ >
62
+ <span
63
+ aria-hidden="true"
64
+ className="block h-full w-px opacity-60 transition-all group-hover:w-0.5 group-hover:opacity-100 group-focus-visible:w-0.5 group-focus-visible:opacity-100 group-active:w-0.5 group-active:opacity-100"
65
+ style={{ background: 'var(--m3-outline-variant)' }}
66
+ />
67
+ </span>
68
+ )
69
+ }
70
+
71
+ export type SortDirection = 'asc' | 'desc'
72
+ export type SortValue = string | number | boolean | null | undefined
73
+
74
+ export interface TableSort {
75
+ key: string | null
76
+ dir: SortDirection
77
+ /** Sorts by the column; repeated calls on the same column go ascending, descending, then back to the original order. */
78
+ toggle: (key: string) => void
79
+ }
80
+
81
+ const isEmpty = (v: SortValue) => v == null || v === ''
82
+
83
+ function compareValues(a: SortValue, b: SortValue): number {
84
+ if (typeof a === 'number' && typeof b === 'number') return a - b
85
+ if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b)
86
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })
87
+ }
88
+
89
+ /** Sorts `rows` by the column picked in a `DataTable` header. `accessors` maps a column key to the value to compare; empty values always go last. */
90
+ export function useTableSort<T>(
91
+ rows: readonly T[],
92
+ accessors: Readonly<Record<string, (row: T) => SortValue>>,
93
+ initial?: { key: string; dir?: SortDirection },
94
+ ): { rows: T[]; sort: TableSort } {
95
+ const [key, setKey] = useState<string | null>(initial?.key ?? null)
96
+ const [dir, setDir] = useState<SortDirection>(initial?.dir ?? 'asc')
97
+
98
+ const toggle = useCallback((next: string) => {
99
+ if (key !== next) { setKey(next); setDir('asc') }
100
+ else if (dir === 'asc') setDir('desc')
101
+ else { setKey(null); setDir('asc') }
102
+ }, [key, dir])
103
+
104
+ // The accessors are declared inline by callers; the rows, column and direction are what change the order.
105
+ const accessorsRef = useRef(accessors)
106
+ accessorsRef.current = accessors
107
+
108
+ const sorted = useMemo(() => {
109
+ const get = key === null ? undefined : accessorsRef.current[key]
110
+ if (!get) return [...rows]
111
+ const sign = dir === 'asc' ? 1 : -1
112
+ return [...rows].sort((x, y) => {
113
+ const a = get(x), b = get(y)
114
+ if (isEmpty(a) || isEmpty(b)) return Number(isEmpty(a)) - Number(isEmpty(b))
115
+ return sign * compareValues(a, b)
116
+ })
117
+ }, [rows, key, dir])
118
+
119
+ return { rows: sorted, sort: useMemo(() => ({ key, dir, toggle }), [key, dir, toggle]) }
120
+ }
121
+
122
+ interface TableContext {
123
+ sort: TableSort | undefined
124
+ widths: number[] | null
125
+ resize: (row: HTMLTableRowElement, index: number, width: number) => void
126
+ reset: () => void
127
+ }
128
+
129
+ const DataTableContext = createContext<TableContext | null>(null)
130
+
131
+ interface DataTableProps extends TableHTMLAttributes<HTMLTableElement> {
132
+ /** From `useTableSort`; without it the headers are not sortable. */
133
+ sort?: TableSort
134
+ children: ReactNode
135
+ }
136
+
137
+ /**
138
+ * `<table>` whose `DataTh` headers can be sorted and resized. The first resize fixes every column at its
139
+ * current width; a drag then moves the boundary to the next column, so the other columns stay where they are.
140
+ */
141
+ export function DataTable({ sort, children, style, className, ...rest }: DataTableProps) {
142
+ const [widths, setWidths] = useState<number[] | null>(null)
143
+
144
+ const resize = useCallback((row: HTMLTableRowElement, index: number, width: number) => {
145
+ setWidths((current) => {
146
+ const next = current ? [...current] : Array.from(row.cells, (cell) => cell.offsetWidth)
147
+ const own = next[index]
148
+ if (own === undefined) return current
149
+ const neighbour = next[index + 1]
150
+ let delta = width - own
151
+ if (neighbour !== undefined) delta = Math.min(delta, neighbour - MIN_COLUMN_WIDTH)
152
+ next[index] = own + delta
153
+ if (neighbour !== undefined) next[index + 1] = neighbour - delta
154
+ return next
155
+ })
156
+ }, [])
157
+
158
+ const context = useMemo<TableContext>(
159
+ () => ({ sort, widths, resize, reset: () => setWidths(null) }),
160
+ [sort, widths, resize],
161
+ )
162
+
163
+ return (
164
+ <DataTableContext.Provider value={context}>
165
+ <table
166
+ {...rest}
167
+ className={`${className ?? ''} ${widths ? '[&_td]:break-words [&_th]:overflow-hidden' : ''}`}
168
+ style={widths ? { ...style, tableLayout: 'fixed', width: widths.reduce((a, b) => a + b, 0), minWidth: 0 } : style}
169
+ >
170
+ {widths && <colgroup>{widths.map((w, i) => <col key={i} style={{ width: w }} />)}</colgroup>}
171
+ {children}
172
+ </table>
173
+ </DataTableContext.Provider>
174
+ )
175
+ }
176
+
177
+ interface DataThProps extends ThHTMLAttributes<HTMLTableCellElement> {
178
+ /** Column key from `useTableSort`'s accessors; makes the header a sort button. */
179
+ sortKey?: string
180
+ /** Set false for columns without a title, such as the row actions. */
181
+ resizable?: boolean
182
+ }
183
+
184
+ /** `<th>` for a `DataTable`: sortable with `sortKey`, resizable unless `resizable={false}`. Give it the classes and colours you would give a `<th>`. */
185
+ export function DataTh({ sortKey, resizable = true, children, className, style, ...rest }: DataThProps) {
186
+ const table = useContext(DataTableContext)
187
+ const ref = useRef<HTMLTableCellElement>(null)
188
+ const sorted = sortKey !== undefined && table?.sort?.key === sortKey
189
+ const dir = table?.sort?.dir
190
+ const label = typeof children === 'string' ? children : sortKey ?? 'this'
191
+
192
+ return (
193
+ <th
194
+ ref={ref}
195
+ {...rest}
196
+ aria-sort={sorted ? (dir === 'asc' ? 'ascending' : 'descending') : rest['aria-sort']}
197
+ className={`${className ?? ''} ${resizable && table ? 'relative' : ''}`}
198
+ style={sorted ? { ...style, color: 'var(--m3-primary)' } : style}
199
+ >
200
+ {sortKey !== undefined && table?.sort ? (
201
+ <button
202
+ type="button"
203
+ onClick={() => table.sort?.toggle(sortKey)}
204
+ className="inline-flex items-center gap-1 uppercase tracking-wider rounded focus-ring"
205
+ style={{ color: 'inherit', userSelect: 'none' }}
206
+ >
207
+ {children}
208
+ {sorted && <span aria-hidden="true" style={{ fontSize: '10px' }}>{dir === 'asc' ? '↑' : '↓'}</span>}
209
+ </button>
210
+ ) : children}
211
+ {resizable && table && (
212
+ <ResizeHandle
213
+ label={label}
214
+ width={table.widths?.[ref.current?.cellIndex ?? -1]}
215
+ measure={() => ref.current?.offsetWidth ?? MIN_COLUMN_WIDTH}
216
+ onResize={(w) => {
217
+ const cell = ref.current
218
+ if (cell?.parentElement instanceof HTMLTableRowElement) table.resize(cell.parentElement, cell.cellIndex, w)
219
+ }}
220
+ onReset={table.reset}
221
+ />
222
+ )}
223
+ </th>
224
+ )
225
+ }
@@ -0,0 +1,63 @@
1
+ import clsx from 'clsx'
2
+ import { cloneElement, isValidElement, useId, type ReactElement, type ReactNode } from 'react'
3
+
4
+ /** Props Field hands to its control so the label, hint and error are programmatically associated. */
5
+ export interface FieldControlProps {
6
+ id: string
7
+ 'aria-describedby'?: string | undefined
8
+ 'aria-invalid'?: true | undefined
9
+ 'aria-required'?: true | undefined
10
+ }
11
+
12
+ interface FieldProps {
13
+ label: ReactNode
14
+ hint?: ReactNode | undefined
15
+ error?: ReactNode | undefined
16
+ required?: boolean | undefined
17
+ /** Use a specific id for the control (otherwise one is generated, or the child's own `id` is kept). */
18
+ id?: string | undefined
19
+ /** `caps`: small mono uppercase label (form default); `plain`: small sentence-case label. */
20
+ variant?: 'caps' | 'plain' | undefined
21
+ className?: string | undefined
22
+ /**
23
+ * Either a single control element (input/select/textarea/custom component that forwards `id` and aria-*),
24
+ * which receives the props via cloneElement, or a render function receiving them to spread where needed.
25
+ */
26
+ children: ReactElement<Partial<FieldControlProps>> | ((control: FieldControlProps) => ReactNode)
27
+ }
28
+
29
+ const LABEL_CLASS = {
30
+ caps: 'block font-mono text-xs uppercase tracking-wider mb-2',
31
+ plain: 'block text-xs mb-1.5',
32
+ }
33
+
34
+ export function Field({ label, hint, error, required = false, id, variant = 'caps', className, children }: FieldProps) {
35
+ const generatedId = useId()
36
+ const childId = typeof children !== 'function' && isValidElement(children) ? children.props.id : undefined
37
+ const controlId = id ?? childId ?? generatedId
38
+ const hintId = hint ? `${controlId}-hint` : undefined
39
+ const errorId = error ? `${controlId}-error` : undefined
40
+ const existingDescribedBy = typeof children !== 'function' && isValidElement(children) ? children.props['aria-describedby'] : undefined
41
+ const describedBy = [existingDescribedBy, hintId, errorId].filter(Boolean).join(' ') || undefined
42
+
43
+ const controlProps: FieldControlProps = {
44
+ id: controlId,
45
+ 'aria-describedby': describedBy,
46
+ ...(error ? { 'aria-invalid': true as const } : {}),
47
+ ...(required ? { 'aria-required': true as const } : {}),
48
+ }
49
+
50
+ const control = typeof children === 'function' ? children(controlProps) : cloneElement(children, controlProps)
51
+
52
+ return (
53
+ <div className={className}>
54
+ <label htmlFor={controlId} className={LABEL_CLASS[variant]} style={{ color: 'var(--m3-secondary)' }}>
55
+ {label}
56
+ {required && <span aria-hidden="true" style={{ color: 'var(--m3-down)' }}> *</span>}
57
+ </label>
58
+ {control}
59
+ {hint && <p id={hintId} className="text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>{hint}</p>}
60
+ {error && <p id={errorId} className={clsx('text-xs', hint ? 'mt-1' : 'mt-1.5')} style={{ color: 'var(--m3-down)' }}>{error}</p>}
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,36 @@
1
+ import clsx from 'clsx'
2
+ import type { ReactNode } from 'react'
3
+
4
+ interface PageContainerProps {
5
+ children: ReactNode
6
+ className?: string | undefined
7
+ }
8
+
9
+ /** Standard page padding and vertical rhythm with the entrance animation every admin page uses. */
10
+ export function PageContainer({ children, className }: PageContainerProps) {
11
+ return <div className={clsx('p-4 md:p-8 space-y-6 fade-up', className)}>{children}</div>
12
+ }
13
+
14
+ interface PageHeaderProps {
15
+ title: ReactNode
16
+ subtitle?: ReactNode | undefined
17
+ /** Right-aligned controls (buttons, filters); they wrap under the title on narrow screens. */
18
+ actions?: ReactNode | undefined
19
+ /** Extra content rendered under the title row, e.g. tabs or a filter bar. */
20
+ children?: ReactNode | undefined
21
+ }
22
+
23
+ export function PageHeader({ title, subtitle, actions, children }: PageHeaderProps) {
24
+ return (
25
+ <header className="space-y-4">
26
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-3">
27
+ <div className="min-w-0">
28
+ <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>{title}</h1>
29
+ {subtitle && <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>{subtitle}</p>}
30
+ </div>
31
+ {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
32
+ </div>
33
+ {children}
34
+ </header>
35
+ )
36
+ }