feat(ui): Frontend MVP — React 19 + AntD 6 + Vite + StaticFS-Wiring
Scaffold und Core-Infrastruktur 1:1 nach enconf-Pattern (netcell- webpanel/management-ui), reduziert auf EdgeGuard-Scope (kein reseller/ customer-Roles, keine codemirror/extensions). Stack: React 19 + AntD 6 + TS strict + Vite + TanStack-Query + zustand + react-i18next. Layout: AppLayout (Sider+Header+Content), Sidebar (Dashboard/Domains), Header (User-Dropdown + Logout). i18n mit de/en common.json. Pages: Login (POST /auth/login), Setup-Wizard (POST /setup/complete), Dashboard (Health-Polling + Statistics), Domains (volles CRUD via TanStack-Query gegen /domains-API). UpdateBanner-Komponente (/system/package-versions, alle 5 min poll, /system/upgrade trigger) ist von Tag 1 wie vom User gefordert eingebaut. API-Wiring: cmd/edgeguard-api/main.go mountUI() — gin StaticFS für /usr/share/edgeguard/ui/ (overridebar via EDGEGUARD_UI_DIR), echte Files werden direkt geserved, alle nicht-API-Pfade fallen via NoRoute auf index.html für React-Router-SPA. Wenn dist/ fehlt: HTML-Placeholder mit Build-Hinweis. Verifiziert: bun install + npx tsc -b strict (0 errors) + bun run build (12 chunks). End-to-end gegen /tmp/eg-api: / serviert echte React-index.html, /domains SPA-Fallback, /api/v1/* JSON, /assets/* direkt, /api/v1/nonexistent korrekt 404. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
management-ui/src/App.tsx
Normal file
87
management-ui/src/App.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Suspense, lazy, useEffect, type ReactNode } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { ConfigProvider, Spin } from 'antd'
|
||||
import deDE from 'antd/locale/de_DE'
|
||||
import enUS from 'antd/locale/en_US'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AppLayout from './components/Layout/AppLayout'
|
||||
import apiClient, { isEnvelope } from './api/client'
|
||||
import { useAuthStore, type SessionUser } from './stores/auth'
|
||||
|
||||
const LoginPage = lazy(() => import('./pages/Login'))
|
||||
const SetupPage = lazy(() => import('./pages/Setup'))
|
||||
const DashboardPage = lazy(() => import('./pages/Dashboard'))
|
||||
const DomainsPage = lazy(() => import('./pages/Domains'))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// RequireAuth wraps protected routes. If we have a cached user but
|
||||
// the cookie is stale, the first API call will 401 and the global
|
||||
// interceptor in api/client.ts boots the user back to /login.
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const location = useLocation()
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// SetupGate fetches /setup/status on mount; if the server says
|
||||
// !completed, hard-redirect to /setup. This catches the case of a
|
||||
// fresh install where the user goes straight to /dashboard.
|
||||
function SetupGate({ children }: { children: ReactNode }) {
|
||||
const location = useLocation()
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/setup')) return
|
||||
apiClient.get('/setup/status').then((r) => {
|
||||
const env = r.data
|
||||
if (isEnvelope(env)) {
|
||||
const data = env.data as { completed?: boolean } | undefined
|
||||
if (data && data.completed === false) {
|
||||
window.location.replace('/setup')
|
||||
}
|
||||
}
|
||||
}).catch(() => { /* setup-gate is best-effort */ })
|
||||
}, [location.pathname])
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { i18n } = useTranslation()
|
||||
const antdLocale = i18n.language?.startsWith('de') ? deDE : enUS
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={antdLocale}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<SetupGate>
|
||||
<Suspense fallback={<Spin size="large" style={{ margin: '40vh auto', display: 'block' }} />}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
|
||||
<Route path="/login" element={<LoginPage onLogin={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
|
||||
|
||||
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</SetupGate>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user