fix: Security hardening, wizard bug fix, and UI redesign
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:
2026-03-10 10:24:57 +01:00
parent 8a2a9d8887
commit a96f12dcb6
18 changed files with 942 additions and 985 deletions
+165 -487
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -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",
+15 -4
View File
@@ -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());
+16 -3
View File
@@ -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');
+2 -1
View File
@@ -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', {
+5
View File
@@ -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
+11 -10
View File
@@ -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;
+16 -5
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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">
+310 -322
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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",
+3 -1
View File
@@ -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"
+53 -24
View File
@@ -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>
);
};
+43 -36
View File
@@ -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;
+65 -42
View File
@@ -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
View File
@@ -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,
},
},
},
+11
View File
@@ -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}}"
}
}
+11
View File
@@ -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}}"
}
}