feat: Phase 1 - Complete authentication system with JWT

Backend Implementation (FastAPI + MongoDB):
- JWT authentication with access/refresh tokens
- User registration and login endpoints
- Password hashing with bcrypt (fixed 72-byte limit)
- Protected endpoints with JWT middleware
- Token refresh mechanism
- Role-Based Access Control (RBAC) structure
- Pydantic v2 models and async MongoDB with Motor
- API endpoints: /api/auth/register, /api/auth/login, /api/auth/me, /api/auth/refresh

Frontend Implementation (React + TypeScript + Material-UI):
- Login and Register pages with validation
- AuthContext for global authentication state
- API client with Axios interceptors for token refresh
- Protected routes with automatic redirect
- User profile display in navigation
- Logout functionality

Technical Achievements:
- Resolved bcrypt 72-byte limit (replaced passlib with native bcrypt)
- Fixed Pydantic v2 compatibility (PyObjectId, ConfigDict)
- Implemented automatic token refresh on 401 errors
- Created comprehensive test suite for all auth endpoints

Docker & Kubernetes:
- Backend image: yakenator/site11-console-backend:latest
- Frontend image: yakenator/site11-console-frontend:latest
- Deployed to site11-pipeline namespace
- Nginx reverse proxy configuration

Documentation:
- CONSOLE_ARCHITECTURE.md - Complete system architecture
- PHASE1_COMPLETION.md - Detailed completion report
- PROGRESS.md - Updated with Phase 1 status

All authentication endpoints tested and verified working.

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-10-28 16:23:07 +09:00
parent 161f206ae2
commit f4b75b96a5
51 changed files with 2480 additions and 100 deletions

View File

@ -0,0 +1,20 @@
# Build stage
FROM node:18-alpine as builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,13 @@
<!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>Console - Microservices Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://console-backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
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;
}
}

View File

@ -0,0 +1,33 @@
{
"name": "console-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.15.2",
"@mui/icons-material": "^5.15.2",
"axios": "^1.6.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View File

@ -0,0 +1,35 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import Login from './pages/Login'
import Register from './pages/Register'
import Dashboard from './pages/Dashboard'
import Services from './pages/Services'
import Users from './pages/Users'
function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="services" element={<Services />} />
<Route path="users" element={<Users />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthProvider>
)
}
export default App

View File

@ -0,0 +1,100 @@
import axios from 'axios';
import type { User, LoginRequest, RegisterRequest, AuthTokens } from '../types/auth';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle token refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
const { data } = await axios.post<AuthTokens>(
`${API_BASE_URL}/api/auth/refresh`,
{ refresh_token: refreshToken }
);
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return api(originalRequest);
}
} catch (refreshError) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export const authAPI = {
login: async (credentials: LoginRequest): Promise<AuthTokens> => {
const formData = new URLSearchParams();
formData.append('username', credentials.username);
formData.append('password', credentials.password);
const { data } = await axios.post<AuthTokens>(
`${API_BASE_URL}/api/auth/login`,
formData,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
return data;
},
register: async (userData: RegisterRequest): Promise<User> => {
const { data } = await axios.post<User>(
`${API_BASE_URL}/api/auth/register`,
userData
);
return data;
},
getCurrentUser: async (): Promise<User> => {
const { data } = await api.get<User>('/api/auth/me');
return data;
},
refreshToken: async (refreshToken: string): Promise<AuthTokens> => {
const { data } = await axios.post<AuthTokens>(
`${API_BASE_URL}/api/auth/refresh`,
{ refresh_token: refreshToken }
);
return data;
},
logout: async (): Promise<void> => {
await api.post('/api/auth/logout');
},
};
export default api;

View File

@ -0,0 +1,155 @@
import { useState } from 'react'
import { Outlet, Link as RouterLink, useNavigate } from 'react-router-dom'
import {
AppBar,
Box,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Menu,
MenuItem,
} from '@mui/material'
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
Cloud as CloudIcon,
People as PeopleIcon,
AccountCircle,
} from '@mui/icons-material'
import { useAuth } from '../contexts/AuthContext'
const drawerWidth = 240
const menuItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
{ text: 'Services', icon: <CloudIcon />, path: '/services' },
{ text: 'Users', icon: <PeopleIcon />, path: '/users' },
]
function Layout() {
const [open, setOpen] = useState(true)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { user, logout } = useAuth()
const navigate = useNavigate()
const handleDrawerToggle = () => {
setOpen(!open)
}
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleLogout = () => {
logout()
navigate('/login')
handleClose()
}
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Site11 Console
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">
{user?.username} ({user?.role})
</Typography>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</Box>
</Toolbar>
</AppBar>
<Drawer
variant="persistent"
anchor="left"
open={open}
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton component={RouterLink} to={item.path}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
marginLeft: open ? `${drawerWidth}px` : 0,
transition: 'margin 0.3s',
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
)
}
export default Layout

View File

