@better-forge/ui 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
|
-
|
|
4
|
-
export const MODAL_Z_INDEX = 9999
|
|
5
|
-
export const MODAL_BACKDROP = 'rgba(0,0,0,0.55)'
|
|
6
|
-
|
|
7
|
-
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
8
|
-
|
|
9
|
-
/** Open shells, innermost last: only the top one reacts to Escape and traps Tab. */
|
|
10
|
-
const openShells: HTMLElement[] = []
|
|
11
|
-
|
|
12
|
-
interface ModalShellProps {
|
|
13
|
-
children: ReactNode
|
|
14
|
-
/** `top` for tall forms that scroll with the page; `center` for short dialogs. */
|
|
15
|
-
align?: 'center' | 'top' | undefined
|
|
16
|
-
/** Called when Escape is pressed while this is the topmost modal. Omit to ignore Escape. */
|
|
17
|
-
onClose?: (() => void) | undefined
|
|
18
|
-
/**
|
|
19
|
-
* Accessible name. When given (or `labelledBy`), the shell itself carries role="dialog" aria-modal —
|
|
20
|
-
* use it when the child panel does not declare its own dialog role.
|
|
21
|
-
*/
|
|
22
|
-
label?: string | undefined
|
|
23
|
-
labelledBy?: string | undefined
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The single backdrop every admin modal renders through, so stacking, dimming and dismissal stay consistent:
|
|
28
|
-
* portalled to <body>, z-index 9999, a 55% black scrollable overlay, and a backdrop click never dismisses —
|
|
29
|
-
* a stray click must not throw away a half-filled form. Dialogs close from their own Cancel / × controls and,
|
|
30
|
-
* when `onClose` is passed, from Escape. Focus moves into the modal on open (unless a child already took it
|
|
31
|
-
* with autoFocus), Tab cycles inside it, and focus returns to the opener on close.
|
|
32
|
-
*/
|
|
33
|
-
export function ModalShell({ children, align = 'center', onClose, label, labelledBy }: ModalShellProps) {
|
|
34
|
-
const panelRef = useRef<HTMLDivElement>(null)
|
|
35
|
-
const onCloseRef = useRef(onClose)
|
|
36
|
-
useEffect(() => { onCloseRef.current = onClose })
|
|
37
|
-
// Read during the first render, before an autoFocus child steals focus, so closing returns it to the opener.
|
|
38
|
-
const [opener] = useState(() => document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
|
39
|
-
|
|
40
|
-
useEffect(() => {
|
|
41
|
-
const panel = panelRef.current
|
|
42
|
-
if (!panel) return
|
|
43
|
-
openShells.push(panel)
|
|
44
|
-
if (!panel.contains(document.activeElement)) panel.focus({ preventScroll: true })
|
|
45
|
-
|
|
46
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
47
|
-
if (openShells[openShells.length - 1] !== panel || event.defaultPrevented || event.isComposing) return
|
|
48
|
-
if (event.key === 'Escape') {
|
|
49
|
-
if (onCloseRef.current) {
|
|
50
|
-
event.preventDefault()
|
|
51
|
-
onCloseRef.current()
|
|
52
|
-
}
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
if (event.key !== 'Tab') return
|
|
56
|
-
const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
|
57
|
-
if (focusable.length === 0) { event.preventDefault(); return }
|
|
58
|
-
const first = focusable[0]!
|
|
59
|
-
const last = focusable[focusable.length - 1]!
|
|
60
|
-
const active = document.activeElement
|
|
61
|
-
if (event.shiftKey && (active === first || active === panel)) { event.preventDefault(); last.focus() }
|
|
62
|
-
else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus() }
|
|
63
|
-
else if (!panel.contains(active)) { event.preventDefault(); first.focus() }
|
|
64
|
-
}
|
|
65
|
-
document.addEventListener('keydown', onKeyDown)
|
|
66
|
-
return () => {
|
|
67
|
-
document.removeEventListener('keydown', onKeyDown)
|
|
68
|
-
const index = openShells.lastIndexOf(panel)
|
|
69
|
-
if (index >= 0) openShells.splice(index, 1)
|
|
70
|
-
if (opener && opener.isConnected) opener.focus({ preventScroll: true })
|
|
71
|
-
}
|
|
72
|
-
}, [opener])
|
|
73
|
-
|
|
74
|
-
const dialogProps = label || labelledBy
|
|
75
|
-
? { role: 'dialog', 'aria-modal': true, 'aria-label': label, 'aria-labelledby': labelledBy }
|
|
76
|
-
: {}
|
|
77
|
-
|
|
78
|
-
return createPortal(
|
|
79
|
-
<div data-testid="modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: MODAL_Z_INDEX, background: MODAL_BACKDROP, overflowY: 'auto' }}>
|
|
80
|
-
<div
|
|
81
|
-
ref={panelRef}
|
|
82
|
-
tabIndex={-1}
|
|
83
|
-
{...dialogProps}
|
|
84
|
-
style={{ display: 'flex', minHeight: '100%', alignItems: align === 'top' ? 'flex-start' : 'center', justifyContent: 'center', padding: '16px', outline: 'none' }}
|
|
85
|
-
>
|
|
86
|
-
{children}
|
|
87
|
-
</div>
|
|
88
|
-
</div>,
|
|
89
|
-
document.body,
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
interface ModalHeaderProps {
|
|
94
|
-
/** Material Symbols name shown before the title, so every dialog header reads the same way. */
|
|
95
|
-
icon: string
|
|
96
|
-
title: ReactNode
|
|
97
|
-
/** Id for the heading, when the dialog is labelled by it via `labelledBy`. */
|
|
98
|
-
titleId?: string | undefined
|
|
99
|
-
onClose: () => void
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The shared dialog header: icon, title and a × close button above a divider. */
|
|
103
|
-
export function ModalHeader({ icon, title, titleId, onClose }: ModalHeaderProps) {
|
|
104
|
-
return (
|
|
105
|
-
<div className="flex items-center justify-between gap-3 px-6 py-5" style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
106
|
-
<div className="flex items-center gap-3 min-w-0">
|
|
107
|
-
<span className="material-symbols-outlined shrink-0" aria-hidden="true" style={{ fontSize: '22px', color: 'var(--m3-primary)' }}>{icon}</span>
|
|
108
|
-
<h3 id={titleId} className="font-headline font-bold text-lg break-words min-w-0" style={{ color: 'var(--m3-on-surface)' }}>{title}</h3>
|
|
109
|
-
</div>
|
|
110
|
-
<button type="button" onClick={onClose} aria-label="Close" className="btn-icon shrink-0">
|
|
111
|
-
<span className="material-symbols-outlined" aria-hidden="true">close</span>
|
|
112
|
-
</button>
|
|
113
|
-
</div>
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
interface ModalProps {
|
|
118
|
-
title: string
|
|
119
|
-
/** Material Symbols name for the header icon. */
|
|
120
|
-
icon: string
|
|
121
|
-
onClose: () => void
|
|
122
|
-
children: ReactNode
|
|
123
|
-
/** Skip the padded body so the content can bring its own, with a `ModalFooter` as the last thing in the dialog. */
|
|
124
|
-
flush?: boolean
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** A titled panel with a close button inside the shared shell, for simple forms. Escape closes it. */
|
|
128
|
-
export function Modal({ title, icon, onClose, children, flush }: ModalProps) {
|
|
129
|
-
return (
|
|
130
|
-
<ModalShell onClose={onClose}>
|
|
131
|
-
<div role="dialog" aria-modal="true" aria-label={title} className="w-full max-w-md rounded-2xl" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
132
|
-
<ModalHeader icon={icon} title={title} onClose={onClose} />
|
|
133
|
-
{flush ? children : <div className="p-6">{children}</div>}
|
|
134
|
-
</div>
|
|
135
|
-
</ModalShell>
|
|
136
|
-
)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
interface ModalFooterProps {
|
|
140
|
-
/** Secondary tools on the left (YAML, Test, …); kept apart from the buttons that close or save. */
|
|
141
|
-
start?: ReactNode
|
|
142
|
-
/** Cancel and the main action, on the right. */
|
|
143
|
-
children: ReactNode
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* The action row of a form dialog, stuck to the bottom of the screen while the form scrolls, so the main
|
|
148
|
-
* action never hides below the fold. Put it last inside the `<form>`, after the padded body.
|
|
149
|
-
*/
|
|
150
|
-
export function ModalFooter({ start, children }: ModalFooterProps) {
|
|
151
|
-
return (
|
|
152
|
-
<div
|
|
153
|
-
className="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-4"
|
|
154
|
-
style={{ position: 'sticky', bottom: 0, zIndex: 1, background: 'var(--m3-surface-container-low)', borderTop: '1px solid var(--m3-outline-variant)', borderRadius: '0 0 15px 15px' }}
|
|
155
|
-
>
|
|
156
|
-
{start ? <div className="flex flex-wrap gap-3">{start}</div> : <span />}
|
|
157
|
-
<div className="flex flex-wrap gap-3 ml-auto">{children}</div>
|
|
158
|
-
</div>
|
|
159
|
-
)
|
|
160
|
-
}
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
|
|
4
|
+
export const MODAL_Z_INDEX = 9999
|
|
5
|
+
export const MODAL_BACKDROP = 'rgba(0,0,0,0.55)'
|
|
6
|
+
|
|
7
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
8
|
+
|
|
9
|
+
/** Open shells, innermost last: only the top one reacts to Escape and traps Tab. */
|
|
10
|
+
const openShells: HTMLElement[] = []
|
|
11
|
+
|
|
12
|
+
interface ModalShellProps {
|
|
13
|
+
children: ReactNode
|
|
14
|
+
/** `top` for tall forms that scroll with the page; `center` for short dialogs. */
|
|
15
|
+
align?: 'center' | 'top' | undefined
|
|
16
|
+
/** Called when Escape is pressed while this is the topmost modal. Omit to ignore Escape. */
|
|
17
|
+
onClose?: (() => void) | undefined
|
|
18
|
+
/**
|
|
19
|
+
* Accessible name. When given (or `labelledBy`), the shell itself carries role="dialog" aria-modal —
|
|
20
|
+
* use it when the child panel does not declare its own dialog role.
|
|
21
|
+
*/
|
|
22
|
+
label?: string | undefined
|
|
23
|
+
labelledBy?: string | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The single backdrop every admin modal renders through, so stacking, dimming and dismissal stay consistent:
|
|
28
|
+
* portalled to <body>, z-index 9999, a 55% black scrollable overlay, and a backdrop click never dismisses —
|
|
29
|
+
* a stray click must not throw away a half-filled form. Dialogs close from their own Cancel / × controls and,
|
|
30
|
+
* when `onClose` is passed, from Escape. Focus moves into the modal on open (unless a child already took it
|
|
31
|
+
* with autoFocus), Tab cycles inside it, and focus returns to the opener on close.
|
|
32
|
+
*/
|
|
33
|
+
export function ModalShell({ children, align = 'center', onClose, label, labelledBy }: ModalShellProps) {
|
|
34
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
35
|
+
const onCloseRef = useRef(onClose)
|
|
36
|
+
useEffect(() => { onCloseRef.current = onClose })
|
|
37
|
+
// Read during the first render, before an autoFocus child steals focus, so closing returns it to the opener.
|
|
38
|
+
const [opener] = useState(() => document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const panel = panelRef.current
|
|
42
|
+
if (!panel) return
|
|
43
|
+
openShells.push(panel)
|
|
44
|
+
if (!panel.contains(document.activeElement)) panel.focus({ preventScroll: true })
|
|
45
|
+
|
|
46
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
47
|
+
if (openShells[openShells.length - 1] !== panel || event.defaultPrevented || event.isComposing) return
|
|
48
|
+
if (event.key === 'Escape') {
|
|
49
|
+
if (onCloseRef.current) {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
onCloseRef.current()
|
|
52
|
+
}
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (event.key !== 'Tab') return
|
|
56
|
+
const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
|
57
|
+
if (focusable.length === 0) { event.preventDefault(); return }
|
|
58
|
+
const first = focusable[0]!
|
|
59
|
+
const last = focusable[focusable.length - 1]!
|
|
60
|
+
const active = document.activeElement
|
|
61
|
+
if (event.shiftKey && (active === first || active === panel)) { event.preventDefault(); last.focus() }
|
|
62
|
+
else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus() }
|
|
63
|
+
else if (!panel.contains(active)) { event.preventDefault(); first.focus() }
|
|
64
|
+
}
|
|
65
|
+
document.addEventListener('keydown', onKeyDown)
|
|
66
|
+
return () => {
|
|
67
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
68
|
+
const index = openShells.lastIndexOf(panel)
|
|
69
|
+
if (index >= 0) openShells.splice(index, 1)
|
|
70
|
+
if (opener && opener.isConnected) opener.focus({ preventScroll: true })
|
|
71
|
+
}
|
|
72
|
+
}, [opener])
|
|
73
|
+
|
|
74
|
+
const dialogProps = label || labelledBy
|
|
75
|
+
? { role: 'dialog', 'aria-modal': true, 'aria-label': label, 'aria-labelledby': labelledBy }
|
|
76
|
+
: {}
|
|
77
|
+
|
|
78
|
+
return createPortal(
|
|
79
|
+
<div data-testid="modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: MODAL_Z_INDEX, background: MODAL_BACKDROP, overflowY: 'auto' }}>
|
|
80
|
+
<div
|
|
81
|
+
ref={panelRef}
|
|
82
|
+
tabIndex={-1}
|
|
83
|
+
{...dialogProps}
|
|
84
|
+
style={{ display: 'flex', minHeight: '100%', alignItems: align === 'top' ? 'flex-start' : 'center', justifyContent: 'center', padding: '16px', outline: 'none' }}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
</div>,
|
|
89
|
+
document.body,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ModalHeaderProps {
|
|
94
|
+
/** Material Symbols name shown before the title, so every dialog header reads the same way. */
|
|
95
|
+
icon: string
|
|
96
|
+
title: ReactNode
|
|
97
|
+
/** Id for the heading, when the dialog is labelled by it via `labelledBy`. */
|
|
98
|
+
titleId?: string | undefined
|
|
99
|
+
onClose: () => void
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The shared dialog header: icon, title and a × close button above a divider. */
|
|
103
|
+
export function ModalHeader({ icon, title, titleId, onClose }: ModalHeaderProps) {
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex items-center justify-between gap-3 px-6 py-5" style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
106
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
107
|
+
<span className="material-symbols-outlined shrink-0" aria-hidden="true" style={{ fontSize: '22px', color: 'var(--m3-primary)' }}>{icon}</span>
|
|
108
|
+
<h3 id={titleId} className="font-headline font-bold text-lg break-words min-w-0" style={{ color: 'var(--m3-on-surface)' }}>{title}</h3>
|
|
109
|
+
</div>
|
|
110
|
+
<button type="button" onClick={onClose} aria-label="Close" className="btn-icon shrink-0">
|
|
111
|
+
<span className="material-symbols-outlined" aria-hidden="true">close</span>
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ModalProps {
|
|
118
|
+
title: string
|
|
119
|
+
/** Material Symbols name for the header icon. */
|
|
120
|
+
icon: string
|
|
121
|
+
onClose: () => void
|
|
122
|
+
children: ReactNode
|
|
123
|
+
/** Skip the padded body so the content can bring its own, with a `ModalFooter` as the last thing in the dialog. */
|
|
124
|
+
flush?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A titled panel with a close button inside the shared shell, for simple forms. Escape closes it. */
|
|
128
|
+
export function Modal({ title, icon, onClose, children, flush }: ModalProps) {
|
|
129
|
+
return (
|
|
130
|
+
<ModalShell onClose={onClose}>
|
|
131
|
+
<div role="dialog" aria-modal="true" aria-label={title} className="w-full max-w-md rounded-2xl" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
132
|
+
<ModalHeader icon={icon} title={title} onClose={onClose} />
|
|
133
|
+
{flush ? children : <div className="p-6">{children}</div>}
|
|
134
|
+
</div>
|
|
135
|
+
</ModalShell>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface ModalFooterProps {
|
|
140
|
+
/** Secondary tools on the left (YAML, Test, …); kept apart from the buttons that close or save. */
|
|
141
|
+
start?: ReactNode
|
|
142
|
+
/** Cancel and the main action, on the right. */
|
|
143
|
+
children: ReactNode
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The action row of a form dialog, stuck to the bottom of the screen while the form scrolls, so the main
|
|
148
|
+
* action never hides below the fold. Put it last inside the `<form>`, after the padded body.
|
|
149
|
+
*/
|
|
150
|
+
export function ModalFooter({ start, children }: ModalFooterProps) {
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
className="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-4"
|
|
154
|
+
style={{ position: 'sticky', bottom: 0, zIndex: 1, background: 'var(--m3-surface-container-low)', borderTop: '1px solid var(--m3-outline-variant)', borderRadius: '0 0 15px 15px' }}
|
|
155
|
+
>
|
|
156
|
+
{start ? <div className="flex flex-wrap gap-3">{start}</div> : <span />}
|
|
157
|
+
<div className="flex flex-wrap gap-3 ml-auto">{children}</div>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
import { useId, useState } from 'react'
|
|
2
|
-
import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
|
|
3
|
-
import { SsoConfirmationNote, isSsoSession, type SsoConfirmationState } from './SsoConfirmation'
|
|
4
|
-
import { Alert } from './ui'
|
|
5
|
-
|
|
6
|
-
const IDLE_CONFIRMATION = { waiting: false, cancel: () => {} }
|
|
7
|
-
|
|
8
|
-
interface ResetTwoFactorModalProps {
|
|
9
|
-
email: string
|
|
10
|
-
pending: boolean
|
|
11
|
-
error?: string
|
|
12
|
-
/** Progress of the SSO confirmation popup, which `onConfirm` opens when the API asks for it. */
|
|
13
|
-
ssoConfirmation?: Pick<SsoConfirmationState, 'waiting' | 'cancel'>
|
|
14
|
-
/** `currentPassword` is undefined in an SSO session, which is confirmed at the identity provider instead. */
|
|
15
|
-
onConfirm: (currentPassword: string | undefined) => void
|
|
16
|
-
onCancel: () => void
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function ResetTwoFactorModal({ email, pending, error, ssoConfirmation = IDLE_CONFIRMATION, onConfirm, onCancel }: ResetTwoFactorModalProps) {
|
|
20
|
-
const sso = isSsoSession()
|
|
21
|
-
const [currentPassword, setCurrentPassword] = useState('')
|
|
22
|
-
const [typedEmail, setTypedEmail] = useState('')
|
|
23
|
-
const titleId = useId()
|
|
24
|
-
const canConfirm = !pending && (sso || currentPassword.length > 0) && typedEmail === email
|
|
25
|
-
const confirm = () => onConfirm(sso ? undefined : currentPassword)
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<ModalShell onClose={pending ? undefined : onCancel}>
|
|
29
|
-
<div
|
|
30
|
-
role="dialog"
|
|
31
|
-
aria-modal="true"
|
|
32
|
-
aria-labelledby={titleId}
|
|
33
|
-
aria-busy={pending || undefined}
|
|
34
|
-
className="rounded-2xl w-full max-w-md"
|
|
35
|
-
style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
|
|
36
|
-
>
|
|
37
|
-
<ModalHeader icon="shield_lock" titleId={titleId} title="Reset two-factor authentication" onClose={pending ? () => {} : onCancel} />
|
|
38
|
-
<div className="p-6 space-y-4">
|
|
39
|
-
<div>
|
|
40
|
-
<p className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
41
|
-
This removes 2FA from <strong>{email}</strong> and signs out all of their active sessions. Their password will not change.
|
|
42
|
-
</p>
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
{error && <Alert tone="error">{error}</Alert>}
|
|
46
|
-
|
|
47
|
-
{sso ? <SsoConfirmationNote confirmation={ssoConfirmation} /> : <label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
48
|
-
<span>Your current password</span>
|
|
49
|
-
<input
|
|
50
|
-
type="password"
|
|
51
|
-
autoComplete="current-password"
|
|
52
|
-
autoFocus
|
|
53
|
-
value={currentPassword}
|
|
54
|
-
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
55
|
-
className="input-sig w-full"
|
|
56
|
-
/>
|
|
57
|
-
</label>}
|
|
58
|
-
|
|
59
|
-
<label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
60
|
-
<span>Type <strong className="font-mono">{email}</strong> to confirm</span>
|
|
61
|
-
<input value={typedEmail} onChange={(event) => setTypedEmail(event.target.value)} autoFocus={sso} autoComplete="off" spellCheck={false} className="input-sig w-full" />
|
|
62
|
-
</label>
|
|
63
|
-
|
|
64
|
-
</div>
|
|
65
|
-
<ModalFooter>
|
|
66
|
-
<button type="button" onClick={onCancel} disabled={pending} className="btn btn-secondary">Cancel</button>
|
|
67
|
-
<button type="button" onClick={confirm} disabled={!canConfirm} className="btn btn-danger-outline">
|
|
68
|
-
{pending ? 'Resetting…' : 'Reset 2FA'}
|
|
69
|
-
</button>
|
|
70
|
-
</ModalFooter>
|
|
71
|
-
</div>
|
|
72
|
-
</ModalShell>
|
|
73
|
-
)
|
|
74
|
-
}
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
|
|
3
|
+
import { SsoConfirmationNote, isSsoSession, type SsoConfirmationState } from './SsoConfirmation'
|
|
4
|
+
import { Alert } from './ui'
|
|
5
|
+
|
|
6
|
+
const IDLE_CONFIRMATION = { waiting: false, cancel: () => {} }
|
|
7
|
+
|
|
8
|
+
interface ResetTwoFactorModalProps {
|
|
9
|
+
email: string
|
|
10
|
+
pending: boolean
|
|
11
|
+
error?: string
|
|
12
|
+
/** Progress of the SSO confirmation popup, which `onConfirm` opens when the API asks for it. */
|
|
13
|
+
ssoConfirmation?: Pick<SsoConfirmationState, 'waiting' | 'cancel'>
|
|
14
|
+
/** `currentPassword` is undefined in an SSO session, which is confirmed at the identity provider instead. */
|
|
15
|
+
onConfirm: (currentPassword: string | undefined) => void
|
|
16
|
+
onCancel: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ResetTwoFactorModal({ email, pending, error, ssoConfirmation = IDLE_CONFIRMATION, onConfirm, onCancel }: ResetTwoFactorModalProps) {
|
|
20
|
+
const sso = isSsoSession()
|
|
21
|
+
const [currentPassword, setCurrentPassword] = useState('')
|
|
22
|
+
const [typedEmail, setTypedEmail] = useState('')
|
|
23
|
+
const titleId = useId()
|
|
24
|
+
const canConfirm = !pending && (sso || currentPassword.length > 0) && typedEmail === email
|
|
25
|
+
const confirm = () => onConfirm(sso ? undefined : currentPassword)
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<ModalShell onClose={pending ? undefined : onCancel}>
|
|
29
|
+
<div
|
|
30
|
+
role="dialog"
|
|
31
|
+
aria-modal="true"
|
|
32
|
+
aria-labelledby={titleId}
|
|
33
|
+
aria-busy={pending || undefined}
|
|
34
|
+
className="rounded-2xl w-full max-w-md"
|
|
35
|
+
style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
|
|
36
|
+
>
|
|
37
|
+
<ModalHeader icon="shield_lock" titleId={titleId} title="Reset two-factor authentication" onClose={pending ? () => {} : onCancel} />
|
|
38
|
+
<div className="p-6 space-y-4">
|
|
39
|
+
<div>
|
|
40
|
+
<p className="text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
41
|
+
This removes 2FA from <strong>{email}</strong> and signs out all of their active sessions. Their password will not change.
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{error && <Alert tone="error">{error}</Alert>}
|
|
46
|
+
|
|
47
|
+
{sso ? <SsoConfirmationNote confirmation={ssoConfirmation} /> : <label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
48
|
+
<span>Your current password</span>
|
|
49
|
+
<input
|
|
50
|
+
type="password"
|
|
51
|
+
autoComplete="current-password"
|
|
52
|
+
autoFocus
|
|
53
|
+
value={currentPassword}
|
|
54
|
+
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
55
|
+
className="input-sig w-full"
|
|
56
|
+
/>
|
|
57
|
+
</label>}
|
|
58
|
+
|
|
59
|
+
<label className="block space-y-2 text-sm" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
60
|
+
<span>Type <strong className="font-mono">{email}</strong> to confirm</span>
|
|
61
|
+
<input value={typedEmail} onChange={(event) => setTypedEmail(event.target.value)} autoFocus={sso} autoComplete="off" spellCheck={false} className="input-sig w-full" />
|
|
62
|
+
</label>
|
|
63
|
+
|
|
64
|
+
</div>
|
|
65
|
+
<ModalFooter>
|
|
66
|
+
<button type="button" onClick={onCancel} disabled={pending} className="btn btn-secondary">Cancel</button>
|
|
67
|
+
<button type="button" onClick={confirm} disabled={!canConfirm} className="btn btn-danger-outline">
|
|
68
|
+
{pending ? 'Resetting…' : 'Reset 2FA'}
|
|
69
|
+
</button>
|
|
70
|
+
</ModalFooter>
|
|
71
|
+
</div>
|
|
72
|
+
</ModalShell>
|
|
73
|
+
)
|
|
74
|
+
}
|