@better-forge/ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +13 -3
- package/dist/api/client.d.ts +42 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +130 -0
- package/dist/api/client.js.map +1 -0
- package/dist/components/ConfirmIdentityModal.d.ts +18 -0
- package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
- package/dist/components/ConfirmIdentityModal.js +30 -0
- package/dist/components/ConfirmIdentityModal.js.map +1 -0
- package/dist/components/ConfirmModal.d.ts +18 -0
- package/dist/components/ConfirmModal.d.ts.map +1 -0
- package/dist/components/ConfirmModal.js +11 -0
- package/dist/components/ConfirmModal.js.map +1 -0
- package/dist/components/CopyButton.d.ts +11 -0
- package/dist/components/CopyButton.d.ts.map +1 -0
- package/dist/components/CopyButton.js +44 -0
- package/dist/components/CopyButton.js.map +1 -0
- package/dist/components/CredentialSection.d.ts +38 -0
- package/dist/components/CredentialSection.d.ts.map +1 -0
- package/dist/components/CredentialSection.js +69 -0
- package/dist/components/CredentialSection.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +25 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/ModalShell.d.ts +58 -0
- package/dist/components/ModalShell.d.ts.map +1 -0
- package/dist/components/ModalShell.js +92 -0
- package/dist/components/ModalShell.js.map +1 -0
- package/dist/components/ResetTwoFactorModal.d.ts +14 -0
- package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
- package/dist/components/ResetTwoFactorModal.js +16 -0
- package/dist/components/ResetTwoFactorModal.js.map +1 -0
- package/dist/components/SidePanel.d.ts +35 -0
- package/dist/components/SidePanel.d.ts.map +1 -0
- package/dist/components/SidePanel.js +19 -0
- package/dist/components/SidePanel.js.map +1 -0
- package/dist/components/SingleSignOnModal.d.ts +4 -0
- package/dist/components/SingleSignOnModal.d.ts.map +1 -0
- package/dist/components/SingleSignOnModal.js +64 -0
- package/dist/components/SingleSignOnModal.js.map +1 -0
- package/dist/components/SsoConfirmation.d.ts +22 -0
- package/dist/components/SsoConfirmation.d.ts.map +1 -0
- package/dist/components/SsoConfirmation.js +87 -0
- package/dist/components/SsoConfirmation.js.map +1 -0
- package/dist/components/VaultTypePicker.d.ts +13 -0
- package/dist/components/VaultTypePicker.d.ts.map +1 -0
- package/dist/components/VaultTypePicker.js +62 -0
- package/dist/components/VaultTypePicker.js.map +1 -0
- package/dist/components/WhatsNew.d.ts +18 -0
- package/dist/components/WhatsNew.d.ts.map +1 -0
- package/dist/components/WhatsNew.js +50 -0
- package/dist/components/WhatsNew.js.map +1 -0
- package/dist/components/formParts.d.ts +40 -0
- package/dist/components/formParts.d.ts.map +1 -0
- package/dist/components/formParts.js +31 -0
- package/dist/components/formParts.js.map +1 -0
- package/dist/components/ui/Alert.d.ts +15 -0
- package/dist/components/ui/Alert.d.ts.map +1 -0
- package/dist/components/ui/Alert.js +14 -0
- package/dist/components/ui/Alert.js.map +1 -0
- package/dist/components/ui/DataTable.d.ts +52 -0
- package/dist/components/ui/DataTable.d.ts.map +1 -0
- package/dist/components/ui/DataTable.js +112 -0
- package/dist/components/ui/DataTable.js.map +1 -0
- package/dist/components/ui/Field.d.ts +27 -0
- package/dist/components/ui/Field.d.ts.map +1 -0
- package/dist/components/ui/Field.js +25 -0
- package/dist/components/ui/Field.js.map +1 -0
- package/dist/components/ui/PageHeader.d.ts +18 -0
- package/dist/components/ui/PageHeader.d.ts.map +1 -0
- package/dist/components/ui/PageHeader.js +10 -0
- package/dist/components/ui/PageHeader.js.map +1 -0
- package/dist/components/ui/Pagination.d.ts +21 -0
- package/dist/components/ui/Pagination.d.ts.map +1 -0
- package/dist/components/ui/Pagination.js +10 -0
- package/dist/components/ui/Pagination.js.map +1 -0
- package/dist/components/ui/QuickStartPanel.d.ts +21 -0
- package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
- package/dist/components/ui/QuickStartPanel.js +11 -0
- package/dist/components/ui/QuickStartPanel.js.map +1 -0
- package/dist/components/ui/StateViews.d.ts +50 -0
- package/dist/components/ui/StateViews.d.ts.map +1 -0
- package/dist/components/ui/StateViews.js +33 -0
- package/dist/components/ui/StateViews.js.map +1 -0
- package/dist/components/ui/Switch.d.ts +16 -0
- package/dist/components/ui/Switch.d.ts.map +1 -0
- package/dist/components/ui/Switch.js +15 -0
- package/dist/components/ui/Switch.js.map +1 -0
- package/dist/components/ui/Toast.d.ts +20 -0
- package/dist/components/ui/Toast.d.ts.map +1 -0
- package/dist/components/ui/Toast.js +46 -0
- package/dist/components/ui/Toast.js.map +1 -0
- package/dist/components/ui/index.d.ts +10 -0
- package/dist/components/ui/index.d.ts.map +1 -0
- package/dist/components/ui/index.js +10 -0
- package/dist/components/ui/index.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/useDarkMode.d.ts +2 -0
- package/dist/hooks/useDarkMode.d.ts.map +1 -0
- package/dist/hooks/useDarkMode.js +37 -0
- package/dist/hooks/useDarkMode.js.map +1 -0
- package/dist/hooks/useDebouncedValue.d.ts +6 -0
- package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
- package/dist/hooks/useDebouncedValue.js +19 -0
- package/dist/hooks/useDebouncedValue.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changelog.d.ts +32 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +71 -0
- package/dist/lib/changelog.js.map +1 -0
- package/dist/lib/dateFormat.d.ts +24 -0
- package/dist/lib/dateFormat.d.ts.map +1 -0
- package/dist/lib/dateFormat.js +67 -0
- package/dist/lib/dateFormat.js.map +1 -0
- package/dist/navigation.d.ts +5 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +7 -0
- package/dist/navigation.js.map +1 -0
- package/dist/pages/ApiTokens.d.ts +9 -0
- package/dist/pages/ApiTokens.d.ts.map +1 -0
- package/dist/pages/ApiTokens.js +116 -0
- package/dist/pages/ApiTokens.js.map +1 -0
- package/dist/pages/AuditLog.d.ts +14 -0
- package/dist/pages/AuditLog.d.ts.map +1 -0
- package/dist/pages/AuditLog.js +104 -0
- package/dist/pages/AuditLog.js.map +1 -0
- package/dist/pages/Backups.d.ts +2 -0
- package/dist/pages/Backups.d.ts.map +1 -0
- package/dist/pages/Backups.js +118 -0
- package/dist/pages/Backups.js.map +1 -0
- package/dist/pages/ChangePassword.d.ts +2 -0
- package/dist/pages/ChangePassword.d.ts.map +1 -0
- package/dist/pages/ChangePassword.js +40 -0
- package/dist/pages/ChangePassword.js.map +1 -0
- package/dist/pages/Login.d.ts +12 -0
- package/dist/pages/Login.d.ts.map +1 -0
- package/dist/pages/Login.js +69 -0
- package/dist/pages/Login.js.map +1 -0
- package/dist/pages/Settings.d.ts +2 -0
- package/dist/pages/Settings.d.ts.map +1 -0
- package/dist/pages/Settings.js +149 -0
- package/dist/pages/Settings.js.map +1 -0
- package/dist/pages/Setup.d.ts +2 -0
- package/dist/pages/Setup.d.ts.map +1 -0
- package/dist/pages/Setup.js +113 -0
- package/dist/pages/Setup.js.map +1 -0
- package/dist/pages/SsoConfirm.d.ts +3 -0
- package/dist/pages/SsoConfirm.d.ts.map +1 -0
- package/dist/pages/SsoConfirm.js +20 -0
- package/dist/pages/SsoConfirm.js.map +1 -0
- package/dist/pages/SsoTestResult.d.ts +2 -0
- package/dist/pages/SsoTestResult.d.ts.map +1 -0
- package/dist/pages/SsoTestResult.js +49 -0
- package/dist/pages/SsoTestResult.js.map +1 -0
- package/dist/pages/Users.d.ts +22 -0
- package/dist/pages/Users.d.ts.map +1 -0
- package/dist/pages/Users.js +124 -0
- package/dist/pages/Users.js.map +1 -0
- package/dist/pages/Vault.d.ts +2 -0
- package/dist/pages/Vault.d.ts.map +1 -0
- package/dist/pages/Vault.js +372 -0
- package/dist/pages/Vault.js.map +1 -0
- package/dist/shell/AdminShell.d.ts +46 -0
- package/dist/shell/AdminShell.d.ts.map +1 -0
- package/dist/shell/AdminShell.js +76 -0
- package/dist/shell/AdminShell.js.map +1 -0
- package/dist/shell/guards.d.ts +28 -0
- package/dist/shell/guards.d.ts.map +1 -0
- package/dist/shell/guards.js +71 -0
- package/dist/shell/guards.js.map +1 -0
- package/icons.json +170 -0
- package/package.json +71 -6
- package/src/api/client.ts +139 -0
- package/src/components/ConfirmIdentityModal.tsx +82 -0
- package/src/components/ConfirmModal.tsx +83 -0
- package/src/components/CopyButton.tsx +71 -0
- package/src/components/CredentialSection.tsx +270 -0
- package/src/components/ErrorBoundary.tsx +47 -0
- package/src/components/ModalShell.tsx +160 -0
- package/src/components/ResetTwoFactorModal.tsx +74 -0
- package/src/components/SidePanel.tsx +96 -0
- package/src/components/SingleSignOnModal.tsx +161 -0
- package/src/components/SsoConfirmation.tsx +96 -0
- package/src/components/VaultTypePicker.tsx +131 -0
- package/src/components/WhatsNew.tsx +84 -0
- package/src/components/formParts.tsx +59 -0
- package/src/components/ui/Alert.tsx +45 -0
- package/src/components/ui/DataTable.tsx +225 -0
- package/src/components/ui/Field.tsx +63 -0
- package/src/components/ui/PageHeader.tsx +36 -0
- package/src/components/ui/Pagination.tsx +37 -0
- package/src/components/ui/QuickStartPanel.tsx +57 -0
- package/src/components/ui/StateViews.tsx +135 -0
- package/src/components/ui/Switch.tsx +65 -0
- package/src/components/ui/Toast.tsx +104 -0
- package/src/components/ui/index.ts +9 -0
- package/src/config.ts +46 -0
- package/src/hooks/useDarkMode.ts +38 -0
- package/src/hooks/useDebouncedValue.ts +22 -0
- package/src/index.ts +42 -0
- package/src/lib/changelog.ts +93 -0
- package/src/lib/dateFormat.ts +90 -0
- package/src/navigation.ts +6 -0
- package/src/pages/ApiTokens.tsx +361 -0
- package/src/pages/AuditLog.tsx +388 -0
- package/src/pages/Backups.tsx +170 -0
- package/src/pages/ChangePassword.tsx +84 -0
- package/src/pages/Login.tsx +275 -0
- package/src/pages/Settings.tsx +264 -0
- package/src/pages/Setup.tsx +438 -0
- package/src/pages/SsoConfirm.tsx +26 -0
- package/src/pages/SsoTestResult.tsx +104 -0
- package/src/pages/Users.tsx +426 -0
- package/src/pages/Vault.tsx +1036 -0
- package/src/shell/AdminShell.tsx +256 -0
- package/src/shell/guards.tsx +88 -0
- package/styles/base.css +521 -0
- package/styles/fonts/inter-latin-1.woff2 +0 -0
- package/styles/fonts/inter-latin-ext.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
- package/styles/fonts/manrope-latin-5.woff2 +0 -0
- package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
- package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
- package/styles/icons.css +4 -0
- package/tailwind-preset.js +79 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
|
|
3
|
+
import { SsoConfirmationNote, isSsoSession, useSsoConfirmation } from './SsoConfirmation'
|
|
4
|
+
import { Alert } from './ui'
|
|
5
|
+
|
|
6
|
+
interface ConfirmIdentityModalProps {
|
|
7
|
+
title: string
|
|
8
|
+
message: string
|
|
9
|
+
confirmLabel: string
|
|
10
|
+
pendingLabel?: string
|
|
11
|
+
danger?: boolean
|
|
12
|
+
/**
|
|
13
|
+
* Runs the action. `currentPassword` is undefined in an SSO session, which is confirmed at the identity
|
|
14
|
+
* provider instead. A rejection is shown in the dialog, which stays open for another try.
|
|
15
|
+
*/
|
|
16
|
+
action: (currentPassword: string | undefined) => Promise<unknown>
|
|
17
|
+
/** Called when the action succeeded or the user gave up. */
|
|
18
|
+
onClose: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Asks the signed-in user to prove it is them (password, or SSO) before a sensitive action goes through. */
|
|
22
|
+
export function ConfirmIdentityModal({ title, message, confirmLabel, pendingLabel = 'Working…', danger = false, action, onClose }: ConfirmIdentityModalProps) {
|
|
23
|
+
const sso = isSsoSession()
|
|
24
|
+
const ssoConfirmation = useSsoConfirmation()
|
|
25
|
+
const [currentPassword, setCurrentPassword] = useState('')
|
|
26
|
+
const [pending, setPending] = useState(false)
|
|
27
|
+
const [error, setError] = useState('')
|
|
28
|
+
const titleId = useId()
|
|
29
|
+
const canConfirm = !pending && (sso || currentPassword.length > 0)
|
|
30
|
+
|
|
31
|
+
async function confirm() {
|
|
32
|
+
setPending(true)
|
|
33
|
+
setError('')
|
|
34
|
+
try {
|
|
35
|
+
await ssoConfirmation.run(() => action(sso ? undefined : currentPassword))
|
|
36
|
+
onClose()
|
|
37
|
+
} catch (err) {
|
|
38
|
+
setError(err instanceof Error && err.message ? err.message : 'Something went wrong. Try again.')
|
|
39
|
+
setPending(false)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ModalShell onClose={pending ? undefined : onClose}>
|
|
45
|
+
<form
|
|
46
|
+
role="dialog"
|
|
47
|
+
aria-modal="true"
|
|
48
|
+
aria-labelledby={titleId}
|
|
49
|
+
aria-busy={pending || undefined}
|
|
50
|
+
className="rounded-2xl w-full max-w-md"
|
|
51
|
+
style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
|
|
52
|
+
onSubmit={(event) => { event.preventDefault(); if (canConfirm) void confirm() }}
|
|
53
|
+
>
|
|
54
|
+
<ModalHeader icon="shield_lock" titleId={titleId} title={title} onClose={pending ? () => {} : onClose} />
|
|
55
|
+
<div className="p-6 space-y-4">
|
|
56
|
+
<p className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>{message}</p>
|
|
57
|
+
{error && <Alert tone="error">{error}</Alert>}
|
|
58
|
+
{sso ? <SsoConfirmationNote confirmation={ssoConfirmation} /> : (
|
|
59
|
+
<label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
60
|
+
<span>Your current password</span>
|
|
61
|
+
<input
|
|
62
|
+
type="password"
|
|
63
|
+
autoComplete="current-password"
|
|
64
|
+
autoFocus
|
|
65
|
+
value={currentPassword}
|
|
66
|
+
disabled={pending}
|
|
67
|
+
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
68
|
+
className="input-sig w-full"
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
<ModalFooter>
|
|
74
|
+
<button type="button" onClick={onClose} disabled={pending} className="btn btn-secondary">Cancel</button>
|
|
75
|
+
<button type="submit" disabled={!canConfirm} className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}>
|
|
76
|
+
{pending ? pendingLabel : confirmLabel}
|
|
77
|
+
</button>
|
|
78
|
+
</ModalFooter>
|
|
79
|
+
</form>
|
|
80
|
+
</ModalShell>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
|
|
3
|
+
|
|
4
|
+
interface ConfirmModalProps {
|
|
5
|
+
title: string
|
|
6
|
+
message: string
|
|
7
|
+
confirmLabel?: string | undefined
|
|
8
|
+
danger?: boolean | undefined
|
|
9
|
+
confirmationText?: string | undefined
|
|
10
|
+
/**
|
|
11
|
+
* While true the modal stays open with both buttons disabled and the confirm button reading
|
|
12
|
+
* `pendingLabel`, so callers can keep it up until their mutation settles and close it in onSuccess.
|
|
13
|
+
*/
|
|
14
|
+
pending?: boolean | undefined
|
|
15
|
+
pendingLabel?: string | undefined
|
|
16
|
+
onConfirm: () => void
|
|
17
|
+
onCancel: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ConfirmModal({
|
|
21
|
+
title,
|
|
22
|
+
message,
|
|
23
|
+
confirmLabel = 'Delete',
|
|
24
|
+
danger = true,
|
|
25
|
+
confirmationText,
|
|
26
|
+
pending = false,
|
|
27
|
+
pendingLabel = 'Working…',
|
|
28
|
+
onConfirm,
|
|
29
|
+
onCancel,
|
|
30
|
+
}: ConfirmModalProps) {
|
|
31
|
+
const [typedConfirmation, setTypedConfirmation] = useState('')
|
|
32
|
+
const titleId = useId()
|
|
33
|
+
const messageId = useId()
|
|
34
|
+
const canConfirm = (!confirmationText || typedConfirmation === confirmationText) && !pending
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<ModalShell onClose={pending ? undefined : onCancel}>
|
|
38
|
+
<div
|
|
39
|
+
role="dialog"
|
|
40
|
+
aria-modal="true"
|
|
41
|
+
aria-labelledby={titleId}
|
|
42
|
+
aria-describedby={messageId}
|
|
43
|
+
aria-busy={pending || undefined}
|
|
44
|
+
className="rounded-2xl w-full max-w-sm"
|
|
45
|
+
style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
|
|
46
|
+
>
|
|
47
|
+
<ModalHeader icon={danger ? 'warning' : 'help'} titleId={titleId} title={title} onClose={pending ? () => {} : onCancel} />
|
|
48
|
+
<div className="p-6 space-y-4">
|
|
49
|
+
<p id={messageId} className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
50
|
+
{message}
|
|
51
|
+
</p>
|
|
52
|
+
{confirmationText && (
|
|
53
|
+
<label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
54
|
+
<span>Type <strong className="font-mono">{confirmationText}</strong> to confirm:</span>
|
|
55
|
+
<input
|
|
56
|
+
autoFocus
|
|
57
|
+
value={typedConfirmation}
|
|
58
|
+
disabled={pending}
|
|
59
|
+
autoComplete="off"
|
|
60
|
+
spellCheck={false}
|
|
61
|
+
onChange={(event) => setTypedConfirmation(event.target.value)}
|
|
62
|
+
className="input-m3"
|
|
63
|
+
/>
|
|
64
|
+
</label>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
<ModalFooter>
|
|
68
|
+
<button type="button" onClick={onCancel} disabled={pending} className="btn btn-secondary">
|
|
69
|
+
Cancel
|
|
70
|
+
</button>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={onConfirm}
|
|
74
|
+
disabled={!canConfirm}
|
|
75
|
+
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
|
76
|
+
>
|
|
77
|
+
{pending ? pendingLabel : confirmLabel}
|
|
78
|
+
</button>
|
|
79
|
+
</ModalFooter>
|
|
80
|
+
</div>
|
|
81
|
+
</ModalShell>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface CopyButtonProps {
|
|
4
|
+
value: string
|
|
5
|
+
label?: string | undefined
|
|
6
|
+
copiedLabel?: string | undefined
|
|
7
|
+
failedLabel?: string | undefined
|
|
8
|
+
/** Show only the icon; the label still names the button for assistive technology. */
|
|
9
|
+
iconOnly?: boolean | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type CopyState = 'idle' | 'copied' | 'failed'
|
|
13
|
+
|
|
14
|
+
/** Clipboard API first; outside a secure context (plain-HTTP deployments) fall back to a hidden textarea. */
|
|
15
|
+
async function writeClipboard(value: string) {
|
|
16
|
+
if (navigator.clipboard?.writeText) {
|
|
17
|
+
await navigator.clipboard.writeText(value)
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
const textarea = document.createElement('textarea')
|
|
21
|
+
textarea.value = value
|
|
22
|
+
textarea.setAttribute('readonly', '')
|
|
23
|
+
textarea.style.position = 'fixed'
|
|
24
|
+
textarea.style.opacity = '0'
|
|
25
|
+
document.body.appendChild(textarea)
|
|
26
|
+
textarea.select()
|
|
27
|
+
try {
|
|
28
|
+
if (!document.execCommand('copy')) throw new Error('Copy command was rejected')
|
|
29
|
+
} finally {
|
|
30
|
+
textarea.remove()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CopyButton({ value, label = 'Copy', copiedLabel = 'Copied!', failedLabel = 'Copy failed', iconOnly = false }: CopyButtonProps) {
|
|
35
|
+
const [state, setState] = useState<CopyState>('idle')
|
|
36
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
37
|
+
|
|
38
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
39
|
+
|
|
40
|
+
async function copy() {
|
|
41
|
+
let next: CopyState
|
|
42
|
+
try {
|
|
43
|
+
await writeClipboard(value)
|
|
44
|
+
next = 'copied'
|
|
45
|
+
} catch {
|
|
46
|
+
next = 'failed'
|
|
47
|
+
}
|
|
48
|
+
setState(next)
|
|
49
|
+
clearTimeout(timer.current)
|
|
50
|
+
timer.current = setTimeout(() => setState('idle'), 2000)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = state === 'copied' ? copiedLabel : state === 'failed' ? failedLabel : label
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={() => void copy()}
|
|
59
|
+
aria-label={text}
|
|
60
|
+
title={iconOnly ? text : undefined}
|
|
61
|
+
className="copy-feedback-button flex items-center gap-1.5 text-xs px-3 py-2 rounded-lg font-semibold"
|
|
62
|
+
data-copied={state === 'copied' || undefined}
|
|
63
|
+
data-failed={state === 'failed' || undefined}
|
|
64
|
+
>
|
|
65
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '14px' }}>
|
|
66
|
+
{state === 'copied' ? 'check' : state === 'failed' ? 'error' : 'content_copy'}
|
|
67
|
+
</span>
|
|
68
|
+
{!iconOnly && <span aria-live="polite">{text}</span>}
|
|
69
|
+
</button>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { VaultRef } from '@better-forge/contracts'
|
|
3
|
+
import { Field, Note, type VaultPickerProps } from './formParts'
|
|
4
|
+
|
|
5
|
+
interface CredentialSectionProps extends VaultPickerProps {
|
|
6
|
+
vault: VaultRef | undefined
|
|
7
|
+
onVaultChange: (v: VaultRef | undefined) => void
|
|
8
|
+
mappingFields: { key: string; label: string }[]
|
|
9
|
+
/** Optional note shown at the top of the vault/direct section */
|
|
10
|
+
note?: string
|
|
11
|
+
/** What a `value` secret is used as; defaults to the password / client secret. */
|
|
12
|
+
valueLabel?: string
|
|
13
|
+
/** Replaces the note shown for a User / Password secret when its username and password are not a username and password. */
|
|
14
|
+
userpassNote?: string
|
|
15
|
+
/** Restricts the secret dropdown to these types; every type is offered when omitted. */
|
|
16
|
+
secretTypes?: string[]
|
|
17
|
+
/** Direct-input fields — rendered only when not using vault */
|
|
18
|
+
children: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wraps a set of credential fields with a Direct / Vault source toggle.
|
|
23
|
+
* In direct mode: shows a warning banner + the credential inputs (children).
|
|
24
|
+
* In vault mode: shows vault → secret dropdowns + JSON field mapping if needed.
|
|
25
|
+
*/
|
|
26
|
+
export function CredentialSection({
|
|
27
|
+
vault, onVaultChange,
|
|
28
|
+
vaults, secretsByVault, onLoadSecrets,
|
|
29
|
+
mappingFields, note, valueLabel = 'password / client secret', userpassNote, secretTypes, children,
|
|
30
|
+
}: CredentialSectionProps) {
|
|
31
|
+
const isVault = !!vault
|
|
32
|
+
|
|
33
|
+
const selectedVaultId = vault?.vaultId
|
|
34
|
+
const selectedSecretId = vault?.secretId
|
|
35
|
+
const fieldMapping = vault?.fieldMapping ?? {}
|
|
36
|
+
|
|
37
|
+
const secrets = selectedVaultId ? (secretsByVault[selectedVaultId]?.filter((s) => !secretTypes || secretTypes.includes(s.type)) ?? null) : null
|
|
38
|
+
const selectedSecret = secrets?.find((s) => s.id === selectedSecretId)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="space-y-3">
|
|
42
|
+
{note && (
|
|
43
|
+
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{note}</p>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{/* Source toggle */}
|
|
47
|
+
<div className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)', width: 'fit-content' }}>
|
|
48
|
+
{(['direct', 'vault'] as const).map((src) => {
|
|
49
|
+
const active = src === 'vault' ? isVault : !isVault
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
key={src}
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => {
|
|
55
|
+
if (src === 'direct') {
|
|
56
|
+
onVaultChange(undefined)
|
|
57
|
+
} else {
|
|
58
|
+
const first = vaults[0]
|
|
59
|
+
if (first) onLoadSecrets(first.id)
|
|
60
|
+
onVaultChange({ vaultId: first?.id ?? 0, secretId: 0, fieldMapping: {} })
|
|
61
|
+
}
|
|
62
|
+
}}
|
|
63
|
+
className={`px-4 py-1.5 text-xs font-medium transition-all ${active ? 'selection-active' : ''}`}
|
|
64
|
+
style={{
|
|
65
|
+
background: active ? 'var(--m3-primary-fixed)' : 'transparent',
|
|
66
|
+
color: active ? 'var(--m3-primary)' : 'var(--m3-secondary)',
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{src === 'direct' ? 'Direct input' : 'From Vault'}
|
|
70
|
+
</button>
|
|
71
|
+
)
|
|
72
|
+
})}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Direct input */}
|
|
76
|
+
{!isVault && (
|
|
77
|
+
<>
|
|
78
|
+
<Note tone="warning">For security, store credentials in Vault rather than entering them directly here.</Note>
|
|
79
|
+
{children}
|
|
80
|
+
</>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{/* Vault picker */}
|
|
84
|
+
{isVault && (
|
|
85
|
+
<div className="space-y-3">
|
|
86
|
+
<div className="grid grid-cols-2 gap-3">
|
|
87
|
+
<Field label="Vault">
|
|
88
|
+
<select
|
|
89
|
+
className="input-sig"
|
|
90
|
+
value={selectedVaultId || ''}
|
|
91
|
+
onChange={(e) => {
|
|
92
|
+
const id = Number(e.target.value)
|
|
93
|
+
onLoadSecrets(id)
|
|
94
|
+
onVaultChange({ vaultId: id, secretId: 0, fieldMapping: {} })
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<option value="">— select vault —</option>
|
|
98
|
+
{vaults.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
99
|
+
</select>
|
|
100
|
+
</Field>
|
|
101
|
+
<Field label="Secret">
|
|
102
|
+
<select
|
|
103
|
+
className="input-sig"
|
|
104
|
+
value={selectedSecretId || ''}
|
|
105
|
+
disabled={!selectedVaultId}
|
|
106
|
+
onChange={(e) => {
|
|
107
|
+
onVaultChange({ vaultId: selectedVaultId!, secretId: Number(e.target.value), fieldMapping: {} })
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<option value="">— select secret —</option>
|
|
111
|
+
{(secrets ?? []).map((s) => (
|
|
112
|
+
<option key={s.id} value={s.id}>{s.name} ({s.type})</option>
|
|
113
|
+
))}
|
|
114
|
+
</select>
|
|
115
|
+
</Field>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* userpass — auto-mapped, no input needed */}
|
|
119
|
+
{selectedSecret?.type === 'userpass' && (
|
|
120
|
+
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
121
|
+
{userpassNote ?? <>Auto-mapped: <strong>username</strong> → username field, <strong>password</strong> → password field.</>}
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{/* value — single value used as password / client secret */}
|
|
126
|
+
{selectedSecret?.type === 'value' && (
|
|
127
|
+
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
128
|
+
The secret value will be used as the <strong>{valueLabel}</strong>.
|
|
129
|
+
</p>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* json — field mapping UI */}
|
|
133
|
+
{selectedSecret?.type === 'json' && selectedSecretId && (
|
|
134
|
+
<div>
|
|
135
|
+
<p className="font-mono text-xs uppercase tracking-wider mb-1" style={{ color: 'var(--m3-secondary)' }}>
|
|
136
|
+
JSON Field Mapping
|
|
137
|
+
</p>
|
|
138
|
+
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
|
|
139
|
+
For each credential, enter the key name from the JSON secret.
|
|
140
|
+
</p>
|
|
141
|
+
<div className="space-y-2">
|
|
142
|
+
{mappingFields.map(({ key, label }) => (
|
|
143
|
+
<div key={key} className="grid items-center gap-3" style={{ gridTemplateColumns: '110px 1fr' }}>
|
|
144
|
+
<span className="text-xs font-medium truncate" style={{ color: 'var(--m3-on-surface-variant)' }}>{label}</span>
|
|
145
|
+
<input
|
|
146
|
+
className="input-sig text-xs"
|
|
147
|
+
placeholder={`JSON key (e.g. "${key}")`}
|
|
148
|
+
value={fieldMapping[key] ?? ''}
|
|
149
|
+
onChange={(e) => {
|
|
150
|
+
onVaultChange({
|
|
151
|
+
vaultId: selectedVaultId!,
|
|
152
|
+
secretId: selectedSecretId,
|
|
153
|
+
fieldMapping: { ...fieldMapping, [key]: e.target.value },
|
|
154
|
+
})
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{vaults.length === 0 && (
|
|
164
|
+
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
165
|
+
No vaults found. Create one in the Vault section first.
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ConnectionStringSectionProps extends VaultPickerProps {
|
|
175
|
+
vault: VaultRef | undefined
|
|
176
|
+
onVaultChange: (v: VaultRef | undefined) => void
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Vault-only picker for SQL database connection strings.
|
|
181
|
+
* Direct input is intentionally not available — a connection string contains credentials.
|
|
182
|
+
*/
|
|
183
|
+
export function ConnectionStringSection({
|
|
184
|
+
vault, onVaultChange, vaults, secretsByVault, onLoadSecrets,
|
|
185
|
+
}: ConnectionStringSectionProps) {
|
|
186
|
+
const selectedVaultId = vault?.vaultId
|
|
187
|
+
const selectedSecretId = vault?.secretId
|
|
188
|
+
const fieldMapping = vault?.fieldMapping ?? {}
|
|
189
|
+
|
|
190
|
+
const secrets = selectedVaultId ? (secretsByVault[selectedVaultId] ?? null) : null
|
|
191
|
+
const selectedSecret = secrets?.find((s) => s.id === selectedSecretId)
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="space-y-3">
|
|
195
|
+
<Note tone="info">Connection strings contain credentials and must always be stored in Vault.</Note>
|
|
196
|
+
|
|
197
|
+
<div className="grid grid-cols-2 gap-3">
|
|
198
|
+
<Field label="Vault">
|
|
199
|
+
<select
|
|
200
|
+
className="input-sig"
|
|
201
|
+
value={selectedVaultId || ''}
|
|
202
|
+
onChange={(e) => {
|
|
203
|
+
const id = Number(e.target.value)
|
|
204
|
+
onLoadSecrets(id)
|
|
205
|
+
onVaultChange({ vaultId: id, secretId: 0, fieldMapping: {} })
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<option value="">— select vault —</option>
|
|
209
|
+
{vaults.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
|
210
|
+
</select>
|
|
211
|
+
</Field>
|
|
212
|
+
<Field label="Secret">
|
|
213
|
+
<select
|
|
214
|
+
className="input-sig"
|
|
215
|
+
value={selectedSecretId || ''}
|
|
216
|
+
disabled={!selectedVaultId}
|
|
217
|
+
onChange={(e) => {
|
|
218
|
+
onVaultChange({ vaultId: selectedVaultId!, secretId: Number(e.target.value), fieldMapping: {} })
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<option value="">— select secret —</option>
|
|
222
|
+
{(secrets ?? []).map((s) => (
|
|
223
|
+
<option key={s.id} value={s.id}>{s.name} ({s.type})</option>
|
|
224
|
+
))}
|
|
225
|
+
</select>
|
|
226
|
+
</Field>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{selectedSecret?.type === 'value' && (
|
|
230
|
+
<p className="text-xs rounded-lg px-3 py-2" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-secondary)' }}>
|
|
231
|
+
The secret value will be used as the full connection string.
|
|
232
|
+
</p>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{selectedSecret?.type === 'userpass' && (
|
|
236
|
+
<Note tone="warning">
|
|
237
|
+
A <strong>userpass</strong> secret cannot be used as a connection string. Use a <strong>value</strong> or <strong>json</strong> secret instead.
|
|
238
|
+
</Note>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{selectedSecret?.type === 'json' && selectedSecretId && (
|
|
242
|
+
<div>
|
|
243
|
+
<p className="font-mono text-xs uppercase tracking-wider mb-1" style={{ color: 'var(--m3-secondary)' }}>JSON Field Mapping</p>
|
|
244
|
+
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>Enter the JSON key that holds the connection string.</p>
|
|
245
|
+
<div className="grid items-center gap-3" style={{ gridTemplateColumns: '130px 1fr' }}>
|
|
246
|
+
<span className="text-xs font-medium" style={{ color: 'var(--m3-on-surface-variant)' }}>Connection String</span>
|
|
247
|
+
<input
|
|
248
|
+
className="input-sig text-xs"
|
|
249
|
+
placeholder='JSON key (e.g. "connectionString")'
|
|
250
|
+
value={fieldMapping['connectionString'] ?? ''}
|
|
251
|
+
onChange={(e) => {
|
|
252
|
+
onVaultChange({
|
|
253
|
+
vaultId: selectedVaultId!,
|
|
254
|
+
secretId: selectedSecretId,
|
|
255
|
+
fieldMapping: { connectionString: e.target.value },
|
|
256
|
+
})
|
|
257
|
+
}}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{vaults.length === 0 && (
|
|
264
|
+
<p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
265
|
+
No vaults found. Create one in the Vault section first.
|
|
266
|
+
</p>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** A lazy chunk that failed to load: after a deploy the old file names are gone, so the page needs a reload. */
|
|
4
|
+
export function isChunkLoadError(error: unknown): boolean {
|
|
5
|
+
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error)
|
|
6
|
+
return /ChunkLoadError|Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i.test(message)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface State {
|
|
14
|
+
error: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Catches errors thrown while rendering (and by lazy imports) so the screen is not left blank; offers a reload. */
|
|
18
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
19
|
+
override state: State = { error: null }
|
|
20
|
+
|
|
21
|
+
static getDerivedStateFromError(error: unknown): State {
|
|
22
|
+
return { error: error ?? new Error('Unknown error') }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
26
|
+
console.error(error, info.componentStack)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override render(): ReactNode {
|
|
30
|
+
const { error } = this.state
|
|
31
|
+
if (!error) return this.props.children
|
|
32
|
+
const stale = isChunkLoadError(error)
|
|
33
|
+
return (
|
|
34
|
+
<div role="alert" className="min-h-screen flex flex-col items-center justify-center gap-3 px-6 text-center" style={{ background: 'var(--m3-surface)', color: 'var(--m3-on-surface)' }}>
|
|
35
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '32px', color: 'var(--m3-down)' }}>error</span>
|
|
36
|
+
<h1 className="font-headline text-lg font-bold">{stale ? 'A new version is available' : 'Something went wrong'}</h1>
|
|
37
|
+
<p className="text-sm max-w-md" style={{ color: 'var(--m3-secondary)' }}>
|
|
38
|
+
{stale ? 'This page was updated while it was open. Reload to continue.' : 'The page could not be displayed. Reloading usually fixes it.'}
|
|
39
|
+
</p>
|
|
40
|
+
<button type="button" onClick={() => window.location.reload()} className="btn btn-secondary btn-sm">
|
|
41
|
+
<span className="material-symbols-outlined" aria-hidden="true">refresh</span>
|
|
42
|
+
Reload
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|