@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
package/src/api/client.ts CHANGED
@@ -1,139 +1,139 @@
1
- import { safeSessionStorage } from '@better-forge/contracts'
2
- import { storageKey, uiConfig } from '../config'
3
- import { navigation } from '../navigation'
4
-
5
- const userKey = () => storageKey('auth-user')
6
-
7
- export interface AuthUser {
8
- userId: number
9
- email: string
10
- role: string
11
- mustChangePassword: boolean
12
- twoFactorEnabled: boolean
13
- authMethod?: 'password' | 'oidc'
14
- }
15
-
16
- export function setSession(user: AuthUser): void {
17
- safeSessionStorage.set(userKey(), JSON.stringify(user))
18
- window.dispatchEvent(new CustomEvent(storageKey('auth-change')))
19
- }
20
-
21
- export function clearSession(): void {
22
- safeSessionStorage.remove(userKey())
23
- window.dispatchEvent(new CustomEvent(storageKey('auth-change')))
24
- }
25
-
26
- export function getCurrentUser(): AuthUser | null {
27
- try {
28
- const stored = safeSessionStorage.get(userKey())
29
- return stored ? JSON.parse(stored) as AuthUser : null
30
- } catch { return null }
31
- }
32
-
33
- export function isAuthenticated(): boolean {
34
- return getCurrentUser() !== null
35
- }
36
-
37
- /**
38
- * Where a signed-in user starts. One who does not belong in this interface goes where the application sends them
39
- * (which may also have them replace a temporary password); everyone else replaces it here first.
40
- */
41
- export function enterAfterSignIn(user: AuthUser, navigate: (path: string) => void): void {
42
- const elsewhere = uiConfig().redirectAfterSignIn?.(user) ?? null
43
- if (elsewhere) navigation.assign(elsewhere)
44
- else if (user.mustChangePassword) navigate(`${uiConfig().basePath}/change-password`)
45
- else navigate(`${uiConfig().basePath}/`)
46
- }
47
-
48
- export function mustChangePassword(): boolean {
49
- return !!getCurrentUser()?.mustChangePassword
50
- }
51
-
52
- /**
53
- * A failed API call: `message` is the server's error text, `code` its machine-readable code when it sent one, and
54
- * `body` the whole JSON answer, for the endpoints that say more than a message (a list of problems, say).
55
- */
56
- export class ApiError extends Error {
57
- constructor(message: string, readonly status: number, readonly code?: string, readonly body?: unknown) {
58
- super(message)
59
- this.name = 'ApiError'
60
- }
61
- }
62
-
63
- function cookie(name: string): string | null {
64
- const prefix = `${encodeURIComponent(name)}=`
65
- const part = document.cookie.split('; ').find((item) => item.startsWith(prefix))
66
- return part ? decodeURIComponent(part.slice(prefix.length)) : null
67
- }
68
-
69
- async function request<T>(
70
- method: string,
71
- path: string,
72
- body?: unknown,
73
- isFormData = false,
74
- /** Sends `body` (a string) as it is with this content type, instead of as JSON. */
75
- contentType?: string,
76
- /** Reads the answer as text instead of JSON. */
77
- asText = false,
78
- ): Promise<T> {
79
- const headers: Record<string, string> = {}
80
- if (contentType) headers['Content-Type'] = contentType
81
- else if (!isFormData && body !== undefined) headers['Content-Type'] = 'application/json'
82
- if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
83
- const csrf = cookie(uiConfig().csrfCookie)
84
- if (csrf) headers['X-CSRF-Token'] = csrf
85
- }
86
-
87
- const fetchBody = contentType ? (body as string) : isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : null
88
- const res = await fetch(`${uiConfig().apiBase}${path}`, {
89
- method,
90
- headers,
91
- credentials: 'same-origin',
92
- ...(fetchBody !== null ? { body: fetchBody } : {}),
93
- })
94
-
95
- if (!res.ok) {
96
- const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string; code?: string }
97
- if (res.status === 403 && err.code === 'PASSWORD_CHANGE_REQUIRED') {
98
- const user = getCurrentUser()
99
- if (user && !user.mustChangePassword) setSession({ ...user, mustChangePassword: true })
100
- if (!window.location.pathname.includes('/change-password')) {
101
- window.location.href = `${uiConfig().basePath}/change-password`
102
- }
103
- }
104
- if (res.status === 401) {
105
- clearSession()
106
- const isLoginRequest = path === '/auth/login' || path === '/auth/2fa/verify'
107
- if (!isLoginRequest && !window.location.pathname.includes('/login')) {
108
- window.location.href = `${uiConfig().basePath}/login`
109
- }
110
- }
111
- throw new ApiError(err.error ?? res.statusText, res.status, err.code, err)
112
- }
113
-
114
- if (res.status === 204) return undefined as T
115
- return (asText ? res.text() : res.json()) as Promise<T>
116
- }
117
-
118
- export const api = {
119
- get: <T>(path: string) => request<T>('GET', path),
120
- /** GET an answer that is text, for example a YAML document. */
121
- getText: (path: string) => request<string>('GET', path, undefined, false, undefined, true),
122
- post: <T>(path: string, body: unknown = {}) => request<T>('POST', path, body),
123
- patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
124
- put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
125
- delete: (path: string) => request<void>('DELETE', path),
126
- upload: <T>(path: string, formData: FormData) => request<T>('POST', path, formData, true),
127
- /** POST a text body as it is, for example a YAML file, with the given content type. */
128
- postText: <T>(path: string, text: string, contentType: string) => request<T>('POST', path, text, false, contentType),
129
- download: async (path: string, filename: string) => {
130
- const res = await fetch(`${uiConfig().apiBase}${path}`, { credentials: 'same-origin' })
131
- if (!res.ok) throw new Error('Download failed')
132
- const url = URL.createObjectURL(await res.blob())
133
- const link = document.createElement('a')
134
- link.href = url
135
- link.download = filename
136
- link.click()
137
- URL.revokeObjectURL(url)
138
- },
139
- }
1
+ import { safeSessionStorage } from '@better-forge/contracts'
2
+ import { storageKey, uiConfig } from '../config'
3
+ import { navigation } from '../navigation'
4
+
5
+ const userKey = () => storageKey('auth-user')
6
+
7
+ export interface AuthUser {
8
+ userId: number
9
+ email: string
10
+ role: string
11
+ mustChangePassword: boolean
12
+ twoFactorEnabled: boolean
13
+ authMethod?: 'password' | 'oidc'
14
+ }
15
+
16
+ export function setSession(user: AuthUser): void {
17
+ safeSessionStorage.set(userKey(), JSON.stringify(user))
18
+ window.dispatchEvent(new CustomEvent(storageKey('auth-change')))
19
+ }
20
+
21
+ export function clearSession(): void {
22
+ safeSessionStorage.remove(userKey())
23
+ window.dispatchEvent(new CustomEvent(storageKey('auth-change')))
24
+ }
25
+
26
+ export function getCurrentUser(): AuthUser | null {
27
+ try {
28
+ const stored = safeSessionStorage.get(userKey())
29
+ return stored ? JSON.parse(stored) as AuthUser : null
30
+ } catch { return null }
31
+ }
32
+
33
+ export function isAuthenticated(): boolean {
34
+ return getCurrentUser() !== null
35
+ }
36
+
37
+ /**
38
+ * Where a signed-in user starts. One who does not belong in this interface goes where the application sends them
39
+ * (which may also have them replace a temporary password); everyone else replaces it here first.
40
+ */
41
+ export function enterAfterSignIn(user: AuthUser, navigate: (path: string) => void): void {
42
+ const elsewhere = uiConfig().redirectAfterSignIn?.(user) ?? null
43
+ if (elsewhere) navigation.assign(elsewhere)
44
+ else if (user.mustChangePassword) navigate(`${uiConfig().basePath}/change-password`)
45
+ else navigate(`${uiConfig().basePath}/`)
46
+ }
47
+
48
+ export function mustChangePassword(): boolean {
49
+ return !!getCurrentUser()?.mustChangePassword
50
+ }
51
+
52
+ /**
53
+ * A failed API call: `message` is the server's error text, `code` its machine-readable code when it sent one, and
54
+ * `body` the whole JSON answer, for the endpoints that say more than a message (a list of problems, say).
55
+ */
56
+ export class ApiError extends Error {
57
+ constructor(message: string, readonly status: number, readonly code?: string, readonly body?: unknown) {
58
+ super(message)
59
+ this.name = 'ApiError'
60
+ }
61
+ }
62
+
63
+ function cookie(name: string): string | null {
64
+ const prefix = `${encodeURIComponent(name)}=`
65
+ const part = document.cookie.split('; ').find((item) => item.startsWith(prefix))
66
+ return part ? decodeURIComponent(part.slice(prefix.length)) : null
67
+ }
68
+
69
+ async function request<T>(
70
+ method: string,
71
+ path: string,
72
+ body?: unknown,
73
+ isFormData = false,
74
+ /** Sends `body` (a string) as it is with this content type, instead of as JSON. */
75
+ contentType?: string,
76
+ /** Reads the answer as text instead of JSON. */
77
+ asText = false,
78
+ ): Promise<T> {
79
+ const headers: Record<string, string> = {}
80
+ if (contentType) headers['Content-Type'] = contentType
81
+ else if (!isFormData && body !== undefined) headers['Content-Type'] = 'application/json'
82
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
83
+ const csrf = cookie(uiConfig().csrfCookie)
84
+ if (csrf) headers['X-CSRF-Token'] = csrf
85
+ }
86
+
87
+ const fetchBody = contentType ? (body as string) : isFormData ? (body as FormData) : body !== undefined ? JSON.stringify(body) : null
88
+ const res = await fetch(`${uiConfig().apiBase}${path}`, {
89
+ method,
90
+ headers,
91
+ credentials: 'same-origin',
92
+ ...(fetchBody !== null ? { body: fetchBody } : {}),
93
+ })
94
+
95
+ if (!res.ok) {
96
+ const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string; code?: string }
97
+ if (res.status === 403 && err.code === 'PASSWORD_CHANGE_REQUIRED') {
98
+ const user = getCurrentUser()
99
+ if (user && !user.mustChangePassword) setSession({ ...user, mustChangePassword: true })
100
+ if (!window.location.pathname.includes('/change-password')) {
101
+ window.location.href = `${uiConfig().basePath}/change-password`
102
+ }
103
+ }
104
+ if (res.status === 401) {
105
+ clearSession()
106
+ const isLoginRequest = path === '/auth/login' || path === '/auth/2fa/verify'
107
+ if (!isLoginRequest && !window.location.pathname.includes('/login')) {
108
+ window.location.href = `${uiConfig().basePath}/login`
109
+ }
110
+ }
111
+ throw new ApiError(err.error ?? res.statusText, res.status, err.code, err)
112
+ }
113
+
114
+ if (res.status === 204) return undefined as T
115
+ return (asText ? res.text() : res.json()) as Promise<T>
116
+ }
117
+
118
+ export const api = {
119
+ get: <T>(path: string) => request<T>('GET', path),
120
+ /** GET an answer that is text, for example a YAML document. */
121
+ getText: (path: string) => request<string>('GET', path, undefined, false, undefined, true),
122
+ post: <T>(path: string, body: unknown = {}) => request<T>('POST', path, body),
123
+ patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
124
+ put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
125
+ delete: (path: string) => request<void>('DELETE', path),
126
+ upload: <T>(path: string, formData: FormData) => request<T>('POST', path, formData, true),
127
+ /** POST a text body as it is, for example a YAML file, with the given content type. */
128
+ postText: <T>(path: string, text: string, contentType: string) => request<T>('POST', path, text, false, contentType),
129
+ download: async (path: string, filename: string) => {
130
+ const res = await fetch(`${uiConfig().apiBase}${path}`, { credentials: 'same-origin' })
131
+ if (!res.ok) throw new Error('Download failed')
132
+ const url = URL.createObjectURL(await res.blob())
133
+ const link = document.createElement('a')
134
+ link.href = url
135
+ link.download = filename
136
+ link.click()
137
+ URL.revokeObjectURL(url)
138
+ },
139
+ }
@@ -1,82 +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
- }
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
+ }
@@ -1,83 +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
- }
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
+ }