@ -0,0 +1,35 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@ -0,0 +1,96 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authAPI } from '../api/auth';
import type { User, LoginRequest, RegisterRequest, AuthContextType } from '../types/auth';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check if user is already logged in
const initAuth = async () => {
const token = localStorage.getItem('access_token');
if (token) {
try {
const userData = await authAPI.getCurrentUser();
setUser(userData);
} catch (error) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
}
setIsLoading(false);
};
initAuth();
}, []);
const login = async (credentials: LoginRequest) => {
const tokens = await authAPI.login(credentials);
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
const userData = await authAPI.getCurrentUser();
setUser(userData);
};
const register = async (data: RegisterRequest) => {
const newUser = await authAPI.register(data);
// Auto login after registration
const tokens = await authAPI.login({
username: data.username,
password: data.password,
});
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
setUser(newUser);
};
const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setUser(null);
// Optional: call backend logout endpoint
authAPI.logout().catch(() => {
// Ignore errors on logout
});
};
const refreshToken = async () => {
const token = localStorage.getItem('refresh_token');
if (token) {
const tokens = await authAPI.refreshToken(token);
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
}
};
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
refreshToken,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@ -0,0 +1,29 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import {
Grid,
Paper,
Typography,
Box,
Card,
CardContent,
Chip,
} from '@mui/material'
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon
} from '@mui/icons-material'
import axios from 'axios'
interface ServiceStatus {
name: string
status: 'healthy' | 'unhealthy'
endpoint: string
lastChecked: string
}
function Dashboard() {
const [services, setServices] = useState<ServiceStatus[]>([])
const [stats, setStats] = useState({
totalServices: 0,
healthyServices: 0,
unhealthyServices: 0,
})
useEffect(() => {
checkServices()
const interval = setInterval(checkServices, 10000)
return () => clearInterval(interval)
}, [])
const checkServices = async () => {
const serviceChecks = [
{ name: 'Console Backend', endpoint: '/api/health' },
{ name: 'Users Service', endpoint: '/api/users/health' },
]
const results = await Promise.all(
serviceChecks.map(async (service) => {
try {
await axios.get(service.endpoint)
return {
...service,
status: 'healthy' as const,
lastChecked: new Date().toLocaleTimeString(),
}
} catch {
return {
...service,
status: 'unhealthy' as const,
lastChecked: new Date().toLocaleTimeString(),
}
}
})
)
setServices(results)
const healthy = results.filter(s => s.status === 'healthy').length
setStats({
totalServices: results.length,
healthyServices: healthy,
unhealthyServices: results.length - healthy,
})
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Services
</Typography>
<Typography variant="h3">
{stats.totalServices}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Healthy Services
</Typography>
<Typography variant="h3" color="success.main">
{stats.healthyServices}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Unhealthy Services
</Typography>
<Typography variant="h3" color="error.main">
{stats.unhealthyServices}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Service Status
</Typography>
<Grid container spacing={2}>
{services.map((service) => (
<Grid item xs={12} md={6} key={service.name}>
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6">{service.name}</Typography>
<Typography variant="body2" color="textSecondary">
{service.endpoint}
</Typography>
<Typography variant="caption" color="textSecondary">
Last checked: {service.lastChecked}
</Typography>
</Box>
<Chip
label={service.status}
color={service.status === 'healthy' ? 'success' : 'error'}
icon={service.status === 'healthy' ? <CheckCircleIcon /> : <ErrorIcon />}
/>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Paper>
</Box>
)
}
export default Dashboard

View File

@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import {
Container,
Box,
Paper,
TextField,
Button,
Typography,
Alert,
Link,
} from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
const Login: React.FC = () => {
const navigate = useNavigate();
const { login } = useAuth();
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(formData);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.detail || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 400,
}}
>
<Typography variant="h4" component="h1" gutterBottom align="center">
Site11 Console
</Typography>
<Typography variant="h6" component="h2" gutterBottom align="center" color="text.secondary">
Sign In
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField
fullWidth
label="Username"
name="username"
value={formData.username}
onChange={handleChange}
margin="normal"
required
autoFocus
disabled={loading}
/>
<TextField
fullWidth
label="Password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
margin="normal"
required
disabled={loading}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2">
Don't have an account?{' '}
<Link component={RouterLink} to="/register" underline="hover">
Sign Up
</Link>
</Typography>
</Box>
</Box>
</Paper>
</Box>
</Container>
);
};
export default Login;

View File

