@better-forge/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -2
  3. package/dist/api/client.d.ts +42 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +130 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/components/ConfirmIdentityModal.d.ts +18 -0
  8. package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
  9. package/dist/components/ConfirmIdentityModal.js +30 -0
  10. package/dist/components/ConfirmIdentityModal.js.map +1 -0
  11. package/dist/components/ConfirmModal.d.ts +18 -0
  12. package/dist/components/ConfirmModal.d.ts.map +1 -0
  13. package/dist/components/ConfirmModal.js +11 -0
  14. package/dist/components/ConfirmModal.js.map +1 -0
  15. package/dist/components/CopyButton.d.ts +11 -0
  16. package/dist/components/CopyButton.d.ts.map +1 -0
  17. package/dist/components/CopyButton.js +44 -0
  18. package/dist/components/CopyButton.js.map +1 -0
  19. package/dist/components/CredentialSection.d.ts +38 -0
  20. package/dist/components/CredentialSection.d.ts.map +1 -0
  21. package/dist/components/CredentialSection.js +69 -0
  22. package/dist/components/CredentialSection.js.map +1 -0
  23. package/dist/components/ErrorBoundary.d.ts +18 -0
  24. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  25. package/dist/components/ErrorBoundary.js +25 -0
  26. package/dist/components/ErrorBoundary.js.map +1 -0
  27. package/dist/components/ModalShell.d.ts +58 -0
  28. package/dist/components/ModalShell.d.ts.map +1 -0
  29. package/dist/components/ModalShell.js +92 -0
  30. package/dist/components/ModalShell.js.map +1 -0
  31. package/dist/components/ResetTwoFactorModal.d.ts +14 -0
  32. package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
  33. package/dist/components/ResetTwoFactorModal.js +16 -0
  34. package/dist/components/ResetTwoFactorModal.js.map +1 -0
  35. package/dist/components/SidePanel.d.ts +35 -0
  36. package/dist/components/SidePanel.d.ts.map +1 -0
  37. package/dist/components/SidePanel.js +19 -0
  38. package/dist/components/SidePanel.js.map +1 -0
  39. package/dist/components/SingleSignOnModal.d.ts +4 -0
  40. package/dist/components/SingleSignOnModal.d.ts.map +1 -0
  41. package/dist/components/SingleSignOnModal.js +64 -0
  42. package/dist/components/SingleSignOnModal.js.map +1 -0
  43. package/dist/components/SsoConfirmation.d.ts +22 -0
  44. package/dist/components/SsoConfirmation.d.ts.map +1 -0
  45. package/dist/components/SsoConfirmation.js +87 -0
  46. package/dist/components/SsoConfirmation.js.map +1 -0
  47. package/dist/components/VaultTypePicker.d.ts +13 -0
  48. package/dist/components/VaultTypePicker.d.ts.map +1 -0
  49. package/dist/components/VaultTypePicker.js +62 -0
  50. package/dist/components/VaultTypePicker.js.map +1 -0
  51. package/dist/components/WhatsNew.d.ts +18 -0
  52. package/dist/components/WhatsNew.d.ts.map +1 -0
  53. package/dist/components/WhatsNew.js +50 -0
  54. package/dist/components/WhatsNew.js.map +1 -0
  55. package/dist/components/formParts.d.ts +40 -0
  56. package/dist/components/formParts.d.ts.map +1 -0
  57. package/dist/components/formParts.js +31 -0
  58. package/dist/components/formParts.js.map +1 -0
  59. package/dist/components/ui/Alert.d.ts +15 -0
  60. package/dist/components/ui/Alert.d.ts.map +1 -0
  61. package/dist/components/ui/Alert.js +14 -0
  62. package/dist/components/ui/Alert.js.map +1 -0
  63. package/dist/components/ui/DataTable.d.ts +52 -0
  64. package/dist/components/ui/DataTable.d.ts.map +1 -0
  65. package/dist/components/ui/DataTable.js +112 -0
  66. package/dist/components/ui/DataTable.js.map +1 -0
  67. package/dist/components/ui/Field.d.ts +27 -0
  68. package/dist/components/ui/Field.d.ts.map +1 -0
  69. package/dist/components/ui/Field.js +25 -0
  70. package/dist/components/ui/Field.js.map +1 -0
  71. package/dist/components/ui/PageHeader.d.ts +18 -0
  72. package/dist/components/ui/PageHeader.d.ts.map +1 -0
  73. package/dist/components/ui/PageHeader.js +10 -0
  74. package/dist/components/ui/PageHeader.js.map +1 -0
  75. package/dist/components/ui/Pagination.d.ts +21 -0
  76. package/dist/components/ui/Pagination.d.ts.map +1 -0
  77. package/dist/components/ui/Pagination.js +10 -0
  78. package/dist/components/ui/Pagination.js.map +1 -0
  79. package/dist/components/ui/QuickStartPanel.d.ts +21 -0
  80. package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
  81. package/dist/components/ui/QuickStartPanel.js +11 -0
  82. package/dist/components/ui/QuickStartPanel.js.map +1 -0
  83. package/dist/components/ui/StateViews.d.ts +50 -0
  84. package/dist/components/ui/StateViews.d.ts.map +1 -0
  85. package/dist/components/ui/StateViews.js +33 -0
  86. package/dist/components/ui/StateViews.js.map +1 -0
  87. package/dist/components/ui/Switch.d.ts +16 -0
  88. package/dist/components/ui/Switch.d.ts.map +1 -0
  89. package/dist/components/ui/Switch.js +15 -0
  90. package/dist/components/ui/Switch.js.map +1 -0
  91. package/dist/components/ui/Toast.d.ts +20 -0
  92. package/dist/components/ui/Toast.d.ts.map +1 -0
  93. package/dist/components/ui/Toast.js +46 -0
  94. package/dist/components/ui/Toast.js.map +1 -0
  95. package/dist/components/ui/index.d.ts +10 -0
  96. package/dist/components/ui/index.d.ts.map +1 -0
  97. package/dist/components/ui/index.js +10 -0
  98. package/dist/components/ui/index.js.map +1 -0
  99. package/dist/config.d.ts +46 -0
  100. package/dist/config.d.ts.map +1 -0
  101. package/dist/config.js +12 -0
  102. package/dist/config.js.map +1 -0
  103. package/dist/hooks/useDarkMode.d.ts +2 -0
  104. package/dist/hooks/useDarkMode.d.ts.map +1 -0
  105. package/dist/hooks/useDarkMode.js +37 -0
  106. package/dist/hooks/useDarkMode.js.map +1 -0
  107. package/dist/hooks/useDebouncedValue.d.ts +6 -0
  108. package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
  109. package/dist/hooks/useDebouncedValue.js +19 -0
  110. package/dist/hooks/useDebouncedValue.js.map +1 -0
  111. package/dist/index.d.ts +36 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +36 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/lib/changelog.d.ts +32 -0
  116. package/dist/lib/changelog.d.ts.map +1 -0
  117. package/dist/lib/changelog.js +71 -0
  118. package/dist/lib/changelog.js.map +1 -0
  119. package/dist/lib/dateFormat.d.ts +24 -0
  120. package/dist/lib/dateFormat.d.ts.map +1 -0
  121. package/dist/lib/dateFormat.js +67 -0
  122. package/dist/lib/dateFormat.js.map +1 -0
  123. package/dist/navigation.d.ts +5 -0
  124. package/dist/navigation.d.ts.map +1 -0
  125. package/dist/navigation.js +7 -0
  126. package/dist/navigation.js.map +1 -0
  127. package/dist/pages/ApiTokens.d.ts +9 -0
  128. package/dist/pages/ApiTokens.d.ts.map +1 -0
  129. package/dist/pages/ApiTokens.js +116 -0
  130. package/dist/pages/ApiTokens.js.map +1 -0
  131. package/dist/pages/AuditLog.d.ts +14 -0
  132. package/dist/pages/AuditLog.d.ts.map +1 -0
  133. package/dist/pages/AuditLog.js +104 -0
  134. package/dist/pages/AuditLog.js.map +1 -0
  135. package/dist/pages/Backups.d.ts +2 -0
  136. package/dist/pages/Backups.d.ts.map +1 -0
  137. package/dist/pages/Backups.js +118 -0
  138. package/dist/pages/Backups.js.map +1 -0
  139. package/dist/pages/ChangePassword.d.ts +2 -0
  140. package/dist/pages/ChangePassword.d.ts.map +1 -0
  141. package/dist/pages/ChangePassword.js +40 -0
  142. package/dist/pages/ChangePassword.js.map +1 -0
  143. package/dist/pages/Login.d.ts +12 -0
  144. package/dist/pages/Login.d.ts.map +1 -0
  145. package/dist/pages/Login.js +69 -0
  146. package/dist/pages/Login.js.map +1 -0
  147. package/dist/pages/Settings.d.ts +2 -0
  148. package/dist/pages/Settings.d.ts.map +1 -0
  149. package/dist/pages/Settings.js +149 -0
  150. package/dist/pages/Settings.js.map +1 -0
  151. package/dist/pages/Setup.d.ts +2 -0
  152. package/dist/pages/Setup.d.ts.map +1 -0
  153. package/dist/pages/Setup.js +113 -0
  154. package/dist/pages/Setup.js.map +1 -0
  155. package/dist/pages/SsoConfirm.d.ts +3 -0
  156. package/dist/pages/SsoConfirm.d.ts.map +1 -0
  157. package/dist/pages/SsoConfirm.js +20 -0
  158. package/dist/pages/SsoConfirm.js.map +1 -0
  159. package/dist/pages/SsoTestResult.d.ts +2 -0
  160. package/dist/pages/SsoTestResult.d.ts.map +1 -0
  161. package/dist/pages/SsoTestResult.js +49 -0
  162. package/dist/pages/SsoTestResult.js.map +1 -0
  163. package/dist/pages/Users.d.ts +22 -0
  164. package/dist/pages/Users.d.ts.map +1 -0
  165. package/dist/pages/Users.js +124 -0
  166. package/dist/pages/Users.js.map +1 -0
  167. package/dist/pages/Vault.d.ts +2 -0
  168. package/dist/pages/Vault.d.ts.map +1 -0
  169. package/dist/pages/Vault.js +372 -0
  170. package/dist/pages/Vault.js.map +1 -0
  171. package/dist/shell/AdminShell.d.ts +46 -0
  172. package/dist/shell/AdminShell.d.ts.map +1 -0
  173. package/dist/shell/AdminShell.js +76 -0
  174. package/dist/shell/AdminShell.js.map +1 -0
  175. package/dist/shell/guards.d.ts +28 -0
  176. package/dist/shell/guards.d.ts.map +1 -0
  177. package/dist/shell/guards.js +71 -0
  178. package/dist/shell/guards.js.map +1 -0
  179. package/icons.json +170 -0
  180. package/package.json +69 -4
  181. package/src/api/client.ts +139 -0
  182. package/src/components/ConfirmIdentityModal.tsx +82 -0
  183. package/src/components/ConfirmModal.tsx +83 -0
  184. package/src/components/CopyButton.tsx +71 -0
  185. package/src/components/CredentialSection.tsx +270 -0
  186. package/src/components/ErrorBoundary.tsx +47 -0
  187. package/src/components/ModalShell.tsx +160 -0
  188. package/src/components/ResetTwoFactorModal.tsx +74 -0
  189. package/src/components/SidePanel.tsx +96 -0
  190. package/src/components/SingleSignOnModal.tsx +161 -0
  191. package/src/components/SsoConfirmation.tsx +96 -0
  192. package/src/components/VaultTypePicker.tsx +131 -0
  193. package/src/components/WhatsNew.tsx +84 -0
  194. package/src/components/formParts.tsx +59 -0
  195. package/src/components/ui/Alert.tsx +45 -0
  196. package/src/components/ui/DataTable.tsx +225 -0
  197. package/src/components/ui/Field.tsx +63 -0
  198. package/src/components/ui/PageHeader.tsx +36 -0
  199. package/src/components/ui/Pagination.tsx +37 -0
  200. package/src/components/ui/QuickStartPanel.tsx +57 -0
  201. package/src/components/ui/StateViews.tsx +135 -0
  202. package/src/components/ui/Switch.tsx +65 -0
  203. package/src/components/ui/Toast.tsx +104 -0
  204. package/src/components/ui/index.ts +9 -0
  205. package/src/config.ts +46 -0
  206. package/src/hooks/useDarkMode.ts +38 -0
  207. package/src/hooks/useDebouncedValue.ts +22 -0
  208. package/src/index.ts +42 -0
  209. package/src/lib/changelog.ts +93 -0
  210. package/src/lib/dateFormat.ts +90 -0
  211. package/src/navigation.ts +6 -0
  212. package/src/pages/ApiTokens.tsx +361 -0
  213. package/src/pages/AuditLog.tsx +388 -0
  214. package/src/pages/Backups.tsx +170 -0
  215. package/src/pages/ChangePassword.tsx +84 -0
  216. package/src/pages/Login.tsx +275 -0
  217. package/src/pages/Settings.tsx +264 -0
  218. package/src/pages/Setup.tsx +438 -0
  219. package/src/pages/SsoConfirm.tsx +26 -0
  220. package/src/pages/SsoTestResult.tsx +104 -0
  221. package/src/pages/Users.tsx +426 -0
  222. package/src/pages/Vault.tsx +1036 -0
  223. package/src/shell/AdminShell.tsx +256 -0
  224. package/src/shell/guards.tsx +88 -0
  225. package/styles/base.css +521 -0
  226. package/styles/fonts/inter-latin-1.woff2 +0 -0
  227. package/styles/fonts/inter-latin-ext.woff2 +0 -0
  228. package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
  229. package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
  230. package/styles/fonts/manrope-latin-5.woff2 +0 -0
  231. package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
  232. package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
  233. package/styles/icons.css +4 -0
  234. package/tailwind-preset.js +79 -0
