@better-forge/ui 0.0.0-stage → 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 -0
- package/README.md +12 -2
- 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 +69 -4
- 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,256 @@
|
|
|
1
|
+
import { Fragment, Suspense, useCallback, useEffect, useRef, useState, type RefObject } from 'react'
|
|
2
|
+
import { Outlet, NavLink, useNavigate, useLocation, Link } from 'react-router-dom'
|
|
3
|
+
import { api, clearSession, getCurrentUser } from '../api/client'
|
|
4
|
+
import { LoadingState } from '../components/ui/StateViews'
|
|
5
|
+
import { WhatsNew, markWhatsNewSeen, pendingWhatsNew } from '../components/WhatsNew'
|
|
6
|
+
import { useDarkMode } from '../hooks/useDarkMode'
|
|
7
|
+
import { useDatePrefs } from '../lib/dateFormat'
|
|
8
|
+
|
|
9
|
+
export interface NavItem {
|
|
10
|
+
to: string
|
|
11
|
+
label: string
|
|
12
|
+
icon: string
|
|
13
|
+
/** The lowest role that sees the item (see `roleRank`). */
|
|
14
|
+
minRole: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NavSection { label: string; items: NavItem[] }
|
|
18
|
+
|
|
19
|
+
/** A link below the sections that leaves the interface (the public page, the documentation). */
|
|
20
|
+
export interface ExternalLink { label: string; icon: string; href: () => string }
|
|
21
|
+
|
|
22
|
+
export interface AdminShellConfig {
|
|
23
|
+
sections: NavSection[]
|
|
24
|
+
/** Roles from the lowest to the highest rank: a user sees an item when their rank reaches the item's `minRole`. */
|
|
25
|
+
roleRank: Readonly<Record<string, number>>
|
|
26
|
+
brand: { iconUrl: string; name: string; title: string; subtitle: string }
|
|
27
|
+
/** The page the first item leads to: it is highlighted only when it is exactly the current one. */
|
|
28
|
+
homePath: string
|
|
29
|
+
settingsPath: string
|
|
30
|
+
loginPath: string
|
|
31
|
+
externalLinks: readonly ExternalLink[]
|
|
32
|
+
/** The release notes shown once to a user who last saw an older version. */
|
|
33
|
+
whatsNew: { changelog: string; version: string; docsUrl: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ICON_STYLE = { fontSize: '20px' }
|
|
37
|
+
|
|
38
|
+
function Brand({ brand }: { brand: AdminShellConfig['brand'] }) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
41
|
+
<img src={brand.iconUrl} alt={brand.name} style={{ width: '40px', height: '40px', objectFit: 'contain', flexShrink: 0 }} />
|
|
42
|
+
<div className="min-w-0">
|
|
43
|
+
<p className="font-headline font-bold text-base leading-none" style={{ color: 'var(--m3-on-surface)' }}>
|
|
44
|
+
{brand.title}
|
|
45
|
+
</p>
|
|
46
|
+
<p className="text-xs leading-none mt-1" style={{ color: 'var(--m3-secondary)' }}>
|
|
47
|
+
{brand.subtitle}
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface NavContentProps {
|
|
55
|
+
config: AdminShellConfig
|
|
56
|
+
sections: NavSection[]
|
|
57
|
+
isDark: boolean
|
|
58
|
+
onToggleDark: () => void
|
|
59
|
+
onLogout: () => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Section links plus the Settings / theme / logout footer, shared by the desktop sidebar and the mobile drawer. */
|
|
63
|
+
function NavContent({ config, sections, isDark, onToggleDark, onLogout }: NavContentProps) {
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<nav aria-label="Main" className="flex-1 px-3 overflow-y-auto pb-2">
|
|
67
|
+
{sections.map((section, si) => (
|
|
68
|
+
<div key={section.label} className={si > 0 ? 'mt-4' : ''}>
|
|
69
|
+
<p
|
|
70
|
+
className="px-4 pb-1 font-mono uppercase tracking-widest"
|
|
71
|
+
style={{ fontSize: '10px', color: 'var(--m3-outline)', letterSpacing: '0.1em' }}
|
|
72
|
+
>
|
|
73
|
+
{section.label}
|
|
74
|
+
</p>
|
|
75
|
+
<div className="space-y-0.5">
|
|
76
|
+
{section.items.map((item) => (
|
|
77
|
+
<NavLink key={item.to} to={item.to} end={item.to === config.homePath} className="admin-nav-item">
|
|
78
|
+
{({ isActive }) => (
|
|
79
|
+
<>
|
|
80
|
+
<span
|
|
81
|
+
aria-hidden="true" className="material-symbols-outlined flex-shrink-0"
|
|
82
|
+
style={{ ...ICON_STYLE, fontVariationSettings: isActive ? "'FILL' 1" : "'FILL' 0" }}
|
|
83
|
+
>
|
|
84
|
+
{item.icon}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="font-sans">{item.label}</span>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
</NavLink>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
</nav>
|
|
95
|
+
|
|
96
|
+
<div className="px-3 pb-4 pt-4 space-y-0.5" style={{ borderTop: '1px solid var(--m3-outline-variant)' }}>
|
|
97
|
+
{config.externalLinks.map((link) => (
|
|
98
|
+
<a key={link.label} href={link.href()} target="_blank" rel="noopener" className="admin-nav-item">
|
|
99
|
+
<span aria-hidden="true" className="material-symbols-outlined flex-shrink-0" style={ICON_STYLE}>{link.icon}</span>
|
|
100
|
+
<span className="font-sans">{link.label}</span>
|
|
101
|
+
</a>
|
|
102
|
+
))}
|
|
103
|
+
|
|
104
|
+
<Link to={config.settingsPath} className="admin-nav-item">
|
|
105
|
+
<span aria-hidden="true" className="material-symbols-outlined flex-shrink-0" style={ICON_STYLE}>manage_accounts</span>
|
|
106
|
+
<span className="font-sans">Settings</span>
|
|
107
|
+
</Link>
|
|
108
|
+
|
|
109
|
+
<button type="button" onClick={onToggleDark} className="admin-nav-item">
|
|
110
|
+
<span aria-hidden="true" className="material-symbols-outlined flex-shrink-0" style={ICON_STYLE}>
|
|
111
|
+
{isDark ? 'light_mode' : 'dark_mode'}
|
|
112
|
+
</span>
|
|
113
|
+
<span className="font-sans">{isDark ? 'Light Mode' : 'Dark Mode'}</span>
|
|
114
|
+
</button>
|
|
115
|
+
|
|
116
|
+
<button type="button" onClick={onLogout} className="admin-nav-item admin-nav-danger">
|
|
117
|
+
<span aria-hidden="true" className="material-symbols-outlined flex-shrink-0" style={ICON_STYLE}>logout</span>
|
|
118
|
+
<span className="font-sans">Logout</span>
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface MobileDrawerProps extends NavContentProps {
|
|
126
|
+
onClose: () => void
|
|
127
|
+
returnFocusRef: RefObject<HTMLButtonElement | null>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function MobileDrawer({ onClose, returnFocusRef, ...nav }: MobileDrawerProps) {
|
|
131
|
+
const closeRef = useRef<HTMLButtonElement>(null)
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
closeRef.current?.focus()
|
|
135
|
+
const returnTo = returnFocusRef.current
|
|
136
|
+
const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
|
|
137
|
+
document.addEventListener('keydown', onKeyDown)
|
|
138
|
+
return () => {
|
|
139
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
140
|
+
returnTo?.focus()
|
|
141
|
+
}
|
|
142
|
+
}, [onClose, returnFocusRef])
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="md:hidden fixed inset-0 z-50">
|
|
146
|
+
<div data-testid="nav-drawer-overlay" className="admin-drawer-overlay absolute inset-0" style={{ background: 'rgba(0,0,0,0.55)' }} onClick={onClose} />
|
|
147
|
+
<div
|
|
148
|
+
id="admin-nav-drawer"
|
|
149
|
+
role="dialog"
|
|
150
|
+
aria-modal="true"
|
|
151
|
+
aria-label="Navigation"
|
|
152
|
+
className="admin-drawer-panel absolute inset-y-0 left-0 flex flex-col w-72 max-w-[85vw]"
|
|
153
|
+
style={{ background: 'var(--m3-surface-container-low)', boxShadow: '0 8px 32px rgba(0,0,0,0.18)' }}
|
|
154
|
+
>
|
|
155
|
+
<div className="flex items-center justify-between gap-2 px-4 pt-4 mb-4">
|
|
156
|
+
<Brand brand={nav.config.brand} />
|
|
157
|
+
<button ref={closeRef} type="button" onClick={onClose} aria-label="Close navigation" className="btn-icon">
|
|
158
|
+
<span aria-hidden="true" className="material-symbols-outlined">close</span>
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
<NavContent {...nav} />
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The signed-in shell of an admin interface: a sidebar (a drawer on a phone) with the sections the user's role may see,
|
|
169
|
+
* the footer links, the theme switch and logout, and the page below it. The sections, roles, brand and links are the
|
|
170
|
+
* application's; the page is whatever the router renders in the `Outlet`.
|
|
171
|
+
*/
|
|
172
|
+
export function createAdminShell(config: AdminShellConfig) {
|
|
173
|
+
return function AdminShell() {
|
|
174
|
+
const navigate = useNavigate()
|
|
175
|
+
const location = useLocation()
|
|
176
|
+
const [isDark, toggleDark] = useDarkMode()
|
|
177
|
+
const datePrefs = useDatePrefs()
|
|
178
|
+
const currentUser = getCurrentUser()
|
|
179
|
+
const userRank = config.roleRank[currentUser?.role ?? ''] ?? 0
|
|
180
|
+
// The drawer remembers the path it was opened on, so navigating anywhere closes it without an effect.
|
|
181
|
+
const [drawerPath, setDrawerPath] = useState<string | null>(null)
|
|
182
|
+
const drawerOpen = drawerPath === location.pathname
|
|
183
|
+
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
|
184
|
+
const closeDrawer = useCallback(() => setDrawerPath(null), [])
|
|
185
|
+
|
|
186
|
+
const navSections = config.sections
|
|
187
|
+
.map((section) => ({
|
|
188
|
+
...section,
|
|
189
|
+
items: section.items.filter((item) => userRank >= (config.roleRank[item.minRole] ?? 99)),
|
|
190
|
+
}))
|
|
191
|
+
.filter((section) => section.items.length > 0)
|
|
192
|
+
|
|
193
|
+
const { changelog, version, docsUrl } = config.whatsNew
|
|
194
|
+
const userId = currentUser?.userId
|
|
195
|
+
const [whatsNew, setWhatsNew] = useState(() => userId !== undefined ? pendingWhatsNew(userId, changelog, version) : [])
|
|
196
|
+
const closeWhatsNew = useCallback(() => {
|
|
197
|
+
setWhatsNew([])
|
|
198
|
+
if (userId !== undefined) markWhatsNewSeen(userId, version)
|
|
199
|
+
}, [userId, version])
|
|
200
|
+
|
|
201
|
+
async function handleLogout() {
|
|
202
|
+
try { await api.post('/auth/logout') } catch { /* local logout still proceeds */ }
|
|
203
|
+
clearSession()
|
|
204
|
+
navigate(config.loginPath)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const nav: NavContentProps = { config, sections: navSections, isDark, onToggleDark: toggleDark, onLogout: () => void handleLogout() }
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="flex flex-col md:flex-row h-screen" style={{ background: 'var(--m3-surface-container-low)' }}>
|
|
211
|
+
{/* Mobile top bar */}
|
|
212
|
+
<header
|
|
213
|
+
className="md:hidden flex items-center justify-between gap-3 px-4 py-3 flex-shrink-0"
|
|
214
|
+
style={{ background: 'var(--m3-surface-container-low)', borderBottom: '1px solid var(--m3-outline-variant)' }}
|
|
215
|
+
>
|
|
216
|
+
<Brand brand={config.brand} />
|
|
217
|
+
<button
|
|
218
|
+
ref={menuButtonRef}
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={() => setDrawerPath(location.pathname)}
|
|
221
|
+
aria-label="Open navigation"
|
|
222
|
+
aria-expanded={drawerOpen}
|
|
223
|
+
aria-controls="admin-nav-drawer"
|
|
224
|
+
className="btn-icon"
|
|
225
|
+
>
|
|
226
|
+
<span aria-hidden="true" className="material-symbols-outlined">menu</span>
|
|
227
|
+
</button>
|
|
228
|
+
</header>
|
|
229
|
+
|
|
230
|
+
{whatsNew.length > 0 && <WhatsNew releases={whatsNew} docsUrl={docsUrl} onClose={closeWhatsNew} />}
|
|
231
|
+
|
|
232
|
+
{drawerOpen && <MobileDrawer {...nav} onClose={closeDrawer} returnFocusRef={menuButtonRef} />}
|
|
233
|
+
|
|
234
|
+
{/* Sidebar */}
|
|
235
|
+
<aside
|
|
236
|
+
className="hidden md:flex flex-col h-screen w-64 sticky top-0 flex-shrink-0"
|
|
237
|
+
style={{ background: 'var(--m3-surface-container-low)' }}
|
|
238
|
+
>
|
|
239
|
+
<div className="mb-6 px-4 pt-5">
|
|
240
|
+
<Brand brand={config.brand} />
|
|
241
|
+
</div>
|
|
242
|
+
<NavContent {...nav} />
|
|
243
|
+
</aside>
|
|
244
|
+
|
|
245
|
+
{/* Main */}
|
|
246
|
+
<main className="flex-1 min-h-0 min-w-0 overflow-auto" style={{ background: 'var(--m3-surface-container-low)' }}>
|
|
247
|
+
{/* Lazy pages suspend here, not above the shell, so the menu stays while a page's code is fetched. */}
|
|
248
|
+
<Suspense fallback={<LoadingState />}>
|
|
249
|
+
{/* Remount the page when the date format changes so every formatted date is recomputed. */}
|
|
250
|
+
<Fragment key={`${datePrefs.date}-${datePrefs.time}`}><Outlet /></Fragment>
|
|
251
|
+
</Suspense>
|
|
252
|
+
</main>
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { Navigate } from 'react-router-dom'
|
|
3
|
+
import { api, clearSession, getCurrentUser, mustChangePassword, setSession, type AuthUser } from '../api/client'
|
|
4
|
+
import { uiConfig } from '../config'
|
|
5
|
+
import { navigation } from '../navigation'
|
|
6
|
+
|
|
7
|
+
/** Sends a first visit to the setup page, while the application has no administrator yet. */
|
|
8
|
+
export function SetupGate({ children }: { children: ReactNode }) {
|
|
9
|
+
const [state, setState] = useState<'loading' | 'setup' | 'ready'>('loading')
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
fetch(`${uiConfig().apiBase}/setup/status`)
|
|
13
|
+
.then((r) => r.json())
|
|
14
|
+
.then((data: { needsSetup: boolean }) => {
|
|
15
|
+
setState(data.needsSetup ? 'setup' : 'ready')
|
|
16
|
+
})
|
|
17
|
+
.catch(() => setState('ready'))
|
|
18
|
+
}, [])
|
|
19
|
+
|
|
20
|
+
if (state === 'loading') {
|
|
21
|
+
return (
|
|
22
|
+
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--m3-surface)' }}>
|
|
23
|
+
<div className="flex flex-col items-center gap-4">
|
|
24
|
+
<div
|
|
25
|
+
className="w-10 h-10 rounded-2xl flex items-center justify-center"
|
|
26
|
+
style={{ background: 'var(--m3-primary-fixed)' }}
|
|
27
|
+
>
|
|
28
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
29
|
+
<circle cx="10" cy="10" r="3.5" fill="var(--m3-primary)" />
|
|
30
|
+
<circle cx="10" cy="10" r="8.5" stroke="var(--m3-primary)" strokeWidth="1.2" strokeOpacity="0.35" fill="none" />
|
|
31
|
+
</svg>
|
|
32
|
+
</div>
|
|
33
|
+
<span className="font-sans text-sm" style={{ color: 'var(--m3-secondary)' }}>Starting…</span>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (state === 'setup') return <Navigate to={`${uiConfig().basePath}/setup`} replace />
|
|
40
|
+
return <>{children}</>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Renders its children for a signed-in user. Someone who is not signed in goes to the sign-in page; someone the
|
|
45
|
+
* application sends elsewhere (`redirectAfterSignIn`) leaves the interface for that address.
|
|
46
|
+
*/
|
|
47
|
+
export function RequireAuth({ children }: { children: ReactNode }) {
|
|
48
|
+
const [state, setState] = useState<'checking' | 'authenticated' | 'anonymous' | 'elsewhere'>('checking')
|
|
49
|
+
const [elsewhere, setElsewhere] = useState<string | null>(null)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
api.get<AuthUser>('/auth/session')
|
|
53
|
+
.then((user) => {
|
|
54
|
+
setSession(user)
|
|
55
|
+
const target = uiConfig().redirectAfterSignIn?.(user) ?? null
|
|
56
|
+
setElsewhere(target)
|
|
57
|
+
setState(target ? 'elsewhere' : 'authenticated')
|
|
58
|
+
})
|
|
59
|
+
.catch(() => { clearSession(); setState('anonymous') })
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (state === 'elsewhere' && elsewhere) navigation.assign(elsewhere)
|
|
64
|
+
}, [state, elsewhere])
|
|
65
|
+
|
|
66
|
+
if (state === 'checking' || state === 'elsewhere') return null
|
|
67
|
+
if (state === 'anonymous') return <Navigate to={`${uiConfig().basePath}/login`} replace />
|
|
68
|
+
return <>{children}</>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Keeps a user who has to replace a temporary password on the page where they do it. */
|
|
72
|
+
export function RequirePasswordChanged({ children }: { children: ReactNode }) {
|
|
73
|
+
if (mustChangePassword()) return <Navigate to={`${uiConfig().basePath}/change-password`} replace />
|
|
74
|
+
return <>{children}</>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A guard for pages that need a role: a user whose rank is below the page's goes to the home page of their role.
|
|
79
|
+
* `roleRank` lists the roles by rank (higher sees more); a role it does not know ranks lowest.
|
|
80
|
+
*/
|
|
81
|
+
export function createRoleGuard(options: { roleRank: Readonly<Record<string, number>>; homeOf: (role?: string) => string }) {
|
|
82
|
+
return function RequireRole({ minRole, children }: { minRole: string; children: ReactNode }) {
|
|
83
|
+
const user = getCurrentUser()
|
|
84
|
+
const rank = options.roleRank[user?.role ?? ''] ?? 0
|
|
85
|
+
if (rank < (options.roleRank[minRole] ?? 99)) return <Navigate to={options.homeOf(user?.role)} replace />
|
|
86
|
+
return <>{children}</>
|
|
87
|
+
}
|
|
88
|
+
}
|