@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import {
Container,
Box,
Paper,
TextField,
Button,
Typography,
Alert,
Link,
} from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
const Register: React.FC = () => {
const navigate = useNavigate();
const { register } = useAuth();
const [formData, setFormData] = useState({
email: '',
username: '',
password: '',
confirmPassword: '',
full_name: '',
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validate passwords match
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return;
}
// Validate password length
if (formData.password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
await register({
email: formData.email,
username: formData.username,
password: formData.password,
full_name: formData.full_name || undefined,
});
navigate('/');
} catch (err: any) {
setError(err.response?.data?.detail || 'Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 400,
}}
>
<Typography variant="h4" component="h1" gutterBottom align="center">
Site11 Console
</Typography>
<Typography variant="h6" component="h2" gutterBottom align="center" color="text.secondary">
Create Account
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField
fullWidth
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
margin="normal"
required
autoFocus
disabled={loading}
/>
<TextField
fullWidth
label="Username"
name="username"
value={formData.username}
onChange={handleChange}
margin="normal"
required
disabled={loading}
inputProps={{ minLength: 3, maxLength: 50 }}
/>
<TextField
fullWidth
label="Full Name"
name="full_name"
value={formData.full_name}
onChange={handleChange}
margin="normal"
disabled={loading}
/>
<TextField
fullWidth
label="Password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
margin="normal"
required
disabled={loading}
inputProps={{ minLength: 6 }}
/>
<TextField
fullWidth
label="Confirm Password"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
margin="normal"
required
disabled={loading}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? 'Creating account...' : 'Sign Up'}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2">
Already have an account?{' '}
<Link component={RouterLink} to="/login" underline="hover">
Sign In
</Link>
</Typography>
</Box>
</Box>
</Paper>
</Box>
</Container>
);
};
export default Register;

View File

@ -0,0 +1,98 @@
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
} from '@mui/material'
const servicesData = [
{
id: 1,
name: 'Console',
type: 'API Gateway',
port: 8011,
status: 'Running',
description: 'Central orchestrator and API gateway',
},
{
id: 2,
name: 'Users',
type: 'Microservice',
port: 8001,
status: 'Running',
description: 'User management service',
},
{
id: 3,
name: 'MongoDB',
type: 'Database',
port: 27017,
status: 'Running',
description: 'Document database for persistence',
},
{
id: 4,
name: 'Redis',
type: 'Cache',
port: 6379,
status: 'Running',
description: 'In-memory cache and pub/sub',
},
]
function Services() {
return (
<Box>
<Typography variant="h4" gutterBottom>
Services
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Service Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Port</TableCell>
<TableCell>Status</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{servicesData.map((service) => (
<TableRow key={service.id}>
<TableCell>
<Typography variant="subtitle2">{service.name}</Typography>
</TableCell>
<TableCell>
<Chip
label={service.type}
size="small"
color={service.type === 'API Gateway' ? 'primary' : 'default'}
/>
</TableCell>
<TableCell>{service.port}</TableCell>
<TableCell>
<Chip
label={service.status}
size="small"
color="success"
/>
</TableCell>
<TableCell>{service.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)
}
export default Services

View File

@ -0,0 +1,208 @@
import { useState, useEffect } from 'react'
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
IconButton,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
} from '@mui/material'
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from '@mui/icons-material'
import axios from 'axios'
interface User {
_id: string
username: string
email: string
full_name?: string
created_at: string
}
function Users() {
const [users, setUsers] = useState<User[]>([])
const [openDialog, setOpenDialog] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [formData, setFormData] = useState({
username: '',
email: '',
full_name: '',
})
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
const response = await axios.get('/api/users/')
setUsers(response.data)
} catch (error) {
console.error('Failed to fetch users:', error)
}
}
const handleOpenDialog = (user?: User) => {
if (user) {
setEditingUser(user)
setFormData({
username: user.username,
email: user.email,
full_name: user.full_name || '',
})
} else {
setEditingUser(null)
setFormData({
username: '',
email: '',
full_name: '',
})
}
setOpenDialog(true)
}
const handleCloseDialog = () => {
setOpenDialog(false)
setEditingUser(null)
setFormData({
username: '',
email: '',
full_name: '',
})
}
const handleSubmit = async () => {
try {
if (editingUser) {
await axios.put(`/api/users/${editingUser._id}`, formData)
} else {
await axios.post('/api/users/', formData)
}
fetchUsers()
handleCloseDialog()
} catch (error) {
console.error('Failed to save user:', error)
}
}
const handleDelete = async (id: string) => {
if (confirm('Are you sure you want to delete this user?')) {
try {
await axios.delete(`/api/users/${id}`)
fetchUsers()
} catch (error) {
console.error('Failed to delete user:', error)
}
}
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h4">
Users
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
Add User
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Full Name</TableCell>
<TableCell>Created At</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user._id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.full_name || '-'}</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => handleOpenDialog(user)}
>
<EditIcon />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(user._id)}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{editingUser ? 'Edit User' : 'Add New User'}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
fullWidth
required
/>
<TextField
label="Email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
fullWidth
required
/>
<TextField
label="Full Name"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
fullWidth
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained">
{editingUser ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
export default Users

View File

@ -0,0 +1,40 @@
export interface User {
_id: string;
email: string;
username: string;
full_name?: string;
role: 'admin' | 'editor' | 'viewer';
permissions: string[];
status: string;
is_active: boolean;
created_at: string;
last_login_at?: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
email: string;
username: string;
password: string;
full_name?: string;
}
export interface AuthTokens {
access_token: string;
refresh_token: string;
token_type: string;
}
export interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>;
register: (data: RegisterRequest) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<void>;
}

View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

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

View File

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

View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://console-backend:8000',
changeOrigin: true
}
}
}
})