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",
|
"generate-password": "node generate-password.js",
|
||||||
"postinstall": "npm rebuild bcrypt --build-from-source"
|
"postinstall": "npm rebuild bcrypt --build-from-source"
|
||||||
},
|
},
|
||||||
"keywords": ["minio", "api", "backend"],
|
"keywords": [
|
||||||
|
"minio",
|
||||||
|
"api",
|
||||||
|
"backend"
|
||||||
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^6.0.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -30,11 +34,11 @@
|
|||||||
"ip-range-check": "^0.2.0",
|
"ip-range-check": "^0.2.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^8.0.2",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"winston-daily-rotate-file": "^4.7.1",
|
"winston-daily-rotate-file": "^4.7.1"
|
||||||
"multer": "^1.4.5-lts.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const authService = require('../../services/auth.service');
|
const authService = require('../../services/auth.service');
|
||||||
|
const config = require('../../config');
|
||||||
const { logger, logAudit } = require('../../utils/logger');
|
const { logger, logAudit } = require('../../utils/logger');
|
||||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||||
|
|
||||||
@@ -74,10 +75,10 @@ router.post('/login', validateLogin, handleValidationErrors, async (req, res, ne
|
|||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', (req, res) => {
|
||||||
const clientIp = req.ip;
|
const clientIp = req.ip;
|
||||||
|
|
||||||
// Clear cookie
|
// Clear cookie - use same options as login for consistency
|
||||||
res.clearCookie('token', {
|
res.clearCookie('token', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: config.app.env === 'production',
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
@@ -128,8 +129,18 @@ router.post('/refresh', (req, res, next) => {
|
|||||||
|
|
||||||
const decoded = authService.verifyToken(token);
|
const decoded = authService.verifyToken(token);
|
||||||
|
|
||||||
// Create new token with same data
|
// Validate IP binding on refresh
|
||||||
const newSession = authService.createSession(decoded.ip || req.ip);
|
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
|
// Set new cookie
|
||||||
res.cookie('token', newSession.token, authService.getCookieOptions());
|
res.cookie('token', newSession.token, authService.getCookieOptions());
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ router.post('/generate',
|
|||||||
.isArray().withMessage('Recipients must be an array')
|
.isArray().withMessage('Recipients must be an array')
|
||||||
.custom((value) => {
|
.custom((value) => {
|
||||||
if (value && value.length > 0) {
|
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;
|
return true;
|
||||||
}).withMessage('Invalid email address in recipients'),
|
}).withMessage('Invalid email address in recipients'),
|
||||||
@@ -129,13 +135,20 @@ router.get('/storage/export', async (req, res, next) => {
|
|||||||
const report = await reportService.generateReport();
|
const report = await reportService.generateReport();
|
||||||
|
|
||||||
if (format === 'csv') {
|
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 = [
|
const csv = [
|
||||||
'Bucket Name,Size (Bytes),Size (Formatted),Objects,Last Modified',
|
'Bucket Name,Size (Bytes),Size (Formatted),Objects,Last Modified',
|
||||||
...report.buckets.map(b =>
|
...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');
|
].join('\n');
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, param, validationResult } = require('express-validator');
|
const { body, param, validationResult } = require('express-validator');
|
||||||
const MinIOService = require('../../services/minio.service');
|
const MinIOService = require('../../services/minio.service');
|
||||||
|
const shellEscape = MinIOService.shellEscape;
|
||||||
const { authMiddleware } = require('../../middleware/auth.middleware');
|
const { authMiddleware } = require('../../middleware/auth.middleware');
|
||||||
const { logger, logAudit } = require('../../utils/logger');
|
const { logger, logAudit } = require('../../utils/logger');
|
||||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||||
@@ -66,7 +67,7 @@ router.post('/',
|
|||||||
|
|
||||||
// Create user without bucket (admin can assign policies later)
|
// Create user without bucket (admin can assign policies later)
|
||||||
await minioService.executeCommand(
|
await minioService.executeCommand(
|
||||||
`mc admin user add ${minioService.alias} ${username} ${password}`
|
`mc admin user add ${minioService.alias} ${shellEscape(username)} ${shellEscape(password)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
logAudit('USER_CREATE', {
|
logAudit('USER_CREATE', {
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ const validateConfig = () => {
|
|||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new Error(`Missing required configuration: ${missing.join(', ')}`);
|
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
|
// Only validate in production
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class MinIOService {
|
|||||||
if (error.stderr.includes('Access Denied')) {
|
if (error.stderr.includes('Access Denied')) {
|
||||||
throw new AppError('Access denied', 403);
|
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);
|
throw new AppError('Resource already exists', 409);
|
||||||
}
|
}
|
||||||
if (error.stderr.includes('policy')) {
|
if (error.stderr.includes('policy')) {
|
||||||
@@ -296,10 +296,10 @@ class MinIOService {
|
|||||||
|
|
||||||
// Create and attach policy
|
// Create and attach policy
|
||||||
await this.executeCommand(
|
await this.executeCommand(
|
||||||
`mc admin policy create ${this.alias} ${policyName} ${policyFile}`
|
`mc admin policy create ${this.alias} ${shellEscape(policyName)} ${shellEscape(policyFile)}`
|
||||||
);
|
);
|
||||||
await this.executeCommand(
|
await this.executeCommand(
|
||||||
`mc admin policy attach ${this.alias} ${policyName} --user ${username}`
|
`mc admin policy attach ${this.alias} ${shellEscape(policyName)} --user ${shellEscape(username)}`
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up temp file
|
// Clean up temp file
|
||||||
@@ -338,17 +338,17 @@ class MinIOService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeUser(username) {
|
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 };
|
return { message: 'User removed successfully', username };
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableUser(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 };
|
return { message: 'User enabled successfully', username };
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableUser(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 };
|
return { message: 'User disabled successfully', username };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +402,7 @@ class MinIOService {
|
|||||||
await fs.access(policyFile, fsConstants.R_OK);
|
await fs.access(policyFile, fsConstants.R_OK);
|
||||||
|
|
||||||
await this.executeCommand(
|
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 };
|
return { message: 'Policy created successfully', policyName };
|
||||||
@@ -417,13 +417,13 @@ class MinIOService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deletePolicy(policyName) {
|
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 };
|
return { message: 'Policy deleted successfully', policyName };
|
||||||
}
|
}
|
||||||
|
|
||||||
async attachPolicy(policyName, username) {
|
async attachPolicy(policyName, username) {
|
||||||
await this.executeCommand(
|
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 };
|
return { message: 'Policy attached successfully', policyName, username };
|
||||||
}
|
}
|
||||||
@@ -594,7 +594,7 @@ class MinIOService {
|
|||||||
throw new AppError('Invalid alias name', 400);
|
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 };
|
return { message: 'Alias removed successfully', aliasName };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,3 +831,4 @@ class MinIOService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = MinIOService;
|
module.exports = MinIOService;
|
||||||
|
module.exports.shellEscape = shellEscape;
|
||||||
@@ -4,6 +4,17 @@ const MinIOService = require('./minio.service');
|
|||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const { logger } = require('../utils/logger');
|
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 {
|
class ReportService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.minioService = new MinIOService();
|
this.minioService = new MinIOService();
|
||||||
@@ -170,10 +181,10 @@ class ReportService {
|
|||||||
<tbody>
|
<tbody>
|
||||||
${report.buckets.map(bucket => `
|
${report.buckets.map(bucket => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${bucket.name}</td>
|
<td>${escapeHtml(bucket.name)}</td>
|
||||||
<td>${bucket.sizeFormatted}</td>
|
<td>${escapeHtml(bucket.sizeFormatted)}</td>
|
||||||
<td>${bucket.objects}</td>
|
<td>${escapeHtml(bucket.objects)}</td>
|
||||||
<td>${bucket.lastModified}</td>
|
<td>${escapeHtml(bucket.lastModified)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -181,7 +192,7 @@ class ReportService {
|
|||||||
|
|
||||||
<h2>Benutzer</h2>
|
<h2>Benutzer</h2>
|
||||||
<ul>
|
<ul>
|
||||||
${report.users.map(user => `<li>${user}</li>`).join('')}
|
${report.users.map(user => `<li>${escapeHtml(user)}</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="footer">
|
<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": "^18.2.47",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.13.6",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"date-fns": "^3.2.0",
|
"date-fns": "^3.2.0",
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
content="MinIO WebUI - Simple management interface for MinIO"
|
content="MinIO WebUI - Simple management interface for MinIO"
|
||||||
/>
|
/>
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<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
|
<link
|
||||||
rel="stylesheet"
|
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
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
Visibility,
|
Visibility,
|
||||||
VisibilityOff,
|
VisibilityOff,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
|
Storage,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import useAuthStore from '../../store/authStore';
|
import useAuthStore from '../../store/authStore';
|
||||||
import { handleApiError } from '../../services/api';
|
import { handleApiError } from '../../services/api';
|
||||||
@@ -26,6 +28,7 @@ interface LoginForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
|
const { t } = useTranslation(['common']);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login, loading } = useAuthStore();
|
const { login, loading } = useAuthStore();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -48,28 +51,46 @@ const Login: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container component="main" maxWidth="xs">
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
marginTop: 8,
|
minHeight: '100vh',
|
||||||
|
width: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
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
|
<Paper
|
||||||
elevation={3}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
padding: 4,
|
padding: { xs: 3, sm: 5 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
mb: 3,
|
mb: 4,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -77,23 +98,24 @@ const Login: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 56,
|
width: 64,
|
||||||
height: 56,
|
height: 64,
|
||||||
borderRadius: '50%',
|
borderRadius: '16px',
|
||||||
backgroundColor: 'primary.main',
|
background: 'linear-gradient(135deg, #C72C41 0%, #9B1B30 100%)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: '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>
|
</Box>
|
||||||
<Typography component="h1" variant="h5">
|
<Typography component="h1" variant="h5" sx={{ fontWeight: 700, color: 'secondary.dark' }}>
|
||||||
MinIO WebUI
|
{t('common:login.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
Sign in to continue
|
{t('common:login.subtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -110,13 +132,13 @@ const Login: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('password', {
|
{...register('password', {
|
||||||
required: 'Password is required',
|
required: t('common:login.passwordRequired'),
|
||||||
})}
|
})}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
name="password"
|
name="password"
|
||||||
label="Admin Password"
|
label={t('common:login.password')}
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
id="password"
|
id="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
@@ -124,14 +146,20 @@ const Login: React.FC = () => {
|
|||||||
error={!!errors.password}
|
error={!!errors.password}
|
||||||
helperText={errors.password?.message}
|
helperText={errors.password?.message}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<LockOutlined sx={{ color: 'text.secondary', fontSize: 20 }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="toggle password visibility"
|
aria-label="toggle password visibility"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
edge="end"
|
edge="end"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
{showPassword ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
@@ -142,13 +170,14 @@ const Login: React.FC = () => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{ mt: 3, mb: 2 }}
|
size="large"
|
||||||
|
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<CircularProgress size={24} color="inherit" />
|
<CircularProgress size={24} color="inherit" />
|
||||||
) : (
|
) : (
|
||||||
'Sign In'
|
t('common:login.signIn')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -157,13 +186,13 @@ const Login: React.FC = () => {
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
align="center"
|
align="center"
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 1, fontSize: '0.8rem' }}
|
||||||
>
|
>
|
||||||
Secure access for administrators only
|
{t('common:login.secureAccess')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
|
||||||
</Container>
|
</Container>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Alert,
|
Alert,
|
||||||
Paper,
|
|
||||||
Button,
|
Button,
|
||||||
|
Skeleton,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Storage as StorageIcon,
|
Storage as StorageIcon,
|
||||||
@@ -32,37 +32,43 @@ interface StatCardProps {
|
|||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
icon: React.ReactElement;
|
icon: React.ReactElement;
|
||||||
color: string;
|
gradient: string;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, color }) => (
|
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, gradient, loading }) => (
|
||||||
<Card>
|
<Card sx={{ height: '100%' }}>
|
||||||
<CardContent>
|
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
{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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
width: 48,
|
width: 44,
|
||||||
height: 48,
|
height: 44,
|
||||||
borderRadius: '50%',
|
borderRadius: '12px',
|
||||||
backgroundColor: `${color}.light`,
|
background: gradient,
|
||||||
color: `${color}.main`,
|
mb: 2,
|
||||||
mr: 2,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{React.cloneElement(icon, { sx: { color: 'white', fontSize: 22 } })}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5, fontWeight: 500 }}>
|
||||||
<Typography color="text.secondary" variant="body2">
|
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" component="div">
|
<Typography variant="h5" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</>
|
||||||
</Box>
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Alert severity="error" sx={{ mt: 2 }}>
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
@@ -123,12 +118,19 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<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>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
{t('dashboard:title')}
|
{t('dashboard:title')}
|
||||||
</Typography>
|
</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.')}
|
{t('dashboard:subtitle', 'Welcome to MinIO WebUI. Monitor your storage infrastructure at a glance.')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -138,18 +140,20 @@ const Dashboard: React.FC = () => {
|
|||||||
startIcon={<RocketIcon />}
|
startIcon={<RocketIcon />}
|
||||||
onClick={() => setWizardOpen(true)}
|
onClick={() => setWizardOpen(true)}
|
||||||
size="large"
|
size="large"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
{t('dashboard:quickStart.button')}
|
{t('dashboard:quickStart.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={2.5}>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('dashboard:stats.totalBuckets')}
|
title={t('dashboard:stats.totalBuckets')}
|
||||||
value={stats?.totalBuckets || 0}
|
value={stats?.totalBuckets || 0}
|
||||||
icon={<FolderIcon />}
|
icon={<FolderIcon />}
|
||||||
color="primary"
|
gradient="linear-gradient(135deg, #C72C41 0%, #E8445A 100%)"
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
@@ -157,7 +161,8 @@ const Dashboard: React.FC = () => {
|
|||||||
title={t('dashboard:stats.totalUsers')}
|
title={t('dashboard:stats.totalUsers')}
|
||||||
value={stats?.totalUsers || 0}
|
value={stats?.totalUsers || 0}
|
||||||
icon={<PeopleIcon />}
|
icon={<PeopleIcon />}
|
||||||
color="secondary"
|
gradient="linear-gradient(135deg, #2D3142 0%, #4F5D75 100%)"
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
@@ -165,7 +170,8 @@ const Dashboard: React.FC = () => {
|
|||||||
title={t('dashboard:stats.totalStorage')}
|
title={t('dashboard:stats.totalStorage')}
|
||||||
value={stats?.totalSizeFormatted || '0 B'}
|
value={stats?.totalSizeFormatted || '0 B'}
|
||||||
icon={<StorageIcon />}
|
icon={<StorageIcon />}
|
||||||
color="success"
|
gradient="linear-gradient(135deg, #2E7D32 0%, #4CAF50 100%)"
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
@@ -173,7 +179,8 @@ const Dashboard: React.FC = () => {
|
|||||||
title={t('dashboard:stats.performance')}
|
title={t('dashboard:stats.performance')}
|
||||||
value="Optimal"
|
value="Optimal"
|
||||||
icon={<SpeedIcon />}
|
icon={<SpeedIcon />}
|
||||||
color="info"
|
gradient="linear-gradient(135deg, #0288D1 0%, #03A9F4 100%)"
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -147,6 +147,17 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
|||||||
let policyCreated = false;
|
let policyCreated = false;
|
||||||
|
|
||||||
try {
|
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)
|
// Step 1: Create bucket (skip if already exists)
|
||||||
try {
|
try {
|
||||||
await api.post('/buckets', { bucketName: setupData.bucketName }, {
|
await api.post('/buckets', { bucketName: setupData.bucketName }, {
|
||||||
@@ -154,9 +165,7 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
|||||||
});
|
});
|
||||||
bucketCreated = true;
|
bucketCreated = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.response?.data?.error?.includes('already own it') ||
|
if (isAlreadyExistsError(err)) {
|
||||||
err.response?.data?.error?.includes('BucketAlreadyOwnedByYou')) {
|
|
||||||
// Bucket already exists, that's ok
|
|
||||||
bucketCreated = true;
|
bucketCreated = true;
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -171,9 +180,7 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
|||||||
});
|
});
|
||||||
userCreated = true;
|
userCreated = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.response?.data?.error?.includes('already exists') ||
|
if (isAlreadyExistsError(err)) {
|
||||||
err.response?.data?.error?.includes('UserAlreadyExists')) {
|
|
||||||
// User already exists, that's ok
|
|
||||||
userCreated = true;
|
userCreated = true;
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -202,9 +209,7 @@ const QuickStartWizard: React.FC<QuickStartWizardProps> = ({
|
|||||||
});
|
});
|
||||||
policyCreated = true;
|
policyCreated = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.response?.data?.error?.includes('already exists') ||
|
if (isAlreadyExistsError(err)) {
|
||||||
err.response?.data?.error?.includes('PolicyAlreadyExists')) {
|
|
||||||
// Policy already exists, that's ok
|
|
||||||
policyCreated = true;
|
policyCreated = true;
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -30,13 +30,12 @@ import {
|
|||||||
Policy as PolicyIcon,
|
Policy as PolicyIcon,
|
||||||
Assessment as AssessmentIcon,
|
Assessment as AssessmentIcon,
|
||||||
Logout as LogoutIcon,
|
Logout as LogoutIcon,
|
||||||
AccountCircle as AccountCircleIcon,
|
|
||||||
Language as LanguageIcon,
|
Language as LanguageIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import useAuthStore from '../../store/authStore';
|
import useAuthStore from '../../store/authStore';
|
||||||
|
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 260;
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -83,6 +82,11 @@ const Layout: React.FC = () => {
|
|||||||
i18n.changeLanguage(event.target.value);
|
i18n.changeLanguage(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') return location.pathname === '/';
|
||||||
|
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', width: '100%' }}>
|
<Box sx={{ display: 'flex', width: '100%' }}>
|
||||||
<AppBar
|
<AppBar
|
||||||
@@ -90,6 +94,8 @@ const Layout: React.FC = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: `calc(100% - ${open ? drawerWidth : 0}px)`,
|
width: `calc(100% - ${open ? drawerWidth : 0}px)`,
|
||||||
ml: `${open ? drawerWidth : 0}px`,
|
ml: `${open ? drawerWidth : 0}px`,
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
color: 'text.primary',
|
||||||
transition: (theme) =>
|
transition: (theme) =>
|
||||||
theme.transitions.create(['margin', 'width'], {
|
theme.transitions.create(['margin', 'width'], {
|
||||||
easing: theme.transitions.easing.sharp,
|
easing: theme.transitions.easing.sharp,
|
||||||
@@ -108,35 +114,24 @@ const Layout: React.FC = () => {
|
|||||||
{open ? <ChevronLeftIcon /> : <MenuIcon />}
|
{open ? <ChevronLeftIcon /> : <MenuIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
{t('common:appName')}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
<FormControl size="small">
|
||||||
<Select
|
<Select
|
||||||
value={i18n.language}
|
value={i18n.language}
|
||||||
onChange={handleLanguageChange}
|
onChange={handleLanguageChange}
|
||||||
displayEmpty
|
displayEmpty
|
||||||
sx={{
|
sx={{
|
||||||
color: 'white',
|
fontSize: '0.85rem',
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
borderColor: 'divider',
|
||||||
},
|
|
||||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
},
|
|
||||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
|
||||||
borderColor: 'white',
|
|
||||||
},
|
|
||||||
'& .MuiSvgIcon-root': {
|
|
||||||
color: 'white',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
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="de">DE</MenuItem>
|
||||||
<MenuItem value="en">English</MenuItem>
|
<MenuItem value="en">EN</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -144,13 +139,20 @@ const Layout: React.FC = () => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ ml: 2 }}
|
|
||||||
aria-controls={Boolean(anchorEl) ? 'account-menu' : undefined}
|
aria-controls={Boolean(anchorEl) ? 'account-menu' : undefined}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={Boolean(anchorEl) ? 'true' : undefined}
|
aria-expanded={Boolean(anchorEl) ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<Avatar sx={{ width: 32, height: 32 }}>
|
<Avatar
|
||||||
<AccountCircleIcon />
|
sx={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -166,14 +168,8 @@ const Layout: React.FC = () => {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
sx: {
|
sx: {
|
||||||
overflow: 'visible',
|
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,
|
mt: 1.5,
|
||||||
'& .MuiAvatar-root': {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
ml: -0.5,
|
|
||||||
mr: 1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
@@ -181,7 +177,7 @@ const Layout: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<MenuItem disabled>
|
<MenuItem disabled>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Logged in as {user?.role || 'Admin'}
|
{t('common:userMenu.loggedInAs', { role: user?.role || 'Admin' })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -202,6 +198,7 @@ const Layout: React.FC = () => {
|
|||||||
'& .MuiDrawer-paper': {
|
'& .MuiDrawer-paper': {
|
||||||
width: drawerWidth,
|
width: drawerWidth,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
backgroundColor: '#FAFBFD',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
variant="persistent"
|
variant="persistent"
|
||||||
@@ -212,25 +209,49 @@ const Layout: React.FC = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: (theme) => theme.spacing(0, 1),
|
gap: 1.5,
|
||||||
...((theme) => theme.mixins.toolbar),
|
px: 2.5,
|
||||||
justifyContent: 'center',
|
py: 2,
|
||||||
|
minHeight: 64,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" noWrap component="div">
|
<Box
|
||||||
MinIO Manager
|
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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
<List>
|
<List sx={{ px: 0.5, pt: 1.5 }}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<ListItem key={item.text} disablePadding>
|
<ListItem key={item.text} disablePadding sx={{ mb: 0.25 }}>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')}
|
selected={isActive(item.path)}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(item.path)}
|
||||||
|
sx={{ py: 1 }}
|
||||||
>
|
>
|
||||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
|
||||||
<ListItemText primary={t(`common:${item.translationKey}`)} />
|
<ListItemText
|
||||||
|
primary={t(`common:${item.translationKey}`)}
|
||||||
|
primaryTypographyProps={{ fontSize: '0.9rem', fontWeight: isActive(item.path) ? 600 : 400 }}
|
||||||
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -249,6 +270,8 @@ const Layout: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
marginLeft: open ? 0 : `-${drawerWidth}px`,
|
marginLeft: open ? 0 : `-${drawerWidth}px`,
|
||||||
mt: 8,
|
mt: 8,
|
||||||
|
minHeight: 'calc(100vh - 64px)',
|
||||||
|
backgroundColor: 'background.default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
+160
-3
@@ -9,20 +9,177 @@ const theme = createTheme({
|
|||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
primary: {
|
primary: {
|
||||||
main: '#1976d2',
|
main: '#C72C41',
|
||||||
|
light: '#E8445A',
|
||||||
|
dark: '#9B1B30',
|
||||||
|
contrastText: '#ffffff',
|
||||||
},
|
},
|
||||||
secondary: {
|
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: {
|
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: {
|
components: {
|
||||||
MuiButton: {
|
MuiButton: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
textTransform: 'none',
|
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",
|
"title": "Bestätigung erforderlich",
|
||||||
"deleteMessage": "Sind Sie sicher, dass Sie {{item}} löschen möchten?",
|
"deleteMessage": "Sind Sie sicher, dass Sie {{item}} löschen möchten?",
|
||||||
"cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden."
|
"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",
|
"title": "Confirm Action",
|
||||||
"deleteMessage": "Are you sure you want to delete {{item}}?",
|
"deleteMessage": "Are you sure you want to delete {{item}}?",
|
||||||
"cannotUndo": "This action cannot be undone."
|
"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