When the database has no users, the UI redirects to /setup and prompts for creation of the first admin account. The setup endpoints are self-disabling — once any user exists, POST /setup/admin returns 403 and the frontend redirects /setup back to /login. The backend uses an atomic transaction to prevent race conditions on concurrent requests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
|
import { AuthProvider, useAuth } from './auth';
|
|
import { api } from './api';
|
|
import Login from './pages/Login';
|
|
import Activate from './pages/Activate';
|
|
import Setup from './pages/Setup';
|
|
import Dashboard from './pages/Dashboard';
|
|
import Schedules from './pages/Schedules';
|
|
import TimeOff from './pages/TimeOff';
|
|
import Volunteers from './pages/Volunteers';
|
|
import Profile from './pages/Profile';
|
|
import './App.css';
|
|
|
|
function Nav() {
|
|
const { role, logout } = useAuth();
|
|
return (
|
|
<nav>
|
|
<span className="nav-brand">Walkies</span>
|
|
<NavLink to="/">Dashboard</NavLink>
|
|
<NavLink to="/schedules">Schedules</NavLink>
|
|
<NavLink to="/timeoff">Time Off</NavLink>
|
|
<NavLink to="/profile">Profile</NavLink>
|
|
{role === 'admin' && <NavLink to="/volunteers">Volunteers</NavLink>}
|
|
<button className="btn-link" onClick={logout}>Sign Out</button>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function ProtectedLayout() {
|
|
const { token } = useAuth();
|
|
if (!token) return <Navigate to="/login" replace />;
|
|
return (
|
|
<div className="layout">
|
|
<Nav />
|
|
<main>
|
|
<Routes>
|
|
<Route path="/" element={<Dashboard />} />
|
|
<Route path="/schedules" element={<Schedules />} />
|
|
<Route path="/timeoff" element={<TimeOff />} />
|
|
<Route path="/profile" element={<Profile />} />
|
|
<Route path="/volunteers" element={<Volunteers />} />
|
|
</Routes>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoginRoute() {
|
|
const { token } = useAuth();
|
|
if (token) return <Navigate to="/" replace />;
|
|
return <Login />;
|
|
}
|
|
|
|
// Setup context lets the Setup page flip needsSetup after creating the admin.
|
|
const SetupContext = createContext<{ setNeedsSetup: (v: boolean) => void }>({
|
|
setNeedsSetup: () => {},
|
|
});
|
|
export const useSetup = () => useContext(SetupContext);
|
|
|
|
function SetupGate({ children }: { children: ReactNode }) {
|
|
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.getSetupStatus()
|
|
.then(r => setNeedsSetup(r.needs_setup))
|
|
.catch(() => setNeedsSetup(false));
|
|
}, []);
|
|
|
|
if (needsSetup === null) return null;
|
|
|
|
if (needsSetup) {
|
|
return (
|
|
<SetupContext.Provider value={{ setNeedsSetup }}>
|
|
<Routes>
|
|
<Route path="/setup" element={<Setup />} />
|
|
<Route path="*" element={<Navigate to="/setup" replace />} />
|
|
</Routes>
|
|
</SetupContext.Provider>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SetupContext.Provider value={{ setNeedsSetup }}>
|
|
<Routes>
|
|
<Route path="/setup" element={<Navigate to="/login" replace />} />
|
|
<Route path="/login" element={<LoginRoute />} />
|
|
<Route path="/activate" element={<Activate />} />
|
|
<Route path="/*" element={<ProtectedLayout />} />
|
|
</Routes>
|
|
</SetupContext.Provider>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<BrowserRouter>
|
|
<SetupGate>
|
|
<Routes>
|
|
<Route path="/login" element={<LoginRoute />} />
|
|
<Route path="/activate" element={<Activate />} />
|
|
<Route path="/*" element={<ProtectedLayout />} />
|
|
</Routes>
|
|
</SetupGate>
|
|
</BrowserRouter>
|
|
</AuthProvider>
|
|
);
|
|
}
|