Initial commit
Some checks failed
CI/CD Pipeline / Backend Tests (push) Failing after 27s
CI/CD Pipeline / Frontend Tests (push) Failing after 15s
CI/CD Pipeline / Docker Build (push) Has been skipped
CI/CD Pipeline / Security Scan (push) Has been skipped

This commit is contained in:
2026-04-15 01:41:49 +02:00
commit 5b447acd1c
773 changed files with 74653 additions and 0 deletions

10
frontend/.dockerignore Executable file
View File

@@ -0,0 +1,10 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
.DS_Store
*.log
npm-debug.log*

41
frontend/.eslintrc.json Executable file
View File

@@ -0,0 +1,41 @@
{
"root": true,
"settings": {
"react": {
"version": "detect"
}
},
"env": {
"browser": true,
"es2020": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended"
],
"ignorePatterns": ["dist", ".eslintrc.cjs"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": ["react-refresh"],
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}

10
frontend/.prettierrc.json Executable file
View File

@@ -0,0 +1,10 @@
{
"semi": false,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}

35
frontend/Dockerfile Executable file
View File

@@ -0,0 +1,35 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy source
COPY . .
# Build
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy build artifacts
COPY --from=builder /app/dist /usr/share/nginx/html
# Create logs directory
RUN mkdir -p /var/log/nginx
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

16
frontend/index.html Executable file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SAP Business One ↔ Plesk Sync</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

79
frontend/nginx.conf Executable file
View File

@@ -0,0 +1,79 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
gzip_disable "MSIE [1-6].";
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# API proxy
location /api/ {
proxy_pass http://backend:3001/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Static files with correct MIME types
location ~* \.(js|json)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Content-Type application/javascript;
}
location ~* \.(css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Content-Type text/css;
}
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

6123
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "sap-sync-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 27"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0",
"@mui/x-date-pickers": "^7.0.0",
"axios": "^1.6.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-diff-viewer-continued": "^3.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.13.0",
"@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

1
frontend/public/vite.svg Executable file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.704c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

292
frontend/src/App.tsx Executable file
View File

@@ -0,0 +1,292 @@
import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom';
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material';
import { Toaster } from 'react-hot-toast';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import SyncPage from './pages/SyncPage';
import ReportsPage from './pages/ReportsPage';
import SettingsPage from './pages/SettingsPage';
import SetupWizardPage from './pages/SetupWizardPage';
import SyncSimulationPage from './pages/SyncSimulationPage';
import ConflictsPage from './pages/ConflictsPage';
import BillingPage from './pages/BillingPage';
import AlertsPage from './pages/AlertsPage';
import ServersPage from './pages/ServersPage';
import AuditPage from './pages/AuditPage';
import Layout from './components/Layout';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { I18nProvider } from './contexts/I18nContext';
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#6366f1',
light: '#818cf8',
dark: '#4f46e5',
},
secondary: {
main: '#ec4899',
light: '#f472b6',
dark: '#db2777',
},
success: {
main: '#10b981',
light: '#34d399',
},
warning: {
main: '#f59e0b',
light: '#fbbf24',
},
error: {
main: '#ef4444',
light: '#f87171',
},
background: {
default: '#f8fafc',
paper: '#ffffff',
},
text: {
primary: '#1e293b',
secondary: '#64748b',
},
grey: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
typography: {
fontFamily: '"Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
h1: {
fontWeight: 700,
letterSpacing: '-0.02em',
},
h2: {
fontWeight: 700,
letterSpacing: '-0.02em',
},
h3: {
fontWeight: 600,
letterSpacing: '-0.01em',
},
h4: {
fontWeight: 600,
letterSpacing: '-0.01em',
},
h5: {
fontWeight: 600,
},
h6: {
fontWeight: 600,
},
subtitle1: {
fontWeight: 500,
},
subtitle2: {
fontWeight: 500,
},
body1: {
lineHeight: 1.6,
},
body2: {
lineHeight: 1.5,
},
button: {
fontWeight: 600,
textTransform: 'none',
},
},
shape: {
borderRadius: 12,
},
shadows: [
'none',
'0 1px 2px 0 rgb(0 0 0 / 0.05)',
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
'0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
],
components: {
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
border: '1px solid',
borderColor: '#e2e8f0',
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 10,
padding: '10px 20px',
fontSize: '0.875rem',
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
},
},
containedPrimary: {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)',
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 8,
fontWeight: 500,
},
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 12,
},
elevation1: {
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: 10,
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#6366f1',
},
},
},
},
},
MuiSelect: {
styleOverrides: {
root: {
borderRadius: 10,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1)',
},
},
},
},
});
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return null;
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
function AppRoutes() {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return null;
}
return (
<Routes>
<Route path="/login" element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="sync" element={<SyncPage />} />
<Route path="simulation" element={<SyncSimulationPage />} />
<Route path="conflicts" element={<ConflictsPage />} />
<Route path="billing" element={<BillingPage />} />
<Route path="alerts" element={<AlertsPage />} />
<Route path="servers" element={<ServersPage />} />
<Route path="audit" element={<AuditPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="setup" element={<SetupWizardPage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<I18nProvider>
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
borderRadius: 12,
background: '#1e293b',
color: '#f8fafc',
},
}}
/>
</I18nProvider>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,99 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { Box, Typography, Button, Paper } from '@mui/material';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
errorInfo: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
this.setState({ error, errorInfo });
}
public render() {
if (this.state.hasError) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f8fafc',
padding: 3,
}}
>
<Paper
elevation={3}
sx={{
maxWidth: 600,
width: '100%',
padding: 4,
borderRadius: 3,
}}
>
<Typography variant="h4" gutterBottom color="error" sx={{ fontWeight: 700 }}>
Something went wrong
</Typography>
<Typography variant="body1" color="textSecondary" sx={{ mb: 3 }}>
The application encountered an unexpected error.
</Typography>
{this.state.error && (
<Box
sx={{
background: '#f1f5f9',
borderRadius: 2,
padding: 2,
mb: 3,
overflow: 'auto',
maxHeight: 200,
}}
>
<Typography variant="body2" component="pre" sx={{ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' }}>
{this.state.error.toString()}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="contained"
onClick={() => window.location.reload()}
>
Reload Page
</Button>
<Button
variant="outlined"
onClick={() => window.location.href = '/login'}
>
Go to Login
</Button>
</Box>
</Paper>
</Box>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,181 @@
import React from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import {
AppBar,
Box,
Divider,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Select,
MenuItem,
FormControl,
} from '@mui/material';
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
Sync as SyncIcon,
Settings as SettingsIcon,
Report as ReportIcon,
ExitToApp as LogoutIcon,
CompareArrows as CompareIcon,
Warning as WarningIcon,
Receipt as ReceiptIcon,
Notifications as NotificationsIcon,
Dns as ServerIcon,
History as HistoryIcon,
} from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
const drawerWidth = 240;
const Layout: React.FC = () => {
const [mobileOpen, setMobileOpen] = React.useState(false);
const navigate = useNavigate();
const { user, logout } = useAuth();
const { t, language, changeLanguage } = useI18n();
const menuItems = [
{ text: t('dashboard.title'), icon: <DashboardIcon />, path: '/dashboard' },
{ text: t('nav.sync'), icon: <SyncIcon />, path: '/sync' },
{ text: t('nav.conflicts'), icon: <WarningIcon />, path: '/conflicts' },
{ text: t('nav.billing'), icon: <ReceiptIcon />, path: '/billing' },
{ text: t('nav.alerts'), icon: <NotificationsIcon />, path: '/alerts' },
{ text: t('nav.servers'), icon: <ServerIcon />, path: '/servers' },
{ text: t('nav.audit'), icon: <HistoryIcon />, path: '/audit' },
{ text: t('nav.simulation'), icon: <CompareIcon />, path: '/simulation' },
{ text: t('nav.reports'), icon: <ReportIcon />, path: '/reports' },
{ text: t('nav.settings'), icon: <SettingsIcon />, path: '/settings' },
];
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const drawer = (
<div>
<Toolbar>
<Typography variant="h6" noWrap component="div">
SAP Sync
</Typography>
</Toolbar>
<Divider />
<List>
{menuItems.map((item) => (
<ListItem key={item.path} disablePadding>
<ListItemButton onClick={() => navigate(item.path)}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
<ListItem disablePadding>
<ListItemButton onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary={t('nav.logout')} />
</ListItemButton>
</ListItem>
</List>
</div>
);
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{t('app.title')}
</Typography>
<Typography variant="body2" sx={{ mr: 2 }}>
{user?.username}
</Typography>
<FormControl sx={{ minWidth: 120 }}>
<Select
value={language}
onChange={(e) => changeLanguage(e.target.value)}
displayEmpty
variant="standard"
sx={{ color: 'white' }}
>
<MenuItem value="de">DE</MenuItem>
<MenuItem value="en">EN</MenuItem>
<MenuItem value="fr">FR</MenuItem>
<MenuItem value="es">ES</MenuItem>
</Select>
</FormControl>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
aria-label="mailbox folders"
>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{ flexGrow: 1, p: 3, width: { sm: `calc(100% - ${drawerWidth}px)` } }}
>
<Toolbar />
<Outlet />
</Box>
</Box>
);
};
export default Layout;

View File

@@ -0,0 +1,186 @@
import { useState } from 'react';
import {
Box,
Typography,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Paper,
Alert,
Divider,
} from '@mui/material';
export const ScheduleBuilderDialog: React.FC<{
open: boolean;
onClose: () => void;
onSave: (schedule: { type: string; config: Record<string, unknown> }) => void;
}> = ({ open, onClose, onSave }) => {
const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'monthly' | 'custom'>('daily');
const [config, setConfig] = useState({
hour: 2,
weekday: 0,
day: 1,
});
const scheduleTypes = [
{ value: 'daily', label: 'Daily', description: 'Run every day at a specific time' },
{ value: 'weekly', label: 'Weekly', description: 'Run every week on a specific day' },
{ value: 'monthly', label: 'Monthly', description: 'Run every month on a specific day' },
{ value: 'custom', label: 'Custom', description: 'Define your own schedule (cron expression)' },
];
const handleSave = () => {
const scheduleConfig: Record<string, unknown> = { type: scheduleType };
if (scheduleType !== 'custom') {
scheduleConfig.hour = config.hour;
if (scheduleType === 'weekly') {
scheduleConfig.weekday = config.weekday;
scheduleConfig.dayName = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][config.weekday];
}
if (scheduleType === 'monthly') {
scheduleConfig.day = config.day;
}
}
onSave({ type: scheduleType, config: scheduleConfig });
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Create Scheduled Sync</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Select Schedule Type</Typography>
<Grid container spacing={1} sx={{ mb: 3 }}>
{scheduleTypes.map((type) => (
<Grid item xs={12} sm={6} key={type.value}>
<Paper
onClick={() => setScheduleType(type.value as 'daily' | 'weekly' | 'monthly' | 'custom')}
sx={{
p: 2,
cursor: 'pointer',
border: scheduleType === type.value ? '2px solid' : '1px solid',
borderColor: scheduleType === type.value ? 'primary.main' : 'grey.300',
transition: 'all 0.2s',
}}
>
<Typography variant="subtitle2" fontWeight={600}>
{type.label}
</Typography>
<Typography variant="caption" color="textSecondary">
{type.description}
</Typography>
</Paper>
</Grid>
))}
</Grid>
{scheduleType !== 'custom' && (
<Box>
<Typography variant="subtitle2" gutterBottom>Schedule Configuration</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" gutterBottom>What time should the sync run?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">
At {config.hour.toString().padStart(2, '0')}:00
</Typography>
<input
type="range"
min="0"
max="23"
value={config.hour}
onChange={(e) => setConfig({ ...config, hour: parseInt(e.target.value) })}
style={{ flex: 1 }}
/>
</Box>
</Box>
{scheduleType === 'weekly' && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" gutterBottom>Which day of the week?</Typography>
<Grid container spacing={1}>
{['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map((day, index) => (
<Grid item xs={6} sm={3} key={day}>
<Button
variant={config.weekday === index ? 'contained' : 'outlined'}
fullWidth
size="small"
onClick={() => setConfig({ ...config, weekday: index })}
>
{day}
</Button>
</Grid>
))}
</Grid>
</Box>
)}
{scheduleType === 'monthly' && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" gutterBottom>Which day of the month?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">
On day {config.day}
</Typography>
<input
type="range"
min="1"
max="31"
value={config.day}
onChange={(e) => setConfig({ ...config, day: parseInt(e.target.value) })}
style={{ flex: 1 }}
/>
</Box>
</Box>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="body2" color="textSecondary">
<strong>Schedule Summary:</strong>
</Typography>
<Alert severity="info" sx={{ mt: 1 }}>
Run {scheduleType === 'daily' && 'daily'}
{scheduleType === 'weekly' && `every ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][config.weekday]}`}
{scheduleType === 'monthly' && `on day ${config.day} of each month`}
{' '}at {config.hour.toString().padStart(2, '0')}:00
</Alert>
</Box>
)}
{scheduleType === 'custom' && (
<Alert severity="warning">
Custom schedules (cron expressions) are not yet supported. Please use the predefined schedule types.
</Alert>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSave} disabled={scheduleType === 'custom'}>
Create Schedule
</Button>
</DialogActions>
</Dialog>
);
};
// This component integrates into the existing SyncPage
export const ScheduleBuilder = () => {
return (
<Box sx={{ p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
Scheduled Syncs
</Typography>
<Typography variant="caption" color="textSecondary">
Create automated sync schedules
</Typography>
</Box>
);
};

View File

@@ -0,0 +1,115 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
import { apiFetch, getErrorMessage } from '../lib/api';
import { logger } from '../lib/logger';
interface User {
id: number;
username: string;
email: string;
role: string;
}
interface AuthContextType {
isAuthenticated: boolean;
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
logger.debug('Checking authentication...');
try {
const response = await apiFetch('/auth/me', { method: 'GET' });
if (response.ok) {
const userData = await response.json();
logger.debug('User authenticated:', userData.username);
setUser(userData);
setIsAuthenticated(true);
} else {
logger.debug('Not authenticated:', response.status);
setUser(null);
setIsAuthenticated(false);
}
} catch (error) {
logger.error('Auth check failed:', error);
setUser(null);
setIsAuthenticated(false);
} finally {
setLoading(false);
logger.debug('Auth check complete');
}
};
const login = async (username: string, password: string) => {
const response = await apiFetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Login failed'));
}
const data = await response.json();
setUser(data.user);
setIsAuthenticated(true);
};
const logout = async () => {
await apiFetch('/auth/logout', {
method: 'POST',
});
setUser(null);
setIsAuthenticated(false);
};
if (loading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: '#f8fafc',
gap: 2,
}}
>
<CircularProgress size={48} />
<Typography variant="body2" color="textSecondary">
Loading...
</Typography>
</Box>
);
}
return (
<AuthContext.Provider value={{ isAuthenticated, user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

File diff suppressed because it is too large Load Diff

25
frontend/src/index.css Executable file
View File

@@ -0,0 +1,25 @@
:root {
font-family: 'Inter', system-ui, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: rgba(0, 0, 0, 0.87);
background-color: #f5f5f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
min-height: 100vh;
}

362
frontend/src/lib/api.ts Executable file
View File

@@ -0,0 +1,362 @@
const API_BASE = (import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/$/, '') || '/api'
export function apiUrl(path: string): string {
if (/^https?:\/\//.test(path)) return path
return `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
}
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const headers: Record<string, string> = {}
if (init.body && typeof init.body === 'string') {
headers['Content-Type'] = 'application/json'
}
return fetch(apiUrl(path), {
credentials: 'include',
...init,
headers: {
...headers,
...init.headers,
},
})
}
export async function getErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const data = await response.json()
return data?.error ?? data?.message ?? fallback
} catch {
try {
const text = await response.text()
return text || fallback
} catch {
return fallback
}
}
}
export async function apiJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await apiFetch(path, init)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData?.error || errorData?.message || 'Request failed')
}
return response.json()
}
// ==================== Types ====================
export interface User {
id: number
username: string
email: string
role: string
}
export interface LoginResponse {
user: User
session_id: string
}
export interface SyncJob {
id: number
job_type: string
sync_direction: string
status: string
records_processed: number
records_failed: number
created_at: string
started_at: string | null
completed_at: string | null
}
export interface SyncStatus {
is_running: boolean
stats: {
running: number
completed_today: number
failed_today: number
}
}
export interface SyncJobsResponse {
jobs: SyncJob[]
}
export interface Conflict {
id: number
sync_job_id: number
entity_type: string
entity_id: string
resolution_status: string
source_data: Record<string, unknown>
conflict_details: Record<string, unknown>
}
export interface PricingConfig {
id: number
metric_type: string
unit: string
rate_per_unit: number
is_active: boolean
}
export interface BillingRecord {
id: number
customer_id: number
subscription_id: number
period_start: string
period_end: string
calculated_amount: number
currency: string
status: string
created_at: string
sent_to_sap: boolean
}
export interface AlertThreshold {
id: number
name: string
subscription_id?: number
metric_type: string
threshold_value: number
comparison_operator: string
action: string
notification_channels: string[]
is_active: boolean
last_triggered?: string
}
export interface AlertHistoryItem {
id: number
threshold_id: number
threshold_name: string
actual_value: number
triggered_at: string
action_taken?: string
notification_sent: boolean
}
export interface Webhook {
id: number
url: string
name: string
event_type?: string
is_active: boolean
created_at: string
}
export interface ScheduledSync {
id: number
name: string
schedule_type: string
schedule_config: Record<string, unknown>
job_type: string
sync_direction: string
is_active: boolean
last_run: string | null
next_run: string | null
}
export interface SapConfig {
host: string
port: number
company_db: string
username: string
password: string
use_ssl: boolean
timeout_seconds: number
}
export interface PleskConfig {
host: string
port: number
username: string
password: string
api_key: string
use_https: boolean
verify_ssl: boolean
two_factor_enabled: boolean
two_factor_method: string
two_factor_secret: string | null
session_id: string | null
}
export interface TestConnectionResponse {
success: boolean
message?: string
error?: string
requires_2fa?: boolean
session_id?: string
method?: string
}
export interface MessageResponse {
message: string
}
export interface ConfigResponse {
config: Record<string, unknown>
}
// ==================== Auth API ====================
export async function login(username: string, password: string): Promise<LoginResponse> {
return apiJson('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export async function logout(): Promise<MessageResponse> {
return apiJson('/auth/logout', { method: 'POST' })
}
export async function getCurrentUser(): Promise<User> {
return apiJson('/auth/me')
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<MessageResponse> {
return apiJson('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
})
}
// ==================== Config API ====================
export async function getConfig(): Promise<ConfigResponse> {
return apiJson('/config')
}
export async function updateConfig(key: string, value: unknown): Promise<MessageResponse> {
return apiJson('/config', {
method: 'PUT',
body: JSON.stringify({ key, value }),
})
}
// ==================== Connection Tests ====================
export async function testSapConnection(config: SapConfig): Promise<TestConnectionResponse> {
return apiJson('/sap/test', {
method: 'POST',
body: JSON.stringify(config),
})
}
export async function testPleskConnection(config: PleskConfig): Promise<TestConnectionResponse> {
return apiJson('/plesk/test', {
method: 'POST',
body: JSON.stringify(config),
})
}
// ==================== Sync API ====================
export async function getSyncStatus(): Promise<SyncStatus> {
return apiJson('/sync/status')
}
export async function startSync(form: { job_type: string; sync_direction: string }): Promise<MessageResponse> {
return apiJson('/sync/start', {
method: 'POST',
body: JSON.stringify(form),
})
}
export async function stopSync(): Promise<MessageResponse> {
return apiJson('/sync/stop', { method: 'POST' })
}
export async function getSyncJobs(): Promise<SyncJobsResponse> {
return apiJson('/sync/jobs')
}
export async function simulateSync(jobType: string, direction: string): Promise<SyncJobsResponse> {
return apiJson('/sync/simulate', {
method: 'POST',
body: JSON.stringify({ job_type: jobType, sync_direction: direction }),
})
}
export async function getConflicts(): Promise<Conflict[]> {
return apiJson('/sync/conflicts')
}
// ==================== Billing API ====================
export async function getPricingConfig(): Promise<PricingConfig[]> {
return apiJson('/pricing')
}
export async function createPricingConfig(config: Omit<PricingConfig, 'id'>): Promise<PricingConfig> {
return apiJson('/pricing', {
method: 'POST',
body: JSON.stringify(config),
})
}
export async function getBillingRecords(): Promise<BillingRecord[]> {
return apiJson('/billing/records')
}
export async function generateInvoice(customerId: number, periodStart: string, periodEnd: string): Promise<MessageResponse> {
return apiJson('/billing/generate', {
method: 'POST',
body: JSON.stringify({ customer_id: customerId, period_start: periodStart, period_end: periodEnd }),
})
}
export async function sendInvoiceToSap(billingRecordId: number): Promise<MessageResponse> {
return apiJson('/billing/send-to-sap', {
method: 'POST',
body: JSON.stringify({ id: billingRecordId }),
})
}
// ==================== Alerts API ====================
export async function getThresholds(): Promise<AlertThreshold[]> {
return apiJson('/alerts/thresholds')
}
export async function createThreshold(threshold: Omit<AlertThreshold, 'id'>): Promise<AlertThreshold> {
return apiJson('/alerts/thresholds', {
method: 'POST',
body: JSON.stringify(threshold),
})
}
export async function getAlertHistory(): Promise<AlertHistoryItem[]> {
return apiJson('/alerts/history')
}
// ==================== Webhooks API ====================
export async function getWebhooks(): Promise<Webhook[]> {
return apiJson('/webhooks')
}
export async function createWebhook(url: string, eventType: string): Promise<Webhook> {
return apiJson('/webhooks', {
method: 'POST',
body: JSON.stringify({ url, event_type: eventType }),
})
}
export async function deleteWebhook(id: number): Promise<void> {
await apiFetch(`/webhooks/${id}`, { method: 'DELETE' })
}
// ==================== Schedules API ====================
export async function getScheduledSyncs(): Promise<ScheduledSync[]> {
return apiJson('/schedules')
}
export async function createScheduledSync(config: Omit<ScheduledSync, 'id' | 'last_run' | 'next_run'>): Promise<MessageResponse> {
return apiJson('/schedules', {
method: 'POST',
body: JSON.stringify(config),
})
}

60
frontend/src/lib/hooks.ts Executable file
View File

@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react';
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
export function usePolling<T>(
fetchFn: () => Promise<T>,
intervalMs: number,
enabled: boolean = true
) {
const savedFetch = useRef(fetchFn);
useEffect(() => {
savedFetch.current = fetchFn;
}, [fetchFn]);
useEffect(() => {
if (!enabled) return;
const performFetch = async () => {
try {
await savedFetch.current();
} catch (error) {
console.error('Polling fetch failed:', error);
}
};
performFetch();
const id = setInterval(performFetch, intervalMs);
return () => clearInterval(id);
}, [intervalMs, enabled]);
}
export const statusColors: Record<string, 'success' | 'error' | 'primary' | 'warning' | 'default'> = {
completed: 'success',
failed: 'error',
running: 'primary',
pending: 'warning',
};
export function getStatusColor(status: string): 'success' | 'error' | 'primary' | 'warning' | 'default' {
return statusColors[status] || 'default';
}
export function formatDate(dateString: string | null): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleString();
}

17
frontend/src/lib/logger.ts Executable file
View File

@@ -0,0 +1,17 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
export const logger = {
debug: (message: string, ...args: any[]) => {
if (import.meta.env.DEV) {
console.debug(`[DEBUG] ${message}`, ...args);
}
},
info: (message: string, ...args: any[]) => {
console.log(`[INFO] ${message}`, ...args);
},
warn: (message: string, ...args: any[]) => {
console.warn(`[WARN] ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${message}`, ...args);
},
};

110
frontend/src/lib/validators.ts Executable file
View File

@@ -0,0 +1,110 @@
export const validators = {
username: (value: string): { valid: boolean; error?: string } => {
if (!value || value.length < 3) {
return { valid: false, error: 'Username must be at least 3 characters' };
}
if (value.length > 50) {
return { valid: false, error: 'Username must not exceed 50 characters' };
}
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
return { valid: false, error: 'Username can only contain letters, numbers, and underscores' };
}
return { valid: true };
},
email: (value: string): { valid: boolean; error?: string } => {
if (!value) {
return { valid: false, error: 'Email is required' };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return { valid: false, error: 'Please enter a valid email address' };
}
if (value.length > 255) {
return { valid: false, error: 'Email must not exceed 255 characters' };
}
return { valid: true };
},
password: (value: string): { valid: boolean; error?: string } => {
if (!value) {
return { valid: false, error: 'Password is required' };
}
if (value.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters' };
}
if (!/[A-Z]/.test(value)) {
return { valid: false, error: 'Password must contain at least one uppercase letter' };
}
if (!/[a-z]/.test(value)) {
return { valid: false, error: 'Password must contain at least one lowercase letter' };
}
if (!/[0-9]/.test(value)) {
return { valid: false, error: 'Password must contain at least one digit' };
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(value)) {
return { valid: false, error: 'Password must contain at least one special character' };
}
return { valid: true };
},
host: (value: string): { valid: boolean; error?: string } => {
if (!value) {
return { valid: false, error: 'Host is required' };
}
const hostRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
if (!hostRegex.test(value)) {
return { valid: false, error: 'Please enter a valid host name or IP address' };
}
return { valid: true };
},
port: (value: number): { valid: boolean; error?: string } => {
if (value < 1 || value > 65535) {
return { valid: false, error: 'Port must be between 1 and 65535' };
}
return { valid: true };
},
syncDirection: (value: string): { valid: boolean; error?: string } => {
const validDirections = ['bidirectional', 'sap_to_plesk', 'plesk_to_sap'];
if (!validDirections.includes(value)) {
return { valid: false, error: 'Invalid sync direction' };
}
return { valid: true };
},
syncInterval: (value: number): { valid: boolean; error?: string } => {
if (value < 1 || value > 1440) {
return { valid: false, error: 'Sync interval must be between 1 and 1440 minutes' };
}
return { valid: true };
},
conflictResolution: (value: string): { valid: boolean; error?: string } => {
const validResolutions = ['sap_first', 'plesk_first', 'manual', 'timestamp_based'];
if (!validResolutions.includes(value)) {
return { valid: false, error: 'Invalid conflict resolution strategy' };
}
return { valid: true };
},
required: (value: string): { valid: boolean; error?: string } => {
if (!value || value.trim() === '') {
return { valid: false, error: 'This field is required' };
}
return { valid: true };
},
url: (value: string): { valid: boolean; error?: string } => {
if (!value) {
return { valid: false, error: 'URL is required' };
}
try {
new URL(value);
return { valid: true };
} catch {
return { valid: false, error: 'Please enter a valid URL' };
}
},
};

38
frontend/src/main.tsx Executable file
View File

@@ -0,0 +1,38 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary.tsx'
import './index.css'
// eslint-disable-next-line no-console
console.log('[SAP Sync] React mounting...')
try {
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element #root not found in DOM')
}
const root = ReactDOM.createRoot(rootElement)
root.render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
)
// eslint-disable-next-line no-console
console.log('[SAP Sync] React mounted successfully')
} catch (error) {
console.error('[SAP Sync] Failed to mount React:', error)
const errorMsg = error instanceof Error ? error.message : String(error)
document.body.innerHTML = [
'<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8fafc;padding:20px;">',
'<div style="max-width:600px;width:100%;background:white;padding:32px;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.1);">',
'<h1 style="color:#ef4444;margin-bottom:16px;">Application Failed to Load</h1>',
'<p style="color:#64748b;margin-bottom:16px;">There was a critical error starting the application.</p>',
'<pre style="background:#f1f5f9;padding:16px;border-radius:8px;overflow:auto;max-height:300px;font-size:12px;">' + errorMsg + '</pre>',
'<button onclick="window.location.reload()" style="margin-top:16px;padding:10px 20px;background:#6366f1;color:white;border:none;border-radius:8px;cursor:pointer;font-size:16px;">Reload Page</button>',
'</div></div>'
].join('')
}

420
frontend/src/pages/AlertsPage.tsx Executable file
View File

@@ -0,0 +1,420 @@
import { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
TextField,
} from '@mui/material';
import {
Warning as WarningIcon,
Refresh as RefreshIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { useI18n } from '../contexts/I18nContext';
import { apiJson, apiFetch } from '../lib/api';
import { logger } from '../lib/logger';
interface AlertThreshold {
id: number;
name: string;
subscription_id?: number;
metric_type: string;
threshold_value: number;
comparison_operator: string;
action: string;
notification_channels: string[];
is_active: boolean;
last_triggered?: string;
}
interface AlertHistoryItem {
id: number;
threshold_id: number;
threshold_name: string;
actual_value: number;
triggered_at: string;
action_taken?: string;
notification_sent: boolean;
}
const metricLabels: Record<string, string> = {
cpu: 'CPU',
ram: 'Memory (RAM)',
disk: 'Disk Storage',
bandwidth: 'Bandwidth',
database: 'Database',
requests: 'Requests',
emails: 'Emails',
};
const operatorLabels: Record<string, string> = {
'>': 'Greater than',
'>=': 'Greater or equal',
'<': 'Less than',
'<=': 'Less or equal',
'=': 'Equal to',
};
const unitLabels: Record<string, string> = {
cpu: '%',
ram: 'GB',
disk: 'GB',
bandwidth: 'GB',
database: 'MB',
requests: 'count',
emails: 'count',
};
const AlertsPage: React.FC = () => {
const { t } = useI18n();
const [thresholds, setThresholds] = useState<AlertThreshold[]>([]);
const [history, setHistory] = useState<AlertHistoryItem[]>([]);
const [, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingThreshold, setEditingThreshold] = useState<AlertThreshold | null>(null);
useEffect(() => {
fetchThresholds();
fetchHistory();
}, []);
const fetchThresholds = async () => {
setLoading(true);
try {
const data = await apiJson<AlertThreshold[]>('/alerts/thresholds');
setThresholds(data);
} catch (error) {
logger.error('Failed to fetch thresholds:', error);
} finally {
setLoading(false);
}
};
const fetchHistory = async () => {
try {
const data = await apiJson<AlertHistoryItem[]>('/alerts/history');
setHistory(data);
} catch (error) {
logger.error('Failed to fetch history:', error);
}
};
const handleOpenDialog = (threshold?: AlertThreshold) => {
if (threshold) {
setEditingThreshold(threshold);
} else {
setEditingThreshold({
id: 0,
name: '',
metric_type: 'cpu',
threshold_value: 80,
comparison_operator: '>',
action: 'notify',
notification_channels: ['email'],
is_active: true,
});
}
setDialogOpen(true);
};
const handleCloseDialog = () => {
setDialogOpen(false);
setEditingThreshold(null);
};
const handleSave = async () => {
try {
if (!editingThreshold) return;
if (editingThreshold.id === 0) {
await apiFetch('/alerts/thresholds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingThreshold),
});
} else {
await apiFetch(`/alerts/thresholds/${editingThreshold.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingThreshold),
});
}
fetchThresholds();
handleCloseDialog();
} catch (error) {
logger.error('Failed to save threshold:', error);
}
};
const handleDelete = async (id: number) => {
try {
await apiFetch(`/alerts/thresholds/${id}`, { method: 'DELETE' });
fetchThresholds();
} catch (error) {
logger.error('Failed to delete threshold:', error);
}
};
return (
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">{t('alerts.title')}</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
{t('alerts.addThreshold')}
</Button>
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={fetchThresholds}
>
{t('alerts.refresh')}
</Button>
</Box>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>{t('alerts.thresholds')}</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('alerts.colName')}</TableCell>
<TableCell>{t('alerts.colMetric')}</TableCell>
<TableCell>{t('alerts.colThreshold')}</TableCell>
<TableCell>{t('alerts.colAction')}</TableCell>
<TableCell>{t('alerts.colStatus')}</TableCell>
<TableCell>{t('alerts.colLastTriggered')}</TableCell>
<TableCell>{t('alerts.colActions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{thresholds.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="textSecondary">
{t('alerts.noThresholds')}
</Typography>
</TableCell>
</TableRow>
) : (
thresholds.map((threshold) => (
<TableRow key={threshold.id}>
<TableCell>{threshold.name}</TableCell>
<TableCell>
<Chip label={metricLabels[threshold.metric_type] || threshold.metric_type} size="small" color="primary" />
</TableCell>
<TableCell>
<Typography variant="body2">
{threshold.comparison_operator} {threshold.threshold_value} {unitLabels[threshold.metric_type]}
</Typography>
</TableCell>
<TableCell>{threshold.action}</TableCell>
<TableCell>
<Chip
label={threshold.is_active ? t('alerts.active') : t('alerts.inactive')}
color={threshold.is_active ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell>
{threshold.last_triggered
? new Date(threshold.last_triggered).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button
size="small"
variant="outlined"
onClick={() => handleOpenDialog(threshold)}
>
{t('common.edit')}
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleDelete(threshold.id)}
>
{t('common.delete')}
</Button>
</Box>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>{t('alerts.recentAlerts')}</Typography>
<Box sx={{ height: 400, overflow: 'auto' }}>
{history.length === 0 ? (
<Typography variant="body2" color="textSecondary">
{t('alerts.noRecentAlerts')}
</Typography>
) : (
history.slice(0, 20).map((alert) => (
<Box key={alert.id} sx={{ mb: 2, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon color="warning" fontSize="small" />
<Typography variant="body2" fontWeight={600} noWrap>
{alert.threshold_name}
</Typography>
</Box>
<Typography variant="caption" color="textSecondary">
{t('alerts.value')}: {alert.actual_value} ({t('alerts.triggeredAt')} {new Date(alert.triggered_at).toLocaleString()})
</Typography>
{alert.action_taken && (
<Typography variant="caption" color="textPrimary" sx={{ display: 'block', mt: 0.5 }}>
{t('alerts.action')}: {alert.action_taken}
</Typography>
)}
</Box>
))
)}
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{editingThreshold?.id === 0 ? t('alerts.addThresholdTitle') : t('alerts.editThresholdTitle')}
</DialogTitle>
<DialogContent>
<Box component="form" sx={{ mt: 2 }}>
<TextField
fullWidth
label={t('alerts.name')}
value={editingThreshold?.name || ''}
onChange={(e) => setEditingThreshold({ ...editingThreshold!, name: e.target.value })}
margin="normal"
/>
<TextField
fullWidth
select
label={t('alerts.metricType')}
value={editingThreshold?.metric_type || 'cpu'}
onChange={(e) => setEditingThreshold({ ...editingThreshold!, metric_type: e.target.value })}
margin="normal"
>
{Object.keys(metricLabels).map((key) => (
<option key={key} value={key}>{metricLabels[key]}</option>
))}
</TextField>
<Grid container spacing={2}>
<Grid item xs={8}>
<TextField
fullWidth
type="number"
label={t('alerts.thresholdValue')}
value={editingThreshold?.threshold_value}
onChange={(e) => setEditingThreshold({ ...editingThreshold!, threshold_value: parseFloat(e.target.value) })}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<TextField
fullWidth
select
label={t('alerts.unit')}
value={unitLabels[editingThreshold?.metric_type || 'cpu'] || ''}
disabled
margin="normal"
>
<option>{unitLabels[editingThreshold?.metric_type || 'cpu'] || ''}</option>
</TextField>
</Grid>
</Grid>
<TextField
fullWidth
select
label={t('alerts.comparison')}
value={editingThreshold?.comparison_operator || '>'}
onChange={(e) => setEditingThreshold({ ...editingThreshold!, comparison_operator: e.target.value })}
margin="normal"
>
{Object.keys(operatorLabels).map((op) => (
<option key={op} value={op}>{operatorLabels[op]}</option>
))}
</TextField>
<TextField
fullWidth
select
label={t('alerts.action')}
value={editingThreshold?.action || 'notify'}
onChange={(e) => setEditingThreshold({ ...editingThreshold!, action: e.target.value })}
margin="normal"
>
<option value="notify">{t('alerts.notifyOnly')}</option>
<option value="notify_and_suspend">{t('alerts.notifySuspend')}</option>
<option value="notify_and_limit">{t('alerts.notifyLimit')}</option>
</TextField>
<TextField
fullWidth
select
label={t('alerts.subscription')}
value={editingThreshold?.subscription_id || ''}
onChange={(e) => setEditingThreshold({ ...editingThreshold!, subscription_id: e.target.value ? parseInt(e.target.value) : undefined })}
margin="normal"
>
<option value="">{t('alerts.allSubscriptions')}</option>
{/* Add subscription options here */}
</TextField>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2 }}>
<input
type="checkbox"
checked={editingThreshold?.is_active ?? true}
onChange={(e) => editingThreshold && setEditingThreshold({ ...editingThreshold, is_active: e.target.checked })}
/>
<Typography variant="body2" sx={{ ml: 1 }}>{t('alerts.active')}</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button variant="contained" onClick={handleSave}>
{editingThreshold?.id === 0 ? t('common.add') : t('common.save')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AlertsPage;

432
frontend/src/pages/AuditPage.tsx Executable file
View File

@@ -0,0 +1,432 @@
import { useState, useEffect, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
TextField,
MenuItem,
} from '@mui/material';
import {
Refresh as RefreshIcon,
Download as DownloadIcon,
} from '@mui/icons-material';
import { useI18n } from '../contexts/I18nContext';
import { apiJson, apiFetch } from '../lib/api';
import { logger } from '../lib/logger';
interface SessionAuditLog {
id: number;
user_id: number;
username?: string;
session_id?: string;
event: string;
ip_address?: string;
user_agent?: string;
metadata: Record<string, unknown>;
timestamp: string;
}
interface SyncAuditLog {
id: number;
sync_job_id: number;
entity_type: string;
entity_id: string;
action: string;
status: string;
error_message?: string;
metadata: Record<string, unknown>;
timestamp: string;
resolution_status?: string;
}
const AuditPage: React.FC = () => {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<'session' | 'sync'>('session');
const [sessionLogs, setSessionLogs] = useState<SessionAuditLog[]>([]);
const [syncLogs, setSyncLogs] = useState<SyncAuditLog[]>([]);
const [, setLoading] = useState(false);
// Filter state
const [eventFilter, setEventFilter] = useState<string>('all');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [selectedLog, setSelectedLog] = useState<SessionAuditLog | SyncAuditLog | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const fetchAuditLogs = useCallback(async () => {
setLoading(true);
try {
const filters = new URLSearchParams();
if (eventFilter !== 'all') filters.append('event_type', eventFilter);
if (dateFrom) filters.append('from', dateFrom);
if (dateTo) filters.append('to', dateTo);
const endpoint = activeTab === 'session' ? '/audit/logs' : '/audit/sync-logs';
const data = await apiJson<SessionAuditLog[] | SyncAuditLog[]>(endpoint + '?' + filters.toString());
if (activeTab === 'session') {
setSessionLogs(data as SessionAuditLog[]);
} else {
setSyncLogs(data as SyncAuditLog[]);
}
} catch (error) {
logger.error('Failed to fetch audit logs:', error);
} finally {
setLoading(false);
}
}, [activeTab, eventFilter, dateFrom, dateTo]);
useEffect(() => {
fetchAuditLogs();
}, [fetchAuditLogs]);
const handleExport = async () => {
try {
const response = await apiFetch('/audit/export');
if (!response.ok) throw new Error('Export failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
logger.error('Failed to export:', error);
}
};
const eventLabels: Record<string, string> = {
login: t('audit.userLogin'),
logout: t('audit.userLogout'),
mfa_enabled: t('audit.mfaEnabled'),
mfa_disabled: t('audit.mfaDisabled'),
password_changed: t('audit.passwordChanged'),
full_sync: t('audit.fullSync'),
incremental_sync: t('audit.incrementalSync'),
partial_sync: t('audit.partialSync'),
manual_sync: t('audit.manualSync'),
};
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
if (status === 'completed' || status === 'success' || status === 'resolved') return 'success';
if (status === 'failed' || status === 'error') return 'error';
if (status === 'pending' || status === 'running') return 'warning';
return 'default';
};
const logs = activeTab === 'session' ? sessionLogs : syncLogs;
const filteredLogs = logs.filter(log => {
if (activeTab === 'session') {
const sessionLog = log as SessionAuditLog;
if (eventFilter !== 'all' && sessionLog.event !== eventFilter) return false;
} else {
const syncLog = log as SyncAuditLog;
if (eventFilter !== 'all' && syncLog.action !== eventFilter) return false;
}
if (dateFrom && new Date(log.timestamp) < new Date(dateFrom)) return false;
if (dateTo && new Date(log.timestamp) > new Date(dateTo)) return false;
return true;
});
return (
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ mb: 3 }}>
<Typography variant="h4">{t('audit.title')}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 3 }}>
{[
{ id: 'session', label: t('audit.loginEvents') },
{ id: 'sync', label: t('audit.syncEvents') },
].map((tab) => (
<Button
key={tab.id}
variant={activeTab === tab.id ? 'contained' : 'outlined'}
onClick={() => setActiveTab(tab.id as 'session' | 'sync')}
sx={{ minWidth: 150 }}
>
{tab.label}
</Button>
))}
</Box>
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">{t('audit.filters')}</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleExport}
>
{t('audit.exportCsv')}
</Button>
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={fetchAuditLogs}
>
{t('common.refresh')}
</Button>
</Box>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} md={3}>
<TextField
fullWidth
select
label={t('audit.eventType')}
value={eventFilter}
onChange={(e) => setEventFilter(e.target.value)}
>
<MenuItem value="all">{t('audit.allEvents')}</MenuItem>
{activeTab === 'session' ? (
<>
<MenuItem value="login">{t('audit.userLogin')}</MenuItem>
<MenuItem value="logout">{t('audit.userLogout')}</MenuItem>
<MenuItem value="mfa_enabled">{t('audit.mfaEnabled')}</MenuItem>
<MenuItem value="mfa_disabled">{t('audit.mfaDisabled')}</MenuItem>
</>
) : (
<>
<MenuItem value="full_sync">{t('audit.fullSync')}</MenuItem>
<MenuItem value="incremental_sync">{t('audit.incrementalSync')}</MenuItem>
<MenuItem value="partial_sync">{t('audit.partialSync')}</MenuItem>
<MenuItem value="manual_sync">{t('audit.manualSync')}</MenuItem>
</>
)}
</TextField>
</Grid>
<Grid item xs={6} md={3}>
<TextField
fullWidth
type="date"
label={t('audit.fromDate')}
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={6} md={3}>
<TextField
fullWidth
type="date"
label={t('audit.toDate')}
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('audit.auditLogs')} ({filteredLogs.length} {t('audit.records')})
</Typography>
<TableContainer sx={{ maxHeight: 600 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell>{t('audit.colId')}</TableCell>
{activeTab === 'session' ? (
<>
<TableCell>{t('audit.colUser')}</TableCell>
<TableCell>{t('audit.colEvent')}</TableCell>
<TableCell>{t('audit.colIpAddress')}</TableCell>
<TableCell>{t('audit.colUserAgent')}</TableCell>
</>
) : (
<>
<TableCell>{t('audit.colJobId')}</TableCell>
<TableCell>{t('audit.colEntityType')}</TableCell>
<TableCell>{t('audit.colEntityId')}</TableCell>
<TableCell>{t('audit.colAction')}</TableCell>
<TableCell>{t('audit.colStatus')}</TableCell>
</>
)}
<TableCell>{t('audit.colTimestamp')}</TableCell>
<TableCell>{t('audit.colActions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredLogs.length === 0 ? (
<TableRow>
<TableCell colSpan={activeTab === 'session' ? 7 : 8} align="center">
<Typography variant="body2" color="textSecondary">
{t('audit.noLogs')}
</Typography>
</TableCell>
</TableRow>
) : (
filteredLogs.map((log: SessionAuditLog | SyncAuditLog) => (
<TableRow key={log.id} hover>
<TableCell>{log.id}</TableCell>
{activeTab === 'session' ? (
<>
<TableCell>{(log as SessionAuditLog).username || `User ${(log as SessionAuditLog).user_id}`}</TableCell>
<TableCell>
<Chip
label={eventLabels[(log as SessionAuditLog).event] || (log as SessionAuditLog).event}
size="small"
color={'event' in log && (log as SessionAuditLog).event === 'login' ? 'success' : (log as SessionAuditLog).event === 'logout' ? 'primary' : 'default'}
/>
</TableCell>
<TableCell>{(log as SessionAuditLog).ip_address || '-'}</TableCell>
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(log as SessionAuditLog).user_agent || '-'}
</TableCell>
</>
) : (
<>
<TableCell>{(log as SyncAuditLog).sync_job_id}</TableCell>
<TableCell>{(log as SyncAuditLog).entity_type}</TableCell>
<TableCell>{(log as SyncAuditLog).entity_id}</TableCell>
<TableCell>{(log as SyncAuditLog).action}</TableCell>
<TableCell>
<Chip
label={(log as SyncAuditLog).resolution_status || (log as SyncAuditLog).status}
size="small"
color={getStatusColor((log as SyncAuditLog).resolution_status || (log as SyncAuditLog).status)}
/>
</TableCell>
</>
)}
<TableCell>
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell>
<Button
size="small"
variant="outlined"
onClick={() => {
setSelectedLog(log);
setDetailDialogOpen(true);
}}
>
{t('audit.details')}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Dialog open={detailDialogOpen} onClose={() => setDetailDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
{t('audit.detailsTitle')}
</DialogTitle>
<DialogContent>
{selectedLog && (
<Box>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colId')}</Typography>
<Typography variant="body1">{selectedLog.id}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colTimestamp')}</Typography>
<Typography variant="body1">
{new Date(selectedLog.timestamp).toLocaleString()}
</Typography>
</Grid>
{activeTab === 'session' && (
<>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colUser')}</Typography>
<Typography variant="body1">{(selectedLog as SessionAuditLog).username || `User ${(selectedLog as SessionAuditLog).user_id}`}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colEvent')}</Typography>
<Typography variant="body1">{eventLabels[(selectedLog as SessionAuditLog).event] || (selectedLog as SessionAuditLog).event}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colIpAddress')}</Typography>
<Typography variant="body1">{(selectedLog as SessionAuditLog).ip_address || '-'}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colUserAgent')}</Typography>
<Typography variant="body2">{(selectedLog as SessionAuditLog).user_agent || '-'}</Typography>
</Grid>
</>
)}
{activeTab === 'sync' && (
<>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colJobId')}</Typography>
<Typography variant="body1">{(selectedLog as SyncAuditLog).sync_job_id}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colEntityType')}</Typography>
<Typography variant="body1">{(selectedLog as SyncAuditLog).entity_type}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colEntityId')}</Typography>
<Typography variant="body1">{(selectedLog as SyncAuditLog).entity_id}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.colAction')}</Typography>
<Typography variant="body1">{(selectedLog as SyncAuditLog).action}</Typography>
</Grid>
</>
)}
<Grid item xs={12}>
<Typography variant="subtitle2" color="textSecondary">{t('audit.metadata')}</Typography>
<Box sx={{
bgcolor: '#f5f5f5',
p: 2,
borderRadius: 1,
maxHeight: 200,
overflow: 'auto',
fontFamily: 'monospace',
fontSize: '0.85rem',
}}>
<pre style={{ margin: 0 }}>
{JSON.stringify(selectedLog.metadata, null, 2)}
</pre>
</Box>
</Grid>
</Grid>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailDialogOpen(false)}>{t('common.close')}</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AuditPage;

View File

@@ -0,0 +1,392 @@
import { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Divider,
LinearProgress,
} from '@mui/material';
import {
Receipt as ReceiptIcon,
Refresh as RefreshIcon,
Description as DescriptionIcon,
CheckCircle as CheckCircleIcon,
} from '@mui/icons-material';
import { useI18n } from '../contexts/I18nContext';
import { apiJson, apiFetch } from '../lib/api';
import { logger } from '../lib/logger';
interface BillingRecord {
id: number;
subscription_id: number;
customer_id: number;
customer_name: string;
period_start: string;
period_end: string;
calculated_amount: number;
currency: string;
invoice_status: string;
created_at: string;
}
interface PricingConfig {
id: number;
name: string;
metric_type: string;
unit: string;
rate_per_unit: number;
currency: string;
is_active: boolean;
}
interface BillingPreview {
customer_name: string;
period_start: string;
period_end: string;
line_items: Array<{
description: string;
quantity: number;
unit: string;
rate: number;
amount: number;
}>;
subtotal: number;
tax: number;
total: number;
currency: string;
}
const BillingPage: React.FC = () => {
const { t } = useI18n();
const [records, setRecords] = useState<BillingRecord[]>([]);
const [pricing, setPricing] = useState<PricingConfig[]>([]);
const [loading, setLoading] = useState(false);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [billingPreview, setBillingPreview] = useState<BillingPreview | null>(null);
useEffect(() => {
fetchBilling();
fetchPricing();
}, []);
const fetchBilling = async () => {
setLoading(true);
try {
const data = await apiJson<BillingRecord[]>('/billing/records');
setRecords(data);
} catch (error) {
logger.error('Failed to fetch billing records:', error);
} finally {
setLoading(false);
}
};
const fetchPricing = async () => {
try {
const data = await apiJson<PricingConfig[]>('/pricing');
setPricing(data);
} catch (error) {
logger.error('Failed to fetch pricing:', error);
}
};
const handlePreview = async (recordId: number) => {
try {
const preview = await apiJson<BillingPreview>(`/billing/preview/${recordId}`);
setBillingPreview(preview);
setPreviewDialogOpen(true);
} catch (error) {
logger.error('Failed to fetch preview:', error);
}
};
const handleSendToSAP = async (recordId: number) => {
try {
await apiFetch(`/billing/send-to-sap/${recordId}`, { method: 'POST' });
fetchBilling();
} catch (error) {
logger.error('Failed to send to SAP:', error);
}
};
const handleExport = async (recordId: number, format: 'pdf' | 'csv' | 'xlsx') => {
try {
const response = await apiFetch(`/reports/export/${format}?billing_id=${recordId}`, {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) throw new Error('Export failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${recordId}.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
logger.error('Failed to export:', error);
}
};
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
switch (status) {
case 'draft': return 'default';
case 'pending': return 'warning';
case 'sent': return 'success';
case 'synced': return 'success';
case 'failed': return 'error';
default: return 'default';
}
};
const getStatusLabel = (status: string): string => {
const statusLabels: Record<string, string> = {
draft: t('billing.draft'),
pending: t('billing.pending'),
sent: t('billing.sent'),
synced: t('billing.synced'),
failed: t('billing.failed'),
};
return statusLabels[status] || status;
};
return (
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">{t('billing.title')}</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
startIcon={<ReceiptIcon />}
onClick={() => {
// Open generate dialog
}}
>
{t('billing.generate')}
</Button>
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={fetchBilling}
>
{t('billing.refresh')}
</Button>
</Box>
</Box>
{loading && <LinearProgress />}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>{t('billing.pricingSummary')}</Typography>
<Grid container spacing={2}>
{pricing.map((p) => (
<Grid item xs={12} md={6} lg={3} key={p.id}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" color="textSecondary">
{p.name}
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{p.rate_per_unit.toLocaleString()} {p.currency} / {p.unit}
</Typography>
<Typography variant="caption" color="textSecondary">
{p.metric_type.toUpperCase()}
</Typography>
<Chip
label={p.is_active ? t('billing.active') : t('billing.inactive')}
size="small"
color={p.is_active ? 'success' : 'default'}
sx={{ mt: 1 }}
/>
</CardContent>
</Card>
</Grid>
))}
{pricing.length === 0 && (
<Grid item xs={12}>
<Typography variant="body2" color="textSecondary">
{t('billing.noPricing')}
</Typography>
</Grid>
)}
</Grid>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('billing.records')}
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>{t('billing.customer')}</TableCell>
<TableCell>{t('billing.period')}</TableCell>
<TableCell align="right">{t('billing.amount')}</TableCell>
<TableCell>{t('billing.status')}</TableCell>
<TableCell>{t('billing.created')}</TableCell>
<TableCell>{t('billing.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="textSecondary">
{t('billing.noRecords')}
</Typography>
</TableCell>
</TableRow>
) : (
records.map((record) => (
<TableRow key={record.id}>
<TableCell>{record.id}</TableCell>
<TableCell>{record.customer_name}</TableCell>
<TableCell>
{new Date(record.period_start).toLocaleDateString()} - {new Date(record.period_end).toLocaleDateString()}
</TableCell>
<TableCell align="right">
{record.calculated_amount.toLocaleString()} {record.currency}
</TableCell>
<TableCell>
<Chip
label={getStatusLabel(record.invoice_status)}
color={getStatusColor(record.invoice_status)}
size="small"
/>
</TableCell>
<TableCell>
{new Date(record.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button
size="small"
variant="outlined"
onClick={() => handlePreview(record.id)}
startIcon={<DescriptionIcon />}
>
{t('billing.preview')}
</Button>
{record.invoice_status === 'draft' && (
<Button
size="small"
variant="outlined"
onClick={() => handleSendToSAP(record.id)}
startIcon={<CheckCircleIcon />}
>
{t('billing.syncToSap')}
</Button>
)}
<Button
size="small"
variant="outlined"
onClick={() => handleExport(record.id, 'pdf')}
>
PDF
</Button>
</Box>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Dialog open={previewDialogOpen} onClose={() => setPreviewDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionIcon />
<Typography variant="h6">{t('billing.invoicePreview')}</Typography>
</Box>
</DialogTitle>
<DialogContent>
{billingPreview && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="subtitle2" color="textSecondary">{t('billing.invoiceTo')}</Typography>
<Typography variant="h6">{billingPreview.customer_name}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="textSecondary">{t('billing.period')}</Typography>
<Typography variant="h6">
{new Date(billingPreview.period_start).toLocaleDateString()} - {new Date(billingPreview.period_end).toLocaleDateString()}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
<Box>
{billingPreview.line_items.length > 0 ? (
billingPreview.line_items.map((item, index) => (
<Box key={index} sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
<Typography>{item.description}</Typography>
<Typography>{item.quantity} {item.unit} @ {item.rate} = {item.amount.toFixed(2)} {billingPreview.currency}</Typography>
</Box>
))
) : (
<Typography variant="body2" color="textSecondary">{t('billing.noLineItems')}</Typography>
)}
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={{ width: 300 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
<Typography variant="subtitle2">{t('billing.subtotal')}</Typography>
<Typography variant="subtitle2">{billingPreview.subtotal.toFixed(2)} {billingPreview.currency}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
<Typography variant="subtitle2">{t('billing.tax')}</Typography>
<Typography variant="subtitle2">{billingPreview.tax.toFixed(2)} {billingPreview.currency}</Typography>
</Box>
<Divider sx={{ my: 1 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
<Typography variant="h6">{t('billing.total')}</Typography>
<Typography variant="h6">{billingPreview.total.toFixed(2)} {billingPreview.currency}</Typography>
</Box>
</Box>
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setPreviewDialogOpen(false)}>{t('common.close')}</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default BillingPage;

View File

@@ -0,0 +1,311 @@
import { useState } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Paper,
} from '@mui/material';
import {
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { useI18n } from '../contexts/I18nContext';
import { apiJson } from '../lib/api';
import { logger } from '../lib/logger';
interface Conflict {
id: number;
sync_job_id: number;
entity_type: string;
entity_id: string;
resolution_status: string;
source_data: Record<string, unknown>;
target_data?: Record<string, unknown>;
conflict_details?: Record<string, unknown>;
}
type ResolutionAction = 'source' | 'target' | 'merge' | 'skip';
const ConflictsPage: React.FC = () => {
const { t } = useI18n();
const [conflicts, setConflicts] = useState<Conflict[]>([]);
const [, setLoading] = useState(false);
const [selectedConflict, setSelectedConflict] = useState<Conflict | null>(null);
const [resolutionDialogOpen, setResolutionDialogOpen] = useState(false);
const [resolutionAction, setResolutionAction] = useState<ResolutionAction>('source');
const fetchConflicts = async () => {
setLoading(true);
try {
const data = await apiJson<Conflict[]>('/sync/conflicts');
setConflicts(data);
} catch (error) {
logger.error('Failed to fetch conflicts:', error);
} finally {
setLoading(false);
}
};
const handleResolve = async () => {
if (!selectedConflict) return;
try {
await apiJson(`/sync/conflicts/${selectedConflict.id}/resolve`, {
method: 'POST',
body: JSON.stringify({
action: resolutionAction,
resolved_data: resolutionAction === 'source'
? selectedConflict.source_data
: selectedConflict.target_data || {},
}),
});
setConflicts(conflicts.filter(c => c.id !== selectedConflict.id));
setResolutionDialogOpen(false);
setSelectedConflict(null);
} catch (error) {
logger.error('Failed to resolve conflict:', error);
}
};
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
if (status === 'resolved') return 'success';
if (status === 'pending') return 'warning';
return 'default';
};
return (
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4">{t('conflicts.title')}</Typography>
<Button
variant="contained"
onClick={fetchConflicts}
startIcon={<CheckCircleIcon />}
>
{t('conflicts.refresh')}
</Button>
</Box>
<Card>
<CardContent>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('conflicts.entityType')}</TableCell>
<TableCell>{t('conflicts.entityId')}</TableCell>
<TableCell>{t('conflicts.sourceData')}</TableCell>
<TableCell>{t('conflicts.targetData')}</TableCell>
<TableCell>{t('conflicts.status')}</TableCell>
<TableCell>{t('conflicts.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{conflicts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography variant="body2" color="textSecondary">
{t('conflicts.noConflicts')}
</Typography>
</TableCell>
</TableRow>
) : (
conflicts.map((conflict) => (
<TableRow key={conflict.id}>
<TableCell>{conflict.entity_type}</TableCell>
<TableCell>{conflict.entity_id}</TableCell>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
{JSON.stringify(conflict.source_data).substr(0, 50)}...
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
{conflict.target_data ? JSON.stringify(conflict.target_data).substr(0, 50) + '...' : '-'}
</Typography>
</TableCell>
<TableCell>
<Chip
label={conflict.resolution_status}
color={getStatusColor(conflict.resolution_status)}
size="small"
/>
</TableCell>
<TableCell>
<Button
variant="outlined"
size="small"
onClick={() => {
setSelectedConflict(conflict);
setResolutionDialogOpen(true);
}}
>
{t('conflicts.resolve')}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Dialog open={resolutionDialogOpen} onClose={() => setResolutionDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>{t('conflicts.resolveConflict')}</DialogTitle>
<DialogContent>
{selectedConflict && (
<Box>
<Typography variant="subtitle1" gutterBottom>
{t('conflicts.entityType')}: {selectedConflict.entity_type} - {selectedConflict.entity_id}
</Typography>
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6">
{t('conflicts.source')}
</Typography>
<Box sx={{ mt: 2, bgcolor: '#f5f5f5', p: 2, borderRadius: 1, maxHeight: 300, overflow: 'auto' }}>
<pre style={{ margin: 0 }}>
{JSON.stringify(selectedConflict.source_data, null, 2)}
</pre>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6">
{t('conflicts.target')}
</Typography>
<Box sx={{ mt: 2, bgcolor: '#f0f7ff', p: 2, borderRadius: 1, maxHeight: 300, overflow: 'auto' }}>
<pre style={{ margin: 0 }}>
{JSON.stringify(selectedConflict.target_data || {}, null, 2)}
</pre>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" gutterBottom>
{t('conflicts.resolutionAction')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<Paper
elevation={resolutionAction === 'source' ? 3 : 1}
onClick={() => setResolutionAction('source')}
sx={{
p: 2,
cursor: 'pointer',
textAlign: 'center',
border: resolutionAction === 'source' ? '2px solid ' : '1px solid ',
borderColor: resolutionAction === 'source' ? 'primary.main' : 'grey.300',
transition: 'all 0.2s'
}}
>
<CheckCircleIcon color="primary" />
<Typography variant="body2" sx={{ mt: 1 }}>
{t('conflicts.keepSource')}
</Typography>
</Paper>
</Grid>
<Grid item xs={6} md={3}>
<Paper
elevation={resolutionAction === 'target' ? 3 : 1}
onClick={() => setResolutionAction('target')}
sx={{
p: 2,
cursor: 'pointer',
textAlign: 'center',
border: resolutionAction === 'target' ? '2px solid ' : '1px solid ',
borderColor: resolutionAction === 'target' ? 'primary.main' : 'grey.300',
transition: 'all 0.2s'
}}
>
<CheckCircleIcon color="primary" />
<Typography variant="body2" sx={{ mt: 1 }}>
{t('conflicts.keepTarget')}
</Typography>
</Paper>
</Grid>
<Grid item xs={6} md={3}>
<Paper
elevation={resolutionAction === 'merge' ? 3 : 1}
onClick={() => setResolutionAction('merge')}
sx={{
p: 2,
cursor: 'pointer',
textAlign: 'center',
border: resolutionAction === 'merge' ? '2px solid ' : '1px solid ',
borderColor: resolutionAction === 'merge' ? 'primary.main' : 'grey.300',
transition: 'all 0.2s'
}}
>
<WarningIcon color="warning" />
<Typography variant="body2" sx={{ mt: 1 }}>
{t('conflicts.merge')}
</Typography>
</Paper>
</Grid>
<Grid item xs={6} md={3}>
<Paper
elevation={resolutionAction === 'skip' ? 3 : 1}
onClick={() => setResolutionAction('skip')}
sx={{
p: 2,
cursor: 'pointer',
textAlign: 'center',
border: resolutionAction === 'skip' ? '2px solid ' : '1px solid ',
borderColor: resolutionAction === 'skip' ? 'primary.main' : 'grey.300',
transition: 'all 0.2s'
}}
>
<DeleteIcon color="error" />
<Typography variant="body2" sx={{ mt: 1 }}>
{t('conflicts.skip')}
</Typography>
</Paper>
</Grid>
</Grid>
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setResolutionDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button variant="contained" onClick={handleResolve}>
{t('conflicts.applyResolution')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default ConflictsPage;

View File

@@ -0,0 +1,240 @@
import { useState, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Grid,
Typography,
CircularProgress,
Chip,
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
import { useI18n } from '../contexts/I18nContext';
import { apiFetch } from '../lib/api';
import { usePolling } from '../lib/hooks';
import { logger } from '../lib/logger';
interface SyncStatus {
is_running: boolean;
current_job: unknown;
recent_jobs: Array<{
id: number;
job_type: string;
sync_direction: string;
status: string;
}>;
stats: {
running: number;
completed_today: number;
failed_today: number;
};
}
interface HealthStatus {
status: string;
database: {
status: string;
healthy: boolean;
};
}
interface ServerInfo {
id: number;
name: string;
host: string;
connection_status: string;
is_active: boolean;
}
const DashboardPage: React.FC = () => {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [healthStatus, setHealthStatus] = useState<HealthStatus | null>(null);
const [pleskServers, setPleskServers] = useState<ServerInfo[]>([]);
const [sapServers, setSapServers] = useState<ServerInfo[]>([]);
const { t } = useI18n();
const fetchData = useCallback(async () => {
try {
const [syncResponse, healthResponse, pleskResponse, sapResponse] = await Promise.all([
apiFetch('/sync/status'),
apiFetch('/health'),
apiFetch('/servers/plesk'),
apiFetch('/servers/sap'),
]);
if (syncResponse.ok) {
setSyncStatus(await syncResponse.json());
}
if (healthResponse.ok) {
setHealthStatus(await healthResponse.json());
}
if (pleskResponse.ok) {
setPleskServers(await pleskResponse.json());
}
if (sapResponse.ok) {
setSapServers(await sapResponse.json());
}
} catch (error) {
logger.error('Failed to fetch dashboard data:', error);
}
}, []);
usePolling(fetchData, 30000);
const recentJobs = syncStatus?.recent_jobs ?? [];
return (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" gutterBottom>
{t('dashboard.title')}
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('dashboard.sync_status')}
</Typography>
<Box display="flex" alignItems="center" mb={2}>
{syncStatus?.is_running ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
<Typography>{t('dashboard.sync_running')}</Typography>
</>
) : (
<>
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
<Typography>{t('dashboard.no_sync')}</Typography>
</>
)}
</Box>
<Grid container spacing={2}>
<Grid item>
<Typography variant="body2" color="textSecondary">
{t('dashboard.running')}: {syncStatus?.stats?.running ?? 0}
</Typography>
</Grid>
<Grid item>
<Typography variant="body2" color="textSecondary">
{t('dashboard.completed_today')}: {syncStatus?.stats?.completed_today ?? 0}
</Typography>
</Grid>
<Grid item>
<Typography variant="body2" color="textSecondary">
{t('dashboard.failed_today')}: {syncStatus?.stats?.failed_today ?? 0}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('dashboard.health_status')}
</Typography>
<Box mb={2}>
<Box display="flex" alignItems="center" mb={1}>
{healthStatus?.database?.healthy ? (
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
) : (
<ErrorIcon color="error" sx={{ mr: 1 }} />
)}
<Typography>
{t('dashboard.internal_db')}: {healthStatus?.database?.healthy ? t('dashboard.connected') : t('dashboard.disconnected')}
</Typography>
</Box>
{pleskServers.length > 0 ? (
pleskServers.map((s) => (
<Box key={`plesk-${s.id}`} display="flex" alignItems="center" mb={1}>
{s.connection_status === 'connected' ? (
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
) : (
<ErrorIcon color="warning" sx={{ mr: 1 }} />
)}
<Typography>Plesk ({s.name}): {s.connection_status}</Typography>
</Box>
))
) : (
<Box display="flex" alignItems="center" mb={1}>
<ErrorIcon color="disabled" sx={{ mr: 1 }} />
<Typography color="textSecondary">{t('dashboard.no_plesk')}</Typography>
</Box>
)}
{sapServers.length > 0 ? (
sapServers.map((s) => (
<Box key={`sap-${s.id}`} display="flex" alignItems="center" mb={1}>
{s.connection_status === 'connected' ? (
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
) : (
<ErrorIcon color="warning" sx={{ mr: 1 }} />
)}
<Typography>SAP ({s.name}): {s.connection_status}</Typography>
</Box>
))
) : (
<Box display="flex" alignItems="center" mb={1}>
<ErrorIcon color="disabled" sx={{ mr: 1 }} />
<Typography color="textSecondary">{t('dashboard.no_sap')}</Typography>
</Box>
)}
</Box>
<Chip
label={healthStatus?.status === 'healthy' ? t('dashboard.healthy') : t('dashboard.unhealthy')}
color={healthStatus?.status === 'healthy' ? 'success' : 'error'}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('dashboard.recent_jobs')}
</Typography>
{recentJobs.length > 0 ? (
<Box>
{recentJobs.map((job) => (
<Box key={job.id} mb={2} p={2} bgcolor="grey.100" borderRadius={1}>
<Typography variant="body2">
Job #{job.id} - {job.job_type} ({job.sync_direction})
</Typography>
<Chip
label={job.status}
size="small"
color={
job.status === 'completed'
? 'success'
: job.status === 'failed'
? 'error'
: 'warning'
}
sx={{ mt: 1 }}
/>
</Box>
))}
</Box>
) : (
<Typography color="textSecondary">{t('dashboard.no_recent_jobs')}</Typography>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
};
export default DashboardPage;

178
frontend/src/pages/LoginPage.tsx Executable file
View File

@@ -0,0 +1,178 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Button,
Container,
TextField,
Typography,
Paper,
Alert,
CircularProgress,
Select,
MenuItem,
FormControl,
} from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
import toast from 'react-hot-toast';
const LoginPage: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const { login } = useAuth();
const { t, language, changeLanguage } = useI18n();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await login(username, password);
toast.success(t('login.success'));
navigate('/dashboard', { replace: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('login.failedHint');
setError(message);
toast.error(t('login.failed'));
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: 2,
}}
>
<Container component="main" maxWidth="xs" disableGutters>
<Paper
elevation={0}
sx={{
padding: 5,
width: '100%',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: 3,
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
}}
>
<Box textAlign="center" sx={{ mb: 4 }}>
<Box
component="span"
sx={{
width: 64,
height: 64,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 2,
}}
>
<span style={{ fontSize: 32, color: 'white' }}>🔄</span>
</Box>
<Typography component="h1" variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
{t('app.title')}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('login.subtitle')}
</Typography>
<FormControl sx={{ mt: 2, minWidth: 120 }}>
<Select
value={language}
onChange={(e) => changeLanguage(e.target.value)}
variant="standard"
size="small"
>
<MenuItem value="de">DE</MenuItem>
<MenuItem value="en">EN</MenuItem>
<MenuItem value="fr">FR</MenuItem>
<MenuItem value="es">ES</MenuItem>
</Select>
</FormControl>
</Box>
{error && (
<Alert severity="error" sx={{ mt: 2, mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label={t('login.username')}
name="username"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('login.password')}
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{
mt: 3,
mb: 2,
py: 1.5,
borderRadius: 2,
textTransform: 'none',
fontSize: '1rem',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #5568d3 0%, #63408a 100%)',
},
}}
disabled={loading}
>
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : t('login.submit')}
</Button>
</Box>
</Paper>
</Container>
</Box>
);
};
export default LoginPage;

View File

@@ -0,0 +1,317 @@
import { useEffect, useState } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Grid,
CircularProgress,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
} from '@mui/material';
import {
Download as DownloadIcon,
} from '@mui/icons-material';
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
BarChart,
Bar,
} from 'recharts';
import toast from 'react-hot-toast';
import { apiFetch, getErrorMessage } from '../lib/api';
import { logger } from '../lib/logger';
import { useI18n } from '../contexts/I18nContext';
interface SyncStats {
running: number;
completed_today: number;
failed_today: number;
}
interface SyncStatusResponse {
stats: SyncStats;
}
interface UsageMetric {
id: number;
subscription_id: number;
metric_type: string;
metric_value: number;
unit: string;
recorded_at: string;
}
const ReportsPage = () => {
const { t } = useI18n();
const [syncStats, setSyncStats] = useState<SyncStats | null>(null);
const [usageMetrics, setUsageMetrics] = useState<UsageMetric[]>([]);
const [loading, setLoading] = useState(true);
const [reportType, setReportType] = useState('sync');
const [dateRange, setDateRange] = useState('7d');
useEffect(() => {
fetchReportData();
}, [reportType, dateRange]);
const fetchReportData = async () => {
setLoading(true);
try {
// Fetch sync stats
const syncResponse = await apiFetch('/sync/status');
if (syncResponse.ok) {
const data: SyncStatusResponse = await syncResponse.json();
setSyncStats(data.stats);
}
// Fetch usage metrics (placeholder)
setUsageMetrics([]);
} catch (error) {
logger.error('Failed to fetch report data:', error);
} finally {
setLoading(false);
}
};
const exportReport = async (format: string) => {
try {
const response = await apiFetch(`/reports/export/${format}?type=${reportType}&range=${dateRange}`);
if (!response.ok) {
toast.error(await getErrorMessage(response, t('reports.exportFailed')));
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${reportType}-report-${dateRange}.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t('reports.downloadSuccess'));
} catch (error) {
toast.error(t('reports.exportFailed'));
}
};
const chartData = [
{ name: 'Mon', completed: 4, failed: 1 },
{ name: 'Tue', completed: 3, failed: 0 },
{ name: 'Wed', completed: 5, failed: 2 },
{ name: 'Thu', completed: 2, failed: 1 },
{ name: 'Fri', completed: 6, failed: 0 },
{ name: 'Sat', completed: 1, failed: 0 },
{ name: 'Sun', completed: 0, failed: 0 },
];
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" gutterBottom>
{t('reports.title')}
</Typography>
<Grid container spacing={3}>
{/* Controls */}
<Grid item xs={12}>
<Card>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid item>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>{t('reports.reportType')}</InputLabel>
<Select
value={reportType}
onChange={(e) => setReportType(e.target.value)}
label={t('reports.reportType')}
>
<MenuItem value="sync">{t('reports.syncHistory')}</MenuItem>
<MenuItem value="usage">{t('reports.usageMetrics')}</MenuItem>
<MenuItem value="revenue">{t('reports.revenue')}</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item>
<FormControl sx={{ minWidth: 150 }}>
<InputLabel>{t('reports.dateRange')}</InputLabel>
<Select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
label={t('reports.dateRange')}
>
<MenuItem value="24h">{t('reports.last24h')}</MenuItem>
<MenuItem value="7d">{t('reports.last7d')}</MenuItem>
<MenuItem value="30d">{t('reports.last30d')}</MenuItem>
<MenuItem value="90d">{t('reports.last90d')}</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs />
<Grid item>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => exportReport('csv')}
sx={{ mr: 1 }}
>
CSV
</Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => exportReport('xlsx')}
sx={{ mr: 1 }}
>
Excel
</Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => exportReport('pdf')}
>
PDF
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
{/* Summary Cards */}
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
{t('reports.runningJobs')}
</Typography>
<Typography variant="h4">{syncStats?.running || 0}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
{t('reports.completedToday')}
</Typography>
<Typography variant="h4" color="success.main">
{syncStats?.completed_today || 0}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
{t('reports.failedToday')}
</Typography>
<Typography variant="h4" color="error.main">
{syncStats?.failed_today || 0}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
{t('reports.reportRange')}
</Typography>
<Typography variant="h4" color="primary.main">
{dateRange}
</Typography>
</CardContent>
</Card>
</Grid>
{/* Charts */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('reports.syncActivity')}
</Typography>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="completed" fill="#4caf50" name={t('reports.completed')} />
<Bar dataKey="failed" fill="#f44336" name={t('reports.failed')} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</Grid>
{/* Usage Metrics Table */}
{reportType === 'usage' && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('reports.usageMetrics')}
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('reports.colId')}</TableCell>
<TableCell>{t('reports.colSubscription')}</TableCell>
<TableCell>{t('reports.colType')}</TableCell>
<TableCell>{t('reports.colValue')}</TableCell>
<TableCell>{t('reports.colUnit')}</TableCell>
<TableCell>{t('reports.colRecorded')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{usageMetrics.map((metric) => (
<TableRow key={metric.id}>
<TableCell>{metric.id}</TableCell>
<TableCell>{metric.subscription_id}</TableCell>
<TableCell>{metric.metric_type}</TableCell>
<TableCell>{metric.metric_value}</TableCell>
<TableCell>{metric.unit}</TableCell>
<TableCell>{new Date(metric.recorded_at).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
)}
</Grid>
</Box>
);
};
export default ReportsPage;

View File

@@ -0,0 +1,623 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Card,
CardContent,
Button,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
Alert,
CircularProgress,
LinearProgress,
} from '@mui/material';
import {
Security as SecurityIcon,
Refresh as RefreshIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Add as AddIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Warning as WarningIcon,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { apiJson, apiFetch } from '../lib/api';
import { logger } from '../lib/logger';
interface Server {
id: number;
name: string;
host: string;
port: number;
company_db?: string;
connection_status: string;
last_connected?: string;
is_active: boolean;
}
interface ServerConfig {
name: string;
host: string;
port: number;
username?: string;
password?: string;
api_key?: string;
company_db?: string;
use_https?: boolean;
verify_ssl?: boolean;
use_ssl?: boolean;
}
interface ConnectionTestResult {
success: boolean;
message: string;
latency_ms?: number;
error?: {
error_type: string;
error_code: string;
message: string;
details?: string;
};
requires_2fa?: boolean;
session_id?: string;
two_factor_method?: string;
}
type ServerType = 'plesk' | 'sap';
export const ServersPage: React.FC = () => {
const [pleskServers, setPleskServers] = useState<Server[]>([]);
const [sapServers, setSapServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [serverToDelete, setServerToDelete] = useState<{id: number, type: ServerType} | null>(null);
const [serverType, setServerType] = useState<ServerType>('plesk');
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [testingServerId, setTestingServerId] = useState<number | null>(null);
const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
const [config, setConfig] = useState<ServerConfig>({
name: '',
host: '',
port: 8443,
username: '',
password: '',
api_key: '',
company_db: '',
use_https: true,
verify_ssl: true,
use_ssl: true,
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
useEffect(() => {
fetchServers();
}, []);
const fetchServers = async () => {
setLoading(true);
try {
const [pleskData, sapData] = await Promise.all([
apiJson<Server[]>('/servers/plesk').catch(() => []),
apiJson<Server[]>('/servers/sap').catch(() => []),
]);
setPleskServers(Array.isArray(pleskData) ? pleskData : []);
setSapServers(Array.isArray(sapData) ? sapData : []);
} catch (error) {
logger.error('Failed to fetch servers:', error);
toast.error('Failed to load servers');
} finally {
setLoading(false);
}
};
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!config.name.trim()) {
errors.name = 'Name is required';
}
if (!config.host.trim()) {
errors.host = 'Host is required';
} else if (!/^[a-zA-Z0-9._-]+(:\d+)?$/.test(config.host) && !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(config.host)) {
errors.host = 'Invalid host format';
}
if (config.port < 1 || config.port > 65535) {
errors.port = 'Port must be between 1 and 65535';
}
if (serverType === 'plesk') {
if (!config.api_key && !config.username) {
errors.api_key = 'API key or username is required';
}
if (config.username && !config.password) {
errors.password = 'Password is required when username is provided';
}
} else {
if (!config.company_db?.trim()) {
errors.company_db = 'Company database is required';
}
if (!config.username?.trim()) {
errors.username = 'Username is required';
}
if (!config.password?.trim()) {
errors.password = 'Password is required';
}
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleOpenDialog = (server?: Server, type?: ServerType) => {
if (server) {
setServerType(type || (server.host.includes('sap') ? 'sap' as ServerType : 'plesk' as ServerType));
setEditingServer(server);
setConfig({
name: server.name,
host: server.host,
port: server.port,
username: '',
password: '',
api_key: '',
company_db: server.company_db || '',
use_https: true,
verify_ssl: true,
use_ssl: true,
});
} else {
setServerType(type || 'plesk');
setEditingServer(null);
setConfig({
name: '',
host: '',
port: type === 'plesk' ? 8443 : 50000,
username: '',
password: '',
api_key: '',
company_db: '',
use_https: true,
verify_ssl: true,
use_ssl: true,
});
}
setFormErrors({});
setTestResult(null);
setDialogOpen(true);
};
const handleCloseDialog = () => {
setDialogOpen(false);
setEditingServer(null);
setFormErrors({});
setTestResult(null);
};
const handleSave = async () => {
if (!validateForm()) {
toast.error('Please fix the form errors');
return;
}
setLoading(true);
try {
const endpoint = `/servers/${serverType}`;
const method = editingServer ? 'PUT' : 'POST';
const path = editingServer ? `${endpoint}/${editingServer.id}` : endpoint;
const response = await apiJson<{message?: string; error?: string}>(path, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (response.error) {
toast.error(response.error);
return;
}
toast.success(editingServer ? 'Server updated successfully' : 'Server added successfully');
fetchServers();
handleCloseDialog();
} catch (error) {
logger.error('Failed to save server:', error);
toast.error('Failed to save server');
} finally {
setLoading(false);
}
};
const handleDeleteClick = (id: number, type: ServerType) => {
setServerToDelete({ id, type });
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!serverToDelete) return;
try {
await apiFetch(`/servers/${serverToDelete.type}/${serverToDelete.id}`, { method: 'DELETE' });
toast.success('Server deleted successfully');
fetchServers();
} catch (error) {
logger.error('Failed to delete server:', error);
toast.error('Failed to delete server');
} finally {
setDeleteDialogOpen(false);
setServerToDelete(null);
}
};
const handleTestConnection = async (server: Server, type: ServerType) => {
setTestingServerId(server.id);
setTestResult(null);
try {
const result = await apiJson<ConnectionTestResult>(`/servers/${type}/${server.id}/test`, {
method: 'POST',
});
setTestResult(result);
if (result.success) {
toast.success(`Connection successful (${result.latency_ms}ms)`);
fetchServers(); // Refresh to update status
} else if (result.requires_2fa) {
toast('Two-factor authentication required', { icon: '🔐' });
} else {
const errorMsg = result.error?.message || result.message;
toast.error(`Connection failed: ${errorMsg}`);
}
} catch (error) {
logger.error('Connection test failed:', error);
toast.error('Connection test failed');
} finally {
setTestingServerId(null);
}
};
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
switch (status) {
case 'connected': return 'success';
case 'disconnected': return 'error';
case 'unknown': return 'default';
default: return 'warning';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'connected': return <CheckCircleIcon fontSize="small" color="success" />;
case 'disconnected': return <ErrorIcon fontSize="small" color="error" />;
default: return <WarningIcon fontSize="small" color="warning" />;
}
};
const renderServers = (servers: Server[], type: ServerType) => (
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
{type === 'plesk' ? 'Plesk Servers' : 'SAP Servers'}
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog(undefined, type)}
>
{type === 'plesk' ? 'Add Plesk Server' : 'Add SAP Server'}
</Button>
</Box>
{loading && <LinearProgress sx={{ mb: 2 }} />}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Host</TableCell>
<TableCell>Port</TableCell>
{type === 'sap' && <TableCell>Company DB</TableCell>}
<TableCell>Status</TableCell>
<TableCell>Last Connected</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{servers.length === 0 ? (
<TableRow>
<TableCell colSpan={type === 'sap' ? 7 : 6} align="center">
<Typography variant="body2" color="textSecondary">
No servers configured
</Typography>
</TableCell>
</TableRow>
) : (
servers.map((server) => (
<TableRow key={server.id}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SecurityIcon fontSize="small" color={server.is_active ? 'primary' : 'disabled'} />
<Typography variant="body2">{server.name}</Typography>
</Box>
</TableCell>
<TableCell>{server.host}</TableCell>
<TableCell>{server.port}</TableCell>
{type === 'sap' && <TableCell>{server.company_db || '-'}</TableCell>}
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{getStatusIcon(server.connection_status)}
<Chip
label={server.connection_status}
color={getStatusColor(server.connection_status)}
size="small"
/>
</Box>
</TableCell>
<TableCell>
{server.last_connected
? new Date(server.last_connected).toLocaleString()
: '-'}
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button
size="small"
variant="outlined"
startIcon={testingServerId === server.id ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={() => handleTestConnection(server, type)}
disabled={testingServerId === server.id}
>
{testingServerId === server.id ? 'Testing...' : 'Test'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<EditIcon />}
onClick={() => handleOpenDialog(server, type)}
>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
startIcon={<DeleteIcon />}
onClick={() => handleDeleteClick(server.id, type)}
>
Delete
</Button>
</Box>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
return (
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ mb: 3 }}>
<Typography variant="h4">Server Management</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={fetchServers}
disabled={loading}
>
Refresh
</Button>
</Box>
{renderServers(pleskServers, 'plesk')}
<Box sx={{ mb: 3 }} />
{renderServers(sapServers, 'sap')}
{/* Add/Edit Server Dialog */}
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{editingServer
? `Edit ${serverType === 'plesk' ? 'Plesk' : 'SAP'} Server`
: `Add ${serverType === 'plesk' ? 'Plesk' : 'SAP'} Server`}
</DialogTitle>
<DialogContent>
<Box component="form" sx={{ mt: 2 }}>
<TextField
fullWidth
label="Server Name"
value={config.name}
onChange={(e) => setConfig({ ...config, name: e.target.value })}
margin="normal"
required
error={!!formErrors.name}
helperText={formErrors.name}
/>
<Grid container spacing={2}>
<Grid item xs={8}>
<TextField
fullWidth
label="Host"
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value })}
margin="normal"
required
error={!!formErrors.host}
helperText={formErrors.host || 'e.g., plesk.example.com or 192.168.1.1'}
/>
</Grid>
<Grid item xs={4}>
<TextField
fullWidth
type="number"
label="Port"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 0 })}
margin="normal"
error={!!formErrors.port}
helperText={formErrors.port}
/>
</Grid>
</Grid>
{serverType === 'plesk' ? (
<>
<TextField
fullWidth
label="API Key"
type="password"
value={config.api_key || ''}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
margin="normal"
error={!!formErrors.api_key}
helperText={formErrors.api_key || 'Provide either API key or username/password'}
/>
<TextField
fullWidth
label="Username (optional)"
value={config.username || ''}
onChange={(e) => setConfig({ ...config, username: e.target.value })}
margin="normal"
/>
<TextField
fullWidth
label="Password (optional)"
type="password"
value={config.password || ''}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
margin="normal"
error={!!formErrors.password}
helperText={formErrors.password}
/>
</>
) : (
<>
<TextField
fullWidth
label="Company Database"
value={config.company_db || ''}
onChange={(e) => setConfig({ ...config, company_db: e.target.value })}
margin="normal"
required
error={!!formErrors.company_db}
helperText={formErrors.company_db || 'e.g., SBODEMOUS'}
/>
<TextField
fullWidth
label="Username"
value={config.username || ''}
onChange={(e) => setConfig({ ...config, username: e.target.value })}
margin="normal"
required
error={!!formErrors.username}
helperText={formErrors.username}
/>
<TextField
fullWidth
label="Password"
type="password"
value={config.password || ''}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
margin="normal"
required
error={!!formErrors.password}
helperText={formErrors.password}
/>
</>
)}
{testResult && (
<Alert
severity={testResult.success ? 'success' : testResult.requires_2fa ? 'warning' : 'error'}
sx={{ mt: 2 }}
>
<Typography variant="body2">
{testResult.message}
</Typography>
{testResult.latency_ms && (
<Typography variant="caption" display="block">
Latency: {testResult.latency_ms}ms
</Typography>
)}
{testResult.error && (
<Typography variant="caption" display="block" color="error">
Error Code: {testResult.error.error_code}
</Typography>
)}
</Alert>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button
variant="outlined"
onClick={() => {
// Test connection with current form data
const testServer: Server = {
id: 0,
name: config.name,
host: config.host,
port: config.port,
company_db: config.company_db,
connection_status: 'unknown',
is_active: true,
};
handleTestConnection(testServer, serverType);
}}
disabled={loading || testingServerId !== null}
>
Test Connection
</Button>
<Button variant="contained" onClick={handleSave} disabled={loading}>
{editingServer ? 'Update' : 'Add'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete this server? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default ServersPage;

View File

@@ -0,0 +1,841 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import {
Box,
Card,
CardContent,
Typography,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
Paper,
Alert,
CircularProgress,
List,
ListItem,
ListItemText,
Switch,
FormControlLabel,
Chip,
IconButton,
MenuItem,
ListItemIcon,
} from '@mui/material';
import {
Security as SecurityIcon,
CheckCircle as CheckCircleIcon,
Delete as DeleteIcon,
ContentCopy as CopyIcon,
Add as AddIcon,
Webhook as WebhookIcon,
PlayArrow as PlayIcon,
Stop as StopIcon,
} from '@mui/icons-material';
import { QRCodeSVG } from 'qrcode.react';
import { useI18n } from '../contexts/I18nContext';
import { apiJson } from '../lib/api';
import { logger } from '../lib/logger';
import { formatDate } from '../lib/hooks';
interface MfaSetupResponse {
method: string;
secret: string;
qr_code_url?: string;
backup_codes: string[];
test_code?: string;
}
const SettingsPage: React.FC = () => {
const { t } = useI18n();
const [activeTab] = useState<'profile' | 'security' | 'sync' | 'notifications'>('profile');
// Profile state
const [profile, setProfile] = useState({
username: '',
email: '',
fullName: '',
company: '',
});
const [profileLoading, setProfileLoading] = useState(true);
const [profileSaving, setProfileSaving] = useState(false);
const [profileSuccess, setProfileSuccess] = useState(false);
// Sync state
const [syncStatus, setSyncStatus] = useState({
is_running: false,
last_sync: null as string | null,
next_sync: null as string | null,
});
const [syncLoading, setSyncLoading] = useState(true);
// Notifications state
const [webhooks, setWebhooks] = useState<{ id: number; url: string; event_type?: string }[]>([]);
const [webhookUrl, setWebhookUrl] = useState('');
const [webhookType, setWebhookType] = useState('sync_complete');
const [webhookLoading, setWebhookLoading] = useState(false);
const [emailNotifications, setEmailNotifications] = useState(false);
const [webhookNotifications, setWebhookNotifications] = useState(false);
const [notificationsLoading, setNotificationsLoading] = useState(true);
// MFA state
const [mfaSetupDialog, setMfaSetupDialog] = useState(false);
const [, setMfaSecret] = useState('');
const [mfaQrCode, setMfaQrCode] = useState<string | null>(null);
const [mfaBackupCodes, setMfaBackupCodes] = useState<string[]>([]);
const [mfaCodeInput, setMfaCodeInput] = useState('');
const [mfaStep, setMfaStep] = useState<'setup' | 'verify' | 'success'>('setup');
const [loading, setLoading] = useState(false);
const [mfaEnabled, setMfaEnabled] = useState(false);
// Password change
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState(false);
const handleMfaSetup = async () => {
setLoading(true);
try {
const data = await apiJson<MfaSetupResponse>('/auth/mfa/setup');
setMfaSecret(data.secret);
setMfaQrCode(data.qr_code_url || null);
setMfaBackupCodes(data.backup_codes || []);
setMfaSetupDialog(true);
} catch (error) {
logger.error('Failed to setup MFA:', error);
} finally {
setLoading(false);
}
};
const handleMfaVerify = async () => {
setLoading(true);
try {
await apiJson('/auth/mfa/verify', {
method: 'POST',
body: JSON.stringify({ code: mfaCodeInput }),
});
setMfaStep('success');
setMfaEnabled(true);
setTimeout(() => {
setMfaSetupDialog(false);
setMfaStep('setup');
}, 2000);
} catch (error) {
toast.error(t('settingsSecurity.mfaInvalidCode'));
setMfaCodeInput('');
} finally {
setLoading(false);
}
};
const handleMfaDisable = async () => {
if (window.confirm(t('settingsSecurity.mfaConfirmDisable'))) {
try {
// Implement disable MFA endpoint
setMfaEnabled(false);
} catch (error) {
logger.error('Failed to disable MFA:', error);
}
}
};
const handleChangePassword = async () => {
if (newPassword !== confirmPassword) {
setPasswordError(t('settingsSecurity.passwordMismatch'));
return;
}
if (newPassword.length < 8) {
setPasswordError(t('settingsSecurity.passwordTooShort'));
return;
}
try {
setPasswordError('');
await apiJson('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
setPasswordSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setPasswordSuccess(false), 3000);
} catch (error: unknown) {
setPasswordError(error instanceof Error ? error.message : 'Failed to change password');
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
// Fetch profile data
useEffect(() => {
if (activeTab === 'profile') {
fetchProfile();
}
}, [activeTab]);
const fetchProfile = async () => {
try {
const data = await apiJson<{ username: string; email: string }>('/auth/me');
setProfile({
username: (data.username) || '',
email: (data.email) || '',
fullName: '',
company: '',
});
} catch (error) {
logger.error('Failed to fetch profile:', error);
} finally {
setProfileLoading(false);
}
};
const handleSaveProfile = async () => {
setProfileSaving(true);
setProfileSuccess(false);
try {
await apiJson('/auth/me', {
method: 'PUT',
body: JSON.stringify(profile),
});
setProfileSuccess(true);
setTimeout(() => setProfileSuccess(false), 3000);
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : 'Failed to save profile');
} finally {
setProfileSaving(false);
}
};
// Fetch sync status
useEffect(() => {
if (activeTab === 'sync') {
fetchSyncStatus();
}
}, [activeTab]);
const fetchSyncStatus = async () => {
try {
const data = await apiJson<{ is_running: boolean; next_sync?: string }>('/sync/status');
setSyncStatus({
is_running: data.is_running || false,
last_sync: null,
next_sync: data.next_sync || null,
});
} catch (error) {
logger.error('Failed to fetch sync status:', error);
} finally {
setSyncLoading(false);
}
};
// Fetch notifications
useEffect(() => {
if (activeTab === 'notifications') {
fetchNotifications();
}
}, [activeTab]);
const fetchNotifications = async () => {
try {
const [webhooksData, configData] = await Promise.all([
apiJson<{ id: number; url: string; event_type?: string }[]>('/webhooks'),
apiJson<{ config: Record<string, unknown> }>('/config'),
]);
setWebhooks(Array.isArray(webhooksData) ? webhooksData : []);
setEmailNotifications((configData.config?.email_notifications as boolean) || false);
setWebhookNotifications((configData.config?.webhook_notifications as boolean) || false);
} catch (error) {
logger.error('Failed to fetch notifications:', error);
} finally {
setNotificationsLoading(false);
}
};
const handleAddWebhook = async () => {
if (!webhookUrl) return;
setWebhookLoading(true);
try {
await apiJson('/webhooks', {
method: 'POST',
body: JSON.stringify({ url: webhookUrl, event_type: webhookType }),
});
setWebhookUrl('');
fetchNotifications();
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : 'Failed to add webhook');
} finally {
setWebhookLoading(false);
}
};
const handleDeleteWebhook = async (id: number) => {
if (!window.confirm(t('settingsNotifications.deleteConfirm'))) return;
try {
await apiJson(`/webhooks/${id}`, { method: 'DELETE' });
fetchNotifications();
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : 'Failed to delete webhook');
}
};
const handleSaveNotificationSettings = async () => {
try {
await apiJson('/config', {
method: 'PUT',
body: JSON.stringify({
email_notifications: emailNotifications,
webhook_notifications: webhookNotifications,
}),
});
toast.success(t('settingsNotifications.success'));
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : 'Failed to save settings');
}
};
return (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" gutterBottom>
{t('settings.title')}
</Typography>
<Card>
<CardContent>
{activeTab === 'security' && (
<Box>
<Typography variant="h6" gutterBottom>
{t('settingsSecurity.title')}
</Typography>
{/* MFA Section */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SecurityIcon color={mfaEnabled ? 'success' : 'action'} />
<Typography variant="subtitle1">{t('settingsSecurity.mfa')}</Typography>
</Box>
{mfaEnabled ? (
<Button variant="outlined" color="error" onClick={handleMfaDisable}>
{t('settingsSecurity.disableMfa')}
</Button>
) : (
<Button variant="contained" startIcon={<SecurityIcon />} onClick={handleMfaSetup}>
{t('settingsSecurity.enableMfa')}
</Button>
)}
</Box>
{mfaEnabled && (
<Alert severity="success">
{t('settingsSecurity.mfaEnabled')}
</Alert>
)}
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', my: 3 }} />
{/* Password Change Section */}
<Box>
<Typography variant="subtitle1" gutterBottom>
{t('settingsSecurity.changePassword')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{t('settingsSecurity.passwordHint')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
type="password"
label={t('settingsSecurity.currentPassword')}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={passwordSuccess}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
type="password"
label={t('settingsSecurity.newPassword')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordSuccess}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
type="password"
label={t('settingsSecurity.confirmPassword')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordSuccess}
/>
</Grid>
</Grid>
{passwordError && (
<Alert severity="error" sx={{ mt: 2 }}>
{passwordError}
</Alert>
)}
{passwordSuccess && (
<Alert severity="success" sx={{ mt: 2 }}>
{t('settingsSecurity.passwordChanged')}
</Alert>
)}
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
onClick={handleChangePassword}
disabled={passwordSuccess}
sx={{ minWidth: 120 }}
>
{t('settingsSecurity.changePassword')}
</Button>
</Box>
</Box>
</Box>
)}
{activeTab === 'profile' && (
<Box>
<Typography variant="h6" gutterBottom>
{t('settingsProfile.title')}
</Typography>
{profileLoading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('settingsProfile.username')}
value={profile.username}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('settingsProfile.email')}
value={profile.email}
disabled
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('settingsProfile.fullName')}
value={profile.fullName}
onChange={(e) => setProfile({ ...profile, fullName: e.target.value })}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('settingsProfile.company')}
value={profile.company}
onChange={(e) => setProfile({ ...profile, company: e.target.value })}
/>
</Grid>
<Grid item xs={12}>
<Button
variant="contained"
onClick={handleSaveProfile}
disabled={profileSaving}
sx={{ minWidth: 120 }}
>
{profileSaving ? t('common.save') : t('settingsProfile.save')}
</Button>
{profileSuccess && (
<Alert severity="success" sx={{ mt: 2 }}>
{t('settingsProfile.success')}
</Alert>
)}
</Grid>
</Grid>
)}
</Box>
)}
{activeTab === 'sync' && (
<Box>
<Typography variant="h6" gutterBottom>
{t('settingsSync.title')}
</Typography>
{syncLoading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3}>
<Grid item xs={12}>
<Card variant="outlined">
<CardContent>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center">
{syncStatus.is_running && (
<Box display="flex" alignItems="center" mr={2}>
<CircularProgress size={20} sx={{ mr: 1 }} />
<Typography>{t('sync.running')}</Typography>
</Box>
)}
<Chip
label={syncStatus.is_running ? t('sync.running') : t('sync.idle')}
color={syncStatus.is_running ? 'primary' : 'default'}
/>
</Box>
<Box>
<Button
variant="contained"
color="primary"
startIcon={<PlayIcon />}
onClick={() => { /* TODO: Start sync */ }}
disabled={syncStatus.is_running}
sx={{ mr: 1 }}
>
{t('sync.startSync')}
</Button>
<Button
variant="contained"
color="error"
startIcon={<StopIcon />}
onClick={() => { /* TODO: Stop sync */ }}
disabled={!syncStatus.is_running}
sx={{ mr: 1 }}
>
{t('sync.stopSync')}
</Button>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" gutterBottom>
{t('settingsSync.status')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="body2" color="textSecondary">
{t('settingsSync.lastSync')}
</Typography>
<Typography variant="body1">
{syncStatus.last_sync ? formatDate(syncStatus.last_sync) : '-'}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="body2" color="textSecondary">
{t('settingsSync.nextSync')}
</Typography>
<Typography variant="body1">
{syncStatus.next_sync ? formatDate(syncStatus.next_sync) : '-'}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
</Grid>
)}
</Box>
)}
{activeTab === 'notifications' && (
<Box>
<Typography variant="h6" gutterBottom>
{t('settingsNotifications.title')}
</Typography>
{notificationsLoading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3}>
<Grid item xs={12}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" gutterBottom>
{t('settingsNotifications.emailNotifications')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{t('settingsNotifications.emailNotificationsDesc')}
</Typography>
<FormControlLabel
control={
<Switch
checked={emailNotifications}
onChange={(e) => setEmailNotifications(e.target.checked)}
/>
}
label={t('settingsNotifications.emailNotifications')}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" gutterBottom>
{t('settingsNotifications.webhookNotifications')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{t('settingsNotifications.webhookNotificationsDesc')}
</Typography>
<FormControlLabel
control={
<Switch
checked={webhookNotifications}
onChange={(e) => setWebhookNotifications(e.target.checked)}
/>
}
label={t('settingsNotifications.webhookNotifications')}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" gutterBottom>
{t('settingsNotifications.webhooks')}
</Typography>
<Box sx={{ mb: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12} md={5}>
<TextField
fullWidth
label={t('settingsNotifications.webhookUrl')}
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://example.com/webhook"
/>
</Grid>
<Grid item xs={12} md={3}>
<TextField
fullWidth
label={t('settingsNotifications.webhookType')}
value={webhookType}
onChange={(e) => setWebhookType(e.target.value)}
select
>
<MenuItem value="sync_complete">Sync Complete</MenuItem>
<MenuItem value="sync_failed">Sync Failed</MenuItem>
<MenuItem value="alert_triggered">Alert Triggered</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} md={4}>
<Button
fullWidth
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddWebhook}
disabled={webhookLoading || !webhookUrl}
>
{t('settingsNotifications.addWebhook')}
</Button>
</Grid>
</Grid>
</Box>
<List>
{webhooks.length === 0 ? (
<Typography variant="body2" color="textSecondary">
{t('settingsNotifications.noWebhooks')}
</Typography>
) : (
webhooks.map((webhook) => (
<ListItem
key={webhook.id}
secondaryAction={
<IconButton
edge="end"
onClick={() => handleDeleteWebhook(webhook.id)}
>
<DeleteIcon />
</IconButton>
}
>
<ListItemIcon>
<WebhookIcon color="primary" />
</ListItemIcon>
<ListItemText
primary={webhook.url}
secondary={webhook.event_type}
/>
</ListItem>
))
)}
</List>
</CardContent>
</Card>
</Grid>
<Grid item xs={12}>
<Button
variant="contained"
onClick={handleSaveNotificationSettings}
>
{t('settingsNotifications.save')}
</Button>
</Grid>
</Grid>
)}
</Box>
)}
</CardContent>
</Card>
{/* MFA Setup Dialog */}
<Dialog open={mfaSetupDialog} onClose={() => setMfaSetupDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
{mfaStep === 'setup' && t('settingsSecurity.mfaSetupTitle')}
{mfaStep === 'verify' && t('settingsSecurity.mfaVerifyTitle')}
{mfaStep === 'success' && t('settingsSecurity.mfaSuccessTitle')}
</DialogTitle>
<DialogContent dividers>
{loading && <CircularProgress sx={{ mb: 2 }} />}
{mfaStep === 'setup' && (
<Box>
<Typography variant="body1" paragraph>
{t('settingsSecurity.mfaSteps')}
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>{t('settingsSecurity.mfaStep1')}</Typography>
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
{mfaQrCode && <QRCodeSVG value={mfaQrCode} size={200} />}
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
{t('settingsSecurity.mfaScanHint')}
</Typography>
</Paper>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>{t('settingsSecurity.mfaStep2')}</Typography>
<Alert severity="warning">
{t('settingsSecurity.mfaBackupWarning')}
</Alert>
<List sx={{ mt: 1, bgcolor: '#f5f5f5', p: 2, borderRadius: 1, maxHeight: 200, overflow: 'auto' }}>
{mfaBackupCodes.map((code, index) => (
<ListItem key={index}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" fontWeight={600}>{code}</Typography>
<Button
size="small"
onClick={() => copyToClipboard(code)}
startIcon={<CopyIcon fontSize="small" />}
>
{t('settingsSecurity.copy')}
</Button>
</Box>
}
/>
</ListItem>
))}
</List>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>{t('settingsSecurity.mfaStep3')}</Typography>
<TextField
fullWidth
variant="outlined"
label={t('settingsSecurity.verificationCode')}
value={mfaCodeInput}
onChange={(e) => setMfaCodeInput(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000 000"
margin="normal"
InputLabelProps={{ shrink: true }}
/>
</Box>
<Box sx={{ mt: 2, textAlign: 'center', mb: 0 }}>
<Button
variant="contained"
size="large"
disabled={mfaCodeInput.length !== 6}
onClick={() => setMfaStep('verify')}
>
{t('settingsSecurity.continueVerify')}
</Button>
</Box>
</Box>
)}
{mfaStep === 'verify' && (
<Box sx={{ p: 2 }}>
<Button
variant="contained"
size="large"
disabled={loading}
onClick={handleMfaVerify}
fullWidth
>
{t('settingsSecurity.verifyEnable')}
</Button>
<Box sx={{ mt: 1, textAlign: 'center' }}>
<Button onClick={() => setMfaStep('setup')} size="small">
Back
</Button>
</Box>
</Box>
)}
{mfaStep === 'success' && (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CheckCircleIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h6" paragraph>
{t('settingsSecurity.mfaSuccess')}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('settingsSecurity.mfaSuccessHint')}
</Typography>
</Box>
)}
</DialogContent>
{mfaStep !== 'success' && (
<DialogActions>
<Button onClick={() => setMfaSetupDialog(false)}>{t('common.cancel')}</Button>
</DialogActions>
)}
</Dialog>
</Box>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,763 @@
import { useState } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
TextField,
Stepper,
Step,
StepLabel,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
CircularProgress,
InputAdornment,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControlLabel,
Switch,
Chip,
} from '@mui/material';
import {
Visibility,
VisibilityOff,
Cloud as PleskIcon,
Business as SapIcon,
Check as CheckIcon,
Key as KeyIcon,
Security as SecurityIcon,
} from '@mui/icons-material';
import { useI18n } from '../contexts/I18nContext';
import toast from 'react-hot-toast';
interface PleskConfig {
host: string;
port: number;
username: string;
password: string;
api_key: string;
use_https: boolean;
verify_ssl: boolean;
two_factor_enabled: boolean;
two_factor_method: 'totp' | 'sms' | 'email' | 'none';
two_factor_secret?: string;
}
interface SapConfig {
host: string;
port: number;
company_db: string;
username: string;
password: string;
use_ssl: boolean;
timeout_seconds: number;
}
interface SyncConfig {
sync_direction: string;
sync_interval_minutes: number;
conflict_resolution: string;
auto_sync_enabled: boolean;
}
const SetupWizardPage = () => {
const { t } = useI18n();
const [activeStep, setActiveStep] = useState(0);
const [loading, setLoading] = useState(false);
const [testResults, setTestResults] = useState<{ plesk: boolean | null; sap: boolean | null }>({
plesk: null,
sap: null,
});
const [showPleskPassword, setShowPleskPassword] = useState(false);
const [showSapPassword, setShowSapPassword] = useState(false);
// 2FA Dialog State
const [twoFactorDialogOpen, setTwoFactorDialogOpen] = useState(false);
const [twoFactorCode, setTwoFactorCode] = useState('');
const [twoFactorWaiting, setTwoFactorWaiting] = useState(false);
const [twoFactorChannel, setTwoFactorChannel] = useState<string>('');
const [twoFactorPrompt, setTwoFactorPrompt] = useState<string>('');
const [connectionSession, setConnectionSession] = useState<string | null>(null);
const [pleskConfig, setPleskConfig] = useState<PleskConfig>({
host: '',
port: 8443,
username: '',
password: '',
api_key: '',
use_https: true,
verify_ssl: true,
two_factor_enabled: false,
two_factor_method: 'none',
two_factor_secret: '',
});
const [sapConfig, setSapConfig] = useState<SapConfig>({
host: '',
port: 50000,
company_db: '',
username: '',
password: '',
use_ssl: false,
timeout_seconds: 30,
});
const [syncConfig, setSyncConfig] = useState<SyncConfig>({
sync_direction: 'sap_to_plesk',
sync_interval_minutes: 60,
conflict_resolution: 'timestamp_based',
auto_sync_enabled: true,
});
const steps = [
{ label: t('wizard.welcome'), icon: null },
{ label: t('wizard.plesk'), icon: <PleskIcon /> },
{ label: t('wizard.sap'), icon: <SapIcon /> },
{ label: t('wizard.sync'), icon: null },
{ label: t('wizard.complete'), icon: null },
];
const handleNext = () => {
setActiveStep((prev) => prev + 1);
};
const handleBack = () => {
setActiveStep((prev) => prev - 1);
};
const testPleskConnection = async () => {
setLoading(true);
setTestResults((prev) => ({ ...prev, plesk: null }));
try {
const response = await fetch('/api/config/test-plesk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
...pleskConfig,
session_id: connectionSession,
}),
});
const data = await response.json();
// Check if 2FA is required
if (data.requires_2fa) {
setTwoFactorChannel(data.channel || 'app');
setTwoFactorPrompt(data.prompt || t('wizard.2fa_enter_code'));
setConnectionSession(data.session_id);
setTwoFactorDialogOpen(true);
setTwoFactorWaiting(true);
setLoading(false);
return;
}
const success = response.ok && data.success;
setTestResults((prev) => ({ ...prev, plesk: success }));
if (success) {
toast.success(t('wizard.plesk_success'));
} else {
toast.error(data.error || t('wizard.plesk_error'));
}
} catch (err) {
setTestResults((prev) => ({ ...prev, plesk: false }));
toast.error(t('wizard.connection_failed'));
} finally {
setLoading(false);
}
};
const submitTwoFactorCode = async () => {
if (!twoFactorCode) {
toast.error(t('wizard.2fa_code_required'));
return;
}
setLoading(true);
try {
const response = await fetch('/api/config/plesk2fa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
session_id: connectionSession,
code: twoFactorCode,
host: pleskConfig.host,
port: pleskConfig.port,
username: pleskConfig.username,
password: pleskConfig.password,
api_key: pleskConfig.api_key,
}),
});
const data = await response.json();
if (data.success) {
setTwoFactorDialogOpen(false);
setTwoFactorCode('');
setConnectionSession(null);
setTestResults((prev) => ({ ...prev, plesk: true }));
toast.success(t('wizard.plesk_success'));
} else if (data.requires_2fa) {
// Still need another code
setTwoFactorPrompt(data.prompt || t('wizard.2fa_invalid'));
setTwoFactorCode('');
} else {
toast.error(data.error || t('wizard.2fa_invalid'));
}
} catch {
toast.error(t('wizard.connection_failed'));
} finally {
setLoading(false);
}
};
const cancelTwoFactor = () => {
setTwoFactorDialogOpen(false);
setTwoFactorCode('');
setConnectionSession(null);
setTwoFactorWaiting(false);
setLoading(false);
};
const testSapConnection = async () => {
setLoading(true);
try {
const response = await fetch('/api/config/test-sap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(sapConfig),
});
const success = response.ok;
setTestResults((prev) => ({ ...prev, sap: success }));
if (success) {
toast.success(t('wizard.sap_success'));
} else {
toast.error(t('wizard.sap_error'));
}
} catch {
setTestResults((prev) => ({ ...prev, sap: false }));
toast.error(t('wizard.connection_failed'));
} finally {
setLoading(false);
}
};
const saveConfiguration = async () => {
setLoading(true);
try {
const response = await fetch('/api/config/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
plesk: pleskConfig,
sap: sapConfig,
sync: syncConfig,
}),
});
if (response.ok) {
toast.success(t('wizard.save_success'));
handleNext();
} else {
toast.error(t('wizard.save_error'));
}
} catch {
toast.error(t('wizard.save_error'));
} finally {
setLoading(false);
}
};
const renderWelcomeStep = () => (
<Box textAlign="center">
<Typography variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
{t('wizard.welcome_title')}
</Typography>
<Typography variant="body1" color="textSecondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
{t('wizard.welcome_desc')}
</Typography>
<Grid container spacing={3} sx={{ mt: 2, maxWidth: 700, mx: 'auto' }}>
<Grid item xs={12} md={6}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent>
<PleskIcon sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
<Typography variant="h6">{t('wizard.plesk_setup')}</Typography>
<Typography variant="body2" color="textSecondary">
{t('wizard.plesk_setup_desc')}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent>
<SapIcon sx={{ fontSize: 48, color: 'secondary.main', mb: 1 }} />
<Typography variant="h6">{t('wizard.sap_setup')}</Typography>
<Typography variant="body2" color="textSecondary">
{t('wizard.sap_setup_desc')}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
const renderPleskStep = () => (
<Box>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
{t('wizard.plesk_config')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
{t('wizard.plesk_config_desc')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={8}>
<TextField
fullWidth
label={t('wizard.plesk_host')}
placeholder="plesk.example.com"
value={pleskConfig.host}
onChange={(e) => setPleskConfig({ ...pleskConfig, host: e.target.value })}
margin="normal"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label={t('wizard.port')}
type="number"
value={pleskConfig.port}
onChange={(e) => setPleskConfig({ ...pleskConfig, port: parseInt(e.target.value) || 8443 })}
margin="normal"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('wizard.username')}
value={pleskConfig.username}
onChange={(e) => setPleskConfig({ ...pleskConfig, username: e.target.value })}
margin="normal"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('wizard.password')}
type={showPleskPassword ? 'text' : 'password'}
value={pleskConfig.password}
onChange={(e) => setPleskConfig({ ...pleskConfig, password: e.target.value })}
margin="normal"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPleskPassword(!showPleskPassword)} edge="end">
{showPleskPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={t('wizard.api_key')}
value={pleskConfig.api_key}
onChange={(e) => setPleskConfig({ ...pleskConfig, api_key: e.target.value })}
margin="normal"
helperText={t('wizard.api_key_helper')}
/>
</Grid>
{/* 2FA Configuration Section */}
<Grid item xs={12}>
<Card variant="outlined" sx={{ mt: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<SecurityIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{t('wizard.2fa_section')}
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={pleskConfig.two_factor_enabled}
onChange={(e) => setPleskConfig({
...pleskConfig,
two_factor_enabled: e.target.checked,
two_factor_method: e.target.checked ? 'totp' : 'none'
})}
/>
}
label={t('wizard.2fa_enabled')}
/>
{pleskConfig.two_factor_enabled && (
<Box sx={{ mt: 2 }}>
<FormControl fullWidth margin="normal">
<InputLabel>{t('wizard.2fa_method')}</InputLabel>
<Select
value={pleskConfig.two_factor_method}
onChange={(e) => setPleskConfig({
...pleskConfig,
two_factor_method: e.target.value as 'totp' | 'sms' | 'email' | 'none'
})}
label={t('wizard.2fa_method')}
>
<MenuItem value="totp">{t('wizard.2fa_totp')}</MenuItem>
<MenuItem value="sms">{t('wizard.2fa_sms')}</MenuItem>
<MenuItem value="email">{t('wizard.2fa_email')}</MenuItem>
</Select>
</FormControl>
{pleskConfig.two_factor_method === 'totp' && (
<Alert severity="info" sx={{ mt: 2 }}>
{t('wizard.2fa_totp_info')}
</Alert>
)}
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
{t('wizard.2fa_tunnel_info')}
</Typography>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="outlined"
onClick={testPleskConnection}
disabled={loading || !pleskConfig.host}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('wizard.test_connection')}
</Button>
{testResults.plesk !== null && (
<Alert severity={testResults.plesk ? 'success' : 'error'} sx={{ flex: 1 }}>
{testResults.plesk ? t('wizard.connection_ok') : t('wizard.connection_failed')}
</Alert>
)}
</Box>
{/* 2FA Dialog */}
<Dialog open={twoFactorDialogOpen} onClose={cancelTwoFactor} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<KeyIcon sx={{ mr: 1 }} />
{t('wizard.2fa_title')}
</Box>
</DialogTitle>
<DialogContent>
{twoFactorWaiting && (
<Box sx={{ mb: 2 }}>
<Alert severity="info" icon={<SecurityIcon />}>
{twoFactorPrompt}
</Alert>
<Chip
label={t('wizard.2fa_channel') + ': ' + twoFactorChannel}
size="small"
sx={{ mt: 1 }}
/>
</Box>
)}
<TextField
fullWidth
autoFocus
label={t('wizard.2fa_code')}
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
margin="normal"
placeholder="000000"
inputProps={{
maxLength: 6,
style: { fontSize: '2rem', textAlign: 'center', letterSpacing: '0.5rem' }
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
submitTwoFactorCode();
}
}}
/>
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
{t('wizard.2fa_code_help')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={cancelTwoFactor}>
{t('wizard.cancel')}
</Button>
<Button
variant="contained"
onClick={submitTwoFactorCode}
disabled={twoFactorCode.length < 4 || loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('wizard.2fa_verify')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
const renderSapStep = () => (
<Box>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
{t('wizard.sap_config')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
{t('wizard.sap_config_desc')}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={8}>
<TextField
fullWidth
label={t('wizard.sap_host')}
placeholder="sap.example.com"
value={sapConfig.host}
onChange={(e) => setSapConfig({ ...sapConfig, host: e.target.value })}
margin="normal"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label={t('wizard.port')}
type="number"
value={sapConfig.port}
onChange={(e) => setSapConfig({ ...sapConfig, port: parseInt(e.target.value) || 50000 })}
margin="normal"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={t('wizard.company_db')}
placeholder="SBODEMO_DE"
value={sapConfig.company_db}
onChange={(e) => setSapConfig({ ...sapConfig, company_db: e.target.value })}
margin="normal"
helperText={t('wizard.company_db_helper')}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('wizard.username')}
value={sapConfig.username}
onChange={(e) => setSapConfig({ ...sapConfig, username: e.target.value })}
margin="normal"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('wizard.password')}
type={showSapPassword ? 'text' : 'password'}
value={sapConfig.password}
onChange={(e) => setSapConfig({ ...sapConfig, password: e.target.value })}
margin="normal"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowSapPassword(!showSapPassword)} edge="end">
{showSapPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="outlined"
onClick={testSapConnection}
disabled={loading || !sapConfig.host || !sapConfig.company_db}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('wizard.test_connection')}
</Button>
{testResults.sap !== null && (
<Alert severity={testResults.sap ? 'success' : 'error'} sx={{ flex: 1 }}>
{testResults.sap ? t('wizard.connection_ok') : t('wizard.connection_failed')}
</Alert>
)}
</Box>
</Box>
);
const renderSyncStep = () => (
<Box>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
{t('wizard.sync_config')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
{t('wizard.sync_config_desc')}
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth margin="normal">
<InputLabel>{t('wizard.sync_direction')}</InputLabel>
<Select
value={syncConfig.sync_direction}
onChange={(e) => setSyncConfig({ ...syncConfig, sync_direction: e.target.value })}
label={t('wizard.sync_direction')}
>
<MenuItem value="sap_to_plesk">SAP Plesk</MenuItem>
<MenuItem value="plesk_to_sap">Plesk SAP</MenuItem>
<MenuItem value="bidirectional">{t('wizard.bidirectional')}</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t('wizard.sync_interval')}
type="number"
value={syncConfig.sync_interval_minutes}
onChange={(e) => setSyncConfig({ ...syncConfig, sync_interval_minutes: parseInt(e.target.value) || 60 })}
margin="normal"
InputProps={{
endAdornment: <InputAdornment position="end">{t('wizard.minutes')}</InputAdornment>,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth margin="normal">
<InputLabel>{t('wizard.conflict_resolution')}</InputLabel>
<Select
value={syncConfig.conflict_resolution}
onChange={(e) => setSyncConfig({ ...syncConfig, conflict_resolution: e.target.value })}
label={t('wizard.conflict_resolution')}
>
<MenuItem value="sap_priority">{t('wizard.sap_priority')}</MenuItem>
<MenuItem value="plesk_priority">{t('wizard.plesk_priority')}</MenuItem>
<MenuItem value="timestamp_based">{t('wizard.timestamp_based')}</MenuItem>
<MenuItem value="manual">{t('wizard.manual')}</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Box>
);
const renderCompleteStep = () => (
<Box textAlign="center">
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 3,
}}
>
<CheckIcon sx={{ fontSize: 48, color: 'white' }} />
</Box>
<Typography variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
{t('wizard.setup_complete')}
</Typography>
<Typography variant="body1" color="textSecondary" sx={{ mb: 4, maxWidth: 500, mx: 'auto' }}>
{t('wizard.setup_complete_desc')}
</Typography>
<Button variant="contained" size="large" href="/dashboard">
{t('wizard.go_dashboard')}
</Button>
</Box>
);
const renderStepContent = () => {
switch (activeStep) {
case 0:
return renderWelcomeStep();
case 1:
return renderPleskStep();
case 2:
return renderSapStep();
case 3:
return renderSyncStep();
case 4:
return renderCompleteStep();
default:
return null;
}
};
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 3 }}>
<Card>
<CardContent sx={{ p: 4 }}>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((step) => (
<Step key={step.label}>
<StepLabel>{step.label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ minHeight: 400 }}>
{renderStepContent()}
</Box>
{activeStep < steps.length - 1 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
{t('wizard.back')}
</Button>
<Button
variant="contained"
onClick={activeStep === steps.length - 2 ? saveConfiguration : handleNext}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{activeStep === steps.length - 2 ? t('wizard.complete_setup') : t('wizard.next')}
</Button>
</Box>
)}
</CardContent>
</Card>
</Box>
);
};
export default SetupWizardPage;

254
frontend/src/pages/SyncPage.tsx Executable file
View File

@@ -0,0 +1,254 @@
import { useState, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Grid,
Chip,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
FormControl,
InputLabel,
Select,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
Stop as StopIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { apiJson } from '../lib/api';
import { usePolling, getStatusColor, formatDate } from '../lib/hooks';
import { logger } from '../lib/logger';
import { useI18n } from '../contexts/I18nContext';
interface SyncJob {
id: number;
job_type: string;
sync_direction: string;
status: string;
records_processed: number;
records_failed: number;
created_at: string;
started_at: string | null;
completed_at: string | null;
error_message: string | null;
}
const SyncPage = () => {
const { t } = useI18n();
const [jobs, setJobs] = useState<SyncJob[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [loading, setLoading] = useState(true);
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const [syncDirection, setSyncDirection] = useState('sap_to_plesk');
const [syncType, setSyncType] = useState('incremental_sync');
const fetchJobs = useCallback(async () => {
try {
const data = await apiJson<{ jobs: SyncJob[] }>('/sync/jobs');
setJobs(data.jobs || []);
setIsRunning(data.jobs?.some((job: SyncJob) => job.status === 'running') || false);
} catch (error) {
logger.error('Failed to fetch jobs:', error);
} finally {
setLoading(false);
}
}, []);
usePolling(fetchJobs, 10000);
const startSync = async () => {
try {
await apiJson('/sync/start', {
method: 'POST',
body: JSON.stringify({
job_type: syncType,
sync_direction: syncDirection,
}),
});
toast.success(t('sync.startedSuccess'));
setSyncDialogOpen(false);
fetchJobs();
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : t('sync.startFailed'));
}
};
const stopSync = async () => {
try {
await apiJson('/sync/stop', { method: 'POST' });
toast.success(t('sync.stoppedSuccess'));
fetchJobs();
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : t('sync.stopFailed'));
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" gutterBottom>
{t('sync.title')}
</Typography>
<Grid container spacing={3}>
<Grid item xs={12}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center">
{isRunning && (
<Box display="flex" alignItems="center" mr={2}>
<CircularProgress size={20} sx={{ mr: 1 }} />
<Typography>{t('sync.running')}</Typography>
</Box>
)}
<Chip
label={isRunning ? t('sync.running') : t('sync.idle')}
color={isRunning ? 'primary' : 'default'}
/>
</Box>
<Box>
<Button
variant="contained"
color="primary"
startIcon={<PlayIcon />}
onClick={() => setSyncDialogOpen(true)}
disabled={isRunning}
sx={{ mr: 1 }}
>
{t('sync.startSync')}
</Button>
<Button
variant="contained"
color="error"
startIcon={<StopIcon />}
onClick={stopSync}
disabled={!isRunning}
sx={{ mr: 1 }}
>
{t('sync.stopSync')}
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={fetchJobs}
>
{t('common.refresh')}
</Button>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{t('sync.syncJobs')}
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('sync.colId')}</TableCell>
<TableCell>{t('sync.colType')}</TableCell>
<TableCell>{t('sync.colDirection')}</TableCell>
<TableCell>{t('sync.colStatus')}</TableCell>
<TableCell>{t('sync.colProcessed')}</TableCell>
<TableCell>{t('sync.colFailed')}</TableCell>
<TableCell>{t('sync.colStarted')}</TableCell>
<TableCell>{t('sync.colCompleted')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>{job.id}</TableCell>
<TableCell>{job.job_type}</TableCell>
<TableCell>{job.sync_direction}</TableCell>
<TableCell>
<Chip
label={job.status}
color={getStatusColor(job.status)}
size="small"
/>
</TableCell>
<TableCell>{job.records_processed}</TableCell>
<TableCell>{job.records_failed}</TableCell>
<TableCell>{formatDate(job.started_at)}</TableCell>
<TableCell>{formatDate(job.completed_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
</Grid>
<Dialog open={syncDialogOpen} onClose={() => setSyncDialogOpen(false)}>
<DialogTitle>{t('sync.startTitle')}</DialogTitle>
<DialogContent>
<Box sx={{ minWidth: 400, mt: 2 }}>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>{t('sync.syncType')}</InputLabel>
<Select
value={syncType}
onChange={(e) => setSyncType(e.target.value)}
label={t('sync.syncType')}
>
<MenuItem value="full_sync">{t('sync.fullSync')}</MenuItem>
<MenuItem value="incremental_sync">{t('sync.incrementalSync')}</MenuItem>
<MenuItem value="partial_sync">{t('sync.partialSync')}</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>{t('sync.direction')}</InputLabel>
<Select
value={syncDirection}
onChange={(e) => setSyncDirection(e.target.value)}
label={t('sync.direction')}
>
<MenuItem value="sap_to_plesk">SAP Plesk</MenuItem>
<MenuItem value="plesk_to_sap">Plesk SAP</MenuItem>
<MenuItem value="bidirectional">Bidirectional</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setSyncDialogOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={startSync} variant="contained" color="primary">
{t('sync.start')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default SyncPage;

View File

@@ -0,0 +1,531 @@
import { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Grid,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
FormControl,
InputLabel,
Select,
MenuItem,
Tabs,
Tab,
Alert,
CircularProgress,
LinearProgress,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
CheckCircle as CheckIcon,
Warning as WarningIcon,
Error as ErrorIcon,
CompareArrows as CompareIcon,
} from '@mui/icons-material';
import { useI18n } from '../contexts/I18nContext';
import toast from 'react-hot-toast';
interface SyncItem {
id: string;
source_id: string;
target_id: string | null;
name: string;
status: 'new' | 'update' | 'conflict' | 'unchanged' | 'delete';
source_data: Record<string, unknown>;
target_data: Record<string, unknown> | null;
diff?: Record<string, { source: unknown; target: unknown }>;
}
interface SimulationResult {
data_type: string;
direction: string;
total_records: number;
new: number;
updated: number;
conflicts: number;
unchanged: number;
deleted: number;
items: SyncItem[];
}
const SyncSimulationPage = () => {
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const [simulating, setSimulating] = useState(false);
const [dataType, setDataType] = useState('customers');
const [direction, setDirection] = useState('sap_to_plesk');
const [tabValue, setTabValue] = useState(0);
const [selectedItem, setSelectedItem] = useState<SyncItem | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [results, setResults] = useState<SimulationResult | null>(null);
const [connectionStatus, setConnectionStatus] = useState<{ plesk: boolean; sap: boolean } | null>(null);
useEffect(() => {
checkConnections();
}, []);
const checkConnections = async () => {
setLoading(true);
try {
const response = await fetch('/api/setup/status', { credentials: 'include' });
if (response.ok) {
const data = await response.json();
setConnectionStatus({
plesk: data.plesk_configured,
sap: data.sap_configured,
});
}
} catch {
toast.error('Failed to check connections');
} finally {
setLoading(false);
}
};
const runSimulation = async () => {
if (!connectionStatus?.plesk || !connectionStatus?.sap) {
toast.error(t('simulation.not_configured'));
return;
}
setSimulating(true);
setResults(null);
try {
const response = await fetch('/api/sync/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
data_type: dataType,
direction: direction,
}),
});
if (response.ok) {
const data = await response.json();
setResults(data);
toast.success(t('simulation.complete'));
} else {
toast.error(t('simulation.error'));
}
} catch {
toast.error(t('simulation.error'));
} finally {
setSimulating(false);
}
};
const getStatusIcon = (status: string): React.ReactElement | undefined => {
switch (status) {
case 'new':
return <CheckIcon fontSize="small" />;
case 'update':
return <CompareIcon fontSize="small" />;
case 'conflict':
return <WarningIcon fontSize="small" />;
case 'unchanged':
return <CheckIcon fontSize="small" />;
case 'delete':
return <ErrorIcon fontSize="small" />;
default:
return undefined;
}
};
const getStatusColor = (status: string): 'success' | 'info' | 'warning' | 'default' | 'error' => {
switch (status) {
case 'new':
return 'success';
case 'update':
return 'info';
case 'conflict':
return 'warning';
case 'unchanged':
return 'default';
case 'delete':
return 'error';
default:
return 'default';
}
};
const getStatusLabel = (status: string): string => {
switch (status) {
case 'new':
return t('simulation.status_new');
case 'update':
return t('simulation.status_update');
case 'conflict':
return t('simulation.status_conflict');
case 'unchanged':
return t('simulation.status_unchanged');
case 'delete':
return t('simulation.status_delete');
default:
return status;
}
};
const showItemDetails = (item: SyncItem) => {
setSelectedItem(item);
setDetailDialogOpen(true);
};
const filteredItems = results?.items.filter((item) => {
if (tabValue === 0) return true;
if (tabValue === 1) return item.status === 'new';
if (tabValue === 2) return item.status === 'update';
if (tabValue === 3) return item.status === 'conflict';
if (tabValue === 4) return item.status === 'unchanged';
return true;
});
return (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
{t('simulation.title')}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
{t('simulation.description')}
</Typography>
{(!connectionStatus?.plesk || !connectionStatus?.sap) && (
<Alert severity="warning" sx={{ mb: 3 }}>
{t('simulation.not_configured')}
<Button size="small" href="/setup" sx={{ ml: 2 }}>
{t('simulation.go_setup')}
</Button>
</Alert>
)}
<Card sx={{ mb: 3 }}>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={3}>
<FormControl fullWidth>
<InputLabel>{t('simulation.data_type')}</InputLabel>
<Select
value={dataType}
onChange={(e) => setDataType(e.target.value)}
label={t('simulation.data_type')}
>
<MenuItem value="customers">{t('simulation.customers')}</MenuItem>
<MenuItem value="domains">{t('simulation.domains')}</MenuItem>
<MenuItem value="subscriptions">{t('simulation.subscriptions')}</MenuItem>
<MenuItem value="invoices">{t('simulation.invoices')}</MenuItem>
<MenuItem value="contacts">{t('simulation.contacts')}</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={3}>
<FormControl fullWidth>
<InputLabel>{t('simulation.direction')}</InputLabel>
<Select
value={direction}
onChange={(e) => setDirection(e.target.value)}
label={t('simulation.direction')}
>
<MenuItem value="sap_to_plesk">SAP Plesk</MenuItem>
<MenuItem value="plesk_to_sap">Plesk SAP</MenuItem>
<MenuItem value="bidirectional">{t('simulation.bidirectional')}</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={3}>
<Button
variant="contained"
size="large"
startIcon={simulating ? <CircularProgress size={20} /> : <PlayIcon />}
onClick={runSimulation}
disabled={simulating || !connectionStatus?.plesk || !connectionStatus?.sap}
fullWidth
>
{simulating ? t('simulation.running') : t('simulation.run')}
</Button>
</Grid>
<Grid item xs={12} md={3}>
<Button
variant="outlined"
size="large"
startIcon={<RefreshIcon />}
onClick={checkConnections}
disabled={loading}
fullWidth
>
{t('simulation.refresh')}
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
{simulating && (
<Box sx={{ mb: 3 }}>
<LinearProgress />
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, textAlign: 'center' }}>
{t('simulation.analyzing')}
</Typography>
</Box>
)}
{results && (
<>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} md={2}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary">
{results.total_records}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('simulation.total')}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={2}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="success.main">
{results.new}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('simulation.status_new')}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={2}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="info.main">
{results.updated}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('simulation.status_update')}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={2}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="warning.main">
{results.conflicts}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('simulation.status_conflict')}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={2}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="text.disabled">
{results.unchanged}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('simulation.status_unchanged')}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={2}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="error.main">
{results.deleted}
</Typography>
<Typography variant="body2" color="textSecondary">
{t('simulation.status_delete')}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<Card>
<Tabs
value={tabValue}
onChange={(_, v) => setTabValue(v)}
variant="scrollable"
scrollButtons="auto"
>
<Tab label={`${t('simulation.all')} (${results.items.length})`} />
<Tab label={`${t('simulation.status_new')} (${results.new})`} />
<Tab label={`${t('simulation.status_update')} (${results.updated})`} />
<Tab label={`${t('simulation.status_conflict')} (${results.conflicts})`} />
<Tab label={`${t('simulation.status_unchanged')} (${results.unchanged})`} />
</Tabs>
<TableContainer component={Paper} variant="outlined">
<Table size="medium">
<TableHead>
<TableRow>
<TableCell>{t('simulation.col_status')}</TableCell>
<TableCell>{t('simulation.col_source_id')}</TableCell>
<TableCell>{t('simulation.col_target_id')}</TableCell>
<TableCell>{t('simulation.col_name')}</TableCell>
<TableCell>{t('simulation.col_actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredItems?.map((item) => (
<TableRow
key={item.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => showItemDetails(item)}
>
<TableCell>
<Chip
icon={getStatusIcon(item.status)}
label={getStatusLabel(item.status)}
color={getStatusColor(item.status)}
size="small"
/>
</TableCell>
<TableCell>{item.source_id}</TableCell>
<TableCell>{item.target_id || '-'}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>
<Tooltip title={t('simulation.view_details')}>
<IconButton size="small">
<InfoIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
{filteredItems?.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="textSecondary" sx={{ py: 4 }}>
{t('simulation.no_records')}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Card>
</>
)}
<Dialog
open={detailDialogOpen}
onClose={() => setDetailDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
{t('simulation.details_title')}: {selectedItem?.name}
</DialogTitle>
<DialogContent dividers>
{selectedItem && (
<Grid container spacing={2}>
<Grid item xs={12}>
<Chip
icon={getStatusIcon(selectedItem.status)}
label={getStatusLabel(selectedItem.status)}
color={getStatusColor(selectedItem.status)}
/>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
{t('simulation.source_data')}:
</Typography>
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'grey.50' }}>
<pre style={{ margin: 0, fontSize: '0.75rem', overflow: 'auto' }}>
{JSON.stringify(selectedItem.source_data, null, 2)}
</pre>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
{t('simulation.target_data')}:
</Typography>
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'grey.50' }}>
{selectedItem.target_data ? (
<pre style={{ margin: 0, fontSize: '0.75rem', overflow: 'auto' }}>
{JSON.stringify(selectedItem.target_data, null, 2)}
</pre>
) : (
<Typography variant="body2" color="textSecondary">
{t('simulation.no_target_data')}
</Typography>
)}
</Paper>
</Grid>
{selectedItem.diff && Object.keys(selectedItem.diff).length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
{t('simulation.differences')}:
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t('simulation.col_field')}</TableCell>
<TableCell>{t('simulation.col_source_value')}</TableCell>
<TableCell>{t('simulation.col_target_value')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(selectedItem.diff).map(([field, values]) => (
<TableRow key={field}>
<TableCell>{field}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{JSON.stringify(values.source)}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{JSON.stringify(values.target)}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
)}
</Grid>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailDialogOpen(false)}>
{t('simulation.close')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default SyncSimulationPage;

1
frontend/src/vite-env.d.ts vendored Executable file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Executable file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
frontend/tsconfig.node.json Executable file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Executable file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})