@better-forge/ui 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
|
@@ -1,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
|
+
}
|