fix: Security hardening, wizard bug fix, and UI redesign
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Fix QuickStartWizard error matching (services wrap errors in new Error(), losing err.response; now checks err.message as fallback) - Add shellEscape() to all remaining unescaped CLI commands (removeUser, enableUser, disableUser, deletePolicy, attachPolicy, createPolicy) - Fix inconsistent cookie secure flag in logout endpoint - Add HTML escaping in email report templates to prevent XSS - Add IP validation on token refresh to enforce IP binding - Harden email recipient validation against header injection - Add CSV injection prevention in report export - Add JWT_SECRET minimum length validation (32 chars) in production - Update bcrypt 5.x→6.x, nodemailer 6.x→8.x, fix all backend npm vulns - Redesign UI: new theme (IBM Plex Sans, MinIO-inspired palette), improved login page, dashboard with skeleton loaders, refined sidebar navigation - Add missing i18n keys for login and user menu (DE + EN) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+165
-487
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,15 @@
|
||||
"generate-password": "node generate-password.js",
|
||||
"postinstall": "npm rebuild bcrypt --build-from-source"
|
||||
},
|
||||
"keywords": ["minio", "api", "backend"],
|
||||
"keywords": [
|
||||
"minio",
|
||||
"api",
|
||||
"backend"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
@@ -30,11 +34,11 @@
|
||||
"ip-range-check": "^0.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.8",
|
||||
"nodemailer": "^8.0.2",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
"winston-daily-rotate-file": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.56.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const authService = require('../../services/auth.service');
|
||||
const config = require('../../config');
|
||||
const { logger, logAudit } = require('../../utils/logger');
|
||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||
|
||||
@@ -74,10 +75,10 @@ router.post('/login', validateLogin, handleValidationErrors, async (req, res, ne
|
||||
router.post('/logout', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Clear cookie
|
||||
// Clear cookie - use same options as login for consistency
|
||||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: config.app.env === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
@@ -128,8 +129,18 @@ router.post('/refresh', (req, res, next) => {
|
||||
|
||||
const decoded = authService.verifyToken(token);
|
||||
|
||||
// Create new token with same data
|
||||
const newSession = authService.createSession(decoded.ip || req.ip);
|
||||
// Validate IP binding on refresh
|
||||
if (decoded.ip && decoded.ip !== req.ip) {
|
||||
logAudit('TOKEN_REFRESH_REJECTED', {
|
||||
ip: req.ip,
|
||||
status: 'failed',
|
||||
details: { reason: 'IP mismatch', originalIp: decoded.ip },
|
||||
});
|
||||
throw new AppError('Token IP mismatch', 401);
|
||||
}
|
||||
|
||||
// Create new token with current IP
|
||||
const newSession = authService.createSession(req.ip);
|
||||
|
||||
// Set new cookie
|
||||
res.cookie('token', newSession.token, authService.getCookieOptions());
|
||||
|
||||
@@ -40,7 +40,13 @@ router.post('/generate',
|
||||
.isArray().withMessage('Recipients must be an array')
|
||||
.custom((value) => {
|
||||
if (value && value.length > 0) {
|
||||
return value.every(email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
return value.every(email =>
|
||||
emailRegex.test(email) &&
|
||||
!email.includes('\n') &&
|
||||
!email.includes('\r') &&
|
||||
email.length <= 254
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}).withMessage('Invalid email address in recipients'),
|
||||
@@ -129,13 +135,20 @@ router.get('/storage/export', async (req, res, next) => {
|
||||
const report = await reportService.generateReport();
|
||||
|
||||
if (format === 'csv') {
|
||||
// Sanitize CSV values to prevent CSV injection (formula injection)
|
||||
const csvSafe = (val) => {
|
||||
const str = String(val ?? '');
|
||||
if (/^[=+\-@\t\r]/.test(str)) return `'${str}`;
|
||||
return str.includes(',') || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str;
|
||||
};
|
||||
|
||||
const csv = [
|
||||
'Bucket Name,Size (Bytes),Size (Formatted),Objects,Last Modified',
|
||||
...report.buckets.map(b =>
|
||||
`"${b.name}",${b.size},"${b.sizeFormatted}",${b.objects},"${b.lastModified}"`
|
||||
`${csvSafe(b.name)},${b.size},${csvSafe(b.sizeFormatted)},${b.objects},${csvSafe(b.lastModified)}`
|
||||
),
|
||||
'',
|
||||
`Total,${report.summary.totalSize},"${report.summary.totalSizeFormatted}",,`,
|
||||
`Total,${report.summary.totalSize},${csvSafe(report.summary.totalSizeFormatted)},,`,
|
||||
].join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const MinIOService = require('../../services/minio.service');
|
||||
const shellEscape = MinIOService.shellEscape;
|
||||
const { authMiddleware } = require('../../middleware/auth.middleware');
|
||||
const { logger, logAudit } = require('../../utils/logger');
|
||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||
@@ -66,7 +67,7 @@ router.post('/',
|
||||
|
||||
// Create user without bucket (admin can assign policies later)
|
||||
await minioService.executeCommand(
|
||||
`mc admin user add ${minioService.alias} ${username} ${password}`
|
||||
`mc admin user add ${minioService.alias} ${shellEscape(username)} ${shellEscape(password)}`
|
||||
);
|
||||
|
||||
logAudit('USER_CREATE', {
|
||||
|
||||
@@ -72,6 +72,11 @@ const validateConfig = () => {
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required configuration: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate JWT secret strength
|
||||
if (config.auth.jwtSecret && config.auth.jwtSecret.length < 32) {
|
||||
throw new Error('JWT_SECRET must be at least 32 characters long for security');
|
||||
}
|
||||
};
|
||||
|
||||
// Only validate in production
|
||||
|
||||
@@ -145,7 +145,7 @@ class MinIOService {
|
||||
if (error.stderr.includes('Access Denied')) {
|
||||
throw new AppError('Access denied', 403);
|
||||
}
|
||||
if (error.stderr.includes('already exists')) {
|
||||
if (error.stderr.includes('already exists') || error.stderr.includes('already own')) {
|
||||
throw new AppError('Resource already exists', 409);
|
||||
}
|
||||
if (error.stderr.includes('policy')) {
|
||||
@@ -296,10 +296,10 @@ class MinIOService {
|
||||
|
||||
// Create and attach policy
|
||||
await this.executeCommand(
|
||||
`mc admin policy create ${this.alias} ${policyName} ${policyFile}`
|
||||
`mc admin policy create ${this.alias} ${shellEscape(policyName)} ${shellEscape(policyFile)}`
|
||||
);
|
||||
await this.executeCommand(
|
||||
`mc admin policy attach ${this.alias} ${policyName} --user ${username}`
|
||||
`mc admin policy attach ${this.alias} ${shellEscape(policyName)} --user ${shellEscape(username)}`
|
||||
);
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
@@ -338,17 +338,17 @@ class MinIOService {
|
||||
}
|
||||
|
||||
async removeUser(username) {
|
||||
await this.executeCommand(`mc admin user remove ${this.alias} ${username}`);
|
||||
await this.executeCommand(`mc admin user remove ${this.alias} ${shellEscape(username)}`);
|
||||
return { message: 'User removed successfully', username };
|
||||
}
|
||||
|
||||
async enableUser(username) {
|
||||
await this.executeCommand(`mc admin user enable ${this.alias} ${username}`);
|
||||
await this.executeCommand(`mc admin user enable ${this.alias} ${shellEscape(username)}`);
|
||||
return { message: 'User enabled successfully', username };
|
||||
}
|
||||
|
||||
async disableUser(username) {
|
||||
await this.executeCommand(`mc admin user disable ${this.alias} ${username}`);
|
||||
await this.executeCommand(`mc admin user disable ${this.alias} ${shellEscape(username)}`);
|
||||
return { message: 'User disabled successfully', username };
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ class MinIOService {
|
||||
await fs.access(policyFile, fsConstants.R_OK);
|
||||
|
||||
await this.executeCommand(
|
||||
`mc admin policy create ${this.alias} ${policyName} ${policyFile}`
|
||||
`mc admin policy create ${this.alias} ${shellEscape(policyName)} ${shellEscape(policyFile)}`
|
||||
);
|
||||
|
||||
return { message: 'Policy created successfully', policyName };
|
||||
@@ -417,13 +417,13 @@ class MinIOService {
|
||||
}
|
||||
|
||||
async deletePolicy(policyName) {
|
||||
await this.executeCommand(`mc admin policy remove ${this.alias} ${policyName}`);
|
||||
await this.executeCommand(`mc admin policy remove ${this.alias} ${shellEscape(policyName)}`);
|
||||
return { message: 'Policy deleted successfully', policyName };
|
||||
}
|
||||
|
||||
async attachPolicy(policyName, username) {
|
||||
await this.executeCommand(
|
||||
`mc admin policy attach ${this.alias} ${policyName} --user ${username}`
|
||||
`mc admin policy attach ${this.alias} ${shellEscape(policyName)} --user ${shellEscape(username)}`
|
||||
);
|
||||
return { message: 'Policy attached successfully', policyName, username };
|
||||
}
|
||||
@@ -594,7 +594,7 @@ class MinIOService {
|
||||
throw new AppError('Invalid alias name', 400);
|
||||
}
|
||||
|
||||
await this.executeCommand(`mc alias remove ${aliasName}`);
|
||||
await this.executeCommand(`mc alias remove ${shellEscape(aliasName)}`);
|
||||
return { message: 'Alias removed successfully', aliasName };
|
||||
}
|
||||
|
||||
@@ -831,3 +831,4 @@ class MinIOService {
|
||||
}
|
||||
|
||||
module.exports = MinIOService;
|
||||
module.exports.shellEscape = shellEscape;
|
||||
@@ -4,6 +4,17 @@ const MinIOService = require('./minio.service');
|
||||
const config = require('../config');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
// Escape HTML entities to prevent XSS in email reports
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
class ReportService {
|
||||
constructor() {
|
||||
this.minioService = new MinIOService();
|
||||
@@ -170,10 +181,10 @@ class ReportService {
|
||||
<tbody>
|
||||
${report.buckets.map(bucket => `
|
||||
<tr>
|
||||
<td>${bucket.name}</td>
|
||||
<td>${bucket.sizeFormatted}</td>
|
||||
<td>${bucket.objects}</td>
|
||||
<td>${bucket.lastModified}</td>
|
||||
<td>${escapeHtml(bucket.name)}</td>
|
||||
<td>${escapeHtml(bucket.sizeFormatted)}</td>
|
||||
<td>${escapeHtml(bucket.objects)}</td>
|
||||
<td>${escapeHtml(bucket.lastModified)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
@@ -181,7 +192,7 @@ class ReportService {
|
||||
|
||||
<h2>Benutzer</h2>
|
||||
<ul>
|
||||
${report.users.map(user => `<li>${user}</li>`).join('')}
|
||||
${report.users.map(user => `<li>${escapeHtml(user)}</li>`).join('')}
|
||||
</ul>
|
||||
|
||||
<div class="footer">
|
||||
|
||||
Generated
+310
-322
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"ajv": "^8.12.0",
|
||||
"axios": "^1.6.5",
|
||||
"axios": "^1.13.6",
|
||||
"chart.js": "^4.4.1",
|
||||
"date-fns": "^3.2.0",
|
||||
"i18next": "^23.7.6",
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
content="MinIO WebUI - Simple management interface for MinIO"
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
LockOutlined,
|
||||
Storage,
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import useAuthStore from '../../store/authStore';
|
||||
import { handleApiError } from '../../services/api';
|
||||
@@ -26,6 +28,7 @@ interface LoginForm {
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const navigate = useNavigate();
|
||||
const { login, loading } = useAuthStore();
|
||||
const [error, setError] = useState('');
|
||||
@@ -48,28 +51,46 @@ const Login: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #1A1D29 0%, #2D3142 50%, #C72C41 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-50%',
|
||||
right: '-30%',
|
||||
width: '80%',
|
||||
height: '150%',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(ellipse, rgba(199,44,65,0.15) 0%, transparent 70%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Paper
|
||||
elevation={3}
|
||||
elevation={0}
|
||||
sx={{
|
||||
padding: 4,
|
||||
padding: { xs: 3, sm: 5 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
backdropFilter: 'blur(20px)',
|
||||
backgroundColor: 'rgba(255,255,255,0.97)',
|
||||
border: '1px solid rgba(224,227,235,0.5)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
mb: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -77,23 +98,24 @@ const Login: React.FC = () => {
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'primary.main',
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, #C72C41 0%, #9B1B30 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 2,
|
||||
mb: 2.5,
|
||||
boxShadow: '0 4px 16px rgba(199,44,65,0.35)',
|
||||
}}
|
||||
>
|
||||
<LockOutlined sx={{ color: 'white', fontSize: 30 }} />
|
||||
<Storage sx={{ color: 'white', fontSize: 32 }} />
|
||||
</Box>
|
||||
<Typography component="h1" variant="h5">
|
||||
MinIO WebUI
|
||||
<Typography component="h1" variant="h5" sx={{ fontWeight: 700, color: 'secondary.dark' }}>
|
||||
{t('common:login.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Sign in to continue
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{t('common:login.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -110,13 +132,13 @@ const Login: React.FC = () => {
|
||||
>
|
||||
<TextField
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
required: t('common:login.passwordRequired'),
|
||||
})}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Admin Password"
|
||||
label={t('common:login.password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
@@ -124,14 +146,20 @@ const Login: React.FC = () => {
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<LockOutlined sx={{ color: 'text.secondary', fontSize: 20 }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
size="small"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
{showPassword ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
@@ -142,13 +170,14 @@ const Login: React.FC = () => {
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
size="large"
|
||||
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Sign In'
|
||||
t('common:login.signIn')
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -157,13 +186,13 @@ const Login: React.FC = () => {
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
sx={{ mt: 2 }}
|
||||
sx={{ mt: 1, fontSize: '0.8rem' }}
|
||||
>
|
||||
Secure access for administrators only
|
||||
{t('common:login.secureAccess')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
Button,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Storage as StorageIcon,
|
||||
@@ -32,37 +32,43 @@ interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactElement;
|
||||
color: string;
|
||||
gradient: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, color }) => (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, gradient, loading }) => (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||
{loading ? (
|
||||
<Box>
|
||||
<Skeleton variant="circular" width={44} height={44} sx={{ mb: 2 }} />
|
||||
<Skeleton variant="text" width="60%" height={20} />
|
||||
<Skeleton variant="text" width="40%" height={32} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${color}.light`,
|
||||
color: `${color}.main`,
|
||||
mr: 2,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: '12px',
|
||||
background: gradient,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{React.cloneElement(icon, { sx: { color: 'white', fontSize: 22 } })}
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5, fontWeight: 500 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h5" component="div">
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -102,17 +108,6 @@ const Dashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ width: '100%', mt: 4 }}>
|
||||
<LinearProgress />
|
||||
<Typography sx={{ textAlign: 'center', mt: 2 }}>
|
||||
{t('common:status.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
@@ -123,12 +118,19 @@ const Dashboard: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
mb={3}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{t('dashboard:title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('dashboard:subtitle', 'Welcome to MinIO WebUI. Monitor your storage infrastructure at a glance.')}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -138,18 +140,20 @@ const Dashboard: React.FC = () => {
|
||||
startIcon={<RocketIcon />}
|
||||
onClick={() => setWizardOpen(true)}
|
||||
size="large"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{t('dashboard:quickStart.button')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard
|
||||
title={t('dashboard:stats.totalBuckets')}
|
||||
value={stats?.totalBuckets || 0}
|
||||
icon={<FolderIcon />}
|
||||
color="primary"
|
||||
gradient="linear-gradient(135deg, #C72C41 0%, #E8445A 100%)"
|
||||
loading={loading}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
@@ -157,7 +161,8 @@ const Dashboard: React.FC = () => {
|
||||
title={t('dashboard:stats.totalUsers')}
|
||||
value={stats?.totalUsers || 0}
|
||||
icon={<PeopleIcon />}
|
||||
color="secondary"
|
||||
gradient="linear-gradient(135deg, #2D3142 0%, #4F5D75 100%)"
|
||||
loading={loading}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
@@ -165,7 +170,8 @@ const Dashboard: React.FC = () => {
|
||||
title={t('dashboard:stats.totalStorage')}
|
||||
value={stats?.totalSizeFormatted || '0 B'}
|
||||
icon={<StorageIcon />}
|
||||
color="success"
|
||||
gradient="linear-gradient(135deg, #2E7D32 0%, #4CAF50 100%)"
|
||||
loading={loading}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
@@ -173,7 +179,8 @@ const Dashboard: React.FC = () => {
|
||||
title={t('dashboard:stats.performance')}
|
||||
value="Optimal"
|
||||
icon={<SpeedIcon />}
|
||||
color="info"
|
||||
gradient="linear-gradient(135deg, #0288D1 0%, #03A9F4 100%)"
|
||||
loading={loading}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -147,6 +147,17 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
||||
let policyCreated = false;
|
||||
|
||||
try {
|
||||
// Helper to check if error indicates resource already exists
|
||||
const isAlreadyExistsError = (err: any): boolean => {
|
||||
const msg = (
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
''
|
||||
).toLowerCase();
|
||||
return msg.includes('already exists') || msg.includes('already own');
|
||||
};
|
||||
|
||||
// Step 1: Create bucket (skip if already exists)
|
||||
try {
|
||||
await api.post('/buckets', { bucketName: setupData.bucketName }, {
|
||||
@@ -154,9 +165,7 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
||||
});
|
||||
bucketCreated = true;
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.error?.includes('already own it') ||
|
||||
err.response?.data?.error?.includes('BucketAlreadyOwnedByYou')) {
|
||||
// Bucket already exists, that's ok
|
||||
if (isAlreadyExistsError(err)) {
|
||||
bucketCreated = true;
|
||||
} else {
|
||||
throw err;
|
||||
@@ -171,9 +180,7 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
||||
});
|
||||
userCreated = true;
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.error?.includes('already exists') ||
|
||||
err.response?.data?.error?.includes('UserAlreadyExists')) {
|
||||
// User already exists, that's ok
|
||||
if (isAlreadyExistsError(err)) {
|
||||
userCreated = true;
|
||||
} else {
|
||||
throw err;
|
||||
@@ -202,9 +209,7 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
||||
});
|
||||
policyCreated = true;
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.error?.includes('already exists') ||
|
||||
err.response?.data?.error?.includes('PolicyAlreadyExists')) {
|
||||
// Policy already exists, that's ok
|
||||
if (isAlreadyExistsError(err)) {
|
||||
policyCreated = true;
|
||||
} else {
|
||||
throw err;
|
||||
|
||||
@@ -30,13 +30,12 @@ import {
|
||||
Policy as PolicyIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Logout as LogoutIcon,
|
||||
AccountCircle as AccountCircleIcon,
|
||||
Language as LanguageIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useAuthStore from '../../store/authStore';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const drawerWidth = 260;
|
||||
|
||||
interface NavItem {
|
||||
text: string;
|
||||
@@ -83,6 +82,11 @@ const Layout: React.FC = () => {
|
||||
i18n.changeLanguage(event.target.value);
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', width: '100%' }}>
|
||||
<AppBar
|
||||
@@ -90,6 +94,8 @@ const Layout: React.FC = () => {
|
||||
sx={{
|
||||
width: `calc(100% - ${open ? drawerWidth : 0}px)`,
|
||||
ml: `${open ? drawerWidth : 0}px`,
|
||||
backgroundColor: 'background.paper',
|
||||
color: 'text.primary',
|
||||
transition: (theme) =>
|
||||
theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
@@ -108,35 +114,24 @@ const Layout: React.FC = () => {
|
||||
{open ? <ChevronLeftIcon /> : <MenuIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{t('common:appName')}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<FormControl size="small">
|
||||
<Select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
displayEmpty
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontSize: '0.85rem',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'white',
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
color: 'white',
|
||||
borderColor: 'divider',
|
||||
},
|
||||
}}
|
||||
startAdornment={<LanguageIcon sx={{ mr: 1, color: 'white' }} />}
|
||||
startAdornment={<LanguageIcon sx={{ mr: 0.5, fontSize: 18, color: 'text.secondary' }} />}
|
||||
>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="de">DE</MenuItem>
|
||||
<MenuItem value="en">EN</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -144,13 +139,20 @@ const Layout: React.FC = () => {
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
aria-controls={Boolean(anchorEl) ? 'account-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={Boolean(anchorEl) ? 'true' : undefined}
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
<AccountCircleIcon />
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
backgroundColor: 'primary.main',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
A
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -166,14 +168,8 @@ const Layout: React.FC = () => {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.15))',
|
||||
mt: 1.5,
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
@@ -181,7 +177,7 @@ const Layout: React.FC = () => {
|
||||
>
|
||||
<MenuItem disabled>
|
||||
<Typography variant="body2">
|
||||
Logged in as {user?.role || 'Admin'}
|
||||
{t('common:userMenu.loggedInAs', { role: user?.role || 'Admin' })}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
@@ -202,6 +198,7 @@ const Layout: React.FC = () => {
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
backgroundColor: '#FAFBFD',
|
||||
},
|
||||
}}
|
||||
variant="persistent"
|
||||
@@ -212,25 +209,49 @@ const Layout: React.FC = () => {
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: (theme) => theme.spacing(0, 1),
|
||||
...((theme) => theme.mixins.toolbar),
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
MinIO Manager
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #C72C41 0%, #9B1B30 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<StorageIcon sx={{ color: 'white', fontSize: 20 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2, color: 'secondary.dark' }}>
|
||||
MinIO
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1 }}>
|
||||
WebUI
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List>
|
||||
<List sx={{ px: 0.5, pt: 1.5 }}>
|
||||
{navItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItem key={item.text} disablePadding sx={{ mb: 0.25 }}>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')}
|
||||
selected={isActive(item.path)}
|
||||
onClick={() => navigate(item.path)}
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={t(`common:${item.translationKey}`)} />
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t(`common:${item.translationKey}`)}
|
||||
primaryTypographyProps={{ fontSize: '0.9rem', fontWeight: isActive(item.path) ? 600 : 400 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
@@ -249,6 +270,8 @@ const Layout: React.FC = () => {
|
||||
}),
|
||||
marginLeft: open ? 0 : `-${drawerWidth}px`,
|
||||
mt: 8,
|
||||
minHeight: 'calc(100vh - 64px)',
|
||||
backgroundColor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
+160
-3
@@ -9,20 +9,177 @@ const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
main: '#C72C41',
|
||||
light: '#E8445A',
|
||||
dark: '#9B1B30',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
main: '#2D3142',
|
||||
light: '#4F5D75',
|
||||
dark: '#1B1F2E',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
background: {
|
||||
default: '#F5F6FA',
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
success: {
|
||||
main: '#2E7D32',
|
||||
light: '#4CAF50',
|
||||
},
|
||||
warning: {
|
||||
main: '#ED6C02',
|
||||
light: '#FF9800',
|
||||
},
|
||||
error: {
|
||||
main: '#D32F2F',
|
||||
light: '#EF5350',
|
||||
},
|
||||
info: {
|
||||
main: '#0288D1',
|
||||
light: '#03A9F4',
|
||||
},
|
||||
text: {
|
||||
primary: '#1A1D29',
|
||||
secondary: '#5A6178',
|
||||
},
|
||||
divider: '#E0E3EB',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Roboto, Arial, sans-serif',
|
||||
fontFamily: '"IBM Plex Sans", "Segoe UI", Roboto, sans-serif',
|
||||
h4: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
subtitle1: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
body2: {
|
||||
color: '#5A6178',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 10,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderRadius: 8,
|
||||
padding: '8px 20px',
|
||||
},
|
||||
contained: {
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0 2px 8px rgba(199, 44, 65, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 14,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
border: '1px solid #E0E3EB',
|
||||
transition: 'box-shadow 0.2s ease, transform 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 14,
|
||||
},
|
||||
elevation0: {
|
||||
border: '1px solid #E0E3EB',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRight: '1px solid #E0E3EB',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
margin: '2px 8px',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(199, 44, 65, 0.08)',
|
||||
color: '#C72C41',
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: '#C72C41',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(199, 44, 65, 0.12)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiTableCell-head': {
|
||||
fontWeight: 600,
|
||||
backgroundColor: '#F5F6FA',
|
||||
color: '#2D3142',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -38,5 +38,16 @@
|
||||
"title": "Bestätigung erforderlich",
|
||||
"deleteMessage": "Sind Sie sicher, dass Sie {{item}} löschen möchten?",
|
||||
"cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"login": {
|
||||
"title": "MinIO WebUI",
|
||||
"subtitle": "Anmelden, um fortzufahren",
|
||||
"password": "Admin-Passwort",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"signIn": "Anmelden",
|
||||
"secureAccess": "Sicherer Zugang nur für Administratoren"
|
||||
},
|
||||
"userMenu": {
|
||||
"loggedInAs": "Angemeldet als {{role}}"
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,16 @@
|
||||
"title": "Confirm Action",
|
||||
"deleteMessage": "Are you sure you want to delete {{item}}?",
|
||||
"cannotUndo": "This action cannot be undone."
|
||||
},
|
||||
"login": {
|
||||
"title": "MinIO WebUI",
|
||||
"subtitle": "Sign in to continue",
|
||||
"password": "Admin Password",
|
||||
"passwordRequired": "Password is required",
|
||||
"signIn": "Sign In",
|
||||
"secureAccess": "Secure access for administrators only"
|
||||
},
|
||||
"userMenu": {
|
||||
"loggedInAs": "Logged in as {{role}}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user