@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
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
|
+
}
|