233 lines
6.6 KiB
JavaScript
233 lines
6.6 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { request } from './api/client.js';
|
|
import { Sidebar } from './components/Sidebar.jsx';
|
|
import { ToastProvider, useToast } from './components/Toast.jsx';
|
|
import { AccountView } from './routes/AccountView.jsx';
|
|
import { AlertsView } from './routes/AlertsView.jsx';
|
|
import { AuthPanel } from './routes/AuthPanel.jsx';
|
|
import { NotificationMethodsView } from './routes/NotificationMethodsView.jsx';
|
|
import { SiteSettingsPanel } from './routes/SiteSettingsPanel.jsx';
|
|
import { SitesView } from './routes/SitesView.jsx';
|
|
|
|
const viewPaths = {
|
|
sites: '/sites',
|
|
alerts: '/alerts',
|
|
notifications: '/notifications',
|
|
account: '/account',
|
|
};
|
|
|
|
function normalizedPath(pathname) {
|
|
if (!pathname || pathname === '/') return '/';
|
|
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
}
|
|
|
|
function parseRoute(pathname) {
|
|
const path = normalizedPath(pathname);
|
|
if (path === '/') return { kind: 'root' };
|
|
if (path === '/login') return { kind: 'auth', mode: 'login' };
|
|
if (path === '/register') return { kind: 'auth', mode: 'register' };
|
|
if (path === '/sites') return { kind: 'sites', activeView: 'sites', protected: true };
|
|
if (path === '/alerts') return { kind: 'alerts', activeView: 'alerts', protected: true };
|
|
if (path === '/notifications') {
|
|
return { kind: 'notifications', activeView: 'notifications', protected: true };
|
|
}
|
|
if (path === '/account') return { kind: 'account', activeView: 'account', protected: true };
|
|
|
|
const siteSettingsMatch = path.match(/^\/sites\/([^/]+)\/settings$/);
|
|
if (siteSettingsMatch) {
|
|
return {
|
|
kind: 'siteSettings',
|
|
activeView: 'sites',
|
|
protected: true,
|
|
siteId: decodeURIComponent(siteSettingsMatch[1]),
|
|
};
|
|
}
|
|
|
|
return { kind: 'notFound' };
|
|
}
|
|
|
|
function isProtectedPath(pathname) {
|
|
return Boolean(parseRoute(pathname).protected);
|
|
}
|
|
|
|
function currentPath() {
|
|
return normalizedPath(window.location.pathname);
|
|
}
|
|
|
|
export function App() {
|
|
const [user, setUser] = useState(null);
|
|
const [locationPath, setLocationPath] = useState(currentPath);
|
|
const [ready, setReady] = useState(false);
|
|
const pendingReturnPath = useRef(null);
|
|
|
|
const route = parseRoute(locationPath);
|
|
|
|
const navigate = useCallback((path, options = {}) => {
|
|
const nextPath = normalizedPath(path);
|
|
if (currentPath() !== nextPath) {
|
|
const method = options.replace ? 'replaceState' : 'pushState';
|
|
window.history[method](null, '', nextPath);
|
|
}
|
|
setLocationPath(nextPath);
|
|
}, []);
|
|
|
|
const logout = useMemo(
|
|
() => async () => {
|
|
await request('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
|
pendingReturnPath.current = null;
|
|
setUser(null);
|
|
navigate('/login', { replace: true });
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
useEffect(() => {
|
|
function handlePopState() {
|
|
setLocationPath(currentPath());
|
|
}
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
async function boot() {
|
|
const csrf = await request('/api/auth/csrf');
|
|
localStorage.setItem('csrfToken', csrf.csrfToken);
|
|
const me = await request('/api/auth/me').catch(() => null);
|
|
setUser(me?.user ?? null);
|
|
setReady(true);
|
|
}
|
|
boot();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!ready) return;
|
|
|
|
if (user) {
|
|
if (route.kind === 'auth' || route.kind === 'root' || route.kind === 'notFound') {
|
|
navigate('/sites', { replace: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (route.protected) {
|
|
pendingReturnPath.current = locationPath;
|
|
navigate('/login', { replace: true });
|
|
return;
|
|
}
|
|
|
|
if (route.kind === 'root' || route.kind === 'notFound') {
|
|
navigate('/login', { replace: true });
|
|
}
|
|
}, [locationPath, navigate, ready, route.kind, route.protected, user]);
|
|
|
|
if (!ready) {
|
|
return <div className="loading">CertRemind</div>;
|
|
}
|
|
|
|
if (!user) {
|
|
const mode = route.kind === 'auth' ? route.mode : 'login';
|
|
return (
|
|
<AuthPanel
|
|
mode={mode}
|
|
onModeChange={(nextMode) => navigate(nextMode === 'register' ? '/register' : '/login')}
|
|
onAuthed={(nextUser) => {
|
|
setUser(nextUser);
|
|
const returnPath = pendingReturnPath.current;
|
|
pendingReturnPath.current = null;
|
|
navigate(isProtectedPath(returnPath) ? returnPath : '/sites', { replace: true });
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function withShell(content) {
|
|
return (
|
|
<ToastProvider>
|
|
<div className="app-frame">
|
|
<Sidebar
|
|
activeView={route.activeView ?? 'sites'}
|
|
user={user}
|
|
onNavigate={(nextView) => navigate(viewPaths[nextView] ?? '/sites')}
|
|
onLogout={logout}
|
|
/>
|
|
<div className="app-content">{content}</div>
|
|
</div>
|
|
</ToastProvider>
|
|
);
|
|
}
|
|
|
|
if (route.kind === 'siteSettings') {
|
|
return withShell(
|
|
<SiteSettingsRoute siteId={route.siteId} onBack={() => navigate('/sites')} navigate={navigate} />,
|
|
);
|
|
}
|
|
|
|
if (route.kind === 'alerts') {
|
|
return withShell(<AlertsView onBack={() => navigate('/sites')} />);
|
|
}
|
|
|
|
if (route.kind === 'notifications') {
|
|
return withShell(<NotificationMethodsView onBack={() => navigate('/sites')} />);
|
|
}
|
|
|
|
if (route.kind === 'account') {
|
|
return withShell(
|
|
<AccountView
|
|
onBack={() => navigate('/sites')}
|
|
onSignedOut={() => {
|
|
pendingReturnPath.current = null;
|
|
setUser(null);
|
|
navigate('/login', { replace: true });
|
|
}}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
return withShell(
|
|
<SitesView
|
|
user={user}
|
|
onConfigureSite={(site) =>
|
|
navigate(`/sites/${encodeURIComponent(site.siteId)}/settings`)
|
|
}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
function SiteSettingsRoute({ siteId, onBack, navigate }) {
|
|
const [site, setSite] = useState(null);
|
|
const [loadingSiteId, setLoadingSiteId] = useState(siteId);
|
|
const { showToast } = useToast();
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
setSite(null);
|
|
setLoadingSiteId(siteId);
|
|
|
|
async function loadSite() {
|
|
try {
|
|
const data = await request(`/api/sites/${siteId}`);
|
|
if (active) {
|
|
setSite(data.site);
|
|
}
|
|
} catch (err) {
|
|
if (active) {
|
|
showToast({ type: 'error', message: err.message });
|
|
navigate('/sites', { replace: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
loadSite();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [navigate, showToast, siteId]);
|
|
|
|
if (!site || loadingSiteId !== siteId) {
|
|
return <div className="loading">CertRemind</div>;
|
|
}
|
|
|
|
return <SiteSettingsPanel site={site} onBack={onBack} />;
|
|
}
|