@better-forge/ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +13 -3
- package/dist/api/client.d.ts +42 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +130 -0
- package/dist/api/client.js.map +1 -0
- package/dist/components/ConfirmIdentityModal.d.ts +18 -0
- package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
- package/dist/components/ConfirmIdentityModal.js +30 -0
- package/dist/components/ConfirmIdentityModal.js.map +1 -0
- package/dist/components/ConfirmModal.d.ts +18 -0
- package/dist/components/ConfirmModal.d.ts.map +1 -0
- package/dist/components/ConfirmModal.js +11 -0
- package/dist/components/ConfirmModal.js.map +1 -0
- package/dist/components/CopyButton.d.ts +11 -0
- package/dist/components/CopyButton.d.ts.map +1 -0
- package/dist/components/CopyButton.js +44 -0
- package/dist/components/CopyButton.js.map +1 -0
- package/dist/components/CredentialSection.d.ts +38 -0
- package/dist/components/CredentialSection.d.ts.map +1 -0
- package/dist/components/CredentialSection.js +69 -0
- package/dist/components/CredentialSection.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +25 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/ModalShell.d.ts +58 -0
- package/dist/components/ModalShell.d.ts.map +1 -0
- package/dist/components/ModalShell.js +92 -0
- package/dist/components/ModalShell.js.map +1 -0
- package/dist/components/ResetTwoFactorModal.d.ts +14 -0
- package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
- package/dist/components/ResetTwoFactorModal.js +16 -0
- package/dist/components/ResetTwoFactorModal.js.map +1 -0
- package/dist/components/SidePanel.d.ts +35 -0
- package/dist/components/SidePanel.d.ts.map +1 -0
- package/dist/components/SidePanel.js +19 -0
- package/dist/components/SidePanel.js.map +1 -0
- package/dist/components/SingleSignOnModal.d.ts +4 -0
- package/dist/components/SingleSignOnModal.d.ts.map +1 -0
- package/dist/components/SingleSignOnModal.js +64 -0
- package/dist/components/SingleSignOnModal.js.map +1 -0
- package/dist/components/SsoConfirmation.d.ts +22 -0
- package/dist/components/SsoConfirmation.d.ts.map +1 -0
- package/dist/components/SsoConfirmation.js +87 -0
- package/dist/components/SsoConfirmation.js.map +1 -0
- package/dist/components/VaultTypePicker.d.ts +13 -0
- package/dist/components/VaultTypePicker.d.ts.map +1 -0
- package/dist/components/VaultTypePicker.js +62 -0
- package/dist/components/VaultTypePicker.js.map +1 -0
- package/dist/components/WhatsNew.d.ts +18 -0
- package/dist/components/WhatsNew.d.ts.map +1 -0
- package/dist/components/WhatsNew.js +50 -0
- package/dist/components/WhatsNew.js.map +1 -0
- package/dist/components/formParts.d.ts +40 -0
- package/dist/components/formParts.d.ts.map +1 -0
- package/dist/components/formParts.js +31 -0
- package/dist/components/formParts.js.map +1 -0
- package/dist/components/ui/Alert.d.ts +15 -0
- package/dist/components/ui/Alert.d.ts.map +1 -0
- package/dist/components/ui/Alert.js +14 -0
- package/dist/components/ui/Alert.js.map +1 -0
- package/dist/components/ui/DataTable.d.ts +52 -0
- package/dist/components/ui/DataTable.d.ts.map +1 -0
- package/dist/components/ui/DataTable.js +112 -0
- package/dist/components/ui/DataTable.js.map +1 -0
- package/dist/components/ui/Field.d.ts +27 -0
- package/dist/components/ui/Field.d.ts.map +1 -0
- package/dist/components/ui/Field.js +25 -0
- package/dist/components/ui/Field.js.map +1 -0
- package/dist/components/ui/PageHeader.d.ts +18 -0
- package/dist/components/ui/PageHeader.d.ts.map +1 -0
- package/dist/components/ui/PageHeader.js +10 -0
- package/dist/components/ui/PageHeader.js.map +1 -0
- package/dist/components/ui/Pagination.d.ts +21 -0
- package/dist/components/ui/Pagination.d.ts.map +1 -0
- package/dist/components/ui/Pagination.js +10 -0
- package/dist/components/ui/Pagination.js.map +1 -0
- package/dist/components/ui/QuickStartPanel.d.ts +21 -0
- package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
- package/dist/components/ui/QuickStartPanel.js +11 -0
- package/dist/components/ui/QuickStartPanel.js.map +1 -0
- package/dist/components/ui/StateViews.d.ts +50 -0
- package/dist/components/ui/StateViews.d.ts.map +1 -0
- package/dist/components/ui/StateViews.js +33 -0
- package/dist/components/ui/StateViews.js.map +1 -0
- package/dist/components/ui/Switch.d.ts +16 -0
- package/dist/components/ui/Switch.d.ts.map +1 -0
- package/dist/components/ui/Switch.js +15 -0
- package/dist/components/ui/Switch.js.map +1 -0
- package/dist/components/ui/Toast.d.ts +20 -0
- package/dist/components/ui/Toast.d.ts.map +1 -0
- package/dist/components/ui/Toast.js +46 -0
- package/dist/components/ui/Toast.js.map +1 -0
- package/dist/components/ui/index.d.ts +10 -0
- package/dist/components/ui/index.d.ts.map +1 -0
- package/dist/components/ui/index.js +10 -0
- package/dist/components/ui/index.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/useDarkMode.d.ts +2 -0
- package/dist/hooks/useDarkMode.d.ts.map +1 -0
- package/dist/hooks/useDarkMode.js +37 -0
- package/dist/hooks/useDarkMode.js.map +1 -0
- package/dist/hooks/useDebouncedValue.d.ts +6 -0
- package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
- package/dist/hooks/useDebouncedValue.js +19 -0
- package/dist/hooks/useDebouncedValue.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changelog.d.ts +32 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +71 -0
- package/dist/lib/changelog.js.map +1 -0
- package/dist/lib/dateFormat.d.ts +24 -0
- package/dist/lib/dateFormat.d.ts.map +1 -0
- package/dist/lib/dateFormat.js +67 -0
- package/dist/lib/dateFormat.js.map +1 -0
- package/dist/navigation.d.ts +5 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +7 -0
- package/dist/navigation.js.map +1 -0
- package/dist/pages/ApiTokens.d.ts +9 -0
- package/dist/pages/ApiTokens.d.ts.map +1 -0
- package/dist/pages/ApiTokens.js +116 -0
- package/dist/pages/ApiTokens.js.map +1 -0
- package/dist/pages/AuditLog.d.ts +14 -0
- package/dist/pages/AuditLog.d.ts.map +1 -0
- package/dist/pages/AuditLog.js +104 -0
- package/dist/pages/AuditLog.js.map +1 -0
- package/dist/pages/Backups.d.ts +2 -0
- package/dist/pages/Backups.d.ts.map +1 -0
- package/dist/pages/Backups.js +118 -0
- package/dist/pages/Backups.js.map +1 -0
- package/dist/pages/ChangePassword.d.ts +2 -0
- package/dist/pages/ChangePassword.d.ts.map +1 -0
- package/dist/pages/ChangePassword.js +40 -0
- package/dist/pages/ChangePassword.js.map +1 -0
- package/dist/pages/Login.d.ts +12 -0
- package/dist/pages/Login.d.ts.map +1 -0
- package/dist/pages/Login.js +69 -0
- package/dist/pages/Login.js.map +1 -0
- package/dist/pages/Settings.d.ts +2 -0
- package/dist/pages/Settings.d.ts.map +1 -0
- package/dist/pages/Settings.js +149 -0
- package/dist/pages/Settings.js.map +1 -0
- package/dist/pages/Setup.d.ts +2 -0
- package/dist/pages/Setup.d.ts.map +1 -0
- package/dist/pages/Setup.js +113 -0
- package/dist/pages/Setup.js.map +1 -0
- package/dist/pages/SsoConfirm.d.ts +3 -0
- package/dist/pages/SsoConfirm.d.ts.map +1 -0
- package/dist/pages/SsoConfirm.js +20 -0
- package/dist/pages/SsoConfirm.js.map +1 -0
- package/dist/pages/SsoTestResult.d.ts +2 -0
- package/dist/pages/SsoTestResult.d.ts.map +1 -0
- package/dist/pages/SsoTestResult.js +49 -0
- package/dist/pages/SsoTestResult.js.map +1 -0
- package/dist/pages/Users.d.ts +22 -0
- package/dist/pages/Users.d.ts.map +1 -0
- package/dist/pages/Users.js +124 -0
- package/dist/pages/Users.js.map +1 -0
- package/dist/pages/Vault.d.ts +2 -0
- package/dist/pages/Vault.d.ts.map +1 -0
- package/dist/pages/Vault.js +372 -0
- package/dist/pages/Vault.js.map +1 -0
- package/dist/shell/AdminShell.d.ts +46 -0
- package/dist/shell/AdminShell.d.ts.map +1 -0
- package/dist/shell/AdminShell.js +76 -0
- package/dist/shell/AdminShell.js.map +1 -0
- package/dist/shell/guards.d.ts +28 -0
- package/dist/shell/guards.d.ts.map +1 -0
- package/dist/shell/guards.js +71 -0
- package/dist/shell/guards.js.map +1 -0
- package/icons.json +170 -0
- package/package.json +71 -6
- package/src/api/client.ts +139 -0
- package/src/components/ConfirmIdentityModal.tsx +82 -0
- package/src/components/ConfirmModal.tsx +83 -0
- package/src/components/CopyButton.tsx +71 -0
- package/src/components/CredentialSection.tsx +270 -0
- package/src/components/ErrorBoundary.tsx +47 -0
- package/src/components/ModalShell.tsx +160 -0
- package/src/components/ResetTwoFactorModal.tsx +74 -0
- package/src/components/SidePanel.tsx +96 -0
- package/src/components/SingleSignOnModal.tsx +161 -0
- package/src/components/SsoConfirmation.tsx +96 -0
- package/src/components/VaultTypePicker.tsx +131 -0
- package/src/components/WhatsNew.tsx +84 -0
- package/src/components/formParts.tsx +59 -0
- package/src/components/ui/Alert.tsx +45 -0
- package/src/components/ui/DataTable.tsx +225 -0
- package/src/components/ui/Field.tsx +63 -0
- package/src/components/ui/PageHeader.tsx +36 -0
- package/src/components/ui/Pagination.tsx +37 -0
- package/src/components/ui/QuickStartPanel.tsx +57 -0
- package/src/components/ui/StateViews.tsx +135 -0
- package/src/components/ui/Switch.tsx +65 -0
- package/src/components/ui/Toast.tsx +104 -0
- package/src/components/ui/index.ts +9 -0
- package/src/config.ts +46 -0
- package/src/hooks/useDarkMode.ts +38 -0
- package/src/hooks/useDebouncedValue.ts +22 -0
- package/src/index.ts +42 -0
- package/src/lib/changelog.ts +93 -0
- package/src/lib/dateFormat.ts +90 -0
- package/src/navigation.ts +6 -0
- package/src/pages/ApiTokens.tsx +361 -0
- package/src/pages/AuditLog.tsx +388 -0
- package/src/pages/Backups.tsx +170 -0
- package/src/pages/ChangePassword.tsx +84 -0
- package/src/pages/Login.tsx +275 -0
- package/src/pages/Settings.tsx +264 -0
- package/src/pages/Setup.tsx +438 -0
- package/src/pages/SsoConfirm.tsx +26 -0
- package/src/pages/SsoTestResult.tsx +104 -0
- package/src/pages/Users.tsx +426 -0
- package/src/pages/Vault.tsx +1036 -0
- package/src/shell/AdminShell.tsx +256 -0
- package/src/shell/guards.tsx +88 -0
- package/styles/base.css +521 -0
- package/styles/fonts/inter-latin-1.woff2 +0 -0
- package/styles/fonts/inter-latin-ext.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
- package/styles/fonts/manrope-latin-5.woff2 +0 -0
- package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
- package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
- package/styles/icons.css +4 -0
- package/tailwind-preset.js +79 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import clsx from 'clsx'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
export interface SidePanelMeta { icon: string; label: string }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `row`: the panel always sits to the right of the form (legacy layout).
|
|
8
|
+
* `responsive`: below the lg breakpoint the panel spans the full width under the form with a top border;
|
|
9
|
+
* from lg up it becomes the right-hand column. The parent must switch to `flex-col lg:flex-row` to match.
|
|
10
|
+
*/
|
|
11
|
+
export type SidePanelLayout = 'row' | 'responsive'
|
|
12
|
+
|
|
13
|
+
/** The expandable column to the right of a modal form: a title bar and the open section. */
|
|
14
|
+
export function SidePanelFrame({ meta, children, layout = 'row', className }: {
|
|
15
|
+
meta: SidePanelMeta
|
|
16
|
+
children: ReactNode
|
|
17
|
+
layout?: SidePanelLayout | undefined
|
|
18
|
+
className?: string | undefined
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
data-testid="side-panel-frame"
|
|
23
|
+
className={clsx(
|
|
24
|
+
'flex flex-col flex-1 min-w-0 border-outline-variant',
|
|
25
|
+
layout === 'row' ? 'border-l lg:min-w-[360px]' : 'w-full border-t lg:w-auto lg:border-t-0 lg:border-l lg:min-w-[360px]',
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
{/* Panel header — just the section title */}
|
|
30
|
+
<div className="flex items-center gap-2 px-5 py-4" style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
31
|
+
<span className="material-symbols-outlined" style={{ fontSize: '16px', color: 'var(--m3-primary)' }}>{meta.icon}</span>
|
|
32
|
+
<span className="font-mono text-xs uppercase tracking-wider font-medium" style={{ color: 'var(--m3-on-surface)', flex: 1 }}>{meta.label}</span>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
{/* Tab content */}
|
|
36
|
+
<div className="p-5 space-y-4 overflow-y-auto" style={{ flex: 1 }}>
|
|
37
|
+
{children}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SideTab<K extends string> { key: K; badge: string | null }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The icon strip that opens and closes side-panel sections: vertical at the form's right edge, or with
|
|
47
|
+
* `layout="responsive"` a horizontal row below lg and the vertical strip from lg up.
|
|
48
|
+
*/
|
|
49
|
+
export function SideTabStrip<K extends string>({ tabs, meta, active, onToggle, layout = 'row', className }: {
|
|
50
|
+
tabs: SideTab<K>[]
|
|
51
|
+
meta: Record<K, SidePanelMeta>
|
|
52
|
+
active: K | null
|
|
53
|
+
onToggle: (key: K | null) => void
|
|
54
|
+
layout?: SidePanelLayout | undefined
|
|
55
|
+
className?: string | undefined
|
|
56
|
+
}) {
|
|
57
|
+
const responsive = layout === 'responsive'
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
role="toolbar"
|
|
61
|
+
aria-orientation={responsive ? undefined : 'vertical'}
|
|
62
|
+
className={clsx(
|
|
63
|
+
'flex items-stretch border-outline-variant',
|
|
64
|
+
responsive ? 'flex-row w-full border-t overflow-hidden rounded-b-2xl lg:w-11 lg:flex-none lg:flex-col lg:border-t-0 lg:border-l lg:rounded-b-none lg:rounded-r-2xl' : 'flex-col flex-none w-11 border-l',
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{tabs.map((tab) => {
|
|
69
|
+
const isActive = active === tab.key
|
|
70
|
+
const current = meta[tab.key]
|
|
71
|
+
return (
|
|
72
|
+
<button key={tab.key} type="button"
|
|
73
|
+
aria-pressed={isActive}
|
|
74
|
+
aria-label={current.label}
|
|
75
|
+
title={current.label}
|
|
76
|
+
onClick={() => onToggle(isActive ? null : tab.key)}
|
|
77
|
+
className={clsx(
|
|
78
|
+
'relative flex flex-col items-center justify-center transition-all border-outline-variant focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-outline',
|
|
79
|
+
responsive ? 'flex-1 py-3 border-r last:border-r-0 lg:flex-none lg:py-4 lg:border-r-0 lg:border-b' : 'py-4 border-b',
|
|
80
|
+
isActive ? 'side-tab-active' : 'hover:bg-surface-container',
|
|
81
|
+
)}
|
|
82
|
+
style={{ color: isActive ? 'var(--m3-primary)' : 'var(--m3-secondary)', background: isActive ? 'var(--m3-primary-fixed)' : undefined }}
|
|
83
|
+
>
|
|
84
|
+
<span className="material-symbols-outlined" style={{ fontSize: '18px' }}>{current.icon}</span>
|
|
85
|
+
{tab.badge && (
|
|
86
|
+
<span className={`absolute top-1.5 right-1 flex items-center justify-center rounded-full font-bold leading-none ${isActive ? 'side-tab-badge-active' : ''}`}
|
|
87
|
+
style={{ background: isActive ? 'var(--m3-primary)' : 'var(--m3-on-surface-variant)', color: isActive ? 'var(--m3-on-primary)' : 'var(--m3-surface)', minWidth: '14px', height: '14px', fontSize: '9px', padding: '0 2px' }}>
|
|
88
|
+
{tab.badge}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</button>
|
|
92
|
+
)
|
|
93
|
+
})}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { api } from '../api/client'
|
|
3
|
+
import { uiConfig } from '../config'
|
|
4
|
+
import { Alert, ErrorState, Field, LoadingState, Switch, useToast } from './ui'
|
|
5
|
+
import { ModalFooter, ModalHeader, ModalShell } from './ModalShell'
|
|
6
|
+
import { SsoConfirmationNote, isSsoSession, useSsoConfirmation } from './SsoConfirmation'
|
|
7
|
+
|
|
8
|
+
interface OidcSettings {
|
|
9
|
+
enabled: boolean
|
|
10
|
+
issuer: string
|
|
11
|
+
clientId: string
|
|
12
|
+
hasClientSecret: boolean
|
|
13
|
+
scopes: string
|
|
14
|
+
redirectUri: string
|
|
15
|
+
buttonLabel: string
|
|
16
|
+
allowUnverifiedEmail: boolean
|
|
17
|
+
disablePasswordLogin: boolean
|
|
18
|
+
}
|
|
19
|
+
interface OidcResponse { source: 'env' | 'database' | 'none'; envManaged: boolean; defaultRedirectUri: string; settings: OidcSettings }
|
|
20
|
+
interface Message { tone: 'success' | 'error'; text: string }
|
|
21
|
+
|
|
22
|
+
const errorText = (e: unknown) => e instanceof Error ? e.message : String(e)
|
|
23
|
+
const CARD = { background: 'var(--m3-surface-container-lowest)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }
|
|
24
|
+
|
|
25
|
+
export function SingleSignOnModal({ onClose }: { onClose: () => void }) {
|
|
26
|
+
const [data, setData] = useState<OidcResponse | null>(null)
|
|
27
|
+
const [form, setForm] = useState<OidcSettings | null>(null)
|
|
28
|
+
const [clientSecret, setClientSecret] = useState('')
|
|
29
|
+
const [currentPassword, setCurrentPassword] = useState('')
|
|
30
|
+
const sso = isSsoSession()
|
|
31
|
+
const confirmation = useSsoConfirmation()
|
|
32
|
+
const [loadError, setLoadError] = useState('')
|
|
33
|
+
const [busy, setBusy] = useState<'save' | 'test' | null>(null)
|
|
34
|
+
const [message, setMessage] = useState<Message | null>(null)
|
|
35
|
+
const toast = useToast()
|
|
36
|
+
|
|
37
|
+
const load = useCallback(() => api.get<OidcResponse>('/admin/oidc')
|
|
38
|
+
.then((res) => { setData(res); setForm(res.settings); setLoadError('') })
|
|
39
|
+
.catch((e: unknown) => setLoadError(errorText(e))), [])
|
|
40
|
+
useEffect(() => { void load() }, [load])
|
|
41
|
+
|
|
42
|
+
if (!data || !form) {
|
|
43
|
+
return <ModalShell align="top" onClose={onClose} label="Single sign-on">
|
|
44
|
+
<div className="rounded-2xl p-6 w-full max-w-2xl" style={CARD}>
|
|
45
|
+
{loadError ? <ErrorState message={loadError} onRetry={() => void load()} /> : <LoadingState label="Loading sign-in settings…" />}
|
|
46
|
+
{loadError && <button type="button" className="btn btn-secondary mt-4" onClick={onClose}>Close</button>}
|
|
47
|
+
</div>
|
|
48
|
+
</ModalShell>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const locked = data.envManaged
|
|
52
|
+
const update = (changes: Partial<OidcSettings>) => setForm({ ...form, ...changes })
|
|
53
|
+
const redirectUri = form.redirectUri || data.defaultRedirectUri
|
|
54
|
+
|
|
55
|
+
async function test() {
|
|
56
|
+
setBusy('test'); setMessage(null)
|
|
57
|
+
try {
|
|
58
|
+
const result = await api.post<{ issuer: string }>('/admin/oidc/test', { issuer: form!.issuer, clientId: form!.clientId, redirectUri: form!.redirectUri })
|
|
59
|
+
setMessage({ tone: 'success', text: `Connected to ${result.issuer}.` })
|
|
60
|
+
} catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function save() {
|
|
64
|
+
setBusy('save'); setMessage(null)
|
|
65
|
+
try {
|
|
66
|
+
const saved = await confirmation.run(() => api.put<OidcSettings>('/admin/oidc', {
|
|
67
|
+
...form, clientSecret, ...(sso ? {} : { currentPassword }),
|
|
68
|
+
}))
|
|
69
|
+
toast.success(saved.enabled ? 'Single sign-on settings saved. They apply immediately.' : 'Single sign-on settings saved.')
|
|
70
|
+
onClose()
|
|
71
|
+
} catch (err) { setMessage({ tone: 'error', text: errorText(err) }); setBusy(null) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return <ModalShell align="top" onClose={busy ? undefined : onClose} label="Single sign-on">
|
|
75
|
+
<div className="w-full max-w-2xl rounded-2xl my-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }}>
|
|
76
|
+
<ModalHeader icon="passkey" title="Single sign-on" onClose={onClose} />
|
|
77
|
+
<form onSubmit={(e) => { e.preventDefault(); void save() }}>
|
|
78
|
+
<div className="p-6 space-y-5">
|
|
79
|
+
{locked && <Alert tone="warning">Single sign-on is configured through OIDC_* environment variables on the server. Remove them to manage it here.</Alert>}
|
|
80
|
+
{message && <Alert tone={message.tone} onDismiss={() => setMessage(null)}>{message.text}</Alert>}
|
|
81
|
+
<div className="flex items-start justify-between gap-4">
|
|
82
|
+
<div>
|
|
83
|
+
<h2 className="font-headline text-base font-semibold">OpenID Connect</h2>
|
|
84
|
+
<p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>
|
|
85
|
+
Users must already exist; they are matched by the verified email from the identity provider.{uiConfig().texts.ssoSignInHint ? ` ${uiConfig().texts.ssoSignInHint}` : ""}
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<Switch checked={form.enabled} disabled={locked} onChange={(enabled) => update({ enabled })} aria-label="Enable single sign-on" />
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<fieldset disabled={locked || busy !== null} className="space-y-4">
|
|
92
|
+
<Field label="Issuer URL" hint="For example https://login.microsoftonline.com/<tenant>/v2.0 or https://keycloak.example.com/realms/main">
|
|
93
|
+
<input className="input-m3" value={form.issuer} onChange={(e) => update({ issuer: e.target.value })} placeholder="https://idp.example.com" />
|
|
94
|
+
</Field>
|
|
95
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
96
|
+
<Field label="Client ID">
|
|
97
|
+
<input className="input-m3" value={form.clientId} onChange={(e) => update({ clientId: e.target.value })} autoComplete="off" />
|
|
98
|
+
</Field>
|
|
99
|
+
<Field label="Client secret" hint={form.hasClientSecret ? 'A secret is stored. Leave empty to keep it. Re-enter it when you change the issuer URL or client ID.' : 'Optional for public clients.'}>
|
|
100
|
+
<input
|
|
101
|
+
className="input-m3" type="password" autoComplete="new-password" value={clientSecret}
|
|
102
|
+
onChange={(e) => setClientSecret(e.target.value)}
|
|
103
|
+
placeholder={form.hasClientSecret ? '••••••••' : ''}
|
|
104
|
+
/>
|
|
105
|
+
</Field>
|
|
106
|
+
</div>
|
|
107
|
+
<Field label="Redirect URI" hint={redirectUri ? <>Register <code>{redirectUri}</code> as the callback URL in your identity provider.</> : 'Set PUBLIC_URL on the server or enter the callback URL here.'}>
|
|
108
|
+
<input className="input-m3" value={form.redirectUri} onChange={(e) => update({ redirectUri: e.target.value })} placeholder={data.defaultRedirectUri} />
|
|
109
|
+
</Field>
|
|
110
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
111
|
+
<Field label="Scopes">
|
|
112
|
+
<input className="input-m3" value={form.scopes} onChange={(e) => update({ scopes: e.target.value })} placeholder="openid email profile" />
|
|
113
|
+
</Field>
|
|
114
|
+
<Field label="Button label">
|
|
115
|
+
<input className="input-m3" value={form.buttonLabel} onChange={(e) => update({ buttonLabel: e.target.value })} placeholder="Sign in with SSO" />
|
|
116
|
+
</Field>
|
|
117
|
+
</div>
|
|
118
|
+
<label className="flex items-start gap-2 text-sm">
|
|
119
|
+
<input type="checkbox" className="mt-1" checked={form.allowUnverifiedEmail} onChange={(e) => update({ allowUnverifiedEmail: e.target.checked })} />
|
|
120
|
+
<span>Accept identity providers that omit <code>email_verified</code>. Enable only if the email claim is trustworthy. Not needed for Microsoft Entra ID: add the <code>xms_edov</code> optional claim instead.</span>
|
|
121
|
+
</label>
|
|
122
|
+
<label className="flex items-start gap-2 text-sm">
|
|
123
|
+
<input type="checkbox" className="mt-1" checked={form.disablePasswordLogin} onChange={(e) => update({ disablePasswordLogin: e.target.checked })} />
|
|
124
|
+
<span>Disable password sign-in. The identity provider must be reachable to save this. Set <code>OIDC_FORCE_PASSWORD_LOGIN=true</code> on the server to recover if it goes down.</span>
|
|
125
|
+
</label>
|
|
126
|
+
</fieldset>
|
|
127
|
+
|
|
128
|
+
{!locked && <>
|
|
129
|
+
{sso
|
|
130
|
+
? <SsoConfirmationNote confirmation={confirmation} />
|
|
131
|
+
: <Field label="Your current password" hint="Required to change sign-in settings.">
|
|
132
|
+
<input className="input-m3" type="password" autoComplete="current-password" required value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
|
|
133
|
+
</Field>}
|
|
134
|
+
</>}
|
|
135
|
+
{!locked && <p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>Test sign-in uses the saved settings, so save changes first.</p>}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<ModalFooter
|
|
139
|
+
start={<>
|
|
140
|
+
{!locked && (
|
|
141
|
+
<button type="button" disabled={busy !== null || !form.issuer || !form.clientId} onClick={() => void test()} className="btn btn-secondary">
|
|
142
|
+
{busy === 'test' ? 'Testing…' : 'Test connection'}
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
<button
|
|
146
|
+
type="button" disabled={!locked && (busy !== null || !data.settings.issuer || !data.settings.clientId)}
|
|
147
|
+
onClick={() => window.open('/api/v1/admin/oidc/test-sign-in', '_blank', 'noopener')}
|
|
148
|
+
title="Signs in at the identity provider in a new tab with the saved settings and shows the claims and the outcome, without signing anyone in."
|
|
149
|
+
className="btn btn-secondary"
|
|
150
|
+
>
|
|
151
|
+
Test sign-in
|
|
152
|
+
</button>
|
|
153
|
+
</>}
|
|
154
|
+
>
|
|
155
|
+
<button type="button" disabled={busy !== null} onClick={onClose} className="btn btn-secondary">{locked ? 'Close' : 'Cancel'}</button>
|
|
156
|
+
{!locked && <button type="submit" disabled={busy !== null} className="btn btn-primary">{busy === 'save' ? 'Saving…' : 'Save'}</button>}
|
|
157
|
+
</ModalFooter>
|
|
158
|
+
</form>
|
|
159
|
+
</div>
|
|
160
|
+
</ModalShell>
|
|
161
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { getCurrentUser, type ApiError } from '../api/client'
|
|
3
|
+
import { storageKey, uiConfig } from '../config'
|
|
4
|
+
import { Alert } from './ui'
|
|
5
|
+
|
|
6
|
+
/** The confirmation popup reports on this channel. Some providers cut `window.opener`, so no postMessage. */
|
|
7
|
+
export const ssoConfirmChannel = (): string => storageKey('sso-confirm')
|
|
8
|
+
const ssoConfirmUrl = () => `${uiConfig().apiBase}/auth/oidc/confirm`
|
|
9
|
+
// The server-side flow expires after 10 minutes, so a popup left open longer cannot succeed.
|
|
10
|
+
const SSO_CONFIRM_TIMEOUT_MS = 10 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
type ConfirmOutcome = 'ok' | 'failed' | 'blocked' | 'cancelled'
|
|
13
|
+
|
|
14
|
+
const OUTCOME_MESSAGES: Record<Exclude<ConfirmOutcome, 'ok'>, string> = {
|
|
15
|
+
failed: 'The single sign-on confirmation did not succeed. Try again.',
|
|
16
|
+
blocked: 'The browser blocked the sign-in window. Allow pop-ups for this site and try again.',
|
|
17
|
+
cancelled: 'The single sign-on confirmation was cancelled.',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when the session signed in with SSO, so sensitive actions are confirmed there instead of with a password. */
|
|
21
|
+
export function isSsoSession(): boolean {
|
|
22
|
+
return getCurrentUser()?.authMethod === 'oidc'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the API refused an action until the SSO session is confirmed at the identity provider. */
|
|
26
|
+
export function isSsoConfirmationRequired(error: unknown): boolean {
|
|
27
|
+
return (error as Partial<ApiError> | null)?.code === 'SSO_CONFIRMATION_REQUIRED'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function openSsoConfirmation(): { result: Promise<ConfirmOutcome>; cancel: () => void } {
|
|
31
|
+
const popup = window.open(ssoConfirmUrl(), ssoConfirmChannel(), 'popup,width=520,height=720')
|
|
32
|
+
if (!popup) return { result: Promise.resolve('blocked'), cancel: () => {} }
|
|
33
|
+
let finish: (outcome: ConfirmOutcome) => void = () => {}
|
|
34
|
+
const result = new Promise<ConfirmOutcome>((resolve) => {
|
|
35
|
+
const channel = new BroadcastChannel(ssoConfirmChannel())
|
|
36
|
+
const timer = setTimeout(() => finish('failed'), SSO_CONFIRM_TIMEOUT_MS)
|
|
37
|
+
finish = (outcome) => {
|
|
38
|
+
channel.close()
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
resolve(outcome)
|
|
41
|
+
}
|
|
42
|
+
channel.onmessage = (event: MessageEvent<{ ok?: boolean }>) => finish(event.data?.ok === true ? 'ok' : 'failed')
|
|
43
|
+
})
|
|
44
|
+
return {
|
|
45
|
+
result,
|
|
46
|
+
cancel: () => {
|
|
47
|
+
finish('cancelled')
|
|
48
|
+
try { popup.close() } catch { /* already closed or no longer ours */ }
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Runs sensitive API calls of an SSO session. When the API asks for a confirmation, `run` opens the identity
|
|
55
|
+
* provider in a popup right away and repeats the call once it succeeds; otherwise it throws with the reason.
|
|
56
|
+
* The popup follows the user's click within one API round trip, which browsers still count as user-initiated.
|
|
57
|
+
*/
|
|
58
|
+
export function useSsoConfirmation() {
|
|
59
|
+
const [waiting, setWaiting] = useState(false)
|
|
60
|
+
const cancelRef = useRef<(() => void) | null>(null)
|
|
61
|
+
useEffect(() => () => cancelRef.current?.(), [])
|
|
62
|
+
|
|
63
|
+
const run = useCallback(async <T,>(action: () => Promise<T>): Promise<T> => {
|
|
64
|
+
try {
|
|
65
|
+
return await action()
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!isSsoConfirmationRequired(error)) throw error
|
|
68
|
+
const { result, cancel } = openSsoConfirmation()
|
|
69
|
+
cancelRef.current = cancel
|
|
70
|
+
setWaiting(true)
|
|
71
|
+
const outcome = await result
|
|
72
|
+
cancelRef.current = null
|
|
73
|
+
setWaiting(false)
|
|
74
|
+
if (outcome !== 'ok') throw new Error(OUTCOME_MESSAGES[outcome], { cause: error })
|
|
75
|
+
return action()
|
|
76
|
+
}
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
const cancel = useCallback(() => cancelRef.current?.(), [])
|
|
80
|
+
return { run, waiting, cancel }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type SsoConfirmationState = ReturnType<typeof useSsoConfirmation>
|
|
84
|
+
|
|
85
|
+
/** Takes the place of a "current password" field in an SSO session: a note, or the popup's progress. */
|
|
86
|
+
export function SsoConfirmationNote({ confirmation }: { confirmation: Pick<SsoConfirmationState, 'waiting' | 'cancel'> }) {
|
|
87
|
+
if (!confirmation.waiting) {
|
|
88
|
+
return <p className="text-xs" style={{ color: 'var(--m3-secondary)' }}>You signed in with single sign-on, so no password is needed. You may be asked to sign in again in a pop-up window.</p>
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<Alert tone="info" title="Confirm it's you">
|
|
92
|
+
<p>Sign in again with your identity provider in the pop-up window. This continues once you are done.</p>
|
|
93
|
+
<button type="button" className="btn btn-secondary btn-sm mt-3" onClick={confirmation.cancel}>Cancel</button>
|
|
94
|
+
</Alert>
|
|
95
|
+
)
|
|
96
|
+
}
|