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>
|
||||
)
|
||||
}
|
||||
66
management-ui/src/api/client.ts
Normal file
66
management-ui/src/api/client.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// edgeguard-api lives at /api/v1 relative to whichever vhost the UI is
|
||||
// being served from (HAProxy proxies /api/* through to 127.0.0.1:9443).
|
||||
// In dev, vite's proxy points /api → http://127.0.0.1:9443.
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// Cookies carry the session — credentials must travel by default
|
||||
// even on same-origin (vite proxy is technically a different origin).
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Envelope unwrap helper for response.data.data callers. The backend
|
||||
// always wraps payloads as {data, error, message}; this strips the
|
||||
// envelope so callers can `.data.<field>` directly.
|
||||
export function isEnvelope(v: unknown): v is { data: unknown; error: string | null; message: string } {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'data' in v &&
|
||||
'error' in v &&
|
||||
'message' in v
|
||||
)
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const path = window.location.pathname
|
||||
|
||||
// 401 → drop local session, kick to /login (unless we're already
|
||||
// there; setup-mode is a separate gate).
|
||||
if (error.response?.status === 401 && !path.startsWith('/login') && !path.startsWith('/setup')) {
|
||||
useAuthStore.getState().clear()
|
||||
window.location.replace('/login')
|
||||
}
|
||||
|
||||
// 503 setup_required → kick to /setup so the wizard takes over.
|
||||
if (
|
||||
error.response?.status === 503 &&
|
||||
isEnvelope(error.response.data) &&
|
||||
error.response.data.error === 'setup_required' &&
|
||||
!path.startsWith('/setup')
|
||||
) {
|
||||
window.location.replace('/setup')
|
||||
}
|
||||
|
||||
// Translate envelope errors into a plain Error with .message + .status
|
||||
// so callers don't have to inspect axios-shape responses.
|
||||
if (error.response && isEnvelope(error.response.data)) {
|
||||
const env = error.response.data
|
||||
const msg = env.message || env.error || error.message
|
||||
const wrapped = new Error(msg) as Error & { status?: number; response?: typeof error.response }
|
||||
wrapped.status = error.response.status
|
||||
wrapped.response = error.response
|
||||
return Promise.reject(wrapped)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
25
management-ui/src/components/Layout/AppLayout.tsx
Normal file
25
management-ui/src/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Layout } from 'antd'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
import UpdateBanner from '../UpdateBanner'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider width={220} theme="dark">
|
||||
<Sidebar />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header />
|
||||
<UpdateBanner />
|
||||
<Content style={{ padding: '24px', background: '#f5f5f5' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
44
management-ui/src/components/Layout/Header.tsx
Normal file
44
management-ui/src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { Dropdown, Layout, Space, Typography } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Header: AntHeader } = Layout
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const clear = useAuthStore((s) => s.clear)
|
||||
|
||||
const onLogout = async () => {
|
||||
try { await apiClient.post('/auth/logout') } catch { /* ignore */ }
|
||||
clear()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<AntHeader style={{ background: '#fff', padding: '0 24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: t('auth.logout'),
|
||||
onClick: onLogout,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<UserOutlined />
|
||||
<Typography.Text>{user?.actor ?? '—'}</Typography.Text>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</AntHeader>
|
||||
)
|
||||
}
|
||||
30
management-ui/src/components/Layout/Sidebar.tsx
Normal file
30
management-ui/src/components/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DashboardOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { Menu, Typography } from 'antd'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Sidebar() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const items = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: t('nav.dashboard') },
|
||||
{ key: '/domains', icon: <GlobalOutlined />, label: t('nav.domains') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ color: '#fff', padding: '16px', margin: 0 }}>
|
||||
{t('app.title')}
|
||||
</Typography.Title>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={items}
|
||||
onClick={(e) => navigate(e.key)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
management-ui/src/components/UpdateBanner.tsx
Normal file
71
management-ui/src/components/UpdateBanner.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Alert, Button, message } from 'antd'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
|
||||
import apiClient, { isEnvelope } from '../api/client'
|
||||
|
||||
interface PackageVersions {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
// hasUpdate compares an *_installed value against its *_available
|
||||
// counterpart. Returns the package base name + both versions when an
|
||||
// update is pending; null otherwise. We pick the first package that
|
||||
// has a real upgrade — matching enconf's "show one upgrade hint at
|
||||
// a time" UX.
|
||||
function pickUpdate(v: PackageVersions): { pkg: string; installed: string; available: string } | null {
|
||||
for (const key of Object.keys(v)) {
|
||||
if (!key.endsWith('_installed')) continue
|
||||
const pkg = key.replace('_installed', '')
|
||||
const installed = v[key]
|
||||
const available = v[`${pkg}_available`]
|
||||
if (installed && available && installed !== available) {
|
||||
return { pkg, installed, available }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function UpdateBanner() {
|
||||
const { t } = useTranslation()
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['system', 'package-versions'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/package-versions')
|
||||
if (isEnvelope(r.data)) return r.data.data as PackageVersions
|
||||
return {} as PackageVersions
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000, // 5 min poll matches enconf cadence
|
||||
})
|
||||
|
||||
const upgrade = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('/system/upgrade')
|
||||
},
|
||||
onSuccess: () => {
|
||||
setApplying(true)
|
||||
message.success(t('update.started'))
|
||||
},
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
const u = pickUpdate(data)
|
||||
if (!u) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
showIcon
|
||||
message={t('update.available', u)}
|
||||
action={
|
||||
<Button size="small" type="primary" loading={applying} onClick={() => upgrade.mutate()}>
|
||||
{applying ? t('update.applying') : t('update.applyNow')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
27
management-ui/src/i18n/index.ts
Normal file
27
management-ui/src/i18n/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import i18n from 'i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import deCommon from './locales/de/common.json'
|
||||
import enCommon from './locales/en/common.json'
|
||||
|
||||
void i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'de',
|
||||
supportedLngs: ['de', 'en'],
|
||||
defaultNS: 'common',
|
||||
ns: ['common'],
|
||||
interpolation: { escapeValue: false },
|
||||
resources: {
|
||||
de: { common: deCommon },
|
||||
en: { common: enCommon },
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
72
management-ui/src/i18n/locales/de/common.json
Normal file
72
management-ui/src/i18n/locales/de/common.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "EdgeGuard",
|
||||
"subtitle": "Native Reverse-Proxy / VPN / Firewall"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"routing": "Routing",
|
||||
"ssl": "SSL-Zertifikate",
|
||||
"vpn": "VPN",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Anmelden",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"loggedInAs": "Angemeldet als"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Erst-Einrichtung",
|
||||
"intro": "Lege den Admin-Account an, gib die öffentliche FQDN an und – optional – einen Lizenzschlüssel. Ohne Lizenz startet eine 30-Tage-Trial.",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"adminPassword": "Admin-Passwort",
|
||||
"passwordRule": "Mindestens 12 Zeichen.",
|
||||
"fqdn": "Öffentliche FQDN",
|
||||
"acmeEmail": "ACME-/Let's-Encrypt-E-Mail",
|
||||
"licenseKey": "Lizenzschlüssel (optional)",
|
||||
"submit": "Setup abschließen",
|
||||
"successTitle": "Setup abgeschlossen",
|
||||
"successHint": "Du wirst zur Anmeldung weitergeleitet."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "Übersicht aller laufenden EdgeGuard-Komponenten."
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
"addDomain": "Domain hinzufügen",
|
||||
"editDomain": "Domain bearbeiten",
|
||||
"name": "Name",
|
||||
"active": "Aktiv",
|
||||
"primaryBackend": "Primary-Backend",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"notes": "Notizen",
|
||||
"actions": "Aktionen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "Domain {{name}} wirklich löschen?"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update verfügbar: {{pkg}} {{installed}} → {{available}}",
|
||||
"applyNow": "Jetzt aktualisieren",
|
||||
"applying": "Update läuft …",
|
||||
"started": "Update wurde gestartet — der Server wird in Kürze neu starten."
|
||||
},
|
||||
"common": {
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"loading": "Lädt …",
|
||||
"error": "Fehler"
|
||||
}
|
||||
}
|
||||
72
management-ui/src/i18n/locales/en/common.json
Normal file
72
management-ui/src/i18n/locales/en/common.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "EdgeGuard",
|
||||
"subtitle": "Native reverse-proxy / VPN / firewall"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"routing": "Routing",
|
||||
"ssl": "SSL certificates",
|
||||
"vpn": "VPN",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"login": "Sign in",
|
||||
"logout": "Sign out",
|
||||
"loginFailed": "Sign-in failed",
|
||||
"loggedInAs": "Signed in as"
|
||||
},
|
||||
"setup": {
|
||||
"title": "First-time setup",
|
||||
"intro": "Create the admin account, declare the public FQDN, and — optionally — paste a license key. Without one, a 30-day trial starts.",
|
||||
"adminEmail": "Admin email",
|
||||
"adminPassword": "Admin password",
|
||||
"passwordRule": "At least 12 characters.",
|
||||
"fqdn": "Public FQDN",
|
||||
"acmeEmail": "ACME / Let's Encrypt email",
|
||||
"licenseKey": "License key (optional)",
|
||||
"submit": "Finish setup",
|
||||
"successTitle": "Setup complete",
|
||||
"successHint": "Redirecting you to sign-in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "Overview of all running EdgeGuard components."
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
"addDomain": "Add domain",
|
||||
"editDomain": "Edit domain",
|
||||
"name": "Name",
|
||||
"active": "Active",
|
||||
"primaryBackend": "Primary backend",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"notes": "Notes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Really delete domain {{name}}?"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update available: {{pkg}} {{installed}} → {{available}}",
|
||||
"applyNow": "Apply now",
|
||||
"applying": "Update in progress …",
|
||||
"started": "Update has started — the server will restart shortly."
|
||||
},
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading …",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
6
management-ui/src/index.css
Normal file
6
management-ui/src/index.css
Normal file
@@ -0,0 +1,6 @@
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
}
|
||||
11
management-ui/src/main.tsx
Normal file
11
management-ui/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
38
management-ui/src/pages/Dashboard/index.tsx
Normal file
38
management-ui/src/pages/Dashboard/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Card, Col, Row, Statistic, Typography } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/health')
|
||||
if (isEnvelope(r.data)) return r.data.data as { status: string; version: string }
|
||||
return null
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('dashboard.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('dashboard.welcomeHint')}</Typography.Paragraph>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="API status" value={health?.status ?? '—'} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="Version" value={health?.version ?? '—'} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
management-ui/src/pages/Domains/index.tsx
Normal file
168
management-ui/src/pages/Domains/index.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
interface Domain {
|
||||
id: number
|
||||
name: string
|
||||
active: boolean
|
||||
primary_backend_id?: number | null
|
||||
http_to_https: boolean
|
||||
hsts_enabled: boolean
|
||||
notes?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface DomainFormValues {
|
||||
name: string
|
||||
active: boolean
|
||||
http_to_https: boolean
|
||||
hsts_enabled: boolean
|
||||
primary_backend_id?: number | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
async function listDomains(): Promise<Domain[]> {
|
||||
const r = await apiClient.get('/domains')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
const payload = r.data.data as { domains?: Domain[] }
|
||||
return payload.domains ?? []
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['domains'],
|
||||
queryFn: listDomains,
|
||||
})
|
||||
|
||||
const [editing, setEditing] = useState<Domain | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form] = Form.useForm<DomainFormValues>()
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (v: DomainFormValues) => {
|
||||
const r = await apiClient.post('/domains', v)
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setCreating(false)
|
||||
form.resetFields()
|
||||
void qc.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, v }: { id: number; v: DomainFormValues }) => {
|
||||
const r = await apiClient.put(`/domains/${id}`, v)
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
void qc.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiClient.delete(`/domains/${id}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
|
||||
const columns: ColumnsType<Domain> = [
|
||||
{ title: t('domains.name'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{
|
||||
title: t('domains.actions'),
|
||||
key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
active: row.active,
|
||||
http_to_https: row.http_to_https,
|
||||
hsts_enabled: row.hsts_enabled,
|
||||
primary_backend_id: row.primary_backend_id ?? null,
|
||||
notes: row.notes ?? '',
|
||||
})
|
||||
}}>{t('domains.edit')}</Button>
|
||||
<Popconfirm
|
||||
title={t('domains.deleteConfirm', { name: row.name })}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('domains.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('domains.title')}</Typography.Title>
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
||||
setCreating(true)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
|
||||
}}>
|
||||
{t('domains.addDomain')}
|
||||
</Button>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
|
||||
open={editing !== null || creating}
|
||||
onCancel={() => { setEditing(null); setCreating(false) }}
|
||||
onOk={() => { void form.submit() }}
|
||||
confirmLoading={create.isPending || update.isPending}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={(v) => {
|
||||
if (editing) update.mutate({ id: editing.id, v })
|
||||
else create.mutate(v)
|
||||
}}
|
||||
>
|
||||
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.hsts')} name="hsts_enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.notes')} name="notes">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
management-ui/src/pages/Login/index.tsx
Normal file
71
management-ui/src/pages/Login/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { SessionUser } from '../../stores/auth'
|
||||
|
||||
interface Props {
|
||||
onLogin: (u: SessionUser) => void
|
||||
}
|
||||
|
||||
interface LoginValues {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const onFinish = async (vals: LoginValues) => {
|
||||
try {
|
||||
const r = await apiClient.post('/auth/login', vals)
|
||||
if (isEnvelope(r.data)) {
|
||||
const u = r.data.data as SessionUser
|
||||
onLogin(u)
|
||||
navigate('/dashboard', { replace: true })
|
||||
return
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; status?: number }
|
||||
if (err.status === 503) {
|
||||
// setup-mode → drop to wizard
|
||||
navigate('/setup', { replace: true })
|
||||
return
|
||||
}
|
||||
message.error(err.message ?? t('auth.loginFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
||||
<Card style={{ width: 400 }}>
|
||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
{t('app.title')}
|
||||
</Typography.Title>
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label={t('auth.email')}
|
||||
name="email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input autoComplete="email" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('auth.password')}
|
||||
name="password"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('auth.login')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
management-ui/src/pages/Setup/index.tsx
Normal file
86
management-ui/src/pages/Setup/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient from '../../api/client'
|
||||
import type { SessionUser } from '../../stores/auth'
|
||||
|
||||
interface Props {
|
||||
onComplete: (u: SessionUser) => void
|
||||
}
|
||||
|
||||
interface SetupValues {
|
||||
admin_email: string
|
||||
admin_password: string
|
||||
fqdn: string
|
||||
acme_email: string
|
||||
license_key?: string
|
||||
}
|
||||
|
||||
export default function SetupPage({ onComplete: _onComplete }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const onFinish = async (vals: SetupValues) => {
|
||||
try {
|
||||
await apiClient.post('/setup/complete', vals)
|
||||
message.success(t('setup.successTitle'))
|
||||
// Setup doesn't issue a session — the operator must log in.
|
||||
navigate('/login', { replace: true })
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err.message ?? t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
||||
<Card style={{ width: 520 }}>
|
||||
<Typography.Title level={3}>{t('setup.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('setup.intro')}</Typography.Paragraph>
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label={t('setup.adminEmail')}
|
||||
name="admin_email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input autoComplete="email" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('setup.adminPassword')}
|
||||
name="admin_password"
|
||||
rules={[{ required: true, min: 12, message: t('setup.passwordRule') }]}
|
||||
help={t('setup.passwordRule')}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('setup.fqdn')}
|
||||
name="fqdn"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="eg.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('setup.acmeEmail')}
|
||||
name="acme_email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('setup.licenseKey')}
|
||||
name="license_key"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('setup.submit')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
management-ui/src/stores/auth.ts
Normal file
57
management-ui/src/stores/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
// Auth state is server-side (HttpOnly cookie). The store mirrors only
|
||||
// the public claims the UI needs to make routing/UX decisions —
|
||||
// actor (email), role, expires_at — so a page reload doesn't have to
|
||||
// roundtrip /auth/me before showing the right layout.
|
||||
|
||||
export interface SessionUser {
|
||||
actor: string
|
||||
role: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: SessionUser | null
|
||||
loading: boolean
|
||||
set: (u: SessionUser | null) => void
|
||||
clear: () => void
|
||||
setLoading: (l: boolean) => void
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'eg_session'
|
||||
|
||||
function load(): SessionUser | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as SessionUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function save(u: SessionUser | null): void {
|
||||
try {
|
||||
if (u) sessionStorage.setItem(SESSION_KEY, JSON.stringify(u))
|
||||
else sessionStorage.removeItem(SESSION_KEY)
|
||||
} catch { /* quota — ignore */ }
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()((set) => ({
|
||||
user: load(),
|
||||
loading: false,
|
||||
set: (u) => {
|
||||
save(u)
|
||||
set({ user: u })
|
||||
},
|
||||
clear: () => {
|
||||
save(null)
|
||||
set({ user: null })
|
||||
},
|
||||
setLoading: (loading) => set({ loading }),
|
||||
}))
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return useAuthStore.getState().user !== null
|
||||
}
|
||||
Reference in New Issue
Block a user