@better-forge/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,96 +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
- }
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
+ }
@@ -1,131 +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
- }
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
+ }
@@ -1,84 +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
- }
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
+ }