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,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