Files
pic/webui/src/App.jsx
T
roof 8650704316 feat: add authentication and authorization system
Backend:
- AuthManager (api/auth_manager.py): server-side user store with bcrypt
  password hashing, account lockout after 5 failed attempts (15 min),
  and atomic file writes
- AuthRoutes (api/auth_routes.py): Blueprint at /api/auth/* — login,
  logout, me, change-password, admin reset-password, list-users
- app.py: register auth_bp blueprint; add enforce_auth before_request
  hook (401 for unauthenticated, 403 for wrong role; only active when
  auth store has users so pre-auth tests remain green); instantiate
  AuthManager; update POST /api/peers to require password >= 10 chars
  and auto-provision email + calendar + files + auth accounts with full
  rollback on any failure; extend DELETE /api/peers to tear down all
  four service accounts; add /api/peer/dashboard and /api/peer/services
  peer-scoped routes; fix is_local_request to also trust the last
  X-Forwarded-For entry appended by the reverse proxy (Caddy)
- Role-based access: admin for /api/* (except /api/auth/* which is
  public and /api/peer/* which is peer-only)
- setup_cell.py: generate and print initial admin password, store in
  .admin_initial_password with 0600 permissions; cleaned up on first
  admin login

Frontend:
- AuthContext.jsx: React context with login/logout/me state and Axios
  interceptor for automatic 401 redirect
- PrivateRoute.jsx: route guard component
- Login.jsx: login page with error handling and must-change-password
  redirect
- AccountSettings.jsx: change-password form for any authenticated user
- PeerDashboard.jsx: peer-role landing page (IP, service list)
- MyServices.jsx: peer service links page
- App.jsx, Sidebar.jsx: AuthContext integration, logout button,
  PrivateRoute wrappers, peer-role routing
- Peers.jsx, WireGuard.jsx, api.js: auth-aware API calls

Tests: 100 new auth tests all pass (test_auth_manager, test_auth_routes,
test_route_protection, test_peer_provisioning). Fix pre-existing test
failures: update WireGuard test keys to valid 44-char base64 format
(test_wireguard_manager, test_peer_wg_integration), add password field
and service manager mocks to test_api_endpoints peer tests, add auth
helpers to conftest.py. Full suite: 845 passed, 0 failures.

Fixed: .admin_initial_password security cleanup on bootstrap, username
minimum length (3 chars enforced by USERNAME_RE regex)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:00:06 -04:00

373 lines
15 KiB
React

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { useState, useEffect, useCallback } from 'react';
import {
Home,
Users,
Network,
Shield,
Mail,
Calendar as CalendarIcon,
FolderOpen,
Activity,
Wifi,
Server,
Key,
Package2,
Settings as SettingsIcon,
Link2,
RefreshCw,
AlertTriangle,
User,
} from 'lucide-react';
import { healthAPI, cellAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext';
import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import Sidebar from './components/Sidebar';
import Dashboard from './pages/Dashboard';
import Peers from './pages/Peers';
import NetworkServices from './pages/NetworkServices';
import WireGuard from './pages/WireGuard';
import Email from './pages/Email';
import Calendar from './pages/Calendar';
import Files from './pages/Files';
import Routing from './pages/Routing';
import Logs from './pages/Logs';
import Settings from './pages/Settings';
import Vault from './pages/Vault';
import ContainerDashboard from './components/ContainerDashboard';
import CellNetwork from './pages/CellNetwork';
import Login from './pages/Login';
import AccountSettings from './pages/AccountSettings';
import PeerDashboard from './pages/PeerDashboard';
import MyServices from './pages/MyServices';
function PendingRestartBanner({ pending, onApply, onCancel }) {
const [confirming, setConfirming] = useState(false);
const [applying, setApplying] = useState(false);
const [cancelling, setCancelling] = useState(false);
const handleApply = async () => {
setApplying(true);
setConfirming(false);
try {
await onApply();
} finally {
setApplying(false);
}
};
const handleCancel = async () => {
setCancelling(true);
try {
await onCancel();
} finally {
setCancelling(false);
}
};
return (
<>
<div className="mb-6 bg-warning-50 border border-warning-300 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-warning-800">
Configuration changes pending containers need restart
</p>
{pending.changes?.length > 0 && (
<ul className="mt-1 text-xs text-warning-700 list-disc list-inside">
{pending.changes.map((c, i) => <li key={i}>{c}</li>)}
</ul>
)}
</div>
</div>
<div className="ml-4 flex-shrink-0 flex items-center gap-2">
<button
onClick={handleCancel}
disabled={applying || cancelling}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white hover:bg-gray-50 disabled:opacity-50 text-warning-700 text-sm font-medium rounded-md border border-warning-300 transition-colors"
>
{cancelling ? 'Discarding…' : 'Discard'}
</button>
<button
onClick={() => setConfirming(true)}
disabled={applying || cancelling}
className="flex items-center gap-1.5 px-3 py-1.5 bg-warning-600 hover:bg-warning-700 disabled:opacity-50 text-white text-sm font-medium rounded-md transition-colors"
>
<RefreshCw className={`h-4 w-4 ${applying ? 'animate-spin' : ''}`} />
{applying ? 'Restarting…' : 'Apply Now'}
</button>
</div>
</div>
</div>
{confirming && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4">
<div className="flex items-center gap-3 mb-3">
<AlertTriangle className="h-6 w-6 text-warning-500 flex-shrink-0" />
<h3 className="text-base font-semibold text-gray-900">Restart containers?</h3>
</div>
<p className="text-sm text-gray-600 mb-5">
All containers will be restarted to apply the new configuration.
The UI will be briefly unavailable during the restart.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setConfirming(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
<button
onClick={handleApply}
className="px-4 py-2 text-sm font-medium text-white bg-warning-600 hover:bg-warning-700 rounded-md transition-colors"
>
Restart now
</button>
</div>
</div>
</div>
)}
</>
);
}
// AppCore is the real application — it consumes DraftConfigContext and must
// be rendered inside DraftConfigProvider (see App below).
function AppCore() {
const [isOnline, setIsOnline] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [pending, setPending] = useState({ needs_restart: false, changes: [] });
const checkHealth = useCallback(async () => {
try {
await healthAPI.check();
setIsOnline(true);
} catch {
setIsOnline(false);
} finally {
setIsLoading(false);
}
}, []);
const checkPending = useCallback(async () => {
try {
const res = await cellAPI.getPending();
setPending(res.data);
} catch {
// ignore — not critical
}
}, []);
useEffect(() => {
checkHealth();
checkPending();
const healthInterval = setInterval(checkHealth, 5000);
const pendingInterval = setInterval(checkPending, 5000);
return () => {
clearInterval(healthInterval);
clearInterval(pendingInterval);
};
}, [checkHealth, checkPending]);
const [applyStatus, setApplyStatus] = useState(null); // null | 'saving' | 'restarting' | 'done' | 'timeout' | 'error'
const [applyError, setApplyError] = useState('');
const { flushAll, hasDirty } = useDraftConfig();
const handleApply = useCallback(async () => {
setApplyError('');
if (hasDirty()) {
setApplyStatus('saving');
try {
await flushAll();
} catch {
// flush errors are shown via Settings toasts; continue with apply
}
}
try {
await cellAPI.applyPending();
} catch (err) {
setApplyStatus('error');
setApplyError(err?.response?.data?.error || 'Apply request failed');
setTimeout(() => setApplyStatus(null), 6000);
return;
}
setPending({ needs_restart: false, changes: [] });
setApplyStatus('restarting');
// Poll health until API responds again (max 45 s; it may briefly drop if cell-api restarts)
const deadline = Date.now() + 45000;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 2000));
try {
await healthAPI.check();
setIsOnline(true);
setApplyStatus('done');
setTimeout(() => setApplyStatus(null), 4000);
return;
} catch {
setIsOnline(false);
}
}
setApplyStatus('timeout');
setApplyError('Containers may still be starting — check docker logs if services are unavailable');
setTimeout(() => setApplyStatus(null), 8000);
}, [flushAll, hasDirty]);
const handleCancel = useCallback(async () => {
await cellAPI.cancelPending();
setPending({ needs_restart: false, changes: [] });
window.dispatchEvent(new CustomEvent('pic-config-discarded'));
}, []);
const adminNavigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{ name: 'Peers', href: '/peers', icon: Users },
{ name: 'Network Services', href: '/network', icon: Network },
{ name: 'WireGuard', href: '/wireguard', icon: Shield },
{ name: 'Email', href: '/email', icon: Mail },
{ name: 'Calendar', href: '/calendar', icon: CalendarIcon },
{ name: 'Files', href: '/files', icon: FolderOpen },
{ name: 'Routing', href: '/routing', icon: Wifi },
{ name: 'Vault', href: '/vault', icon: Key },
{ name: 'Containers', href: '/containers', icon: Package2 },
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
{ name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
{ name: 'Account', href: '/account', icon: User },
];
const peerNavigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{ name: 'My Services', href: '/my-services', icon: FolderOpen },
{ name: 'Account', href: '/account', icon: User },
];
const { user } = useAuth();
const navigation = user?.role === 'peer' ? peerNavigation : adminNavigation;
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Connecting to Personal Internet Cell...</p>
</div>
</div>
);
}
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={
<ConfigProvider>
<div className="min-h-screen bg-gray-50">
<Sidebar navigation={navigation} isOnline={isOnline} />
<div className="lg:pl-72">
<main className="py-10">
<div className="px-4 sm:px-6 lg:px-8">
{!isOnline && (
<div className="mb-6 bg-danger-50 border border-danger-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<Server className="h-5 w-5 text-danger-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Backend Unavailable
</h3>
<div className="mt-2 text-sm text-danger-700">
<p>
Unable to connect to the Personal Internet Cell backend.
Please ensure the API server is running on port 3000.
</p>
</div>
</div>
</div>
</div>
)}
{isOnline && pending.needs_restart && !applyStatus && (
<PendingRestartBanner pending={pending} onApply={handleApply} onCancel={handleCancel} />
)}
{applyStatus === 'saving' && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center gap-3">
<RefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
<span className="text-sm font-medium text-blue-800">Saving settings</span>
</div>
)}
{applyStatus === 'restarting' && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center gap-3">
<RefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
<span className="text-sm font-medium text-blue-800">Restarting containers please wait</span>
</div>
)}
{applyStatus === 'done' && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
<span className="h-5 w-5 text-green-500 flex-shrink-0 text-lg leading-none"></span>
<span className="text-sm font-medium text-green-800">Containers restarted successfully</span>
</div>
)}
{(applyStatus === 'timeout' || applyStatus === 'error') && (
<div className="mb-6 bg-danger-50 border border-danger-200 rounded-lg p-4 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-danger-500 flex-shrink-0" />
<span className="text-sm font-medium text-danger-800">{applyError}</span>
</div>
)}
<Routes>
<Route path="/" element={<PrivateRoute><RoleHome isOnline={isOnline} /></PrivateRoute>} />
<Route path="/account" element={<PrivateRoute><AccountSettings /></PrivateRoute>} />
<Route path="/my-services" element={<PrivateRoute requireRole="peer"><MyServices /></PrivateRoute>} />
<Route path="/peers" element={<PrivateRoute requireRole="admin"><Peers /></PrivateRoute>} />
<Route path="/network" element={<PrivateRoute requireRole="admin"><NetworkServices /></PrivateRoute>} />
<Route path="/wireguard" element={<PrivateRoute requireRole="admin"><WireGuard /></PrivateRoute>} />
<Route path="/email" element={<PrivateRoute requireRole="admin"><Email /></PrivateRoute>} />
<Route path="/calendar" element={<PrivateRoute requireRole="admin"><Calendar /></PrivateRoute>} />
<Route path="/files" element={<PrivateRoute requireRole="admin"><Files /></PrivateRoute>} />
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
</Routes>
</div>
</main>
</div>
</div>
</ConfigProvider>
} />
</Routes>
</Router>
);
}
function RoleHome({ isOnline }) {
const { user } = useAuth();
return user?.role === 'peer' ? <PeerDashboard /> : <Dashboard isOnline={isOnline} />;
}
function App() {
return (
<AuthProvider>
<DraftConfigProvider>
<AppCore />
</DraftConfigProvider>
</AuthProvider>
);
}
export default App;