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