@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,96 +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
- }
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
+ }
@@ -1,161 +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
- }
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
+ }