@better-forge/ui 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
package/src/pages/SsoConfirm.tsx
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { useEffect } from 'react'
|
|
2
|
-
import { useSearchParams } from 'react-router-dom'
|
|
3
|
-
import { ssoConfirmChannel } from '../components/SsoConfirmation'
|
|
4
|
-
|
|
5
|
-
/** Where the SSO confirmation popup lands: tells the window that opened it how it went, then closes. */
|
|
6
|
-
export default function SsoConfirmPage() {
|
|
7
|
-
const [params] = useSearchParams()
|
|
8
|
-
const ok = params.get('status') === 'ok'
|
|
9
|
-
|
|
10
|
-
useEffect(() => {
|
|
11
|
-
const channel = new BroadcastChannel(ssoConfirmChannel())
|
|
12
|
-
channel.postMessage({ ok })
|
|
13
|
-
channel.close()
|
|
14
|
-
if (ok) window.close()
|
|
15
|
-
}, [ok])
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<div className="min-h-screen flex items-center justify-center px-6" style={{ background: 'var(--m3-surface)', color: 'var(--m3-on-surface)' }}>
|
|
19
|
-
<p className="text-sm text-center max-w-sm">
|
|
20
|
-
{ok
|
|
21
|
-
? 'Confirmed. You can close this window.'
|
|
22
|
-
: 'The confirmation did not succeed. Close this window and try again.'}
|
|
23
|
-
</p>
|
|
24
|
-
</div>
|
|
25
|
-
)
|
|
26
|
-
}
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { ssoConfirmChannel } from '../components/SsoConfirmation'
|
|
4
|
+
|
|
5
|
+
/** Where the SSO confirmation popup lands: tells the window that opened it how it went, then closes. */
|
|
6
|
+
export default function SsoConfirmPage() {
|
|
7
|
+
const [params] = useSearchParams()
|
|
8
|
+
const ok = params.get('status') === 'ok'
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const channel = new BroadcastChannel(ssoConfirmChannel())
|
|
12
|
+
channel.postMessage({ ok })
|
|
13
|
+
channel.close()
|
|
14
|
+
if (ok) window.close()
|
|
15
|
+
}, [ok])
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="min-h-screen flex items-center justify-center px-6" style={{ background: 'var(--m3-surface)', color: 'var(--m3-on-surface)' }}>
|
|
19
|
+
<p className="text-sm text-center max-w-sm">
|
|
20
|
+
{ok
|
|
21
|
+
? 'Confirmed. You can close this window.'
|
|
22
|
+
: 'The confirmation did not succeed. Close this window and try again.'}
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -1,104 +1,104 @@
|
|
|
1
|
-
import { useQuery } from '@tanstack/react-query'
|
|
2
|
-
import { useSearchParams } from 'react-router-dom'
|
|
3
|
-
import { api } from '../api/client'
|
|
4
|
-
import { uiConfig } from '../config'
|
|
5
|
-
import { Alert, ErrorState, LoadingState, PageContainer, PageHeader, DataTable, DataTh } from '../components/ui'
|
|
6
|
-
import { formatDateTime } from '../lib/dateFormat'
|
|
7
|
-
|
|
8
|
-
interface TestResult {
|
|
9
|
-
issuer: string
|
|
10
|
-
testedAt: number
|
|
11
|
-
claims: Record<string, unknown> | null
|
|
12
|
-
outcome: 'sign_in' | 'link' | 'provision' | 'deny'
|
|
13
|
-
user: string | null
|
|
14
|
-
denial: { code: string; reason: string; email?: string } | null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Claims that decide the outcome, shown first.
|
|
18
|
-
const KEY_CLAIMS = ['sub', 'email', 'email_verified', 'xms_edov', 'preferred_username', 'name']
|
|
19
|
-
|
|
20
|
-
function formatClaim(value: unknown) {
|
|
21
|
-
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function Outcome({ result }: { result: TestResult }) {
|
|
25
|
-
if (result.outcome === 'sign_in') {
|
|
26
|
-
return <Alert tone="success" title="Sign-in would succeed">Signs in as <strong>{result.user}</strong>, matched by their linked identity-provider account.</Alert>
|
|
27
|
-
}
|
|
28
|
-
if (result.outcome === 'link') {
|
|
29
|
-
return <Alert tone="success" title="Sign-in would succeed">Signs in as <strong>{result.user}</strong>, matched by email. The first real sign-in links this identity-provider account to the user.</Alert>
|
|
30
|
-
}
|
|
31
|
-
if (result.outcome === 'provision') {
|
|
32
|
-
return <Alert tone="success" title="Sign-in would succeed">{newAccountNote(result.user)}</Alert>
|
|
33
|
-
}
|
|
34
|
-
return (
|
|
35
|
-
<Alert tone="error" title={`Sign-in would be refused (${result.denial?.code})`}>
|
|
36
|
-
{result.denial?.reason}
|
|
37
|
-
</Alert>
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export default function SsoTestResultPage() {
|
|
42
|
-
const [params] = useSearchParams()
|
|
43
|
-
const id = params.get('result') ?? ''
|
|
44
|
-
const { data, isLoading, error } = useQuery<TestResult>({
|
|
45
|
-
queryKey: ['sso-test', id],
|
|
46
|
-
queryFn: () => api.get(`/admin/oidc/test-sign-in/${encodeURIComponent(id)}`),
|
|
47
|
-
enabled: !!id,
|
|
48
|
-
retry: false,
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
const claims = data?.claims ? Object.entries(data.claims) : []
|
|
52
|
-
const ordered = [
|
|
53
|
-
...KEY_CLAIMS.flatMap((key) => claims.filter(([name]) => name === key)),
|
|
54
|
-
...claims.filter(([name]) => !KEY_CLAIMS.includes(name)).sort(([a], [b]) => a.localeCompare(b)),
|
|
55
|
-
]
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<PageContainer className="max-w-3xl">
|
|
59
|
-
<PageHeader
|
|
60
|
-
title="Single sign-on test"
|
|
61
|
-
subtitle="Nobody was signed in and nothing was saved. Close this tab when you are done."
|
|
62
|
-
/>
|
|
63
|
-
{!id ? <ErrorState message="No test result to show. Start a test sign-in from Users → Single sign-on." />
|
|
64
|
-
: isLoading ? <LoadingState label="Loading the test result…" />
|
|
65
|
-
: error || !data ? <ErrorState message={error instanceof Error ? error.message : 'Could not load the test result.'} />
|
|
66
|
-
: (
|
|
67
|
-
<>
|
|
68
|
-
<Outcome result={data} />
|
|
69
|
-
<p className="text-sm" style={{ color: 'var(--m3-secondary)' }}>
|
|
70
|
-
Issuer <code>{data.issuer}</code> · tested {formatDateTime(data.testedAt)}
|
|
71
|
-
</p>
|
|
72
|
-
{ordered.length > 0 && (
|
|
73
|
-
<div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
74
|
-
<DataTable className="w-full text-sm">
|
|
75
|
-
<caption className="sr-only">ID token claims</caption>
|
|
76
|
-
<thead>
|
|
77
|
-
<tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
78
|
-
{['Claim', 'Value'].map((h) => (
|
|
79
|
-
<DataTh key={h} className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-left" style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}>{h}</DataTh>
|
|
80
|
-
))}
|
|
81
|
-
</tr>
|
|
82
|
-
</thead>
|
|
83
|
-
<tbody>
|
|
84
|
-
{ordered.map(([name, value], i) => (
|
|
85
|
-
<tr key={name} style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
|
|
86
|
-
<td className="px-4 py-2 font-mono text-xs whitespace-nowrap align-top" style={{ color: 'var(--m3-secondary)' }}>{name}</td>
|
|
87
|
-
<td className="px-4 py-2 font-mono text-xs break-all" style={{ color: 'var(--m3-on-surface)' }}>{formatClaim(value)}</td>
|
|
88
|
-
</tr>
|
|
89
|
-
))}
|
|
90
|
-
</tbody>
|
|
91
|
-
</DataTable>
|
|
92
|
-
</div>
|
|
93
|
-
)}
|
|
94
|
-
</>
|
|
95
|
-
)}
|
|
96
|
-
</PageContainer>
|
|
97
|
-
)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** The note for a test sign-in that would create an account, worded by the application. */
|
|
101
|
-
function newAccountNote(user: string | null) {
|
|
102
|
-
const [before, after] = uiConfig().texts.ssoNewAccount.split('{user}')
|
|
103
|
-
return <>{before}<strong>{user}</strong>{after}</>
|
|
104
|
-
}
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { api } from '../api/client'
|
|
4
|
+
import { uiConfig } from '../config'
|
|
5
|
+
import { Alert, ErrorState, LoadingState, PageContainer, PageHeader, DataTable, DataTh } from '../components/ui'
|
|
6
|
+
import { formatDateTime } from '../lib/dateFormat'
|
|
7
|
+
|
|
8
|
+
interface TestResult {
|
|
9
|
+
issuer: string
|
|
10
|
+
testedAt: number
|
|
11
|
+
claims: Record<string, unknown> | null
|
|
12
|
+
outcome: 'sign_in' | 'link' | 'provision' | 'deny'
|
|
13
|
+
user: string | null
|
|
14
|
+
denial: { code: string; reason: string; email?: string } | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Claims that decide the outcome, shown first.
|
|
18
|
+
const KEY_CLAIMS = ['sub', 'email', 'email_verified', 'xms_edov', 'preferred_username', 'name']
|
|
19
|
+
|
|
20
|
+
function formatClaim(value: unknown) {
|
|
21
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Outcome({ result }: { result: TestResult }) {
|
|
25
|
+
if (result.outcome === 'sign_in') {
|
|
26
|
+
return <Alert tone="success" title="Sign-in would succeed">Signs in as <strong>{result.user}</strong>, matched by their linked identity-provider account.</Alert>
|
|
27
|
+
}
|
|
28
|
+
if (result.outcome === 'link') {
|
|
29
|
+
return <Alert tone="success" title="Sign-in would succeed">Signs in as <strong>{result.user}</strong>, matched by email. The first real sign-in links this identity-provider account to the user.</Alert>
|
|
30
|
+
}
|
|
31
|
+
if (result.outcome === 'provision') {
|
|
32
|
+
return <Alert tone="success" title="Sign-in would succeed">{newAccountNote(result.user)}</Alert>
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<Alert tone="error" title={`Sign-in would be refused (${result.denial?.code})`}>
|
|
36
|
+
{result.denial?.reason}
|
|
37
|
+
</Alert>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function SsoTestResultPage() {
|
|
42
|
+
const [params] = useSearchParams()
|
|
43
|
+
const id = params.get('result') ?? ''
|
|
44
|
+
const { data, isLoading, error } = useQuery<TestResult>({
|
|
45
|
+
queryKey: ['sso-test', id],
|
|
46
|
+
queryFn: () => api.get(`/admin/oidc/test-sign-in/${encodeURIComponent(id)}`),
|
|
47
|
+
enabled: !!id,
|
|
48
|
+
retry: false,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const claims = data?.claims ? Object.entries(data.claims) : []
|
|
52
|
+
const ordered = [
|
|
53
|
+
...KEY_CLAIMS.flatMap((key) => claims.filter(([name]) => name === key)),
|
|
54
|
+
...claims.filter(([name]) => !KEY_CLAIMS.includes(name)).sort(([a], [b]) => a.localeCompare(b)),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<PageContainer className="max-w-3xl">
|
|
59
|
+
<PageHeader
|
|
60
|
+
title="Single sign-on test"
|
|
61
|
+
subtitle="Nobody was signed in and nothing was saved. Close this tab when you are done."
|
|
62
|
+
/>
|
|
63
|
+
{!id ? <ErrorState message="No test result to show. Start a test sign-in from Users → Single sign-on." />
|
|
64
|
+
: isLoading ? <LoadingState label="Loading the test result…" />
|
|
65
|
+
: error || !data ? <ErrorState message={error instanceof Error ? error.message : 'Could not load the test result.'} />
|
|
66
|
+
: (
|
|
67
|
+
<>
|
|
68
|
+
<Outcome result={data} />
|
|
69
|
+
<p className="text-sm" style={{ color: 'var(--m3-secondary)' }}>
|
|
70
|
+
Issuer <code>{data.issuer}</code> · tested {formatDateTime(data.testedAt)}
|
|
71
|
+
</p>
|
|
72
|
+
{ordered.length > 0 && (
|
|
73
|
+
<div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
74
|
+
<DataTable className="w-full text-sm">
|
|
75
|
+
<caption className="sr-only">ID token claims</caption>
|
|
76
|
+
<thead>
|
|
77
|
+
<tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
78
|
+
{['Claim', 'Value'].map((h) => (
|
|
79
|
+
<DataTh key={h} className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-left" style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}>{h}</DataTh>
|
|
80
|
+
))}
|
|
81
|
+
</tr>
|
|
82
|
+
</thead>
|
|
83
|
+
<tbody>
|
|
84
|
+
{ordered.map(([name, value], i) => (
|
|
85
|
+
<tr key={name} style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
|
|
86
|
+
<td className="px-4 py-2 font-mono text-xs whitespace-nowrap align-top" style={{ color: 'var(--m3-secondary)' }}>{name}</td>
|
|
87
|
+
<td className="px-4 py-2 font-mono text-xs break-all" style={{ color: 'var(--m3-on-surface)' }}>{formatClaim(value)}</td>
|
|
88
|
+
</tr>
|
|
89
|
+
))}
|
|
90
|
+
</tbody>
|
|
91
|
+
</DataTable>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</PageContainer>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The note for a test sign-in that would create an account, worded by the application. */
|
|
101
|
+
function newAccountNote(user: string | null) {
|
|
102
|
+
const [before, after] = uiConfig().texts.ssoNewAccount.split('{user}')
|
|
103
|
+
return <>{before}<strong>{user}</strong>{after}</>
|
|
104
|
+
}
|