@@ -0,0 +1,160 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ export const MODAL_Z_INDEX = 9999
5
+ export const MODAL_BACKDROP = 'rgba(0,0,0,0.55)'
6
+
7
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
8
+
9
+ /** Open shells, innermost last: only the top one reacts to Escape and traps Tab. */
10
+ const openShells: HTMLElement[] = []
11
+
12
+ interface ModalShellProps {
13
+ children: ReactNode
14
+ /** `top` for tall forms that scroll with the page; `center` for short dialogs. */
15
+ align?: 'center' | 'top' | undefined
16
+ /** Called when Escape is pressed while this is the topmost modal. Omit to ignore Escape. */
17
+ onClose?: (() => void) | undefined
18
+ /**
19
+ * Accessible name. When given (or `labelledBy`), the shell itself carries role="dialog" aria-modal —
20
+ * use it when the child panel does not declare its own dialog role.
21
+ */
22
+ label?: string | undefined
23
+ labelledBy?: string | undefined
24
+ }
25
+
26
+ /**
27
+ * The single backdrop every admin modal renders through, so stacking, dimming and dismissal stay consistent:
28
+ * portalled to <body>, z-index 9999, a 55% black scrollable overlay, and a backdrop click never dismisses —
29
+ * a stray click must not throw away a half-filled form. Dialogs close from their own Cancel / × controls and,
30
+ * when `onClose` is passed, from Escape. Focus moves into the modal on open (unless a child already took it
31
+ * with autoFocus), Tab cycles inside it, and focus returns to the opener on close.
32
+ */
33
+ export function ModalShell({ children, align = 'center', onClose, label, labelledBy }: ModalShellProps) {
34
+ const panelRef = useRef<HTMLDivElement>(null)
35
+ const onCloseRef = useRef(onClose)
36
+ useEffect(() => { onCloseRef.current = onClose })
37
+ // Read during the first render, before an autoFocus child steals focus, so closing returns it to the opener.
38
+ const [opener] = useState(() => document.activeElement instanceof HTMLElement ? document.activeElement : null)
39
+
40
+ useEffect(() => {
41
+ const panel = panelRef.current
42
+ if (!panel) return
43
+ openShells.push(panel)
44
+ if (!panel.contains(document.activeElement)) panel.focus({ preventScroll: true })
45
+
46
+ const onKeyDown = (event: KeyboardEvent) => {
47
+ if (openShells[openShells.length - 1] !== panel || event.defaultPrevented || event.isComposing) return
48
+ if (event.key === 'Escape') {
49
+ if (onCloseRef.current) {
50
+ event.preventDefault()
51
+ onCloseRef.current()
52
+ }
53
+ return
54
+ }
55
+ if (event.key !== 'Tab') return
56
+ const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
57
+ if (focusable.length === 0) { event.preventDefault(); return }
58
+ const first = focusable[0]!
59
+ const last = focusable[focusable.length - 1]!
60
+ const active = document.activeElement
61
+ if (event.shiftKey && (active === first || active === panel)) { event.preventDefault(); last.focus() }
62
+ else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus() }
63
+ else if (!panel.contains(active)) { event.preventDefault(); first.focus() }
64
+ }
65
+ document.addEventListener('keydown', onKeyDown)
66
+ return () => {
67
+ document.removeEventListener('keydown', onKeyDown)
68
+ const index = openShells.lastIndexOf(panel)
69
+ if (index >= 0) openShells.splice(index, 1)
70
+ if (opener && opener.isConnected) opener.focus({ preventScroll: true })
71
+ }
72
+ }, [opener])
73
+
74
+ const dialogProps = label || labelledBy
75
+ ? { role: 'dialog', 'aria-modal': true, 'aria-label': label, 'aria-labelledby': labelledBy }
76
+ : {}
77
+
78
+ return createPortal(
79
+ <div data-testid="modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: MODAL_Z_INDEX, background: MODAL_BACKDROP, overflowY: 'auto' }}>
80
+ <div
81
+ ref={panelRef}
82
+ tabIndex={-1}
83
+ {...dialogProps}
84
+ style={{ display: 'flex', minHeight: '100%', alignItems: align === 'top' ? 'flex-start' : 'center', justifyContent: 'center', padding: '16px', outline: 'none' }}
85
+ >
86
+ {children}
87
+ </div>
88
+ </div>,
89
+ document.body,
90
+ )
91
+ }
92
+
93
+ interface ModalHeaderProps {
94
+ /** Material Symbols name shown before the title, so every dialog header reads the same way. */
95
+ icon: string
96
+ title: ReactNode
97
+ /** Id for the heading, when the dialog is labelled by it via `labelledBy`. */
98
+ titleId?: string | undefined
99
+ onClose: () => void
100
+ }
101
+
102
+ /** The shared dialog header: icon, title and a × close button above a divider. */
103
+ export function ModalHeader({ icon, title, titleId, onClose }: ModalHeaderProps) {
104
+ return (
105
+ <div className="flex items-center justify-between gap-3 px-6 py-5" style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
106
+ <div className="flex items-center gap-3 min-w-0">
107
+ <span className="material-symbols-outlined shrink-0" aria-hidden="true" style={{ fontSize: '22px', color: 'var(--m3-primary)' }}>{icon}</span>
108
+ <h3 id={titleId} className="font-headline font-bold text-lg break-words min-w-0" style={{ color: 'var(--m3-on-surface)' }}>{title}</h3>
109
+ </div>
110
+ <button type="button" onClick={onClose} aria-label="Close" className="btn-icon shrink-0">
111
+ <span className="material-symbols-outlined" aria-hidden="true">close</span>
112
+ </button>
113
+ </div>
114
+ )
115
+ }
116
+
117
+ interface ModalProps {
118
+ title: string
119
+ /** Material Symbols name for the header icon. */
120
+ icon: string
121
+ onClose: () => void
122
+ children: ReactNode
123
+ /** Skip the padded body so the content can bring its own, with a `ModalFooter` as the last thing in the dialog. */
124
+ flush?: boolean
125
+ }
126
+
127
+ /** A titled panel with a close button inside the shared shell, for simple forms. Escape closes it. */
128
+ export function Modal({ title, icon, onClose, children, flush }: ModalProps) {
129
+ return (
130
+ <ModalShell onClose={onClose}>
131
+ <div role="dialog" aria-modal="true" aria-label={title} className="w-full max-w-md rounded-2xl" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
132
+ <ModalHeader icon={icon} title={title} onClose={onClose} />
133
+ {flush ? children : <div className="p-6">{children}</div>}
134
+ </div>
135
+ </ModalShell>
136
+ )
137
+ }
138
+
139
+ interface ModalFooterProps {
140
+ /** Secondary tools on the left (YAML, Test, …); kept apart from the buttons that close or save. */
141
+ start?: ReactNode
142
+ /** Cancel and the main action, on the right. */
143
+ children: ReactNode
144
+ }
145
+
146
+ /**
147
+ * The action row of a form dialog, stuck to the bottom of the screen while the form scrolls, so the main
148
+ * action never hides below the fold. Put it last inside the `<form>`, after the padded body.
149
+ */
150
+ export function ModalFooter({ start, children }: ModalFooterProps) {
151
+ return (
152
+ <div
153
+ className="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-4"
154
+ style={{ position: 'sticky', bottom: 0, zIndex: 1, background: 'var(--m3-surface-container-low)', borderTop: '1px solid var(--m3-outline-variant)', borderRadius: '0 0 15px 15px' }}
155
+ >
156
+ {start ? <div className="flex flex-wrap gap-3">{start}</div> : <span />}
157
+ <div className="flex flex-wrap gap-3 ml-auto">{children}</div>
158
+ </div>
159
+ )
160
+ }
@@ -0,0 +1,74 @@
1
+ import { useId, useState } from 'react'
2
+ import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
3
+ import { SsoConfirmationNote, isSsoSession, type SsoConfirmationState } from './SsoConfirmation'
4
+ import { Alert } from './ui'
5
+
6
+ const IDLE_CONFIRMATION = { waiting: false, cancel: () => {} }
7
+
8
+ interface ResetTwoFactorModalProps {
9
+ email: string
10
+ pending: boolean
11
+ error?: string
12
+ /** Progress of the SSO confirmation popup, which `onConfirm` opens when the API asks for it. */
13
+ ssoConfirmation?: Pick<SsoConfirmationState, 'waiting' | 'cancel'>
14
+ /** `currentPassword` is undefined in an SSO session, which is confirmed at the identity provider instead. */
15
+ onConfirm: (currentPassword: string | undefined) => void
16
+ onCancel: () => void
17
+ }
18
+
19
+ export function ResetTwoFactorModal({ email, pending, error, ssoConfirmation = IDLE_CONFIRMATION, onConfirm, onCancel }: ResetTwoFactorModalProps) {
20
+ const sso = isSsoSession()
21
+ const [currentPassword, setCurrentPassword] = useState('')
22
+ const [typedEmail, setTypedEmail] = useState('')
23
+ const titleId = useId()
24
+ const canConfirm = !pending && (sso || currentPassword.length > 0) && typedEmail === email
25
+ const confirm = () => onConfirm(sso ? undefined : currentPassword)
26
+
27
+ return (
28
+ <ModalShell onClose={pending ? undefined : onCancel}>
29
+ <div
30
+ role="dialog"
31
+ aria-modal="true"
32
+ aria-labelledby={titleId}
33
+ aria-busy={pending || undefined}
34
+ className="rounded-2xl w-full max-w-md"
35
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
36
+ >
37
+ <ModalHeader icon="shield_lock" titleId={titleId} title="Reset two-factor authentication" onClose={pending ? () => {} : onCancel} />
38
+ <div className="p-6 space-y-4">
39
+ <div>
40
+ <p className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
41
+ This removes 2FA from <strong>{email}</strong> and signs out all of their active sessions. Their password will not change.
42
+ </p>
43
+ </div>
44
+
45
+ {error && <Alert tone="error">{error}</Alert>}
46
+
47
+ {sso ? <SsoConfirmationNote confirmation={ssoConfirmation} /> : <label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
48
+ <span>Your current password</span>
49
+ <input
50
+ type="password"
51
+ autoComplete="current-password"
52
+ autoFocus
53
+ value={currentPassword}
54
+ onChange={(event) => setCurrentPassword(event.target.value)}
55
+ className="input-sig w-full"
56
+ />
57
+ </label>}
58
+
59
+ <label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
60
+ <span>Type <strong className="font-mono">{email}</strong> to confirm</span>
61
+ <input value={typedEmail} onChange={(event) => setTypedEmail(event.target.value)} autoFocus={sso} autoComplete="off" spellCheck={false} className="input-sig w-full" />
62
+ </label>
63
+
64
+ </div>
65
+ <ModalFooter>
66
+ <button type="button" onClick={onCancel} disabled={pending} className="btn btn-secondary">Cancel</button>
67
+ <button type="button" onClick={confirm} disabled={!canConfirm} className="btn btn-danger-outline">
68
+ {pending ? 'Resetting…' : 'Reset 2FA'}
69
+ </button>
70
+ </ModalFooter>
71
+ </div>
72
+ </ModalShell>
73
+ )
74
+ }
@@ -0,0 +1,96 @@
1
+ import clsx from 'clsx'
2
+ import type { ReactNode } from 'react'
3
+
4
+ export interface SidePanelMeta { icon: string; label: string }
5
+
6
+ /**
7
+ * `row`: the panel always sits to the right of the form (legacy layout).
8
+ * `responsive`: below the lg breakpoint the panel spans the full width under the form with a top border;
9
+ * from lg up it becomes the right-hand column. The parent must switch to `flex-col lg:flex-row` to match.
10
+ */
11
+ export type SidePanelLayout = 'row' | 'responsive'
12
+
13
+ /** The expandable column to the right of a modal form: a title bar and the open section. */
14
+ export function SidePanelFrame({ meta, children, layout = 'row', className }: {
15
+ meta: SidePanelMeta
16
+ children: ReactNode
17
+ layout?: SidePanelLayout | undefined
18
+ className?: string | undefined
19
+ }) {
20
+ return (
21
+ <div
22
+ data-testid="side-panel-frame"
23
+ className={clsx(
24
+ 'flex flex-col flex-1 min-w-0 border-outline-variant',
25
+ layout === 'row' ? 'border-l lg:min-w-[360px]' : 'w-full border-t lg:w-auto lg:border-t-0 lg:border-l lg:min-w-[360px]',
26
+ className,
27
+ )}
28
+ >
29
+ {/* Panel header — just the section title */}
30
+ <div className="flex items-center gap-2 px-5 py-4" style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
31
+ <span className="material-symbols-outlined" style={{ fontSize: '16px', color: 'var(--m3-primary)' }}>{meta.icon}</span>
32
+ <span className="font-mono text-xs uppercase tracking-wider font-medium" style={{ color: 'var(--m3-on-surface)', flex: 1 }}>{meta.label}</span>
33
+ </div>
34
+
35
+ {/* Tab content */}
36
+ <div className="p-5 space-y-4 overflow-y-auto" style={{ flex: 1 }}>
37
+ {children}
38
+ </div>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ export interface SideTab<K extends string> { key: K; badge: string | null }
44
+
45
+ /**
46
+ * The icon strip that opens and closes side-panel sections: vertical at the form's right edge, or with
47
+ * `layout="responsive"` a horizontal row below lg and the vertical strip from lg up.
48
+ */
49
+ export function SideTabStrip<K extends string>({ tabs, meta, active, onToggle, layout = 'row', className }: {
50
+ tabs: SideTab<K>[]
51
+ meta: Record<K, SidePanelMeta>
52
+ active: K | null
53
+ onToggle: (key: K | null) => void
54
+ layout?: SidePanelLayout | undefined
55
+ className?: string | undefined
56
+ }) {
57
+ const responsive = layout === 'responsive'
58
+ return (
59
+ <div
60
+ role="toolbar"
61
+ aria-orientation={responsive ? undefined : 'vertical'}
62
+ className={clsx(
63
+ 'flex items-stretch border-outline-variant',
64
+ responsive ? 'flex-row w-full border-t overflow-hidden rounded-b-2xl lg:w-11 lg:flex-none lg:flex-col lg:border-t-0 lg:border-l lg:rounded-b-none lg:rounded-r-2xl' : 'flex-col flex-none w-11 border-l',
65
+ className,
66
+ )}
67
+ >
68
+ {tabs.map((tab) => {
69
+ const isActive = active === tab.key
70
+ const current = meta[tab.key]
71
+ return (
72
+ <button key={tab.key} type="button"
73
+ aria-pressed={isActive}
74
+ aria-label={current.label}
75
+ title={current.label}
76
+ onClick={() => onToggle(isActive ? null : tab.key)}
77
+ className={clsx(
78
+ 'relative flex flex-col items-center justify-center transition-all border-outline-variant focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-outline',
79
+ responsive ? 'flex-1 py-3 border-r last:border-r-0 lg:flex-none lg:py-4 lg:border-r-0 lg:border-b' : 'py-4 border-b',
80
+ isActive ? 'side-tab-active' : 'hover:bg-surface-container',
81
+ )}
82
+ style={{ color: isActive ? 'var(--m3-primary)' : 'var(--m3-secondary)', background: isActive ? 'var(--m3-primary-fixed)' : undefined }}
83
+ >
84
+ <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>{current.icon}</span>
85
+ {tab.badge && (
86
+ <span className={`absolute top-1.5 right-1 flex items-center justify-center rounded-full font-bold leading-none ${isActive ? 'side-tab-badge-active' : ''}`}
87
+ style={{ background: isActive ? 'var(--m3-primary)' : 'var(--m3-on-surface-variant)', color: isActive ? 'var(--m3-on-primary)' : 'var(--m3-surface)', minWidth: '14px', height: '14px', fontSize: '9px', padding: '0 2px' }}>
88
+ {tab.badge}
89
+ </span>
90
+ )}
91
+ </button>
92
+ )
93
+ })}
94
+ </div>
95
+ )
96
+ }
@@ -0,0 +1,161 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { api } from '../api/client'
3
+ import { uiConfig } from '../config'
4
+ import { Alert, ErrorState, Field, LoadingState, Switch, useToast } from './ui'
5
+ import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
6
+ import { SsoConfirmationNote, isSsoSession, useSsoConfirmation } from './SsoConfirmation'
7
+
8
+ interface OidcSettings {
9
+ enabled: boolean
10
+ issuer: string
11
+ clientId: string
12
+ hasClientSecret: boolean
13
+ scopes: string
14
+ redirectUri: string
15
+ buttonLabel: string
16
+ allowUnverifiedEmail: boolean
17
+ disablePasswordLogin: boolean
18
+ }
19
+ interface OidcResponse { source: 'env' | 'database' | 'none'; envManaged: boolean; defaultRedirectUri: string; settings: OidcSettings }
20
+ interface Message { tone: 'success' | 'error'; text: string }
21
+
22
+ const errorText = (e: unknown) => e instanceof Error ? e.message : String(e)
23
+ const CARD = { background: 'var(--m3-surface-container-lowest)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }
24
+
25
+ export function SingleSignOnModal({ onClose }: { onClose: () => void }) {
26
+ const [data, setData] = useState<OidcResponse | null>(null)
27
+ const [form, setForm] = useState<OidcSettings | null>(null)
28
+ const [clientSecret, setClientSecret] = useState('')
29
+ const [currentPassword, setCurrentPassword] = useState('')
30
+ const sso = isSsoSession()
31
+ const confirmation = useSsoConfirmation()
32
+ const [loadError, setLoadError] = useState('')
33
+ const [busy, setBusy] = useState<'save' | 'test' | null>(null)
34
+ const [message, setMessage] = useState<Message | null>(null)
35
+ const toast = useToast()
36
+
37
+ const load = useCallback(() => api.get<OidcResponse>('/admin/oidc')
38
+ .then((res) => { setData(res); setForm(res.settings); setLoadError('') })
39
+ .catch((e: unknown) => setLoadError(errorText(e))), [])
40
+ useEffect(() => { void load() }, [load])
41
+
42
+ if (!data || !form) {
43
+ return <ModalShell align="top" onClose={onClose} label="Single sign-on">
44
+ <div className="rounded-2xl p-6 w-full max-w-2xl" style={CARD}>
45
+ {loadError ? <ErrorState message={loadError} onRetry={() => void load()} /> : <LoadingState label="Loading sign-in settings…" />}
46
+ {loadError && <button type="button" className="btn btn-secondary mt-4" onClick={onClose}>Close</button>}
47
+ </div>
48
+ </ModalShell>
49
+ }
50
+
51
+ const locked = data.envManaged
52
+ const update = (changes: Partial<OidcSettings>) => setForm({ ...form, ...changes })
53
+ const redirectUri = form.redirectUri || data.defaultRedirectUri
54
+
55
+ async function test() {
56
+ setBusy('test'); setMessage(null)
57
+ try {
58
+ const result = await api.post<{ issuer: string }>('/admin/oidc/test', { issuer: form!.issuer, clientId: form!.clientId, redirectUri: form!.redirectUri })
59
+ setMessage({ tone: 'success', text: `Connected to ${result.issuer}.` })
60
+ } catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
61
+ }
62
+
63
+ async function save() {
64
+ setBusy('save'); setMessage(null)
65
+ try {
66
+ const saved = await confirmation.run(() => api.put<OidcSettings>('/admin/oidc', {
67
+ ...form, clientSecret, ...(sso ? {} : { currentPassword }),
68
+ }))
69
+ toast.success(saved.enabled ? 'Single sign-on settings saved. They apply immediately.' : 'Single sign-on settings saved.')
70
+ onClose()
71
+ } catch (err) { setMessage({ tone: 'error', text: errorText(err) }); setBusy(null) }
72
+ }
73
+
74
+ return <ModalShell align="top" onClose={busy ? undefined : onClose} label="Single sign-on">
75
+ <div className="w-full max-w-2xl rounded-2xl my-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }}>
76
+ <ModalHeader icon="passkey" title="Single sign-on" onClose={onClose} />
77
+ <form onSubmit={(e) => { e.preventDefault(); void save() }}>
78
+ <div className="p-6 space-y-5">
79
+ {locked && <Alert tone="warning">Single sign-on is configured through OIDC_* environment variables on the server. Remove them to manage it here.</Alert>}
80
+ {message && <Alert tone={message.tone} onDismiss={() => setMessage(null)}>{message.text}</Alert>}
81
+ <div className="flex items-start justify-between gap-4">
82
+ <div>
83
+ <h2 className="font-headline text-base font-semibold">OpenID Connect</h2>
84
+ <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>
85
+ Users must already exist; they are matched by the verified email from the identity provider.{uiConfig().texts.ssoSignInHint ? ` ${uiConfig().texts.ssoSignInHint}` : ""}
86
+ </p>
87
+ </div>
88
+ <Switch checked={form.enabled} disabled={locked} onChange={(enabled) => update({ enabled })} aria-label="Enable single sign-on" />
89
+ </div>
90
+
91
+ <fieldset disabled={locked || busy !== null} className="space-y-4">
92
+ <Field label="Issuer URL" hint="For example https://login.microsoftonline.com/<tenant>/v2.0 or https://keycloak.example.com/realms/main">
93
+ <input className="input-m3" value={form.issuer} onChange={(e) => update({ issuer: e.target.value })} placeholder="https://idp.example.com" />
94
+ </Field>
95
+ <div className="grid gap-4 md:grid-cols-2">
96
+ <Field label="Client ID">
97
+ <input className="input-m3" value={form.clientId} onChange={(e) => update({ clientId: e.target.value })} autoComplete="off" />
98
+ </Field>
99
+ <Field label="Client secret" hint={form.hasClientSecret ? 'A secret is stored. Leave empty to keep it. Re-enter it when you change the issuer URL or client ID.' : 'Optional for public clients.'}>
100
+ <input
101
+ className="input-m3" type="password" autoComplete="new-password" value={clientSecret}
102
+ onChange={(e) => setClientSecret(e.target.value)}
103
+ placeholder={form.hasClientSecret ? '••••••••' : ''}
104
+ />
105
+ </Field>
106
+ </div>
107
+ <Field label="Redirect URI" hint={redirectUri ? <>Register <code>{redirectUri}</code> as the callback URL in your identity provider.</> : 'Set PUBLIC_URL on the server or enter the callback URL here.'}>
108
+ <input className="input-m3" value={form.redirectUri} onChange={(e) => update({ redirectUri: e.target.value })} placeholder={data.defaultRedirectUri} />
109
+ </Field>
110
+ <div className="grid gap-4 md:grid-cols-2">
111
+ <Field label="Scopes">
112
+ <input className="input-m3" value={form.scopes} onChange={(e) => update({ scopes: e.target.value })} placeholder="openid email profile" />
113
+ </Field>
114
+ <Field label="Button label">
115
+ <input className="input-m3" value={form.buttonLabel} onChange={(e) => update({ buttonLabel: e.target.value })} placeholder="Sign in with SSO" />
116
+ </Field>
117
+ </div>
118
+ <label className="flex items-start gap-2 text-sm">
119
+ <input type="checkbox" className="mt-1" checked={form.allowUnverifiedEmail} onChange={(e) => update({ allowUnverifiedEmail: e.target.checked })} />
120
+ <span>Accept identity providers that omit <code>email_verified</code>. Enable only if the email claim is trustworthy. Not needed for Microsoft Entra ID: add the <code>xms_edov</code> optional claim instead.</span>
121
+ </label>
122
+ <label className="flex items-start gap-2 text-sm">
123
+ <input type="checkbox" className="mt-1" checked={form.disablePasswordLogin} onChange={(e) => update({ disablePasswordLogin: e.target.checked })} />
124
+ <span>Disable password sign-in. The identity provider must be reachable to save this. Set <code>OIDC_FORCE_PASSWORD_LOGIN=true</code> on the server to recover if it goes down.</span>
125
+ </label>
126
+ </fieldset>
127
+
128
+ {!locked && <>
129
+ {sso
130
+ ? <SsoConfirmationNote confirmation={confirmation} />
131
+ : <Field label="Your current password" hint="Required to change sign-in settings.">
132
+ <input className="input-m3" type="password" autoComplete="current-password" required value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
133
+ </Field>}
134
+ </>}
135
+ {!locked && <p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>Test sign-in uses the saved settings, so save changes first.</p>}
136
+ </div>
137
+
138
+ <ModalFooter
139
+ start={<>
140
+ {!locked && (
141
+ <button type="button" disabled={busy !== null || !form.issuer || !form.clientId} onClick={() => void test()} className="btn btn-secondary">
142
+ {busy === 'test' ? 'Testing…' : 'Test connection'}
143
+ </button>
144
+ )}
145
+ <button
146
+ type="button" disabled={!locked && (busy !== null || !data.settings.issuer || !data.settings.clientId)}
147
+ onClick={() => window.open('/api/v1/admin/oidc/test-sign-in', '_blank', 'noopener')}
148
+ title="Signs in at the identity provider in a new tab with the saved settings and shows the claims and the outcome, without signing anyone in."
149
+ className="btn btn-secondary"
150
+ >
151
+ Test sign-in
152
+ </button>
153
+ </>}
154
+ >
155
+ <button type="button" disabled={busy !== null} onClick={onClose} className="btn btn-secondary">{locked ? 'Close' : 'Cancel'}</button>
156
+ {!locked && <button type="submit" disabled={busy !== null} className="btn btn-primary">{busy === 'save' ? 'Saving…' : 'Save'}</button>}
157
+ </ModalFooter>
158
+ </form>
159
+ </div>
160
+ </ModalShell>
161
+ }
@@ -0,0 +1,96 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { getCurrentUser, type ApiError } from '../api/client'
3
+ import { storageKey, uiConfig } from '../config'
4
+ import { Alert } from './ui'
5
+
6
+ /** The confirmation popup reports on this channel. Some providers cut `window.opener`, so no postMessage. */
7
+ export const ssoConfirmChannel = (): string => storageKey('sso-confirm')
8
+ const ssoConfirmUrl = () => `${uiConfig().apiBase}/auth/oidc/confirm`
9
+ // The server-side flow expires after 10 minutes, so a popup left open longer cannot succeed.
10
+ const SSO_CONFIRM_TIMEOUT_MS = 10 * 60 * 1000
11
+
12
+ type ConfirmOutcome = 'ok' | 'failed' | 'blocked' | 'cancelled'
13
+
14
+ const OUTCOME_MESSAGES: Record<Exclude<ConfirmOutcome, 'ok'>, string> = {
15
+ failed: 'The single sign-on confirmation did not succeed. Try again.',
16
+ blocked: 'The browser blocked the sign-in window. Allow pop-ups for this site and try again.',
17
+ cancelled: 'The single sign-on confirmation was cancelled.',
18
+ }
19
+
20
+ /** True when the session signed in with SSO, so sensitive actions are confirmed there instead of with a password. */
21
+ export function isSsoSession(): boolean {
22
+ return getCurrentUser()?.authMethod === 'oidc'
23
+ }
24
+
25
+ /** True when the API refused an action until the SSO session is confirmed at the identity provider. */
26
+ export function isSsoConfirmationRequired(error: unknown): boolean {
27
+ return (error as Partial<ApiError> | null)?.code === 'SSO_CONFIRMATION_REQUIRED'
28
+ }
29
+
30
+ function openSsoConfirmation(): { result: Promise<ConfirmOutcome>; cancel: () => void } {
31
+ const popup = window.open(ssoConfirmUrl(), ssoConfirmChannel(), 'popup,width=520,height=720')
32
+ if (!popup) return { result: Promise.resolve('blocked'), cancel: () => {} }
33
+ let finish: (outcome: ConfirmOutcome) => void = () => {}
34
+ const result = new Promise<ConfirmOutcome>((resolve) => {
35
+ const channel = new BroadcastChannel(ssoConfirmChannel())
36
+ const timer = setTimeout(() => finish('failed'), SSO_CONFIRM_TIMEOUT_MS)
37
+ finish = (outcome) => {
38
+ channel.close()
39
+ clearTimeout(timer)
40
+ resolve(outcome)
41
+ }
42
+ channel.onmessage = (event: MessageEvent<{ ok?: boolean }>) => finish(event.data?.ok === true ? 'ok' : 'failed')
43
+ })
44
+ return {
45
+ result,
46
+ cancel: () => {
47
+ finish('cancelled')
48
+ try { popup.close() } catch { /* already closed or no longer ours */ }
49
+ },
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Runs sensitive API calls of an SSO session. When the API asks for a confirmation, `run` opens the identity
55
+ * provider in a popup right away and repeats the call once it succeeds; otherwise it throws with the reason.
56
+ * The popup follows the user's click within one API round trip, which browsers still count as user-initiated.
57
+ */
58
+ export function useSsoConfirmation() {
59
+ const [waiting, setWaiting] = useState(false)
60
+ const cancelRef = useRef<(() => void) | null>(null)
61
+ useEffect(() => () => cancelRef.current?.(), [])
62
+
63
+ const run = useCallback(async <T,>(action: () => Promise<T>): Promise<T> => {
64
+ try {
65
+ return await action()
66
+ } catch (error) {
67
+ if (!isSsoConfirmationRequired(error)) throw error
68
+ const { result, cancel } = openSsoConfirmation()
69
+ cancelRef.current = cancel
70
+ setWaiting(true)
71
+ const outcome = await result
72
+ cancelRef.current = null
73
+ setWaiting(false)
74
+ if (outcome !== 'ok') throw new Error(OUTCOME_MESSAGES[outcome], { cause: error })
75
+ return action()
76
+ }
77
+ }, [])
78
+
79
+ const cancel = useCallback(() => cancelRef.current?.(), [])
80
+ return { run, waiting, cancel }
81
+ }
82
+
83
+ export type SsoConfirmationState = ReturnType<typeof useSsoConfirmation>
84
+
85
+ /** Takes the place of a "current password" field in an SSO session: a note, or the popup's progress. */
86
+ export function SsoConfirmationNote({ confirmation }: { confirmation: Pick<SsoConfirmationState, 'waiting' | 'cancel'> }) {
87
+ if (!confirmation.waiting) {
88
+ return <p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>You signed in with single sign-on, so no password is needed. You may be asked to sign in again in a pop-up window.</p>
89
+ }
90
+ return (
91
+ <Alert tone="info" title="Confirm it's you">
92
+ <p>Sign in again with your identity provider in the pop-up window. This continues once you are done.</p>
93
+ <button type="button" className="btn btn-secondary btn-sm mt-3" onClick={confirmation.cancel}>Cancel</button>
94
+ </Alert>
95
+ )
96
+ }