Initial commit: StreamFlow IPTV platform
41
frontend/.eslintrc.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:security/recommended',
|
||||
],
|
||||
plugins: ['react', 'react-hooks', 'security'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Security rules
|
||||
'security/detect-object-injection': 'warn',
|
||||
'security/detect-non-literal-regexp': 'warn',
|
||||
'security/detect-unsafe-regex': 'error',
|
||||
'security/detect-eval-with-expression': 'error',
|
||||
|
||||
// React rules
|
||||
'react/prop-types': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
|
||||
// Best practices
|
||||
'no-console': 'warn',
|
||||
'no-eval': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
},
|
||||
};
|
||||
31
frontend/index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover" />
|
||||
<meta name="description" content="StreamFlow - Premium IPTV Player with Live TV, Radio, Movies and Series" />
|
||||
<meta name="theme-color" content="#a855f7" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="StreamFlow" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" sizes="192x192" href="/icons/icon-192x192.svg" />
|
||||
<link rel="icon" type="image/svg+xml" sizes="512x512" href="/icons/icon-512x512.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192x192.svg" />
|
||||
|
||||
<title>StreamFlow - IPTV Player</title>
|
||||
|
||||
<!-- Google Cast SDK -->
|
||||
<script type="text/javascript" src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/viewport-fix.js"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5931
frontend/package-lock.json
generated
Normal file
40
frontend/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "streamflow-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "StreamFlow IPTV Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"security:audit": "npm audit --audit-level=moderate",
|
||||
"security:lint": "eslint . --ext .js,.jsx",
|
||||
"security:check": "npm run security:audit && npm run security:lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"@mui/material": "^5.15.3",
|
||||
"@mui/icons-material": "^5.15.3",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"axios": "^1.6.5",
|
||||
"i18next": "^23.7.16",
|
||||
"react-i18next": "^14.0.0",
|
||||
"video.js": "^8.9.0",
|
||||
"react-player": "^2.14.1",
|
||||
"zustand": "^4.4.7",
|
||||
"date-fns": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.11",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-security": "^3.0.1"
|
||||
}
|
||||
}
|
||||
10
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#grad1)"/>
|
||||
<path d="M 12 9 L 12 23 L 23 16 Z" fill="#ffffff" opacity="0.95"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
292
frontend/public/icons-preview.html
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>StreamFlow Icons Preview</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 30px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.icon-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.icon-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.icon-card img {
|
||||
width: 100%;
|
||||
max-width: 150px;
|
||||
height: auto;
|
||||
margin-bottom: 15px;
|
||||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
.icon-size {
|
||||
font-weight: 700;
|
||||
color: #a855f7;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.icon-type {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.color-palette {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
margin-bottom: 40px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.color-palette h2 {
|
||||
color: #333;
|
||||
margin-bottom: 25px;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.colors {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.color-swatch {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
.color-box {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.color-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.color-hex {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.usage-section {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.usage-section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.usage-card {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.usage-card img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.usage-info h3 {
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.usage-info p {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.gradient-demo {
|
||||
background: linear-gradient(135deg, #a855f7 0%, #3b82f6 100%);
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 8px 24px rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎬 StreamFlow Logo & Icons</h1>
|
||||
<p class="subtitle">Professional IPTV Application Branding</p>
|
||||
|
||||
<!-- Color Palette -->
|
||||
<div class="color-palette">
|
||||
<h2>🎨 Brand Colors</h2>
|
||||
<div class="colors">
|
||||
<div class="color-swatch">
|
||||
<div class="color-box" style="background: #a855f7;"></div>
|
||||
<div class="color-name">Purple Primary</div>
|
||||
<div class="color-hex">#a855f7</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-box" style="background: #3b82f6;"></div>
|
||||
<div class="color-name">Blue Secondary</div>
|
||||
<div class="color-hex">#3b82f6</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-box" style="background: #c084fc;"></div>
|
||||
<div class="color-name">Purple Light</div>
|
||||
<div class="color-hex">#c084fc</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-box" style="background: #9333ea;"></div>
|
||||
<div class="color-name">Purple Dark</div>
|
||||
<div class="color-hex">#9333ea</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gradient-demo"></div>
|
||||
<p style="text-align: center; color: #666; margin-top: 10px;">
|
||||
<strong>Gradient:</strong> linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Icon Sizes -->
|
||||
<h2 style="color: white; text-align: center; margin-bottom: 30px; font-size: 2rem;">📱 PWA Icons (Rounded)</h2>
|
||||
<div class="grid">
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-72x72.svg" alt="72x72">
|
||||
<div class="icon-size">72×72</div>
|
||||
<div class="icon-type">Mobile Small</div>
|
||||
</div>
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-96x96.svg" alt="96x96">
|
||||
<div class="icon-size">96×96</div>
|
||||
<div class="icon-type">Mobile Medium</div>
|
||||
</div>
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-128x128.svg" alt="128x128">
|
||||
<div class="icon-size">128×128</div>
|
||||
<div class="icon-type">Tablet</div>
|
||||
</div>
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-144x144.svg" alt="144x144">
|
||||
<div class="icon-size">144×144</div>
|
||||
<div class="icon-type">Mobile High-Res</div>
|
||||
</div>
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-152x152.svg" alt="152x152">
|
||||
<div class="icon-size">152×152</div>
|
||||
<div class="icon-type">iPad Touch</div>
|
||||
</div>
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-192x192.svg" alt="192x192">
|
||||
<div class="icon-size">192×192</div>
|
||||
<div class="icon-type">Standard PWA</div>
|
||||
</div>
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-384x384.svg" alt="384x384">
|
||||
<div class="icon-size">384×384</div>
|
||||
<div class="icon-type">High-DPI</div>
|
||||
</div>
|
||||
<div class="icon-card">
|
||||
<img src="icons/icon-512x512.svg" alt="512x512">
|
||||
<div class="icon-size">512×512</div>
|
||||
<div class="icon-type">Desktop / Splash</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Examples -->
|
||||
<div class="usage-section">
|
||||
<h2>🚀 Platform Usage</h2>
|
||||
<div class="usage-grid">
|
||||
<div class="usage-card">
|
||||
<img src="favicon.svg" alt="Favicon">
|
||||
<div class="usage-info">
|
||||
<h3>Browser Tab</h3>
|
||||
<p>favicon.svg (32×32)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-card">
|
||||
<img src="icons/icon-192x192.svg" alt="Android">
|
||||
<div class="usage-info">
|
||||
<h3>Android Home Screen</h3>
|
||||
<p>192×192 maskable icon</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-card">
|
||||
<img src="icons/icon-192x192.svg" alt="iOS">
|
||||
<div class="usage-info">
|
||||
<h3>iOS Home Screen</h3>
|
||||
<p>Apple touch icon</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-card">
|
||||
<img src="icons/icon-512x512.svg" alt="Desktop">
|
||||
<div class="usage-info">
|
||||
<h3>Desktop PWA</h3>
|
||||
<p>512×512 for shortcuts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-card">
|
||||
<img src="icons/icon-512x512.svg" alt="Linux">
|
||||
<div class="usage-info">
|
||||
<h3>Linux AppImage</h3>
|
||||
<p>512×512 app icon</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-card">
|
||||
<img src="icons/icon-192x192.svg" alt="APK">
|
||||
<div class="usage-info">
|
||||
<h3>Android APK</h3>
|
||||
<p>Adaptive icon resource</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px; padding: 20px; background: #f9fafb; border-radius: 12px; border-left: 4px solid #a855f7;">
|
||||
<h3 style="color: #333; margin-bottom: 10px;">✅ All icons feature:</h3>
|
||||
<ul style="color: #666; line-height: 1.8; margin-left: 20px;">
|
||||
<li><strong>Rounded corners</strong> for modern aesthetics (~22% radius)</li>
|
||||
<li><strong>Purple-blue gradient</strong> matching brand identity</li>
|
||||
<li><strong>SVG format</strong> for crisp rendering at any scale</li>
|
||||
<li><strong>Maskable safe zone</strong> for adaptive launcher icons</li>
|
||||
<li><strong>Flow lines</strong> suggesting streaming motion</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
12
frontend/public/icons/icon-128x128.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="url(#grad1)"/>
|
||||
<path d="M 48 35 L 48 93 L 93 64 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 64 64 C 64 64, 54 56, 48 50 C 42 44, 38 38, 38 36 L 96 64 C 96 64, 94 68, 88 74 C 82 80, 74 88, 66 93"
|
||||
fill="none" stroke="#ffffff" stroke-width="3" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 686 B |
12
frontend/public/icons/icon-144x144.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="144" height="144" rx="32" fill="url(#grad1)"/>
|
||||
<path d="M 54 40 L 54 104 L 104 72 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 72 72 C 72 72, 60 62, 54 56 C 48 50, 42 42, 42 40 L 108 72 C 108 72, 106 76, 99 83 C 92 90, 83 99, 74 104"
|
||||
fill="none" stroke="#ffffff" stroke-width="3.5" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 694 B |
12
frontend/public/icons/icon-152x152.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="152" height="152" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="152" height="152" rx="34" fill="url(#grad1)"/>
|
||||
<path d="M 57 42 L 57 110 L 110 76 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 76 76 C 76 76, 64 66, 57 59 C 50 52, 44 44, 44 42 L 114 76 C 114 76, 112 80, 105 87 C 98 94, 88 104, 78 110"
|
||||
fill="none" stroke="#ffffff" stroke-width="3.5" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 696 B |
12
frontend/public/icons/icon-192x192.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="192" height="192" rx="42" fill="url(#grad1)"/>
|
||||
<path d="M 72 53 L 72 139 L 139 96 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 96 96 C 96 96, 80 83, 72 74 C 64 65, 56 55, 56 53 L 144 96 C 144 96, 141 101, 132 110 C 123 119, 111 131, 98 139"
|
||||
fill="none" stroke="#ffffff" stroke-width="4" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 699 B |
12
frontend/public/icons/icon-384x384.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="384" height="384" viewBox="0 0 384 384" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="384" height="384" rx="84" fill="url(#grad1)"/>
|
||||
<path d="M 144 106 L 144 278 L 278 192 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 192 192 C 192 192, 160 166, 144 148 C 128 130, 112 110, 112 106 L 288 192 C 288 192, 282 202, 264 220 C 246 238, 222 262, 196 278"
|
||||
fill="none" stroke="#ffffff" stroke-width="6" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 720 B |
12
frontend/public/icons/icon-512x512.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#grad1)"/>
|
||||
<path d="M 192 142 L 192 370 L 370 256 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 256 256 C 256 256, 214 222, 192 198 C 170 174, 150 146, 150 142 L 384 256 C 384 256, 376 270, 352 294 C 328 318, 296 350, 262 370"
|
||||
fill="none" stroke="#ffffff" stroke-width="8" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
12
frontend/public/icons/icon-72x72.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="72" height="72" rx="16" fill="url(#grad1)"/>
|
||||
<path d="M 28 20 L 28 52 L 52 36 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 36 36 C 36 36, 30 32, 28 30 C 26 28, 24 26, 24 24 L 54 36 C 54 36, 52 38, 50 40 C 48 42, 44 46, 40 48"
|
||||
fill="none" stroke="#ffffff" stroke-width="2" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 680 B |
12
frontend/public/icons/icon-96x96.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="96" height="96" rx="21" fill="url(#grad1)"/>
|
||||
<path d="M 36 26 L 36 70 L 70 48 Z" fill="#ffffff" opacity="0.95"/>
|
||||
<path d="M 48 48 C 48 48, 40 42, 36 38 C 32 34, 30 30, 30 28 L 72 48 C 72 48, 70 52, 66 56 C 62 60, 56 66, 50 70"
|
||||
fill="none" stroke="#ffffff" stroke-width="2.5" opacity="0.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
78
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"name": "StreamFlow IPTV",
|
||||
"short_name": "StreamFlow",
|
||||
"description": "Professional IPTV Streaming with Multi-Screen & Picture-in-Picture",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#121212",
|
||||
"theme_color": "#a855f7",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72x72.svg",
|
||||
"sizes": "72x72",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96x96.svg",
|
||||
"sizes": "96x96",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128x128.svg",
|
||||
"sizes": "128x128",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144x144.svg",
|
||||
"sizes": "144x144",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152x152.svg",
|
||||
"sizes": "152x152",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192x192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384x384.svg",
|
||||
"sizes": "384x384",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["entertainment", "multimedia", "video"],
|
||||
"screenshots": [],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Live TV",
|
||||
"short_name": "Live",
|
||||
"description": "Watch Live TV channels",
|
||||
"url": "/live-tv",
|
||||
"icons": [{ "src": "/icons/icon-96x96.svg", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Movies",
|
||||
"short_name": "Movies",
|
||||
"description": "Browse movies",
|
||||
"url": "/movies",
|
||||
"icons": [{ "src": "/icons/icon-96x96.svg", "sizes": "96x96" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
6
frontend/public/placeholder-channel.png
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#f5f5f5"/>
|
||||
<text x="50%" y="50%" font-family="Arial" font-size="16" fill="#999" text-anchor="middle" dy=".3em">
|
||||
Channel Logo
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
6
frontend/public/placeholder-show.png
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="120" height="80" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="120" height="80" fill="#e0e0e0"/>
|
||||
<text x="50%" y="50%" font-family="Arial" font-size="12" fill="#666" text-anchor="middle" dy=".3em">
|
||||
Show
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
178
frontend/public/service-worker.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
const CACHE_NAME = 'streamflow-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Global error handler for service worker (CWE-391 protection)
|
||||
self.addEventListener('error', (event) => {
|
||||
// Log error but don't expose to user
|
||||
console.error('Service Worker error:', event.error);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections in service worker
|
||||
self.addEventListener('unhandledrejection', (event) => {
|
||||
// Log error but don't expose to user
|
||||
console.error('Service Worker unhandled rejection:', event.reason);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(urlsToCache))
|
||||
.then(() => self.skipWaiting())
|
||||
.catch((error) => {
|
||||
// Handle cache errors gracefully
|
||||
console.error('Cache installation error:', error);
|
||||
// Still skip waiting even if caching fails
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache, fallback to network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests - let them pass through to network
|
||||
if (event.request.method !== 'GET') {
|
||||
event.respondWith(fetch(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip API requests and streaming URLs
|
||||
if (event.request.url.includes('/api/') ||
|
||||
event.request.url.includes('m3u8') ||
|
||||
event.request.url.includes('ts')) {
|
||||
event.respondWith(fetch(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => {
|
||||
// Cache hit - return response
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return fetch(event.request).then((response) => {
|
||||
// Check if valid response
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone the response
|
||||
const responseToCache = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
})
|
||||
.catch((error) => {
|
||||
// Silently handle cache write errors
|
||||
console.error('Cache write error:', error);
|
||||
});
|
||||
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
// Network error - return generic offline response
|
||||
console.error('Fetch error:', error);
|
||||
// Return a basic error response instead of throwing
|
||||
return new Response('Offline', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable'
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
// Handle any cache read errors
|
||||
console.error('Cache read error:', error);
|
||||
// Try to fetch from network as fallback
|
||||
return fetch(event.request).catch(() => {
|
||||
return new Response('Offline', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable'
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle background sync
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'sync-playlists') {
|
||||
event.waitUntil(syncPlaylists());
|
||||
}
|
||||
});
|
||||
|
||||
// Handle push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data ? event.data.json() : {};
|
||||
const options = {
|
||||
body: data.body || 'New notification from StreamFlow',
|
||||
icon: '/icons/icon-192x192.svg',
|
||||
badge: '/icons/icon-72x72.svg',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: data.tag || 'streamflow-notification',
|
||||
renotify: true,
|
||||
data: data.data || {},
|
||||
actions: data.actions || [
|
||||
{
|
||||
action: 'open',
|
||||
title: 'Open'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'StreamFlow', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle notification clicks
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const urlToOpen = event.notification.data.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Check if there's already a window open
|
||||
for (let i = 0; i < clientList.length; i++) {
|
||||
const client = clientList[i];
|
||||
if (client.url.includes(urlToOpen) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// If no window is open, open a new one
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Sync function
|
||||
async function syncPlaylists() {
|
||||
// Implementation for syncing playlists
|
||||
console.log('Syncing playlists...');
|
||||
}
|
||||
8
frontend/public/viewport-fix.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Fix for mobile viewport height with address bar
|
||||
function setVh() {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
}
|
||||
setVh();
|
||||
window.addEventListener('resize', setVh);
|
||||
window.addEventListener('orientationchange', setVh);
|
||||
73
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { lightTheme, darkTheme } from './theme';
|
||||
import { SecurityNotificationProvider } from './components/SecurityNotificationProvider';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import LiveTV from './pages/LiveTV';
|
||||
import Radio from './pages/Radio';
|
||||
import Movies from './pages/Movies';
|
||||
import Series from './pages/Series';
|
||||
import Favorites from './pages/Favorites';
|
||||
import Settings from './pages/Settings';
|
||||
import Stats from './pages/Stats';
|
||||
import SecurityDashboard from './pages/SecurityDashboard';
|
||||
import CSPDashboard from './components/CSPDashboard';
|
||||
import RBACDashboard from './components/RBACDashboard';
|
||||
import SecurityMonitor from './pages/SecurityMonitor';
|
||||
import SecurityHeadersDashboard from './components/SecurityHeadersDashboard';
|
||||
import SecurityTestingDashboard from './components/SecurityTestingDashboard';
|
||||
import SecurityIntelligenceDashboard from './pages/SecurityIntelligenceDashboard';
|
||||
import SecurityConfigDashboard from './pages/SecurityConfigDashboard';
|
||||
import LogManagementDashboard from './components/LogManagementDashboard';
|
||||
import EncryptionManagementDashboard from './components/EncryptionManagementDashboard';
|
||||
|
||||
function App() {
|
||||
const { mode } = useThemeStore();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
const theme = useMemo(() => (mode === 'light' ? lightTheme : darkTheme), [mode]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SecurityNotificationProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={!isAuthenticated ? <Login /> : <Navigate to="/" />} />
|
||||
{/* Registration disabled - redirect to login */}
|
||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/" element={isAuthenticated ? <Dashboard /> : <Navigate to="/login" />}>
|
||||
<Route index element={<LiveTV />} />
|
||||
<Route path="live-tv" element={<LiveTV />} />
|
||||
<Route path="radio" element={<Radio />} />
|
||||
<Route path="movies" element={<Movies />} />
|
||||
<Route path="series" element={<Series />} />
|
||||
<Route path="favorites" element={<Favorites />} />
|
||||
<Route path="favorites/:type" element={<Favorites />} />
|
||||
<Route path="stats" element={<Stats />} />
|
||||
<Route path="security" element={<SecurityDashboard />} />
|
||||
<Route path="security/csp" element={<CSPDashboard />} />
|
||||
<Route path="security/rbac" element={<RBACDashboard />} />
|
||||
<Route path="security/monitor" element={<SecurityMonitor />} />
|
||||
<Route path="security/headers" element={<SecurityHeadersDashboard />} />
|
||||
<Route path="security/testing" element={<SecurityTestingDashboard />} />
|
||||
<Route path="security/intelligence" element={<SecurityIntelligenceDashboard />} />
|
||||
<Route path="security/config" element={<SecurityConfigDashboard />} />
|
||||
<Route path="security/logs" element={<LogManagementDashboard />} />
|
||||
<Route path="security/encryption" element={<EncryptionManagementDashboard />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="settings/sessions" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</SecurityNotificationProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
7
frontend/src/api/axiosConfig.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import axios from 'axios';
|
||||
|
||||
// Axios configuration file
|
||||
// Note: Global 401 interceptor removed to prevent login loops
|
||||
// Individual components handle their own authentication errors
|
||||
|
||||
export default axios;
|
||||
704
frontend/src/components/AudioPlayer.jsx
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Box, Paper, IconButton, Typography, Slider, LinearProgress, Tooltip, Fade, Chip } from '@mui/material';
|
||||
import {
|
||||
PlayArrow,
|
||||
Pause,
|
||||
VolumeUp,
|
||||
VolumeOff,
|
||||
SkipNext,
|
||||
SkipPrevious,
|
||||
Cast,
|
||||
CastConnected,
|
||||
MusicNote,
|
||||
GraphicEq
|
||||
} from '@mui/icons-material';
|
||||
import ReactPlayer from 'react-player';
|
||||
import Logo from './Logo';
|
||||
import AudioVisualizer from './AudioVisualizer';
|
||||
import { useChromecast } from '../utils/useChromecast';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
|
||||
function AudioPlayer({ station, onNext, onPrevious }) {
|
||||
const playerRef = useRef(null);
|
||||
const audioElementRef = useRef(null);
|
||||
const metadataIntervalRef = useRef(null);
|
||||
const { token } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [visualizerType, setVisualizerType] = useState(0);
|
||||
const [metadata, setMetadata] = useState(null);
|
||||
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const hideControlsTimer = useRef(null);
|
||||
|
||||
// Chromecast support
|
||||
const {
|
||||
castAvailable,
|
||||
casting,
|
||||
castMedia,
|
||||
stopCasting,
|
||||
openCastDialog,
|
||||
setCastVolume,
|
||||
setCastMuted
|
||||
} = useChromecast();
|
||||
|
||||
// Auto-hide controls after 2 seconds when playing
|
||||
const resetControlsTimer = () => {
|
||||
setShowControls(true);
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
if (playing) {
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset timer when playing changes
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
resetControlsTimer();
|
||||
} else {
|
||||
setShowControls(true);
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
// Fetch metadata for radio station
|
||||
const fetchMetadata = async () => {
|
||||
if (!station?.id || !playing) return;
|
||||
|
||||
try {
|
||||
setLoadingMetadata(true);
|
||||
const response = await axios.get(`/api/metadata/radio/${station.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setMetadata(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Metadata] Error fetching metadata:', error);
|
||||
setMetadata(null);
|
||||
} finally {
|
||||
setLoadingMetadata(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll metadata when playing
|
||||
useEffect(() => {
|
||||
if (playing && station?.id) {
|
||||
// Fetch immediately
|
||||
fetchMetadata();
|
||||
|
||||
// Poll every 10 seconds
|
||||
metadataIntervalRef.current = setInterval(fetchMetadata, 10000);
|
||||
} else {
|
||||
// Clear interval when not playing
|
||||
if (metadataIntervalRef.current) {
|
||||
clearInterval(metadataIntervalRef.current);
|
||||
metadataIntervalRef.current = null;
|
||||
}
|
||||
setMetadata(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (metadataIntervalRef.current) {
|
||||
clearInterval(metadataIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [playing, station?.id, token]);
|
||||
|
||||
// Cycle through visualizers every 15 seconds when playing
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
|
||||
const cycleInterval = setInterval(() => {
|
||||
setVisualizerType(prev => (prev + 1) % 10);
|
||||
}, 15000);
|
||||
|
||||
return () => clearInterval(cycleInterval);
|
||||
}, [playing]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setPlaying(!playing);
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
const handleVolumeChange = (event, newValue) => {
|
||||
setVolume(newValue / 100);
|
||||
setMuted(newValue === 0);
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
setMuted(!muted);
|
||||
};
|
||||
|
||||
// Get stream URL with token
|
||||
const getStreamUrl = () => {
|
||||
if (!station?.id) return station?.url || '';
|
||||
// Use backend proxy for radio streams
|
||||
return `/api/stream/proxy/${station.id}?token=${token}`;
|
||||
};
|
||||
|
||||
// Get full URL for Chromecast (needs absolute URL)
|
||||
const getFullStreamUrl = () => {
|
||||
const streamUrl = station?.url || getStreamUrl();
|
||||
if (!streamUrl) return '';
|
||||
// If it's already an absolute URL, use it; otherwise make it absolute
|
||||
if (streamUrl.startsWith('http')) {
|
||||
return streamUrl;
|
||||
}
|
||||
return `${window.location.origin}${streamUrl}`;
|
||||
};
|
||||
|
||||
// Get original station URL for Chromecast (bypasses proxy)
|
||||
const getOriginalStationUrl = async () => {
|
||||
if (!station?.id) return null;
|
||||
|
||||
try {
|
||||
// Fetch station data to get original URL
|
||||
const response = await fetch(`/api/radio/${station.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const stationData = await response.json();
|
||||
console.log('[Cast] Original station URL:', stationData.url);
|
||||
return stationData.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Cast] Error fetching station URL:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle Chromecast
|
||||
const handleCast = async () => {
|
||||
if (casting) {
|
||||
// Stop casting
|
||||
stopCasting();
|
||||
// Resume local playback
|
||||
setPlaying(false);
|
||||
} else {
|
||||
// Start casting
|
||||
if (!station) return;
|
||||
|
||||
// Pause local playback
|
||||
setPlaying(false);
|
||||
|
||||
// Try to get original URL first (Chromecast can access it directly)
|
||||
const originalUrl = await getOriginalStationUrl();
|
||||
const castUrl = originalUrl || getFullStreamUrl();
|
||||
|
||||
console.log('[Cast] Casting URL:', castUrl);
|
||||
console.log('[Cast] Station:', station.name);
|
||||
|
||||
// Detect content type from URL
|
||||
let contentType = 'audio/mpeg';
|
||||
if (castUrl.includes('.m3u8')) {
|
||||
contentType = 'application/x-mpegURL';
|
||||
} else if (castUrl.includes('.aac')) {
|
||||
contentType = 'audio/aac';
|
||||
}
|
||||
|
||||
console.log('[Cast] Content type:', contentType);
|
||||
|
||||
// Cast the media
|
||||
const success = await castMedia({
|
||||
url: castUrl,
|
||||
title: station.name || 'Radio Station',
|
||||
subtitle: station.genre || 'Live Radio',
|
||||
contentType: contentType,
|
||||
imageUrl: station.logo || '',
|
||||
isLive: true
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.log('[Cast] Cast failed, opening device selector');
|
||||
// If casting failed, open device selector
|
||||
openCastDialog();
|
||||
} else {
|
||||
console.log('[Cast] Cast successful');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sync volume with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastVolume) {
|
||||
setCastVolume(volume);
|
||||
}
|
||||
}, [volume, casting, setCastVolume]);
|
||||
|
||||
// Sync mute with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastMuted) {
|
||||
setCastMuted(muted);
|
||||
}
|
||||
}, [muted, casting, setCastMuted]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: { xs: 300, md: 180 },
|
||||
maxHeight: { xs: 'none', md: 250 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{/* Audio Player - hidden visually but accessible for visualizer */}
|
||||
{!casting && (
|
||||
<Box sx={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
||||
<ReactPlayer
|
||||
ref={playerRef}
|
||||
url={getStreamUrl()}
|
||||
playing={playing}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
width="100%"
|
||||
height="100%"
|
||||
progressInterval={5000}
|
||||
onReady={() => {
|
||||
setIsReady(true);
|
||||
setBuffering(false);
|
||||
}}
|
||||
onPlay={() => {
|
||||
setBuffering(false);
|
||||
// Capture audio element when actually playing
|
||||
if (playerRef.current && !audioElementRef.current) {
|
||||
const player = playerRef.current.getInternalPlayer();
|
||||
if (player && (player.tagName === 'AUDIO' || player.tagName === 'VIDEO')) {
|
||||
audioElementRef.current = player;
|
||||
console.log('[AudioPlayer] Audio element captured for visualizer');
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBuffer={() => setBuffering(true)}
|
||||
onBufferEnd={() => setBuffering(false)}
|
||||
config={{
|
||||
file: {
|
||||
forceAudio: true,
|
||||
attributes: {
|
||||
crossOrigin: 'anonymous'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Visual Display */}
|
||||
<Box
|
||||
onMouseMove={resetControlsTimer}
|
||||
onTouchStart={resetControlsTimer}
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Audio Visualizer Background */}
|
||||
{playing && audioElementRef.current && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<AudioVisualizer
|
||||
audioElement={audioElementRef.current}
|
||||
isPlaying={playing && !casting}
|
||||
visualizerType={visualizerType}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content Overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: playing
|
||||
? 'linear-gradient(135deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.5) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(59, 130, 246, 0.05) 100%)',
|
||||
p: { xs: 4, md: 1.5 },
|
||||
gap: { xs: 2, md: 0.5 }
|
||||
}}
|
||||
>
|
||||
{/* Buffering/Loading Indicator */}
|
||||
{(buffering || (playing && !isReady)) && (
|
||||
<Fade in={true}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 3,
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||
{t('audio.loading', 'Loading...')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Visualizer Type Indicator */}
|
||||
{playing && !buffering && isReady && (
|
||||
<Fade in={showControls}>
|
||||
<Chip
|
||||
icon={<GraphicEq />}
|
||||
label={t('audio.visualizer_type', { count: visualizerType + 1, defaultValue: `Visualizer ${visualizerType + 1}` })}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Logo as Play/Pause Button */}
|
||||
<Box
|
||||
onClick={handlePlayPause}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
opacity: showControls ? 1 : 0.3,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
opacity: 1
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)'
|
||||
},
|
||||
'& > *': {
|
||||
width: { xs: playing ? 80 : 120, md: playing ? 50 : 60 },
|
||||
height: { xs: playing ? 80 : 120, md: playing ? 50 : 60 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Logo size={playing ? 80 : 120} />
|
||||
{/* Buffering overlay on logo */}
|
||||
{buffering && playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: '3px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<PlayArrow sx={{ fontSize: { xs: 60, md: 40 }, color: 'white' }} />
|
||||
</Box>
|
||||
)}
|
||||
{playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '50%'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pause sx={{ fontSize: { xs: 40, md: 30 }, color: 'white' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Station Info */}
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
fontWeight={600}
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontSize: { xs: '1.5rem', md: '1.1rem' },
|
||||
mb: { xs: 1, md: 0.5 },
|
||||
color: playing ? 'white' : 'text.primary',
|
||||
textShadow: playing ? '0 2px 10px rgba(0,0,0,0.5)' : 'none'
|
||||
}}
|
||||
>
|
||||
{station?.name || t('audio.select_station', 'Select a Station')}
|
||||
</Typography>
|
||||
{station?.genre && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: playing ? 'rgba(255,255,255,0.8)' : 'text.secondary',
|
||||
textShadow: playing ? '0 1px 5px rgba(0,0,0,0.5)' : 'none'
|
||||
}}
|
||||
>
|
||||
{station.genre}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Now Playing Metadata */}
|
||||
{metadata && playing && (metadata.title || metadata.artist || (metadata.song && !metadata.song.match(/^\w+_aacp?_\d+k?$/i))) && (
|
||||
<Fade in={true}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 1, md: 0.5 },
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
px: { xs: 3, md: 2 },
|
||||
py: { xs: 1.5, md: 0.75 },
|
||||
borderRadius: 3,
|
||||
maxWidth: '90%'
|
||||
}}
|
||||
>
|
||||
<MusicNote sx={{ color: 'white', fontSize: { xs: 20, md: 16 } }} />
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
mb: { xs: 0.5, md: 0.25 },
|
||||
fontSize: { xs: '0.875rem', md: '0.75rem' }
|
||||
}}
|
||||
>
|
||||
{t('audio.now_playing', 'Now Playing')}
|
||||
</Typography>
|
||||
{metadata.title && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '1rem', md: '0.875rem' }
|
||||
}}
|
||||
>
|
||||
{metadata.title}
|
||||
</Typography>
|
||||
)}
|
||||
{metadata.artist && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: { xs: '0.875rem', md: '0.75rem' }
|
||||
}}
|
||||
>
|
||||
{metadata.artist}
|
||||
</Typography>
|
||||
)}
|
||||
{!metadata.title && !metadata.artist && metadata.streamTitle && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{metadata.streamTitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Casting Indicator */}
|
||||
{casting && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CastConnected sx={{ color: 'white', fontSize: 20 }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 1px 5px rgba(0,0,0,0.5)'
|
||||
}}
|
||||
>
|
||||
{t('audio.casting', 'Casting to Device')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation Controls */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton
|
||||
onClick={onPrevious}
|
||||
disabled={!onPrevious}
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
<SkipPrevious />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ width: 56 }} /> {/* Spacer where play button was */}
|
||||
|
||||
<IconButton
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
<SkipNext />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Volume Control */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%', maxWidth: 300 }}>
|
||||
<IconButton size="small" onClick={handleToggleMute} sx={{ color: 'text.primary' }}>
|
||||
{muted ? <VolumeOff /> : <VolumeUp />}
|
||||
</IconButton>
|
||||
|
||||
<Slider
|
||||
value={muted ? 0 : volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
{castAvailable && (
|
||||
<Tooltip title={casting ? 'Stop Casting' : 'Cast'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCast}
|
||||
sx={{
|
||||
color: casting ? 'primary.main' : 'text.primary',
|
||||
bgcolor: casting ? 'rgba(168, 85, 247, 0.1)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{casting ? <CastConnected /> : <Cast />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Live Indicator */}
|
||||
{playing && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'error.main',
|
||||
animation: 'blink 1.5s ease-in-out infinite',
|
||||
'@keyframes blink': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.3 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600}>
|
||||
LIVE
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{playing && !isReady && (
|
||||
<LinearProgress sx={{ position: 'absolute', top: 0, left: 0, right: 0 }} />
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioPlayer;
|
||||
434
frontend/src/components/AudioVisualizer.jsx
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { Box, IconButton, Tooltip } from '@mui/material';
|
||||
import { Audiotrack } from '@mui/icons-material';
|
||||
|
||||
/**
|
||||
* AudioVisualizer Component
|
||||
* Provides 10 different audio visualizations for radio playback
|
||||
*/
|
||||
const AudioVisualizer = ({ audioElement, isPlaying, visualizerType = 0 }) => {
|
||||
const canvasRef = useRef(null);
|
||||
const animationRef = useRef(null);
|
||||
const audioContextRef = useRef(null);
|
||||
const analyserRef = useRef(null);
|
||||
const dataArrayRef = useRef(null);
|
||||
const sourceRef = useRef(null);
|
||||
const connectedElementRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[AudioVisualizer] Props:', { audioElement, isPlaying, visualizerType });
|
||||
|
||||
if (!audioElement || !isPlaying) {
|
||||
console.log('[AudioVisualizer] Not rendering - no audio element or not playing');
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
// Clear canvas
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
console.log('[AudioVisualizer] No canvas element');
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas size
|
||||
const updateCanvasSize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
};
|
||||
updateCanvasSize();
|
||||
|
||||
// Initialize Web Audio API (only once per audio element)
|
||||
if (!audioContextRef.current) {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
audioContextRef.current = new AudioContext();
|
||||
analyserRef.current = audioContextRef.current.createAnalyser();
|
||||
analyserRef.current.fftSize = 128; // Smaller for better performance
|
||||
analyserRef.current.smoothingTimeConstant = 0.8;
|
||||
|
||||
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||
dataArrayRef.current = new Uint8Array(bufferLength);
|
||||
} catch (error) {
|
||||
console.error('[AudioVisualizer] Error initializing audio context:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create media source only once per audio element
|
||||
// Check if this is the same element we already connected
|
||||
const needsNewSource = !sourceRef.current || connectedElementRef.current !== audioElement;
|
||||
|
||||
if (needsNewSource && audioContextRef.current) {
|
||||
// Clean up old source if switching elements
|
||||
if (sourceRef.current && connectedElementRef.current !== audioElement) {
|
||||
try {
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
connectedElementRef.current = null;
|
||||
} catch (e) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[AudioVisualizer] Creating media element source');
|
||||
sourceRef.current = audioContextRef.current.createMediaElementSource(audioElement);
|
||||
sourceRef.current.connect(analyserRef.current);
|
||||
analyserRef.current.connect(audioContextRef.current.destination);
|
||||
connectedElementRef.current = audioElement;
|
||||
console.log('[AudioVisualizer] Audio context initialized successfully');
|
||||
} catch (error) {
|
||||
// If element is already connected, it means we already set it up
|
||||
console.log('[AudioVisualizer] Element already connected, reusing existing connection');
|
||||
// Don't return, continue with existing setup if available
|
||||
if (!analyserRef.current || !audioContextRef.current) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resume audio context if suspended
|
||||
if (audioContextRef.current.state === 'suspended') {
|
||||
audioContextRef.current.resume();
|
||||
}
|
||||
|
||||
const analyser = analyserRef.current;
|
||||
const dataArray = dataArrayRef.current;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
|
||||
// Visualization functions
|
||||
const visualizers = [
|
||||
// 0: Classic Bars
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||
let barHeight;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
barHeight = (dataArray[i] / 255) * canvas.height * 0.8;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`;
|
||||
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
|
||||
|
||||
x += barWidth + 1;
|
||||
}
|
||||
},
|
||||
|
||||
// 1: Circular Spectrum
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const radius = Math.min(centerX, centerY) * 0.6;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const angle = (i / bufferLength) * Math.PI * 2;
|
||||
const barHeight = (dataArray[i] / 255) * radius * 0.8;
|
||||
|
||||
const x1 = centerX + Math.cos(angle) * radius;
|
||||
const y1 = centerY + Math.sin(angle) * radius;
|
||||
const x2 = centerX + Math.cos(angle) * (radius + barHeight);
|
||||
const y2 = centerY + Math.sin(angle) * (radius + barHeight);
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.strokeStyle = `hsl(${hue}, 70%, 50%)`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
// 2: Waveform
|
||||
() => {
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#a855f7';
|
||||
ctx.beginPath();
|
||||
|
||||
const sliceWidth = canvas.width / bufferLength;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const v = dataArray[i] / 128.0;
|
||||
const y = (v * canvas.height) / 2;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
ctx.lineTo(canvas.width, canvas.height / 2);
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
// 3: Symmetric Bars
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * canvas.height * 0.4;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`;
|
||||
|
||||
// Top bars
|
||||
ctx.fillRect(x, canvas.height / 2 - barHeight, barWidth, barHeight);
|
||||
// Bottom bars (mirrored)
|
||||
ctx.fillRect(x, canvas.height / 2, barWidth, barHeight);
|
||||
|
||||
x += barWidth + 1;
|
||||
}
|
||||
},
|
||||
|
||||
// 4: Particles
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let i = 0; i < bufferLength; i += 2) {
|
||||
const value = dataArray[i];
|
||||
if (value > 100) {
|
||||
const x = (i / bufferLength) * canvas.width;
|
||||
const y = Math.random() * canvas.height;
|
||||
const size = (value / 255) * 5;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.fillStyle = `hsla(${hue}, 70%, 50%, 0.8)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 5: Frequency Rings
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const maxRadius = Math.min(centerX, centerY) * 0.9;
|
||||
|
||||
for (let i = 0; i < bufferLength; i += 4) {
|
||||
const value = dataArray[i];
|
||||
const radius = (value / 255) * maxRadius;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.strokeStyle = `hsla(${hue}, 70%, 50%, 0.5)`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
// 6: Line Spectrum
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const x = (i / bufferLength) * canvas.width;
|
||||
const y = canvas.height - (dataArray[i] / 255) * canvas.height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
gradient.addColorStop(0, '#a855f7');
|
||||
gradient.addColorStop(0.5, '#3b82f6');
|
||||
gradient.addColorStop(1, '#10b981');
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
// 7: Radial Bars
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const baseRadius = Math.min(centerX, centerY) * 0.3;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const angle = (i / bufferLength) * Math.PI * 2;
|
||||
const barHeight = (dataArray[i] / 255) * baseRadius;
|
||||
|
||||
const innerX = centerX + Math.cos(angle) * baseRadius;
|
||||
const innerY = centerY + Math.sin(angle) * baseRadius;
|
||||
const outerX = centerX + Math.cos(angle) * (baseRadius + barHeight);
|
||||
const outerY = centerY + Math.sin(angle) * (baseRadius + barHeight);
|
||||
|
||||
const gradient = ctx.createLinearGradient(innerX, innerY, outerX, outerY);
|
||||
gradient.addColorStop(0, '#a855f7');
|
||||
gradient.addColorStop(1, '#3b82f6');
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(innerX, innerY);
|
||||
ctx.lineTo(outerX, outerY);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
// 8: Block Grid
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const cols = 16;
|
||||
const rows = 8;
|
||||
const blockWidth = canvas.width / cols;
|
||||
const blockHeight = canvas.height / rows;
|
||||
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const dataIndex = Math.floor((i / cols) * bufferLength);
|
||||
const value = dataArray[dataIndex];
|
||||
const activedRows = Math.floor((value / 255) * rows);
|
||||
|
||||
for (let j = 0; j < activedRows; j++) {
|
||||
const hue = (i / cols) * 360;
|
||||
const brightness = 50 + (j / rows) * 30;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, ${brightness}%)`;
|
||||
ctx.fillRect(
|
||||
i * blockWidth + 1,
|
||||
canvas.height - (j + 1) * blockHeight + 1,
|
||||
blockWidth - 2,
|
||||
blockHeight - 2
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 9: Spiral
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
let radius = 10;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const angle = (i / bufferLength) * Math.PI * 8;
|
||||
const value = dataArray[i];
|
||||
radius += (value / 255) * 2;
|
||||
|
||||
const x = centerX + Math.cos(angle) * radius;
|
||||
const y = centerY + Math.sin(angle) * radius;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
||||
gradient.addColorStop(0, '#a855f7');
|
||||
gradient.addColorStop(0.5, '#3b82f6');
|
||||
gradient.addColorStop(1, '#10b981');
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.stroke();
|
||||
}
|
||||
];
|
||||
|
||||
// Animation loop
|
||||
const draw = () => {
|
||||
visualizers[visualizerType]();
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [audioElement, isPlaying, visualizerType]);
|
||||
|
||||
// Cleanup audio context on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isPlaying) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(59, 130, 246, 0.05) 100%)'
|
||||
}}
|
||||
>
|
||||
<Audiotrack sx={{ fontSize: 80, color: 'text.disabled', opacity: 0.3 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioVisualizer;
|
||||
385
frontend/src/components/BackupRestore.jsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
DialogContentText,
|
||||
Chip,
|
||||
Divider,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Backup as BackupIcon,
|
||||
Restore as RestoreIcon,
|
||||
Delete as DeleteIcon,
|
||||
Download as DownloadIcon,
|
||||
CloudUpload as UploadIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const BackupRestore = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useAuthStore();
|
||||
const [backups, setBackups] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
const [selectedBackup, setSelectedBackup] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackups();
|
||||
}, []);
|
||||
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/backup/list', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setBackups(response.data);
|
||||
} catch (err) {
|
||||
setError(t('backup.fetchError'));
|
||||
console.error('Failed to fetch backups:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
try {
|
||||
setCreating(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const response = await axios.post('/api/backup/create', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('backup.createSuccess'));
|
||||
fetchBackups();
|
||||
} catch (err) {
|
||||
setError(t('backup.createError'));
|
||||
console.error('Failed to create backup:', err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackup = (backup) => {
|
||||
window.open(`/api/backup/download/${backup.filename}?token=${token}`, '_blank');
|
||||
};
|
||||
|
||||
const handleDeleteBackup = async () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/backup/${selectedBackup.filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('backup.deleteSuccess'));
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedBackup(null);
|
||||
fetchBackups();
|
||||
} catch (err) {
|
||||
setError(t('backup.deleteError'));
|
||||
console.error('Failed to delete backup:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
try {
|
||||
setRestoring(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const response = await axios.post(
|
||||
`/api/backup/restore/${selectedBackup.filename}`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setSuccess(
|
||||
`${t('backup.restoreSuccess')}: ${response.data.stats.playlists} playlists, ${response.data.stats.channels} channels, ${response.data.stats.favorites} favorites`
|
||||
);
|
||||
setRestoreDialogOpen(false);
|
||||
setSelectedBackup(null);
|
||||
|
||||
// Refresh page after 2 seconds to show restored data
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (err) {
|
||||
setError(t('backup.restoreError'));
|
||||
console.error('Failed to restore backup:', err);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadBackup = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setUploadProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
|
||||
await axios.post('/api/backup/upload', formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
setUploadProgress(percentCompleted);
|
||||
}
|
||||
});
|
||||
|
||||
setSuccess(t('backup.uploadSuccess'));
|
||||
fetchBackups();
|
||||
} catch (err) {
|
||||
setError(t('backup.uploadError'));
|
||||
console.error('Failed to upload backup:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setUploadProgress(0);
|
||||
event.target.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1rem', fontWeight: 600 }}>
|
||||
{t('backup.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="label"
|
||||
startIcon={<UploadIcon fontSize="small" />}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('backup.upload')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".zip"
|
||||
onChange={handleUploadBackup}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleCreateBackup}
|
||||
startIcon={creating ? <CircularProgress size={16} /> : <BackupIcon fontSize="small" />}
|
||||
disabled={creating || loading}
|
||||
>
|
||||
{t('backup.create')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{uploadProgress > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
||||
{t('backup.uploading')}: {uploadProgress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess('')} sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert severity="info" icon={<InfoIcon fontSize="small" />} sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
{t('backup.description')}
|
||||
</Alert>
|
||||
|
||||
{loading && !uploadProgress ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : backups.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('backup.noBackups')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{backups.map((backup, index) => (
|
||||
<React.Fragment key={backup.filename}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem
|
||||
sx={{
|
||||
px: 0,
|
||||
py: 1.5,
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
alignItems: { xs: 'flex-start', sm: 'center' }
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{t('backup.backup')} #{backups.length - index}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={formatFileSize(backup.size)}
|
||||
size="small"
|
||||
sx={{ height: '20px', fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('backup.created')}: {formatDate(backup.created)}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: { xs: 1, sm: 0 } }}
|
||||
/>
|
||||
<ListItemSecondaryAction sx={{ position: { xs: 'relative', sm: 'absolute' }, right: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDownloadBackup(backup)}
|
||||
title={t('backup.download')}
|
||||
>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedBackup(backup);
|
||||
setRestoreDialogOpen(true);
|
||||
}}
|
||||
title={t('backup.restore')}
|
||||
color="primary"
|
||||
>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedBackup(backup);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
title={t('backup.delete')}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('backup.deleteTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('backup.deleteConfirm')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)} size="small">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteBackup} color="error" variant="contained" size="small">
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Restore Confirmation Dialog */}
|
||||
<Dialog open={restoreDialogOpen} onClose={() => !restoring && setRestoreDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('backup.restoreTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('backup.restoreConfirm')}
|
||||
</DialogContentText>
|
||||
<Alert severity="warning" sx={{ mt: 2, fontSize: '0.75rem' }}>
|
||||
{t('backup.restoreWarning')}
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRestoreDialogOpen(false)} size="small" disabled={restoring}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRestoreBackup}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={restoring}
|
||||
startIcon={restoring ? <CircularProgress size={16} /> : <RestoreIcon fontSize="small" />}
|
||||
>
|
||||
{t('backup.restore')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupRestore;
|
||||
571
frontend/src/components/CSPDashboard.jsx
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
BugReport,
|
||||
Refresh,
|
||||
Delete,
|
||||
Visibility,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Policy,
|
||||
Shield,
|
||||
Lock,
|
||||
VpnLock,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const CSPDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token, user } = useAuthStore();
|
||||
const { notifySecuritySuccess, notifySecurityError } = useSecurityNotification();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// CSP State
|
||||
const [cspStats, setCspStats] = useState(null);
|
||||
const [cspViolations, setCspViolations] = useState([]);
|
||||
const [cspPolicy, setCspPolicy] = useState(null);
|
||||
const [violationDialogOpen, setViolationDialogOpen] = useState(false);
|
||||
const [selectedViolation, setSelectedViolation] = useState(null);
|
||||
const [clearDays, setClearDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === 'admin') {
|
||||
fetchCSPData();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchCSPData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, violationsRes, policyRes] = await Promise.all([
|
||||
axios.get('/api/csp/stats?days=7', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get('/api/csp/violations?limit=50', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get('/api/csp/policy', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
setCspStats(statsRes.data);
|
||||
setCspViolations(violationsRes.data.violations);
|
||||
setCspPolicy(policyRes.data);
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to fetch CSP data'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearViolations = async () => {
|
||||
try {
|
||||
const response = await axios.delete(`/api/csp/violations?days=${clearDays}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(
|
||||
`${response.data.deleted} ${t('security.cspViolationsCleared')}`
|
||||
);
|
||||
fetchCSPData();
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to clear violations'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewViolation = (violation) => {
|
||||
setSelectedViolation(violation);
|
||||
setViolationDialogOpen(true);
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Alert severity="warning" sx={{ mt: 4 }}>
|
||||
{t('security.adminAccessRequired')}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ py: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Security fontSize="large" color="primary" />
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('security.cspDashboard')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/security')}
|
||||
>
|
||||
{t('backToSecurity')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchCSPData}
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* CSP Policy Status */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Shield color="primary" />
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('security.cspPolicyStatus')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Policy color="primary" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('security.mode')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={cspPolicy?.mode === 'enforce' ? t('security.enforcing') : t('security.reportOnly')}
|
||||
color={cspPolicy?.mode === 'enforce' ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<BugReport color="error" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('security.totalViolations')} (7 {t('days')})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{cspStats?.total || 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Lock color="success" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('security.policyDirectives')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{cspPolicy ? Object.keys(cspPolicy.policy).length : 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)}>
|
||||
<Tab label={t('security.violations')} icon={<Warning />} iconPosition="start" />
|
||||
<Tab label={t('security.statistics')} icon={<BugReport />} iconPosition="start" />
|
||||
<Tab label={t('security.policy')} icon={<Policy />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Tab Content */}
|
||||
{tabValue === 0 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('security.recentViolations')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>{t('security.clearOlderThan')}</InputLabel>
|
||||
<Select
|
||||
value={clearDays}
|
||||
onChange={(e) => setClearDays(e.target.value)}
|
||||
label={t('security.clearOlderThan')}
|
||||
>
|
||||
<MenuItem value={7}>7 {t('days')}</MenuItem>
|
||||
<MenuItem value={14}>14 {t('days')}</MenuItem>
|
||||
<MenuItem value={30}>30 {t('days')}</MenuItem>
|
||||
<MenuItem value={90}>90 {t('days')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<Delete />}
|
||||
onClick={handleClearViolations}
|
||||
>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{cspViolations.length === 0 ? (
|
||||
<Alert severity="success" icon={<CheckCircle />}>
|
||||
{t('security.noViolations')}
|
||||
</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.timestamp')}</TableCell>
|
||||
<TableCell>{t('security.violatedDirective')}</TableCell>
|
||||
<TableCell>{t('security.blockedUri')}</TableCell>
|
||||
<TableCell>{t('security.sourceFile')}</TableCell>
|
||||
<TableCell>{t('security.ipAddress')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cspViolations.map((violation) => (
|
||||
<TableRow key={violation.id}>
|
||||
<TableCell>
|
||||
{format(new Date(violation.created_at), 'MMM d, HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={violation.violated_directive}
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 200, display: 'block' }}>
|
||||
{violation.blocked_uri || 'N/A'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 200, display: 'block' }}>
|
||||
{violation.source_file || 'N/A'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption">
|
||||
{violation.ip_address}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('security.viewDetails')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleViewViolation(violation)}
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
{t('security.violationStatistics')}
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{/* By Directive */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('security.byDirective')}
|
||||
</Typography>
|
||||
{cspStats?.byDirective?.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.directive')}</TableCell>
|
||||
<TableCell align="right">{t('count')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cspStats.byDirective.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>
|
||||
<Chip label={item.violated_directive} size="small" color="error" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="h6">{item.count}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">{t('security.noData')}</Alert>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* By URI */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('security.byBlockedUri')}
|
||||
</Typography>
|
||||
{cspStats?.byUri?.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.blockedUri')}</TableCell>
|
||||
<TableCell align="right">{t('count')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cspStats.byUri.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 300, display: 'block' }}>
|
||||
{item.blocked_uri || 'inline'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="h6">{item.count}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">{t('security.noData')}</Alert>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{tabValue === 2 && cspPolicy && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
{t('security.currentCspPolicy')}
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{cspPolicy.mode === 'enforce'
|
||||
? t('security.cspEnforcedDescription')
|
||||
: t('security.cspReportOnlyDescription')}
|
||||
</Alert>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.directive')}</TableCell>
|
||||
<TableCell>{t('security.allowedSources')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(cspPolicy.policy).map(([directive, sources]) => (
|
||||
sources && (
|
||||
<TableRow key={directive}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{directive}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{Array.isArray(sources) ? sources.map((source, idx) => (
|
||||
<Chip key={idx} label={source} size="small" variant="outlined" />
|
||||
)) : (
|
||||
<Chip label={sources.toString()} size="small" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Violation Details Dialog */}
|
||||
<Dialog
|
||||
open={violationDialogOpen}
|
||||
onClose={() => setViolationDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ErrorIcon color="error" />
|
||||
{t('security.violationDetails')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedViolation && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.timestamp')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{format(new Date(selectedViolation.created_at), 'PPpp')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.violatedDirective')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
<Chip label={selectedViolation.violated_directive} color="error" size="small" />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.blockedUri')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
|
||||
{selectedViolation.blocked_uri || 'inline'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.sourceFile')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
|
||||
{selectedViolation.source_file || 'N/A'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
{selectedViolation.line_number && (
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.lineNumber')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedViolation.line_number}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{selectedViolation.column_number && (
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.columnNumber')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedViolation.column_number}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.documentUri')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
|
||||
{selectedViolation.document_uri}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.ipAddress')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedViolation.ip_address}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.userAgent')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
|
||||
{selectedViolation.user_agent}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setViolationDialogOpen(false)}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CSPDashboard;
|
||||
180
frontend/src/components/ChangePasswordDialog.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import PasswordStrengthMeter from './PasswordStrengthMeter';
|
||||
import { useErrorNotification } from './ErrorNotificationProvider';
|
||||
|
||||
const ChangePasswordDialog = ({ open, onClose, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token, clearPasswordFlag } = useAuthStore();
|
||||
const { showError, showSuccess } = useErrorNotification();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsDoNotMatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
clearPasswordFlag();
|
||||
|
||||
// Show success notification
|
||||
showSuccess(t('auth.passwordChangeSuccess') || 'Password changed successfully!', {
|
||||
duration: 4000
|
||||
});
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
if (onClose) onClose();
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
// Handle detailed password policy errors
|
||||
if (data.details) {
|
||||
setError(data.details.join('. '));
|
||||
// Also show as notification for better visibility
|
||||
showError(
|
||||
new Error(data.details.join('. ')),
|
||||
{
|
||||
title: t('errors.validation.title'),
|
||||
duration: 10000
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setError(data.error || t('auth.passwordChangeFailed'));
|
||||
showError(
|
||||
new Error(data.error || t('auth.passwordChangeFailed')),
|
||||
{
|
||||
title: t('auth.changePasswordFailed') || 'Password Change Failed',
|
||||
duration: 6000
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('auth.passwordChangeFailed'));
|
||||
showError(err, {
|
||||
title: t('auth.changePasswordFailed') || 'Password Change Failed',
|
||||
defaultMessage: t('auth.passwordChangeFailed'),
|
||||
duration: 6000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={null}
|
||||
disableEscapeKeyDown
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{t('auth.changePasswordRequired')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('auth.changePasswordWarning')}
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('auth.currentPassword')}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
size="small"
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('auth.newPassword')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('auth.confirmPassword')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
size="small"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : t('auth.changePassword')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordDialog;
|
||||
232
frontend/src/components/ChannelLogoManager.jsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import { CloudUpload, Delete, Close } from '@mui/icons-material';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import Logo from './Logo';
|
||||
|
||||
function ChannelLogoManager({ open, onClose, channel, onLogoUpdated }) {
|
||||
const { token } = useAuthStore();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [preview, setPreview] = useState(null);
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size (5MB max)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.match(/image\/(jpeg|jpg|png|gif|svg\+xml|webp)/)) {
|
||||
setError('Only image files are allowed (JPG, PNG, GIF, SVG, WebP)');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setUploading(true);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setPreview(e.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload to server
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/channels/${channel.id}/logo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
onLogoUpdated({ ...channel, logo: data.logoUrl });
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setPreview(null);
|
||||
}, 1000);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to upload logo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLogo = async () => {
|
||||
setDeleting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/channels/${channel.id}/logo`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onLogoUpdated({ ...channel, logo: null });
|
||||
onClose();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to delete logo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentLogo = preview || channel?.logo;
|
||||
const hasCustomLogo = channel?.custom_logo || preview;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">Manage Channel Logo</Typography>
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{channel?.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Current Logo Preview */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 200,
|
||||
bgcolor: 'background.default',
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
position: 'relative',
|
||||
border: '2px dashed',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<CircularProgress />
|
||||
) : currentLogo ? (
|
||||
<img
|
||||
src={currentLogo}
|
||||
alt={channel?.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!currentLogo && !uploading && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Logo size={80} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
No logo uploaded
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Upload Instructions */}
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 2 }}>
|
||||
• Supported formats: JPG, PNG, GIF, SVG, WebP<br />
|
||||
• Maximum size: 5MB<br />
|
||||
• Recommended: Square images (1:1 ratio)
|
||||
</Typography>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexDirection: 'column' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
startIcon={<CloudUpload />}
|
||||
disabled={uploading || deleting}
|
||||
fullWidth
|
||||
>
|
||||
{hasCustomLogo ? 'Replace Logo' : 'Upload Logo'}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/svg+xml,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{hasCustomLogo && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={deleting ? <CircularProgress size={20} /> : <Delete />}
|
||||
onClick={handleDeleteLogo}
|
||||
disabled={uploading || deleting}
|
||||
fullWidth
|
||||
>
|
||||
Delete Custom Logo
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasCustomLogo && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block', textAlign: 'center' }}>
|
||||
Custom logo will be used instead of the official channel logo
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={uploading || deleting}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelLogoManager;
|
||||
612
frontend/src/components/EncryptionManagementDashboard.jsx
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Security,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Info,
|
||||
VpnKey,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
Scanner,
|
||||
Refresh,
|
||||
VerifiedUser,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useErrorNotification } from './ErrorNotificationProvider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function EncryptionManagementDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const { showError, showSuccess, showWarning } = useErrorNotification();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [scanResults, setScanResults] = useState(null);
|
||||
const [verifyResults, setVerifyResults] = useState(null);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
// Dialogs
|
||||
const [scanDialogOpen, setScanDialogOpen] = useState(false);
|
||||
const [migrateDialogOpen, setMigrateDialogOpen] = useState(false);
|
||||
const [verifyDialogOpen, setVerifyDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEncryptionStatus();
|
||||
}, []);
|
||||
|
||||
const fetchEncryptionStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/encryption/status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setStatus(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.fetchError'));
|
||||
console.error('Error fetching encryption status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
setScanning(true);
|
||||
const response = await axios.get('/api/encryption/scan', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setScanResults(response.data.data);
|
||||
setScanDialogOpen(true);
|
||||
|
||||
if (response.data.data.totalIssues === 0) {
|
||||
showSuccess(t('encryption.scanComplete'));
|
||||
} else {
|
||||
showWarning(t('encryption.issuesFound', { count: response.data.data.totalIssues }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.scanError'));
|
||||
console.error('Error scanning:', error);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrate = async () => {
|
||||
try {
|
||||
setMigrating(true);
|
||||
const response = await axios.post('/api/encryption/migrate', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('encryption.migrationComplete', { count: response.data.data.totalMigrated }));
|
||||
setMigrateDialogOpen(false);
|
||||
|
||||
// Refresh status
|
||||
await fetchEncryptionStatus();
|
||||
|
||||
// Re-scan to show updated results
|
||||
await handleScan();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.migrationError'));
|
||||
console.error('Error migrating:', error);
|
||||
} finally {
|
||||
setMigrating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
try {
|
||||
setVerifying(true);
|
||||
const response = await axios.post('/api/encryption/verify', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setVerifyResults(response.data.data);
|
||||
setVerifyDialogOpen(true);
|
||||
showSuccess(t('encryption.verifyComplete'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.verifyError'));
|
||||
console.error('Error verifying:', error);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{t('encryption.statusError')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
if (status === 'secure') return 'success';
|
||||
if (status === 'default-key') return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getEncryptionPercentage = (stats) => {
|
||||
if (!stats) return 0;
|
||||
const total = Object.values(stats).reduce((sum, s) => sum + (s.total || 0), 0);
|
||||
const encrypted = Object.values(stats).reduce((sum, s) => sum + (s.encrypted || 0), 0);
|
||||
return total > 0 ? Math.round((encrypted / total) * 100) : 100;
|
||||
};
|
||||
|
||||
const encryptionPercentage = getEncryptionPercentage(status.statistics);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header with Back Button */}
|
||||
<Box mb={3}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/security')}
|
||||
variant="text"
|
||||
color="primary"
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ ml: 1 }}>
|
||||
<Shield sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchEncryptionStatus}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ ml: 10 }}>
|
||||
{t('encryption.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Warning if default key is being used */}
|
||||
{status.status === 'default-key' && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }} icon={<Warning />}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{status.warning}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{t('encryption.recommendations')}:
|
||||
</Typography>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
{status.recommendations.map((rec, idx) => (
|
||||
<li key={idx}><Typography variant="body2">{rec}</Typography></li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status Cards */}
|
||||
<Grid container spacing={3} mb={3}>
|
||||
{/* Encryption Status */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<Lock sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="h6">
|
||||
{t('encryption.status')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
label={status.status === 'secure' ? t('encryption.secure') : t('encryption.defaultKey')}
|
||||
color={getStatusColor(status.status)}
|
||||
icon={status.status === 'secure' ? <CheckCircle /> : <Warning />}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{t('encryption.algorithm')}: <strong>{status.algorithm}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('encryption.keySize')}: <strong>{status.keySize} bits</strong>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Encryption Coverage */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<VerifiedUser sx={{ mr: 1, color: 'success.main' }} />
|
||||
<Typography variant="h6">
|
||||
{t('encryption.coverage')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" mb={1}>
|
||||
<Typography variant="h3" color="primary">
|
||||
{encryptionPercentage}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" ml={1}>
|
||||
{t('encryption.encrypted')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={encryptionPercentage}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
||||
color={encryptionPercentage === 100 ? 'success' : 'warning'}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{status.statistics && (
|
||||
<>
|
||||
{Object.entries(status.statistics).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
{t(`encryption.${key}`)}: {value.encrypted}/{value.total}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Statistics Table */}
|
||||
{status.statistics && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<VpnKey sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.dataTypes')}
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('encryption.dataType')}</TableCell>
|
||||
<TableCell align="right">{t('encryption.total')}</TableCell>
|
||||
<TableCell align="right">{t('encryption.encrypted')}</TableCell>
|
||||
<TableCell align="right">{t('encryption.percentage')}</TableCell>
|
||||
<TableCell align="center">{t('encryption.status')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(status.statistics).map(([key, value]) => {
|
||||
const percentage = value.total > 0
|
||||
? Math.round((value.encrypted / value.total) * 100)
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
{t(`encryption.${key}`)}
|
||||
</TableCell>
|
||||
<TableCell align="right">{value.total}</TableCell>
|
||||
<TableCell align="right">{value.encrypted}</TableCell>
|
||||
<TableCell align="right">{percentage}%</TableCell>
|
||||
<TableCell align="center">
|
||||
{percentage === 100 ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Warning color="warning" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<SettingsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.actions')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={scanning ? <CircularProgress size={20} /> : <Scanner />}
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
>
|
||||
{t('encryption.scanButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
color="warning"
|
||||
startIcon={migrating ? <CircularProgress size={20} /> : <Lock />}
|
||||
onClick={() => setMigrateDialogOpen(true)}
|
||||
disabled={migrating || encryptionPercentage === 100}
|
||||
>
|
||||
{t('encryption.migrateButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={verifying ? <CircularProgress size={20} /> : <VerifiedUser />}
|
||||
onClick={handleVerify}
|
||||
disabled={verifying}
|
||||
>
|
||||
{t('encryption.verifyButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" mt={2}>
|
||||
<Info sx={{ fontSize: 16, verticalAlign: 'middle', mr: 0.5 }} />
|
||||
{t('encryption.actionsHelp')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scan Results Dialog */}
|
||||
<Dialog
|
||||
open={scanDialogOpen}
|
||||
onClose={() => setScanDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Scanner sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.scanResults')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{scanResults && (
|
||||
<>
|
||||
{scanResults.findings.length === 0 ? (
|
||||
<Alert severity="success" icon={<CheckCircle />}>
|
||||
{t('encryption.noIssuesFound')}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('encryption.foundIssues', { count: scanResults.totalIssues })}
|
||||
</Alert>
|
||||
|
||||
<List>
|
||||
{scanResults.findings.map((finding, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Warning color={finding.severity === 'high' ? 'error' : 'warning'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={`${finding.table}.${finding.field}`}
|
||||
secondary={
|
||||
<>
|
||||
<Typography component="span" variant="body2">
|
||||
{finding.description}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography component="span" variant="caption" color="error">
|
||||
{t('encryption.unencryptedCount')}: {finding.count}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label={finding.severity}
|
||||
color={finding.severity === 'high' ? 'error' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
{idx < scanResults.findings.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" mt={2}>
|
||||
<strong>{t('encryption.recommendation')}:</strong> {scanResults.recommendation}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setScanDialogOpen(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
{scanResults && scanResults.findings.length > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => {
|
||||
setScanDialogOpen(false);
|
||||
setMigrateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('encryption.migrateNow')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Migrate Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={migrateDialogOpen}
|
||||
onClose={() => !migrating && setMigrateDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Lock sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.confirmMigration')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('encryption.migrationWarning')}
|
||||
</Alert>
|
||||
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{t('encryption.migrationDescription')}
|
||||
</Typography>
|
||||
|
||||
<ul style={{ marginTop: '8px' }}>
|
||||
<li><Typography variant="body2">{t('encryption.migrationStep1')}</Typography></li>
|
||||
<li><Typography variant="body2">{t('encryption.migrationStep2')}</Typography></li>
|
||||
<li><Typography variant="body2">{t('encryption.migrationStep3')}</Typography></li>
|
||||
</ul>
|
||||
|
||||
{migrating && (
|
||||
<Box mt={2}>
|
||||
<LinearProgress />
|
||||
<Typography variant="body2" color="textSecondary" align="center" mt={1}>
|
||||
{t('encryption.migrating')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setMigrateDialogOpen(false)} disabled={migrating}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={handleMigrate}
|
||||
disabled={migrating}
|
||||
>
|
||||
{migrating ? t('encryption.migrating') : t('encryption.startMigration')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Verify Results Dialog */}
|
||||
<Dialog
|
||||
open={verifyDialogOpen}
|
||||
onClose={() => setVerifyDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<VerifiedUser sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.verificationResults')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{verifyResults && (
|
||||
<>
|
||||
{Object.entries(verifyResults).map(([key, value]) => (
|
||||
<Box key={key} mb={2}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t(`encryption.${key}`)}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('encryption.tested')}: {value.tested}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="body2" color="success.main">
|
||||
{t('encryption.valid')}: {value.valid}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="body2" color="error.main">
|
||||
{t('encryption.invalid')}: {value.invalid}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{value.invalid > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{t('encryption.invalidDataFound')}
|
||||
</Alert>
|
||||
)}
|
||||
{value.tested > 0 && value.invalid === 0 && (
|
||||
<Alert severity="success" sx={{ mt: 1 }}>
|
||||
{t('encryption.allDataValid')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setVerifyDialogOpen(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EncryptionManagementDashboard;
|
||||
121
frontend/src/components/ErrorBoundary.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import { Box, Button, Container, Paper, Typography } from '@mui/material';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Error Boundary Component
|
||||
* Catches JavaScript errors anywhere in the child component tree
|
||||
* Prevents application crashes and displays user-friendly error messages
|
||||
*
|
||||
* Security: Does NOT expose stack traces or technical details to users
|
||||
*/
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
errorCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log error details (only in development)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Error Boundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
// Update error count
|
||||
this.setState(prevState => ({
|
||||
errorCount: prevState.errorCount + 1
|
||||
}));
|
||||
|
||||
// You can log to an error reporting service here
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
errorCount: 0
|
||||
});
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <ErrorFallback onReset={this.handleReset} onReload={this.handleReload} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Fallback Component
|
||||
* Displays a user-friendly error message with recovery options
|
||||
*/
|
||||
function ErrorFallback({ onReset, onReload }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ mt: 8 }}>
|
||||
<Paper elevation={3} sx={{ p: 4, textAlign: 'center' }}>
|
||||
<ErrorOutlineIcon
|
||||
sx={{
|
||||
fontSize: 80,
|
||||
color: 'error.main',
|
||||
mb: 2
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="h4" gutterBottom color="error">
|
||||
{t('errors.general.unexpected')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('errors.general.tryAgain')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onReset}
|
||||
color="primary"
|
||||
>
|
||||
{t('errors.general.tryAgain')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onReload}
|
||||
color="primary"
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ mt: 3 }}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t('errors.general.contactSupport')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
178
frontend/src/components/ErrorNotificationProvider.jsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { Snackbar, Alert, AlertTitle, IconButton } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getErrorMessage, getErrorType, getErrorSeverity } from '../utils/errorHandler';
|
||||
|
||||
const ErrorNotificationContext = createContext(null);
|
||||
|
||||
/**
|
||||
* Error Notification Provider
|
||||
* Provides global error notification functionality throughout the app
|
||||
*/
|
||||
export function ErrorNotificationProvider({ children }) {
|
||||
const { t } = useTranslation();
|
||||
const [notification, setNotification] = useState(null);
|
||||
|
||||
/**
|
||||
* Show error notification from error object
|
||||
*/
|
||||
const showError = useCallback((error, options = {}) => {
|
||||
const {
|
||||
title,
|
||||
defaultMessage = t('errors.general.unexpected'),
|
||||
duration = 6000
|
||||
} = options;
|
||||
|
||||
const message = getErrorMessage(error, defaultMessage);
|
||||
const type = getErrorType(error);
|
||||
const severity = getErrorSeverity(error);
|
||||
|
||||
// Get translated title based on error type
|
||||
const errorTitle = title || getErrorTitle(type, t);
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title: errorTitle,
|
||||
severity,
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
/**
|
||||
* Show success notification
|
||||
*/
|
||||
const showSuccess = useCallback((message, options = {}) => {
|
||||
const { title, duration = 4000 } = options;
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title,
|
||||
severity: 'success',
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Show warning notification
|
||||
*/
|
||||
const showWarning = useCallback((message, options = {}) => {
|
||||
const { title, duration = 5000 } = options;
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title,
|
||||
severity: 'warning',
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Show info notification
|
||||
*/
|
||||
const showInfo = useCallback((message, options = {}) => {
|
||||
const { title, duration = 4000 } = options;
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title,
|
||||
severity: 'info',
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear notification
|
||||
*/
|
||||
const clearNotification = useCallback(() => {
|
||||
setNotification(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = (event, reason) => {
|
||||
if (reason === 'clickaway') {
|
||||
return;
|
||||
}
|
||||
clearNotification();
|
||||
};
|
||||
|
||||
const value = {
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
showInfo,
|
||||
clearNotification
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
{notification && (
|
||||
<Snackbar
|
||||
open={true}
|
||||
autoHideDuration={notification.duration}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ mt: 8 }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleClose}
|
||||
severity={notification.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%', maxWidth: 500 }}
|
||||
action={
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="close"
|
||||
color="inherit"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{notification.title && (
|
||||
<AlertTitle>{notification.title}</AlertTitle>
|
||||
)}
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
)}
|
||||
</ErrorNotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error title based on error type
|
||||
*/
|
||||
function getErrorTitle(type, t) {
|
||||
const titles = {
|
||||
auth: t('errors.auth.title'),
|
||||
permission: t('errors.permission.title'),
|
||||
validation: t('errors.validation.title'),
|
||||
network: t('errors.network.title'),
|
||||
server: t('errors.server.title'),
|
||||
unknown: t('error')
|
||||
};
|
||||
|
||||
return titles[type] || titles.unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use error notifications
|
||||
*/
|
||||
export function useErrorNotification() {
|
||||
const context = useContext(ErrorNotificationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useErrorNotification must be used within ErrorNotificationProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export default ErrorNotificationProvider;
|
||||
410
frontend/src/components/GlobalSearch.jsx
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
InputBase,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Typography,
|
||||
Chip,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Popper,
|
||||
ClickAwayListener
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Tv,
|
||||
Radio as RadioIcon,
|
||||
Person,
|
||||
Settings as SettingsIcon,
|
||||
Folder,
|
||||
Close,
|
||||
PlayArrow
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Logo from './Logo';
|
||||
import api from '../utils/api';
|
||||
|
||||
// Debounce hook
|
||||
function useDebounce(value, delay) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function GlobalSearch({ onChannelSelect }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
// Search function
|
||||
const performSearch = useCallback(async (query) => {
|
||||
if (!query || query.trim().length < 2) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/search', {
|
||||
params: { q: query }
|
||||
});
|
||||
setResults(response.data);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
setResults(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Perform search when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearch) {
|
||||
performSearch(debouncedSearch);
|
||||
} else {
|
||||
setResults(null);
|
||||
}
|
||||
}, [debouncedSearch, performSearch]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
setOpen(value.length >= 2);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchQuery('');
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleChannelClick = (channel) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
if (onChannelSelect) {
|
||||
onChannelSelect(channel);
|
||||
}
|
||||
// Navigate to appropriate page
|
||||
if (channel.is_radio === 1) {
|
||||
navigate('/radio', { state: { autoPlayChannel: channel }, replace: false });
|
||||
} else {
|
||||
navigate('/live-tv', { state: { autoPlayChannel: channel }, replace: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserClick = (user) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
navigate('/settings?tab=users', { state: { selectedUserId: user.id } });
|
||||
};
|
||||
|
||||
const handleSettingClick = (setting) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
navigate(setting.path);
|
||||
};
|
||||
|
||||
const handleGroupClick = (group) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
if (group.is_radio === 1) {
|
||||
navigate('/radio', { state: { selectedGroup: group.name } });
|
||||
} else {
|
||||
navigate('/live', { state: { selectedGroup: group.name } });
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = results && (
|
||||
results.channels?.length > 0 ||
|
||||
results.radio?.length > 0 ||
|
||||
results.users?.length > 0 ||
|
||||
results.settings?.length > 0 ||
|
||||
results.groups?.length > 0
|
||||
);
|
||||
|
||||
const getCategoryIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'channel': return <Tv fontSize="small" />;
|
||||
case 'radio': return <RadioIcon fontSize="small" />;
|
||||
case 'user': return <Person fontSize="small" />;
|
||||
case 'setting': return <SettingsIcon fontSize="small" />;
|
||||
case 'group': return <Folder fontSize="small" />;
|
||||
default: return <SearchIcon fontSize="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, maxWidth: 600, position: 'relative' }}>
|
||||
<Box
|
||||
ref={anchorRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1.5,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
transition: 'all 0.2s',
|
||||
border: open ? 2 : 0,
|
||||
borderColor: 'primary.main'
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ color: 'text.secondary', mr: 1, fontSize: 20 }} />
|
||||
<InputBase
|
||||
ref={inputRef}
|
||||
placeholder={t('search') || 'Search channels, settings, users...'}
|
||||
fullWidth
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
sx={{ color: 'text.primary', fontSize: '0.875rem' }}
|
||||
onFocus={() => searchQuery.length >= 2 && setOpen(true)}
|
||||
/>
|
||||
{(loading || searchQuery) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 1 }}>
|
||||
{loading && <CircularProgress size={16} sx={{ mr: 1 }} />}
|
||||
{searchQuery && (
|
||||
<IconButton size="small" onClick={handleClear}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Popper
|
||||
open={open && (hasResults || (searchQuery.length >= 2 && !loading))}
|
||||
anchorEl={anchorRef.current}
|
||||
placement="bottom-start"
|
||||
style={{ width: anchorRef.current?.offsetWidth, zIndex: 1300 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
mt: 1,
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
{!hasResults && !loading && searchQuery.length >= 2 && (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<SearchIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
|
||||
<Typography color="text.secondary">
|
||||
No results found for "{searchQuery}"
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* TV Channels */}
|
||||
{results?.channels?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
TV CHANNELS ({results.channels.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.channels.map((channel) => (
|
||||
<ListItem key={`channel-${channel.id}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleChannelClick(channel)}>
|
||||
<ListItemAvatar>
|
||||
{channel.logo ? (
|
||||
<Avatar
|
||||
src={channel.logo.startsWith('http') ? channel.logo : `/logos/${channel.logo}`}
|
||||
variant="rounded"
|
||||
sx={{ width: 40, height: 40 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar variant="rounded" sx={{ width: 40, height: 40 }}>
|
||||
<Logo size={24} />
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{channel.name}
|
||||
<PlayArrow sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
</Box>
|
||||
}
|
||||
secondary={channel.group_name}
|
||||
/>
|
||||
<Chip icon={getCategoryIcon('channel')} label="TV" size="small" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Radio Stations */}
|
||||
{results?.radio?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
RADIO STATIONS ({results.radio.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.radio.map((channel) => (
|
||||
<ListItem key={`radio-${channel.id}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleChannelClick(channel)}>
|
||||
<ListItemAvatar>
|
||||
{channel.logo ? (
|
||||
<Avatar
|
||||
src={channel.logo.startsWith('http') ? channel.logo : `/logos/${channel.logo}`}
|
||||
variant="rounded"
|
||||
sx={{ width: 40, height: 40 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar variant="rounded" sx={{ width: 40, height: 40 }}>
|
||||
<Logo size={24} />
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{channel.name}
|
||||
<PlayArrow sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
</Box>
|
||||
}
|
||||
secondary={channel.group_name}
|
||||
/>
|
||||
<Chip icon={getCategoryIcon('radio')} label="Radio" size="small" color="secondary" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
{results?.groups?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
GROUPS ({results.groups.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.groups.map((group, index) => (
|
||||
<ListItem key={`group-${index}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleGroupClick(group)}>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
{getCategoryIcon('group')}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={group.name}
|
||||
secondary={group.is_radio === 1 ? 'Radio Group' : 'TV Group'}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
{results?.settings?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
PAGES & SETTINGS ({results.settings.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.settings.map((setting) => (
|
||||
<ListItem key={setting.id} disablePadding>
|
||||
<ListItemButton onClick={() => handleSettingClick(setting)}>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
{getCategoryIcon('setting')}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={setting.name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Users (Admin only) */}
|
||||
{results?.users?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
USERS ({results.users.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.users.map((user) => (
|
||||
<ListItem key={`user-${user.id}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleUserClick(user)}>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
{getCategoryIcon('user')}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
secondary={user.email}
|
||||
/>
|
||||
<Chip
|
||||
label={user.role}
|
||||
size="small"
|
||||
color={user.role === 'admin' ? 'error' : 'default'}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalSearch;
|
||||
105
frontend/src/components/Header.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Logout,
|
||||
Menu as MenuIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
|
||||
function Header({ onMenuClick }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const { user } = useAuthStore();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
const handleMenuOpen = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'transparent',
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Toolbar variant="dense" sx={{ minHeight: 48 }}>
|
||||
<IconButton
|
||||
onClick={onMenuClick}
|
||||
sx={{
|
||||
mr: 2,
|
||||
p: 0.5,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<GlobalSearch />
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user?.username?.[0]?.toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout sx={{ mr: 1 }} fontSize="small" />
|
||||
{t('logout')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
504
frontend/src/components/LogManagementDashboard.jsx
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Archive,
|
||||
Delete,
|
||||
Download,
|
||||
Security,
|
||||
VerifiedUser,
|
||||
Warning,
|
||||
CleaningServices,
|
||||
Storage,
|
||||
Refresh,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useErrorNotification } from './ErrorNotificationProvider';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const LogManagementDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const { showError, showSuccess, showWarning } = useErrorNotification();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statistics, setStatistics] = useState(null);
|
||||
const [archives, setArchives] = useState([]);
|
||||
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false);
|
||||
const [retentionDays, setRetentionDays] = useState(90);
|
||||
const [verifyDialogOpen, setVerifyDialogOpen] = useState(false);
|
||||
const [integrityResult, setIntegrityResult] = useState(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'admin') {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, navigate]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, archivesRes] = await Promise.all([
|
||||
axios.get('/api/log-management/statistics', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get('/api/log-management/archives', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
setStatistics(statsRes.data.data);
|
||||
setArchives(archivesRes.data.data);
|
||||
} catch (err) {
|
||||
if (err.response?.status !== 401) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to load log management data'
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualCleanup = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await axios.post('/api/log-management/cleanup',
|
||||
{ retentionDays },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
showSuccess(response.data.message, { duration: 5000 });
|
||||
setCleanupDialogOpen(false);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to perform cleanup'
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyIntegrity = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await axios.post('/api/log-management/verify-integrity', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setIntegrityResult(response.data.data);
|
||||
setVerifyDialogOpen(true);
|
||||
|
||||
if (response.data.alert) {
|
||||
showWarning(response.data.message, {
|
||||
title: 'Security Alert',
|
||||
duration: 10000
|
||||
});
|
||||
} else {
|
||||
showSuccess(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to verify log integrity'
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadArchive = (filename) => {
|
||||
window.location.href = `/api/log-management/archives/download/${filename}?token=${token}`;
|
||||
};
|
||||
|
||||
const handleDeleteArchive = async (filename) => {
|
||||
if (!confirm(`Delete archive "${filename}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/log-management/archives/${filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
showSuccess('Archive deleted successfully');
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to delete archive'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const totalLogs = statistics?.sources?.reduce((sum, s) => sum + s.total, 0) || 0;
|
||||
const archiveSize = statistics?.archives?.totalSizeMB || 0;
|
||||
const archiveCount = statistics?.archives?.count || 0;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header with Back Button */}
|
||||
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton
|
||||
onClick={() => navigate('/security')}
|
||||
color="primary"
|
||||
size="large"
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Archive sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('logManagement.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('logManagement.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchData}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<strong>CWE-53 Compliance:</strong> Automated log retention, archival, and integrity verification are active.
|
||||
Logs are preserved securely with tamper detection and encrypted archives.
|
||||
</Alert>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.totalLogs')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{totalLogs.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Storage sx={{ fontSize: 40, color: 'primary.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.archives')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{archiveCount}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{archiveSize} MB
|
||||
</Typography>
|
||||
</Box>
|
||||
<Archive sx={{ fontSize: 40, color: 'success.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.retention')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
90
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.days')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CleaningServices sx={{ fontSize: 40, color: 'warning.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.integrity')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold" color="success.main">
|
||||
✓
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.protected')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<VerifiedUser sx={{ fontSize: 40, color: 'success.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="warning"
|
||||
startIcon={<CleaningServices />}
|
||||
onClick={() => setCleanupDialogOpen(true)}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('logManagement.manualCleanup')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Security />}
|
||||
onClick={handleVerifyIntegrity}
|
||||
disabled={actionLoading}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{actionLoading ? <CircularProgress size={24} /> : t('logManagement.verifyIntegrity')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Archives Table */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2 }}>
|
||||
{t('logManagement.archivesList')}
|
||||
</Typography>
|
||||
|
||||
{archives.length === 0 ? (
|
||||
<Alert severity="info">{t('logManagement.noArchives')}</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>{t('logManagement.filename')}</strong></TableCell>
|
||||
<TableCell><strong>{t('logManagement.size')}</strong></TableCell>
|
||||
<TableCell><strong>{t('logManagement.created')}</strong></TableCell>
|
||||
<TableCell align="right"><strong>{t('common.actions')}</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{archives.map((archive) => (
|
||||
<TableRow key={archive.filename} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontFamily="monospace">
|
||||
{archive.filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={`${archive.sizeMB} MB`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(archive.created), 'PPpp')}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('common.download')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleDownloadArchive(archive.filename)}
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteArchive(archive.filename)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cleanup Dialog */}
|
||||
<Dialog open={cleanupDialogOpen} onClose={() => setCleanupDialogOpen(false)}>
|
||||
<DialogTitle>{t('logManagement.manualCleanup')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('logManagement.cleanupWarning')}
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={t('logManagement.retentionDays')}
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(parseInt(e.target.value) || 90)}
|
||||
inputProps={{ min: 7, max: 365 }}
|
||||
helperText={t('logManagement.retentionHelp')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCleanupDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={handleManualCleanup}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? <CircularProgress size={24} /> : t('logManagement.performCleanup')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Integrity Result Dialog */}
|
||||
<Dialog open={verifyDialogOpen} onClose={() => setVerifyDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{integrityResult?.tampered > 0 ? (
|
||||
<ErrorIcon color="error" />
|
||||
) : (
|
||||
<CheckCircle color="success" />
|
||||
)}
|
||||
{t('logManagement.integrityResults')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{integrityResult && (
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.verified')}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="success.main">
|
||||
{integrityResult.verified}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.tampered')}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="error.main">
|
||||
{integrityResult.tampered}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{integrityResult.tampered > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<strong>{t('logManagement.securityAlert')}:</strong> {integrityResult.tampered} tampered logs detected.
|
||||
Immediate investigation required!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{integrityResult.tampered === 0 && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{t('logManagement.allLogsVerified')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setVerifyDialogOpen(false)} variant="contained">
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogManagementDashboard;
|
||||
34
frontend/src/components/Logo.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
const Logo = ({ size = 40 }) => {
|
||||
return (
|
||||
<Box
|
||||
component="svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 128 128"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#3b82f6', stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="url(#logoGrad)" />
|
||||
<path d="M 48 35 L 48 93 L 93 64 Z" fill="#ffffff" opacity="0.95" />
|
||||
<path
|
||||
d="M 64 64 C 64 64, 54 56, 48 50 C 42 44, 38 38, 38 36 L 96 64 C 96 64, 94 68, 88 74 C 82 80, 74 88, 66 93"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="3"
|
||||
opacity="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
265
frontend/src/components/MultiScreen.jsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
IconButton,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Close,
|
||||
ViewModule,
|
||||
ViewComfy,
|
||||
GridView,
|
||||
SwapHoriz
|
||||
} from '@mui/icons-material';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import Logo from './Logo';
|
||||
|
||||
function MultiScreen({ channels }) {
|
||||
const [screens, setScreens] = useState([null, null]); // Start with 2 screens
|
||||
const [layout, setLayout] = useState('2x1'); // '2x1', '2x2', '3x1', '1x2'
|
||||
const [selectDialogOpen, setSelectDialogOpen] = useState(false);
|
||||
const [selectingScreenIndex, setSelectingScreenIndex] = useState(null);
|
||||
|
||||
const layouts = [
|
||||
{ value: '1x2', label: '1×2', icon: <ViewModule />, screens: 2, cols: 1, rows: 2 },
|
||||
{ value: '2x1', label: '2×1', icon: <ViewComfy />, screens: 2, cols: 2, rows: 1 },
|
||||
{ value: '2x2', label: '2×2', icon: <GridView />, screens: 4, cols: 2, rows: 2 },
|
||||
{ value: '3x1', label: '3×1', icon: <ViewModule />, screens: 3, cols: 3, rows: 1 }
|
||||
];
|
||||
|
||||
const currentLayout = layouts.find(l => l.value === layout) || layouts[1];
|
||||
|
||||
const handleLayoutChange = (newLayout) => {
|
||||
const newLayoutConfig = layouts.find(l => l.value === newLayout);
|
||||
setLayout(newLayout);
|
||||
|
||||
// Adjust screens array to match new layout
|
||||
const newScreens = [...screens];
|
||||
while (newScreens.length < newLayoutConfig.screens) {
|
||||
newScreens.push(null);
|
||||
}
|
||||
while (newScreens.length > newLayoutConfig.screens) {
|
||||
newScreens.pop();
|
||||
}
|
||||
setScreens(newScreens);
|
||||
};
|
||||
|
||||
const handleOpenSelectDialog = (screenIndex) => {
|
||||
setSelectingScreenIndex(screenIndex);
|
||||
setSelectDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectChannel = (channel) => {
|
||||
const newScreens = [...screens];
|
||||
newScreens[selectingScreenIndex] = channel;
|
||||
setScreens(newScreens);
|
||||
setSelectDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveChannel = (screenIndex) => {
|
||||
const newScreens = [...screens];
|
||||
newScreens[screenIndex] = null;
|
||||
setScreens(newScreens);
|
||||
};
|
||||
|
||||
const handleSwapScreens = (index1, index2) => {
|
||||
const newScreens = [...screens];
|
||||
[newScreens[index1], newScreens[index2]] = [newScreens[index2], newScreens[index1]];
|
||||
setScreens(newScreens);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Layout Controls */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
Multi-Screen View
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Watch up to {currentLayout.screens} channels simultaneously
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{layouts.map((layoutOption) => (
|
||||
<Tooltip key={layoutOption.value} title={layoutOption.label}>
|
||||
<IconButton
|
||||
onClick={() => handleLayoutChange(layoutOption.value)}
|
||||
color={layout === layoutOption.value ? 'primary' : 'default'}
|
||||
sx={{
|
||||
border: layout === layoutOption.value ? 2 : 1,
|
||||
borderColor: layout === layoutOption.value ? 'primary.main' : 'divider'
|
||||
}}
|
||||
>
|
||||
{layoutOption.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Multi-Screen Grid */}
|
||||
<Grid container spacing={2}>
|
||||
{screens.map((channel, index) => (
|
||||
<Grid
|
||||
item
|
||||
key={index}
|
||||
xs={12 / currentLayout.cols}
|
||||
md={12 / currentLayout.cols}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 2,
|
||||
borderColor: channel ? 'primary.main' : 'divider'
|
||||
}}
|
||||
>
|
||||
{channel ? (
|
||||
<>
|
||||
{/* Screen Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
gap: 0.5
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={`Screen ${index + 1}`}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.7)', color: 'white' }}
|
||||
/>
|
||||
{index < screens.length - 1 && screens[index + 1] && (
|
||||
<Tooltip title="Swap with next">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleSwapScreens(index, index + 1)}
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.7)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.9)' } }}
|
||||
>
|
||||
<SwapHoriz fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Remove channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRemoveChannel(index)}
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.7)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.9)' } }}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Video Player (PiP disabled for multi-screen) */}
|
||||
<VideoPlayer channel={channel} enablePip={false} />
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 2,
|
||||
bgcolor: 'action.hover'
|
||||
}}
|
||||
>
|
||||
<Logo size={80} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Screen {index + 1}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => handleOpenSelectDialog(index)}
|
||||
>
|
||||
Select Channel
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Channel Selection Dialog */}
|
||||
<Dialog
|
||||
open={selectDialogOpen}
|
||||
onClose={() => setSelectDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Select Channel for Screen {selectingScreenIndex !== null ? selectingScreenIndex + 1 : ''}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
{channels.map((channel) => {
|
||||
const alreadySelected = screens.some(s => s?.id === channel.id);
|
||||
return (
|
||||
<ListItem key={channel.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => handleSelectChannel(channel)}
|
||||
disabled={alreadySelected}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
{channel.logo ? (
|
||||
<Avatar
|
||||
src={channel.logo.startsWith('http') ? channel.logo : `/logos/${channel.logo}`}
|
||||
alt={channel.name}
|
||||
variant="rounded"
|
||||
/>
|
||||
) : (
|
||||
<Avatar variant="rounded">
|
||||
<Logo size={32} />
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={channel.name}
|
||||
secondary={channel.group_name}
|
||||
/>
|
||||
{alreadySelected && (
|
||||
<Chip label="In Use" size="small" color="primary" />
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiScreen;
|
||||
233
frontend/src/components/PasswordStrengthMeter.jsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import { CheckCircle, Cancel, Info } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PasswordStrengthMeter = ({ password, username = '', email = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [strength, setStrength] = useState({ score: 0, level: 'veryWeak', feedback: [] });
|
||||
const [requirements, setRequirements] = useState({
|
||||
minLength: false,
|
||||
uppercase: false,
|
||||
lowercase: false,
|
||||
number: false,
|
||||
special: false,
|
||||
noUsername: true,
|
||||
noEmail: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
setStrength({ score: 0, level: 'veryWeak', feedback: [] });
|
||||
setRequirements({
|
||||
minLength: false,
|
||||
uppercase: false,
|
||||
lowercase: false,
|
||||
number: false,
|
||||
special: false,
|
||||
noUsername: true,
|
||||
noEmail: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check requirements
|
||||
const reqs = {
|
||||
minLength: password.length >= 12,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
|
||||
noUsername: username ? !password.toLowerCase().includes(username.toLowerCase()) : true,
|
||||
noEmail: email ? !password.toLowerCase().includes(email.split('@')[0].toLowerCase()) : true
|
||||
};
|
||||
setRequirements(reqs);
|
||||
|
||||
// Calculate score
|
||||
let score = 0;
|
||||
if (reqs.minLength) score += 20;
|
||||
if (reqs.uppercase) score += 15;
|
||||
if (reqs.lowercase) score += 15;
|
||||
if (reqs.number) score += 15;
|
||||
if (reqs.special) score += 15;
|
||||
if (password.length > 15) score += 10;
|
||||
if (password.length > 20) score += 10;
|
||||
if (!reqs.noUsername || !reqs.noEmail) score -= 30;
|
||||
|
||||
// Determine level
|
||||
let level = 'veryWeak';
|
||||
let color = '#d32f2f';
|
||||
if (score >= 91) {
|
||||
level = 'veryStrong';
|
||||
color = '#2e7d32';
|
||||
} else if (score >= 76) {
|
||||
level = 'strong';
|
||||
color = '#66bb6a';
|
||||
} else if (score >= 51) {
|
||||
level = 'good';
|
||||
color = '#fbc02d';
|
||||
} else if (score >= 26) {
|
||||
level = 'weak';
|
||||
color = '#f57c00';
|
||||
}
|
||||
|
||||
setStrength({
|
||||
score: Math.max(0, Math.min(100, score)),
|
||||
level,
|
||||
color,
|
||||
feedback: []
|
||||
});
|
||||
}, [password, username, email]);
|
||||
|
||||
const getStrengthColor = () => {
|
||||
if (strength.score >= 91) return '#2e7d32'; // green
|
||||
if (strength.score >= 76) return '#66bb6a'; // light green
|
||||
if (strength.score >= 51) return '#fbc02d'; // yellow
|
||||
if (strength.score >= 26) return '#f57c00'; // orange
|
||||
return '#d32f2f'; // red
|
||||
};
|
||||
|
||||
const getStrengthLabel = () => {
|
||||
if (strength.score >= 91) return t('security.veryStrong');
|
||||
if (strength.score >= 76) return t('security.strong');
|
||||
if (strength.score >= 51) return t('security.good');
|
||||
if (strength.score >= 26) return t('security.weak');
|
||||
return t('security.veryWeak');
|
||||
};
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
|
||||
{t('security.passwordStrength')}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={strength.score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getStrengthColor(),
|
||||
transition: 'all 0.3s ease'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
sx={{ color: getStrengthColor(), minWidth: 80, textAlign: 'right' }}
|
||||
>
|
||||
{getStrengthLabel()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Collapse in={password.length > 0}>
|
||||
<Box sx={{ mt: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||
<Info fontSize="small" />
|
||||
{t('security.requirements')}
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.minLength ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.minLength')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.uppercase ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.uppercase')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.lowercase ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.lowercase')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.number ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.number')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.special ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.special')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
{username && (
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.noUsername ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.noUsername')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordStrengthMeter;
|
||||
858
frontend/src/components/RBACDashboard.jsx
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Chip,
|
||||
IconButton,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Tooltip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Edit,
|
||||
Delete,
|
||||
Security,
|
||||
Group,
|
||||
Assessment,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Info,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Block,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import api from '../utils/api';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
|
||||
function TabPanel(props) {
|
||||
const { children, value, index, ...other } = props;
|
||||
return (
|
||||
<div role="tabpanel" hidden={value !== index} {...other}>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RBACDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const { showNotification } = useSecurityNotification();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Roles state
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [selectedRole, setSelectedRole] = useState(null);
|
||||
const [roleDialog, setRoleDialog] = useState(false);
|
||||
const [roleFormData, setRoleFormData] = useState({
|
||||
role_key: '',
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
});
|
||||
|
||||
// Permissions state
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [permissionCategories, setPermissionCategories] = useState({});
|
||||
const [myPermissions, setMyPermissions] = useState(null);
|
||||
|
||||
// Users state
|
||||
const [users, setUsers] = useState([]);
|
||||
const [userRoleDialog, setUserRoleDialog] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [newUserRole, setNewUserRole] = useState('');
|
||||
|
||||
// Audit log state
|
||||
const [auditLogs, setAuditLogs] = useState([]);
|
||||
const [auditFilters, setAuditFilters] = useState({
|
||||
action: '',
|
||||
userId: '',
|
||||
targetType: ''
|
||||
});
|
||||
|
||||
// Statistics state
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [tabValue]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (tabValue === 0) {
|
||||
await Promise.all([loadRoles(), loadPermissions()]);
|
||||
} else if (tabValue === 1) {
|
||||
await Promise.all([loadUsers(), loadRoles()]);
|
||||
} else if (tabValue === 2) {
|
||||
await loadAuditLog();
|
||||
} else if (tabValue === 3) {
|
||||
await loadStats();
|
||||
} else if (tabValue === 4) {
|
||||
await loadMyPermissions();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load data');
|
||||
showNotification('error', err.response?.data?.error || 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/roles');
|
||||
setRoles(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load roles:', err);
|
||||
setRoles([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPermissions = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/permissions');
|
||||
setPermissions(Array.isArray(response.data.permissions) ? response.data.permissions : []);
|
||||
setPermissionCategories(response.data.categories || {});
|
||||
} catch (err) {
|
||||
console.error('Failed to load permissions:', err);
|
||||
setPermissions([]);
|
||||
setPermissionCategories({});
|
||||
}
|
||||
};
|
||||
|
||||
const loadMyPermissions = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/my-permissions');
|
||||
setMyPermissions(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load my permissions:', err);
|
||||
setMyPermissions({ permissions: [], role: null });
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await api.get('/users');
|
||||
setUsers(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err);
|
||||
setUsers([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuditLog = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (auditFilters.action) params.append('action', auditFilters.action);
|
||||
if (auditFilters.userId) params.append('userId', auditFilters.userId);
|
||||
if (auditFilters.targetType) params.append('targetType', auditFilters.targetType);
|
||||
|
||||
const response = await api.get(`/rbac/audit-log?${params.toString()}`);
|
||||
setAuditLogs(Array.isArray(response.data.logs) ? response.data.logs : []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load audit log:', err);
|
||||
setAuditLogs([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/stats');
|
||||
setStats(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
setStats(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRole = () => {
|
||||
setSelectedRole(null);
|
||||
setRoleFormData({
|
||||
role_key: '',
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
});
|
||||
setRoleDialog(true);
|
||||
};
|
||||
|
||||
const handleEditRole = (role) => {
|
||||
setSelectedRole(role);
|
||||
setRoleFormData({
|
||||
role_key: role.role_key,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
permissions: role.permissions
|
||||
});
|
||||
setRoleDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveRole = async () => {
|
||||
try {
|
||||
if (selectedRole) {
|
||||
// Update existing role
|
||||
await api.patch(`/rbac/roles/${selectedRole.role_key}`, {
|
||||
name: roleFormData.name,
|
||||
description: roleFormData.description,
|
||||
permissions: roleFormData.permissions
|
||||
});
|
||||
showNotification('success', t('rbac.roleUpdated'));
|
||||
} else {
|
||||
// Create new role
|
||||
await api.post('/rbac/roles', roleFormData);
|
||||
showNotification('success', t('rbac.roleCreated'));
|
||||
}
|
||||
setRoleDialog(false);
|
||||
loadRoles();
|
||||
} catch (err) {
|
||||
showNotification('error', err.response?.data?.error || 'Failed to save role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (roleKey) => {
|
||||
if (!window.confirm(t('rbac.confirmDeleteRole'))) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/rbac/roles/${roleKey}`);
|
||||
showNotification('success', t('rbac.roleDeleted'));
|
||||
loadRoles();
|
||||
} catch (err) {
|
||||
showNotification('error', err.response?.data?.error || 'Failed to delete role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePermission = (permissionKey) => {
|
||||
setRoleFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(permissionKey)
|
||||
? prev.permissions.filter(p => p !== permissionKey)
|
||||
: [...prev.permissions, permissionKey]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAssignRole = (user) => {
|
||||
setSelectedUser(user);
|
||||
setNewUserRole(user.role);
|
||||
setUserRoleDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveUserRole = async () => {
|
||||
try {
|
||||
await api.post(`/rbac/users/${selectedUser.id}/role`, {
|
||||
role: newUserRole
|
||||
});
|
||||
showNotification('success', t('rbac.roleAssigned'));
|
||||
setUserRoleDialog(false);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
showNotification('error', err.response?.data?.error || 'Failed to assign role');
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (roleKey) => {
|
||||
const colors = {
|
||||
admin: 'error',
|
||||
moderator: 'warning',
|
||||
user: 'primary',
|
||||
viewer: 'info'
|
||||
};
|
||||
return colors[roleKey] || 'default';
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category) => {
|
||||
const icons = {
|
||||
'User Management': <Group />,
|
||||
'Session Management': <Security />,
|
||||
'Content Management': <Assessment />,
|
||||
'System & Settings': <Info />,
|
||||
'Security Management': <Security />,
|
||||
'Search & Discovery': <Visibility />,
|
||||
'VPN & Network': <Warning />
|
||||
};
|
||||
return icons[category] || <Info />;
|
||||
};
|
||||
|
||||
if (loading && tabValue === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Security sx={{ mr: 1, fontSize: 32 }} />
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('rbac.dashboard')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/security')}
|
||||
>
|
||||
{t('backToSecurity')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)}>
|
||||
<Tab label={t('rbac.rolesAndPermissions')} />
|
||||
<Tab label={t('rbac.userRoles')} />
|
||||
<Tab label={t('rbac.auditLog')} />
|
||||
<Tab label={t('rbac.statistics')} />
|
||||
<Tab label={t('rbac.myPermissions')} />
|
||||
</Tabs>
|
||||
|
||||
{/* Roles & Permissions Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6">{t('rbac.roles')}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateRole}
|
||||
>
|
||||
{t('rbac.createRole')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('rbac.roleName')}</TableCell>
|
||||
<TableCell>{t('rbac.description')}</TableCell>
|
||||
<TableCell>{t('rbac.permissions')}</TableCell>
|
||||
<TableCell>{t('rbac.type')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.role_key}>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={role.name}
|
||||
color={getRoleColor(role.role_key)}
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({role.role_key})
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{role.description}</TableCell>
|
||||
<TableCell>
|
||||
<Badge badgeContent={role.permissions.length} color="primary">
|
||||
<Chip
|
||||
label={t('rbac.permissionsCount')}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.is_system_role ? (
|
||||
<Chip label={t('rbac.system')} size="small" color="default" />
|
||||
) : (
|
||||
<Chip label={t('rbac.custom')} size="small" color="secondary" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('edit')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditRole(role)}
|
||||
disabled={role.is_system_role}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteRole(role.role_key)}
|
||||
disabled={role.is_system_role}
|
||||
color="error"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* User Roles Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.manageUserRoles')}
|
||||
</Typography>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('username')}</TableCell>
|
||||
<TableCell>{t('email')}</TableCell>
|
||||
<TableCell>{t('role')}</TableCell>
|
||||
<TableCell>{t('status')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={u.role}
|
||||
color={getRoleColor(u.role)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.is_active ? (
|
||||
<Chip
|
||||
label={t('active')}
|
||||
color="success"
|
||||
size="small"
|
||||
icon={<CheckCircle />}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={t('inactive')}
|
||||
color="default"
|
||||
size="small"
|
||||
icon={<Block />}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleAssignRole(u)}
|
||||
disabled={u.id === user?.id}
|
||||
>
|
||||
{t('rbac.changeRole')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Audit Log Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.permissionAuditLog')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('rbac.filterByAction')}</InputLabel>
|
||||
<Select
|
||||
value={auditFilters.action}
|
||||
onChange={(e) => setAuditFilters({ ...auditFilters, action: e.target.value })}
|
||||
>
|
||||
<MenuItem value="">{t('all')}</MenuItem>
|
||||
<MenuItem value="role_created">{t('rbac.roleCreated')}</MenuItem>
|
||||
<MenuItem value="role_updated">{t('rbac.roleUpdated')}</MenuItem>
|
||||
<MenuItem value="role_deleted">{t('rbac.roleDeleted')}</MenuItem>
|
||||
<MenuItem value="role_assigned">{t('rbac.roleAssigned')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={loadAuditLog}
|
||||
>
|
||||
{t('apply')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('timestamp')}</TableCell>
|
||||
<TableCell>{t('user')}</TableCell>
|
||||
<TableCell>{t('action')}</TableCell>
|
||||
<TableCell>{t('rbac.target')}</TableCell>
|
||||
<TableCell>{t('rbac.changes')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{auditLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>{log.username}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={log.action} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.target_type} #{log.target_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={JSON.stringify({ old: log.old_value, new: log.new_value }, null, 2)}>
|
||||
<IconButton size="small">
|
||||
<Info />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Statistics Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
{t('rbac.rbacStatistics')}
|
||||
</Typography>
|
||||
|
||||
{stats && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('rbac.roleDistribution')}
|
||||
</Typography>
|
||||
<List>
|
||||
{Array.isArray(stats.role_distribution) && stats.role_distribution.map((role) => (
|
||||
<ListItem key={role.role_key}>
|
||||
<ListItemText
|
||||
primary={role.name}
|
||||
secondary={`${role.user_count} ${t('users')}`}
|
||||
/>
|
||||
<Chip
|
||||
label={role.user_count}
|
||||
color={getRoleColor(role.role_key)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('rbac.recentActions')}
|
||||
</Typography>
|
||||
<List>
|
||||
{Array.isArray(stats.recent_actions) && stats.recent_actions.map((action, idx) => (
|
||||
<ListItem key={idx}>
|
||||
<ListItemText
|
||||
primary={action.action}
|
||||
secondary={`${action.count} ${t('rbac.times')}`}
|
||||
/>
|
||||
<Badge badgeContent={action.count} color="primary" />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
<Typography>
|
||||
{t('rbac.totalPermissions')}: <strong>{stats.total_permissions}</strong> |
|
||||
{t('rbac.totalRoles')}: <strong>{stats.total_roles}</strong>
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* My Permissions Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
{t('rbac.yourPermissions')}
|
||||
</Typography>
|
||||
|
||||
{myPermissions && (
|
||||
<>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('rbac.yourRole')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Chip
|
||||
label={myPermissions.role_name}
|
||||
color={getRoleColor(myPermissions.role)}
|
||||
size="large"
|
||||
/>
|
||||
<Typography color="text.secondary">
|
||||
{myPermissions.role_description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('rbac.permissionsGranted')}: <strong>{myPermissions.permissions.length}</strong>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.permissionsList')}
|
||||
</Typography>
|
||||
|
||||
{Object.entries(permissionCategories).map(([category, perms]) => {
|
||||
const userPerms = Array.isArray(perms) ? perms.filter(p =>
|
||||
Array.isArray(myPermissions?.permissions) && myPermissions.permissions.includes(p)
|
||||
) : [];
|
||||
if (userPerms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={category} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
{getCategoryIcon(category)}
|
||||
<Typography variant="h6" sx={{ ml: 1 }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${userPerms.length}/${perms.length}`}
|
||||
size="small"
|
||||
sx={{ ml: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
<Grid container spacing={1}>
|
||||
{userPerms.map((perm) => {
|
||||
const permDetail = myPermissions.permission_details.find(p => p.key === perm);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={perm}>
|
||||
<Chip
|
||||
label={permDetail?.key || perm}
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircle />}
|
||||
sx={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
|
||||
{/* Role Create/Edit Dialog */}
|
||||
<Dialog
|
||||
open={roleDialog}
|
||||
onClose={() => setRoleDialog(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{selectedRole ? t('rbac.editRole') : t('rbac.createRole')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('rbac.roleKey')}
|
||||
value={roleFormData.role_key}
|
||||
onChange={(e) => setRoleFormData({ ...roleFormData, role_key: e.target.value })}
|
||||
disabled={!!selectedRole}
|
||||
sx={{ mb: 2 }}
|
||||
helperText={t('rbac.roleKeyHelp')}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('rbac.roleName')}
|
||||
value={roleFormData.name}
|
||||
onChange={(e) => setRoleFormData({ ...roleFormData, name: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
label={t('rbac.description')}
|
||||
value={roleFormData.description}
|
||||
onChange={(e) => setRoleFormData({ ...roleFormData, description: e.target.value })}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.selectPermissions')} ({roleFormData.permissions.length} {t('rbac.selected')})
|
||||
</Typography>
|
||||
|
||||
{Object.entries(permissionCategories).map(([category, perms]) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{getCategoryIcon(category)}
|
||||
<span style={{ marginLeft: 8 }}>{category}</span>
|
||||
</Typography>
|
||||
<FormGroup>
|
||||
{perms.map((perm) => {
|
||||
const permDetail = permissions.find(p => p.key === perm);
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={perm}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={roleFormData.permissions.includes(perm)}
|
||||
onChange={() => handleTogglePermission(perm)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2">{perm}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{permDetail?.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FormGroup>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRoleDialog(false)}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveRole}
|
||||
disabled={!roleFormData.role_key || !roleFormData.name}
|
||||
>
|
||||
{selectedRole ? t('save') : t('create')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* User Role Assignment Dialog */}
|
||||
<Dialog
|
||||
open={userRoleDialog}
|
||||
onClose={() => setUserRoleDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{t('rbac.assignRole')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedUser && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{t('rbac.assigningRoleTo')}: <strong>{selectedUser.username}</strong>
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('role')}</InputLabel>
|
||||
<Select
|
||||
value={newUserRole}
|
||||
onChange={(e) => setNewUserRole(e.target.value)}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<MenuItem key={role.role_key} value={role.role_key}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={role.name}
|
||||
size="small"
|
||||
color={getRoleColor(role.role_key)}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{role.permissions.length} {t('rbac.permissions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUserRoleDialog(false)}>{t('cancel')}</Button>
|
||||
<Button variant="contained" onClick={handleSaveUserRole}>
|
||||
{t('assign')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RBACDashboard;
|
||||
969
frontend/src/components/SecurityHeadersDashboard.jsx
Normal file
|
|
@ -0,0 +1,969 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Button,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
Refresh,
|
||||
Save,
|
||||
PlayArrow,
|
||||
ArrowBack,
|
||||
Add,
|
||||
Delete,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Error as ErrorIcon,
|
||||
Info,
|
||||
ExpandMore,
|
||||
ContentCopy,
|
||||
History,
|
||||
Settings
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../utils/api';
|
||||
|
||||
function TabPanel({ children, value, index }) {
|
||||
return value === index ? <Box sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
const SecurityHeadersDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// Data states
|
||||
const [currentConfig, setCurrentConfig] = useState(null);
|
||||
const [savedConfigs, setSavedConfigs] = useState([]);
|
||||
const [presets, setPresets] = useState({});
|
||||
const [recommendations, setRecommendations] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
|
||||
// Dialog states
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [testDialogOpen, setTestDialogOpen] = useState(false);
|
||||
const [presetDialogOpen, setPresetDialogOpen] = useState(false);
|
||||
|
||||
// Form states
|
||||
const [configName, setConfigName] = useState('');
|
||||
const [selectedPreset, setSelectedPreset] = useState('');
|
||||
const [editableConfig, setEditableConfig] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await api.get('/security-headers/current');
|
||||
setCurrentConfig(response.data.current);
|
||||
setSavedConfigs(response.data.savedConfigs);
|
||||
setPresets(response.data.presets);
|
||||
|
||||
// Initialize editable config with current config
|
||||
if (response.data.current && !editableConfig) {
|
||||
setEditableConfig(convertToEditableFormat(response.data.current));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('security.failedToLoad'));
|
||||
console.error('Error fetching security headers:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-headers/recommendations');
|
||||
setRecommendations(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-headers/history');
|
||||
setHistory(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching history:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConfiguration = async () => {
|
||||
if (!editableConfig) return;
|
||||
|
||||
setTestDialogOpen(true);
|
||||
try {
|
||||
const response = await api.post('/security-headers/test', {
|
||||
config: editableConfig
|
||||
});
|
||||
setTestResults(response.data);
|
||||
} catch (err) {
|
||||
setError(t('security.testFailed'));
|
||||
console.error('Error testing configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfiguration = async () => {
|
||||
if (!configName || !editableConfig) {
|
||||
setError(t('security.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/security-headers/save', {
|
||||
configName,
|
||||
config: editableConfig,
|
||||
setActive: false
|
||||
});
|
||||
|
||||
setSuccess(t('security.configSaved'));
|
||||
setSaveDialogOpen(false);
|
||||
setConfigName('');
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setError(t('security.saveFailed'));
|
||||
console.error('Error saving configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyConfiguration = async (configId) => {
|
||||
if (!window.confirm(t('security.confirmApply'))) return;
|
||||
|
||||
try {
|
||||
const response = await api.post(`/security-headers/apply/${configId}`);
|
||||
|
||||
if (response.data.requiresRestart) {
|
||||
setSuccess(t('security.configAppliedRestart'));
|
||||
} else {
|
||||
setSuccess(t('security.configApplied'));
|
||||
}
|
||||
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setError(t('security.applyFailed'));
|
||||
console.error('Error applying configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfiguration = async (configId) => {
|
||||
if (!window.confirm(t('security.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/security-headers/${configId}`);
|
||||
setSuccess(t('security.configDeleted'));
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setError(t('security.deleteFailed'));
|
||||
console.error('Error deleting configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = () => {
|
||||
if (!selectedPreset || !presets[selectedPreset]) return;
|
||||
|
||||
setEditableConfig(presets[selectedPreset].config);
|
||||
setPresetDialogOpen(false);
|
||||
setSuccess(t('security.presetApplied', { preset: presets[selectedPreset].name }));
|
||||
};
|
||||
|
||||
const convertToEditableFormat = (config) => {
|
||||
return {
|
||||
csp_default_src: JSON.stringify(config.csp?.directives?.defaultSrc || ["'self'"]),
|
||||
csp_script_src: JSON.stringify(config.csp?.directives?.scriptSrc || ["'self'"]),
|
||||
csp_style_src: JSON.stringify(config.csp?.directives?.styleSrc || ["'self'"]),
|
||||
csp_img_src: JSON.stringify(config.csp?.directives?.imgSrc || ["'self'"]),
|
||||
csp_media_src: JSON.stringify(config.csp?.directives?.mediaSrc || ["'self'"]),
|
||||
csp_connect_src: JSON.stringify(config.csp?.directives?.connectSrc || ["'self'"]),
|
||||
csp_font_src: JSON.stringify(config.csp?.directives?.fontSrc || ["'self'"]),
|
||||
csp_frame_src: JSON.stringify(config.csp?.directives?.frameSrc || ["'self'"]),
|
||||
csp_object_src: JSON.stringify(config.csp?.directives?.objectSrc || ["'none'"]),
|
||||
csp_base_uri: JSON.stringify(config.csp?.directives?.baseUri || ["'self'"]),
|
||||
csp_form_action: JSON.stringify(config.csp?.directives?.formAction || ["'self'"]),
|
||||
csp_frame_ancestors: JSON.stringify(config.csp?.directives?.frameAncestors || ["'self'"]),
|
||||
hsts_enabled: config.hsts?.enabled !== false,
|
||||
hsts_max_age: config.hsts?.maxAge || 31536000,
|
||||
hsts_include_subdomains: config.hsts?.includeSubDomains !== false,
|
||||
hsts_preload: config.hsts?.preload !== false,
|
||||
referrer_policy: config.referrerPolicy || 'strict-origin-when-cross-origin',
|
||||
x_content_type_options: config.xContentTypeOptions !== false,
|
||||
x_frame_options: config.xFrameOptions || 'SAMEORIGIN',
|
||||
x_xss_protection: config.xssProtection !== false
|
||||
};
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'error': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity) => {
|
||||
switch (severity) {
|
||||
case 'error': return <ErrorIcon />;
|
||||
case 'warning': return <Warning />;
|
||||
case 'info': return <Info />;
|
||||
default: return <CheckCircle />;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={() => navigate('/security')} size="small">
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Security sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('security.securityHeaders')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.manageHttpHeaders')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchData}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess('')} sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Security Score Card */}
|
||||
{recommendations && (
|
||||
<Card sx={{ mb: 3, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box sx={{ textAlign: 'center', color: 'white' }}>
|
||||
<Typography variant="h2" fontWeight="bold">
|
||||
{recommendations.score}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('security.securityScore')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${t('security.grade')}: ${recommendations.grade}`}
|
||||
sx={{ mt: 1, bgcolor: 'white', color: 'primary.main', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={9}>
|
||||
<Box sx={{ color: 'white' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.securitySummary')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ErrorIcon />
|
||||
<Typography>
|
||||
{recommendations.summary.critical} {t('security.critical')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning />
|
||||
<Typography>
|
||||
{recommendations.summary.warnings} {t('security.warnings')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Info />
|
||||
<Typography>
|
||||
{recommendations.summary.info} {t('security.info')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label={t('security.currentConfig')} />
|
||||
<Tab label={t('security.editor')} />
|
||||
<Tab label={t('security.savedConfigs')} />
|
||||
<Tab label={t('security.recommendations')} />
|
||||
<Tab label={t('security.history')} onClick={() => tabValue === 4 && fetchHistory()} />
|
||||
</Tabs>
|
||||
|
||||
{/* Current Configuration Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<CardContent>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('security.currentConfigDescription')}
|
||||
</Alert>
|
||||
|
||||
{currentConfig && (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.environment')}: {currentConfig.environment}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
CSP {t('security.mode')}: {currentConfig.csp.mode}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.contentSecurityPolicy')}
|
||||
</Typography>
|
||||
{currentConfig.csp.directives && Object.entries(currentConfig.csp.directives).map(([key, value]) => (
|
||||
<Accordion key={key}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Typography fontWeight="medium">{key}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ bgcolor: 'grey.100', p: 2, borderRadius: 1, fontFamily: 'monospace' }}>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.otherHeaders')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
HSTS
|
||||
</Typography>
|
||||
<Typography>
|
||||
{currentConfig.hsts.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
{currentConfig.hsts.enabled && ` (${currentConfig.hsts.maxAge}s)`}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Referrer-Policy
|
||||
</Typography>
|
||||
<Typography>{currentConfig.referrerPolicy}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
X-Content-Type-Options
|
||||
</Typography>
|
||||
<Typography>
|
||||
{currentConfig.xContentTypeOptions ? 'nosniff' : t('common.disabled')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
X-Frame-Options
|
||||
</Typography>
|
||||
<Typography>{currentConfig.xFrameOptions}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Editor Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<CardContent>
|
||||
<Box sx={{ mb: 3, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
startIcon={<Settings />}
|
||||
onClick={() => setPresetDialogOpen(true)}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('security.loadPreset')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PlayArrow />}
|
||||
onClick={handleTestConfiguration}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
>
|
||||
{t('security.testConfig')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<Save />}
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
variant="contained"
|
||||
>
|
||||
{t('security.saveConfig')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{t('security.editorWarning')}
|
||||
</Alert>
|
||||
|
||||
{editableConfig && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.cspDirectives')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(editableConfig).filter(([key]) => key.startsWith('csp_')).map(([key, value]) => (
|
||||
<Grid item xs={12} key={key}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={key.replace('csp_', '').replace(/_/g, '-')}
|
||||
value={value}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, [key]: e.target.value })}
|
||||
multiline
|
||||
rows={2}
|
||||
helperText={t('security.jsonArrayFormat')}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.hstsConfiguration')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.hsts_enabled}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('security.enableHSTS')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={t('security.maxAge')}
|
||||
value={editableConfig.hsts_max_age}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_max_age: parseInt(e.target.value) })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.hsts_include_subdomains}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_include_subdomains: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('security.includeSubdomains')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.hsts_preload}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_preload: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('security.preload')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.otherHeaders')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('security.referrerPolicy')}</InputLabel>
|
||||
<Select
|
||||
value={editableConfig.referrer_policy}
|
||||
label={t('security.referrerPolicy')}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, referrer_policy: e.target.value })}
|
||||
>
|
||||
<MenuItem value="no-referrer">no-referrer</MenuItem>
|
||||
<MenuItem value="no-referrer-when-downgrade">no-referrer-when-downgrade</MenuItem>
|
||||
<MenuItem value="origin">origin</MenuItem>
|
||||
<MenuItem value="origin-when-cross-origin">origin-when-cross-origin</MenuItem>
|
||||
<MenuItem value="same-origin">same-origin</MenuItem>
|
||||
<MenuItem value="strict-origin">strict-origin</MenuItem>
|
||||
<MenuItem value="strict-origin-when-cross-origin">strict-origin-when-cross-origin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('security.xFrameOptions')}</InputLabel>
|
||||
<Select
|
||||
value={editableConfig.x_frame_options}
|
||||
label={t('security.xFrameOptions')}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, x_frame_options: e.target.value })}
|
||||
>
|
||||
<MenuItem value="DENY">DENY</MenuItem>
|
||||
<MenuItem value="SAMEORIGIN">SAMEORIGIN</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.x_content_type_options}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, x_content_type_options: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="X-Content-Type-Options: nosniff"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.x_xss_protection}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, x_xss_protection: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="X-XSS-Protection"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Saved Configurations Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<CardContent>
|
||||
{savedConfigs.length === 0 ? (
|
||||
<Alert severity="info">{t('security.noSavedConfigs')}</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{savedConfigs.map((config) => (
|
||||
<Grid item xs={12} key={config.id}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{config.config_name}
|
||||
{config.is_active && (
|
||||
<Chip label={t('security.active')} color="success" size="small" sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.created')}: {new Date(config.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ContentCopy />}
|
||||
onClick={() => {
|
||||
setEditableConfig({
|
||||
csp_default_src: config.csp_default_src,
|
||||
csp_script_src: config.csp_script_src,
|
||||
csp_style_src: config.csp_style_src,
|
||||
csp_img_src: config.csp_img_src,
|
||||
csp_media_src: config.csp_media_src,
|
||||
csp_connect_src: config.csp_connect_src,
|
||||
csp_font_src: config.csp_font_src,
|
||||
csp_frame_src: config.csp_frame_src,
|
||||
csp_object_src: config.csp_object_src,
|
||||
csp_base_uri: config.csp_base_uri,
|
||||
csp_form_action: config.csp_form_action,
|
||||
csp_frame_ancestors: config.csp_frame_ancestors,
|
||||
hsts_enabled: config.hsts_enabled,
|
||||
hsts_max_age: config.hsts_max_age,
|
||||
hsts_include_subdomains: config.hsts_include_subdomains,
|
||||
hsts_preload: config.hsts_preload,
|
||||
referrer_policy: config.referrer_policy,
|
||||
x_content_type_options: config.x_content_type_options,
|
||||
x_frame_options: config.x_frame_options,
|
||||
x_xss_protection: config.x_xss_protection
|
||||
});
|
||||
setTabValue(1);
|
||||
setSuccess(t('security.configLoaded'));
|
||||
}}
|
||||
>
|
||||
{t('security.loadToEditor')}
|
||||
</Button>
|
||||
{!config.is_active && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => handleApplyConfiguration(config.id)}
|
||||
>
|
||||
{t('security.apply')}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteConfiguration(config.id)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Recommendations Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<CardContent>
|
||||
{recommendations && recommendations.recommendations.length > 0 ? (
|
||||
<List>
|
||||
{recommendations.recommendations.map((rec, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Box sx={{ color: `${getSeverityColor(rec.severity)}.main` }}>
|
||||
{getSeverityIcon(rec.severity)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{rec.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${rec.severity.toUpperCase()}`}
|
||||
size="small"
|
||||
color={getSeverityColor(rec.severity)}
|
||||
/>
|
||||
{rec.impact && (
|
||||
<Chip
|
||||
label={`Impact: ${rec.impact}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ pl: 5 }}>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
<strong>Description:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-line' }}>
|
||||
{rec.description}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="body2" color="primary.main" paragraph>
|
||||
<strong>Recommended Action:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-line' }}>
|
||||
{rec.action}
|
||||
</Typography>
|
||||
|
||||
{rec.details && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
<strong>Additional Details:</strong>
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
|
||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
p: 2,
|
||||
borderRadius: 1
|
||||
}}>
|
||||
{Object.entries(rec.details).map(([key, value]) => (
|
||||
<Box key={key} sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="primary" display="block" fontWeight="medium">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}:
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.primary">{value}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Alert severity="success">{t('security.noRecommendations')}</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<CardContent>
|
||||
{history.length === 0 ? (
|
||||
<Alert severity="info">{t('security.noHistory')}</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.timestamp')}</TableCell>
|
||||
<TableCell>{t('security.action')}</TableCell>
|
||||
<TableCell>{t('security.configuration')}</TableCell>
|
||||
<TableCell>{t('security.changedBy')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{history.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell>{new Date(entry.timestamp).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={entry.action} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{entry.config_name || '-'}</TableCell>
|
||||
<TableCell>{entry.username || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
</Card>
|
||||
|
||||
{/* Save Dialog */}
|
||||
<Dialog open={saveDialogOpen} onClose={() => setSaveDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('security.saveConfiguration')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('security.configurationName')}
|
||||
value={configName}
|
||||
onChange={(e) => setConfigName(e.target.value)}
|
||||
sx={{ mt: 2 }}
|
||||
helperText={t('security.configNameHelper')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSaveDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSaveConfiguration} variant="contained">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Test Results Dialog */}
|
||||
<Dialog open={testDialogOpen} onClose={() => setTestDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('security.testResults')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{testResults && (
|
||||
<Box>
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h3" fontWeight="bold" color="primary.main">
|
||||
{testResults.score}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('security.grade')}: {testResults.grade}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{testResults.summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{testResults.passed.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" fontWeight="medium" color="success.main" gutterBottom>
|
||||
✓ {t('security.passed')}
|
||||
</Typography>
|
||||
{testResults.passed.map((item, index) => (
|
||||
<Alert key={index} severity="success" sx={{ mb: 1 }}>
|
||||
<strong>{item.test}</strong>: {item.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{testResults.warnings.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" fontWeight="medium" color="warning.main" gutterBottom>
|
||||
⚠ {t('security.warnings')}
|
||||
</Typography>
|
||||
{testResults.warnings.map((item, index) => (
|
||||
<Alert key={index} severity="warning" sx={{ mb: 1 }}>
|
||||
<strong>{item.test}</strong>: {item.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{testResults.errors.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="medium" color="error.main" gutterBottom>
|
||||
✗ {t('security.errors')}
|
||||
</Typography>
|
||||
{testResults.errors.map((item, index) => (
|
||||
<Alert key={index} severity="error" sx={{ mb: 1 }}>
|
||||
<strong>{item.test}</strong>: {item.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTestDialogOpen(false)}>{t('common.close')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Preset Selection Dialog */}
|
||||
<Dialog open={presetDialogOpen} onClose={() => setPresetDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('security.selectPreset')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>{t('security.preset')}</InputLabel>
|
||||
<Select
|
||||
value={selectedPreset}
|
||||
label={t('security.preset')}
|
||||
onChange={(e) => setSelectedPreset(e.target.value)}
|
||||
>
|
||||
{Object.entries(presets).map(([key, preset]) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{preset.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{preset.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPresetDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleApplyPreset} variant="contained" disabled={!selectedPreset}>
|
||||
{t('security.loadPreset')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityHeadersDashboard;
|
||||
216
frontend/src/components/SecurityNotificationProvider.jsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { Snackbar, Alert, AlertTitle, IconButton, Box, Collapse } from '@mui/material';
|
||||
import { Close, ExpandMore, ExpandLess } from '@mui/icons-material';
|
||||
|
||||
const SecurityNotificationContext = createContext();
|
||||
|
||||
export const useSecurityNotification = () => {
|
||||
const context = useContext(SecurityNotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useSecurityNotification must be used within SecurityNotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SecurityNotificationProvider = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
const addNotification = useCallback(({
|
||||
type = 'info', // 'success', 'info', 'warning', 'error'
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
duration = 6000,
|
||||
persistent = false
|
||||
}) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const notification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
duration,
|
||||
persistent,
|
||||
expanded: false,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setNotifications(prev => [...prev, notification]);
|
||||
|
||||
if (!persistent && duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeNotification = useCallback((id) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const toggleExpanded = useCallback((id) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, expanded: !n.expanded } : n)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Specific notification types for security events
|
||||
const notifySecurityWarning = useCallback((message, details) => {
|
||||
return addNotification({
|
||||
type: 'warning',
|
||||
title: 'Security Warning',
|
||||
message,
|
||||
details,
|
||||
duration: 8000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifySecurityError = useCallback((message, details) => {
|
||||
return addNotification({
|
||||
type: 'error',
|
||||
title: 'Security Alert',
|
||||
message,
|
||||
details,
|
||||
persistent: true
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifyAccountLocked = useCallback((remainingMinutes) => {
|
||||
return addNotification({
|
||||
type: 'error',
|
||||
title: 'Account Locked',
|
||||
message: `Your account has been locked due to multiple failed login attempts.`,
|
||||
details: `Please try again in ${remainingMinutes} minutes.`,
|
||||
persistent: true
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifyPasswordExpiring = useCallback((daysRemaining) => {
|
||||
return addNotification({
|
||||
type: 'warning',
|
||||
title: 'Password Expiring Soon',
|
||||
message: `Your password will expire in ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''}.`,
|
||||
details: 'Please change your password to maintain account security.',
|
||||
duration: 10000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifyInvalidInput = useCallback((fieldName, errors) => {
|
||||
return addNotification({
|
||||
type: 'error',
|
||||
title: 'Invalid Input',
|
||||
message: `Please check the ${fieldName} field.`,
|
||||
details: Array.isArray(errors) ? errors.join(', ') : errors,
|
||||
duration: 5000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifySecuritySuccess = useCallback((message) => {
|
||||
return addNotification({
|
||||
type: 'success',
|
||||
title: 'Security Update',
|
||||
message,
|
||||
duration: 4000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const value = {
|
||||
addNotification,
|
||||
removeNotification,
|
||||
notifySecurityWarning,
|
||||
notifySecurityError,
|
||||
notifyAccountLocked,
|
||||
notifyPasswordExpiring,
|
||||
notifyInvalidInput,
|
||||
notifySecuritySuccess
|
||||
};
|
||||
|
||||
return (
|
||||
<SecurityNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 80,
|
||||
right: 24,
|
||||
zIndex: 9999,
|
||||
maxWidth: 400,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{notifications.map((notification, index) => (
|
||||
<Snackbar
|
||||
key={notification.id}
|
||||
open={true}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mt: index * 0.5,
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity={notification.type}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
boxShadow: 3,
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%'
|
||||
}
|
||||
}}
|
||||
action={
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="close"
|
||||
color="inherit"
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{notification.title && (
|
||||
<AlertTitle sx={{ fontWeight: 600 }}>
|
||||
{notification.title}
|
||||
</AlertTitle>
|
||||
)}
|
||||
<Box>
|
||||
{notification.message}
|
||||
{notification.details && (
|
||||
<>
|
||||
{notification.details.length > 100 ? (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => toggleExpanded(notification.id)}
|
||||
sx={{ ml: 1, p: 0 }}
|
||||
>
|
||||
{notification.expanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
<Collapse in={notification.expanded}>
|
||||
<Box sx={{ mt: 1, fontSize: '0.875rem', opacity: 0.9 }}>
|
||||
{notification.details}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ mt: 0.5, fontSize: '0.875rem', opacity: 0.9 }}>
|
||||
{notification.details}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</Box>
|
||||
</SecurityNotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityNotificationProvider;
|
||||
301
frontend/src/components/SecuritySettingsPanel.jsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Divider,
|
||||
Alert,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
Shield,
|
||||
Lock,
|
||||
VpnKey,
|
||||
DevicesOther,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
import axios from 'axios';
|
||||
|
||||
const SecuritySettingsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user } = useAuthStore();
|
||||
const { notifySecuritySuccess, notifySecurityError } = useSecurityNotification();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settings, setSettings] = useState({
|
||||
twoFactorEnabled: false,
|
||||
sessionTimeout: 7 * 24 * 60, // minutes
|
||||
passwordExpiryDays: 90,
|
||||
requireStrongPassword: true,
|
||||
accountLockoutEnabled: true,
|
||||
maxFailedAttempts: 5,
|
||||
lockoutDuration: 30 // minutes
|
||||
});
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [showTerminateDialog, setShowTerminateDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecuritySettings();
|
||||
fetchActiveSessions();
|
||||
}, []);
|
||||
|
||||
const fetchSecuritySettings = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/security-status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
twoFactorEnabled: response.data.twoFactorEnabled || false
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching security settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchActiveSessions = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/sessions', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setSessions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateAllSessions = async () => {
|
||||
try {
|
||||
await axios.post('/api/auth/sessions/terminate-all', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(t('security.terminateAllSessions'));
|
||||
setShowTerminateDialog(false);
|
||||
fetchActiveSessions();
|
||||
} catch (error) {
|
||||
notifySecurityError(t('error'), error.response?.data?.error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security color="primary" />
|
||||
{t('security.title')}
|
||||
</Typography>
|
||||
|
||||
{/* Security Status Overview */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Shield color="primary" />
|
||||
{t('security.securityStatus')}
|
||||
</Typography>
|
||||
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{settings.twoFactorEnabled ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Warning color="warning" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('twoFactor.title')}
|
||||
secondary={
|
||||
settings.twoFactorEnabled
|
||||
? t('security.twoFactorEnabled')
|
||||
: t('twoFactor.notEnabled')
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label={settings.twoFactorEnabled ? t('enabled') : t('disabled')}
|
||||
color={settings.twoFactorEnabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{settings.requireStrongPassword ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Info color="info" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.passwordPolicy')}
|
||||
secondary={t('security.minLength')}
|
||||
/>
|
||||
<Chip
|
||||
label={settings.requireStrongPassword ? t('enabled') : t('disabled')}
|
||||
color={settings.requireStrongPassword ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{settings.accountLockoutEnabled ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Info color="info" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.accountLockout')}
|
||||
secondary={`${settings.maxFailedAttempts} ${t('security.failedAttempts')}`}
|
||||
/>
|
||||
<Chip
|
||||
label={settings.accountLockoutEnabled ? t('enabled') : t('disabled')}
|
||||
color={settings.accountLockoutEnabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Sessions */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DevicesOther color="primary" />
|
||||
{t('security.activeSessions')}
|
||||
</Typography>
|
||||
|
||||
{sessions.length > 0 ? (
|
||||
<>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('security.multipleDevices', { count: sessions.length })}
|
||||
</Alert>
|
||||
|
||||
<List>
|
||||
{sessions.map((session, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText
|
||||
primary={session.device || 'Unknown Device'}
|
||||
secondary={`Last active: ${new Date(session.lastActive).toLocaleString()}`}
|
||||
/>
|
||||
{session.current && (
|
||||
<Chip label="Current" color="primary" size="small" />
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{sessions.length > 1 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setShowTerminateDialog(true)}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.noRecentActivity')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Input Validation Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<VpnKey color="primary" />
|
||||
{t('security.inputValidation')}
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('security.inputSanitized')}
|
||||
</Alert>
|
||||
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('security.xssAttemptBlocked')} />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('security.sqlInjectionBlocked')} />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('security.rateLimitExceeded').split('.')[0]} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminate Sessions Dialog */}
|
||||
<Dialog open={showTerminateDialog} onClose={() => setShowTerminateDialog(false)}>
|
||||
<DialogTitle>{t('security.terminateAllSessions')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('security.sessionExpired')}
|
||||
</Alert>
|
||||
<Typography>
|
||||
This will log you out from all other devices. Your current session will remain active.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowTerminateDialog(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleTerminateAllSessions} color="error" variant="contained">
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecuritySettingsPanel;
|
||||
261
frontend/src/components/SecurityStatusCard.jsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
IconButton,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Error as ErrorIcon,
|
||||
ExpandMore,
|
||||
Shield,
|
||||
Key,
|
||||
Devices,
|
||||
History
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const SecurityStatusCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useAuthStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [securityStatus, setSecurityStatus] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecurityStatus();
|
||||
}, []);
|
||||
|
||||
const fetchSecurityStatus = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await axios.get('/api/auth/security-status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setSecurityStatus(response.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load security status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getPasswordExpiryStatus = () => {
|
||||
if (!securityStatus?.passwordExpiry) return null;
|
||||
const { expired, warning, daysRemaining } = securityStatus.passwordExpiry;
|
||||
|
||||
if (expired) {
|
||||
return { severity: 'error', icon: <ErrorIcon />, message: t('security.passwordExpired') };
|
||||
}
|
||||
if (warning) {
|
||||
return {
|
||||
severity: 'warning',
|
||||
icon: <Warning />,
|
||||
message: t('security.passwordExpiresIn', { days: daysRemaining })
|
||||
};
|
||||
}
|
||||
return { severity: 'success', icon: <CheckCircle />, message: 'Password is valid' };
|
||||
};
|
||||
|
||||
const passwordExpiryStatus = getPasswordExpiryStatus();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security color="primary" />
|
||||
<Typography variant="h6">{t('security.securityStatus')}</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s'
|
||||
}}
|
||||
>
|
||||
<ExpandMore />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Password Expiry Warning */}
|
||||
{passwordExpiryStatus && passwordExpiryStatus.severity !== 'success' && (
|
||||
<Alert
|
||||
severity={passwordExpiryStatus.severity}
|
||||
icon={passwordExpiryStatus.icon}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{passwordExpiryStatus.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Security Overview */}
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Shield color={securityStatus?.twoFactorEnabled ? 'success' : 'disabled'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.twoFactorEnabled')}
|
||||
secondary={securityStatus?.twoFactorEnabled ? t('security.success') : 'Not enabled'}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={securityStatus?.twoFactorEnabled ? 'ON' : 'OFF'}
|
||||
color={securityStatus?.twoFactorEnabled ? 'success' : 'default'}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Key color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.passwordAge', { days: securityStatus?.passwordAge || 0 })}
|
||||
secondary={
|
||||
securityStatus?.passwordExpiry?.daysRemaining
|
||||
? t('security.passwordExpiresIn', { days: securityStatus.passwordExpiry.daysRemaining })
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Devices color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.activeSessions')}
|
||||
secondary={
|
||||
securityStatus?.lastActivity
|
||||
? (() => {
|
||||
try {
|
||||
return format(new Date(securityStatus.lastActivity), 'PPpp');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<Chip size="small" label={securityStatus?.activeSessions || 0} color="primary" />
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Warning color={securityStatus?.failedLoginAttempts > 0 ? 'error' : 'success'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.failedAttempts')}
|
||||
secondary={
|
||||
securityStatus?.lastLogin?.timestamp
|
||||
? (() => {
|
||||
try {
|
||||
return format(new Date(securityStatus.lastLogin.timestamp), 'PPpp');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
})()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={securityStatus?.failedLoginAttempts || 0}
|
||||
color={securityStatus?.failedLoginAttempts > 0 ? 'error' : 'success'}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<History fontSize="small" />
|
||||
{t('security.recentActivity')}
|
||||
</Typography>
|
||||
{Array.isArray(securityStatus?.recentEvents) && securityStatus.recentEvents.length > 0 ? (
|
||||
<List dense>
|
||||
{securityStatus.recentEvents.slice(0, 5).map((event, index) => (
|
||||
<ListItem key={index} sx={{ pl: 0 }}>
|
||||
<ListItemIcon>
|
||||
{event.status === 'success' ? (
|
||||
<CheckCircle fontSize="small" color="success" />
|
||||
) : event.status === 'failed' ? (
|
||||
<ErrorIcon fontSize="small" color="error" />
|
||||
) : (
|
||||
<Warning fontSize="small" color="warning" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={event.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
secondary={
|
||||
event.timestamp
|
||||
? (() => {
|
||||
try {
|
||||
return format(new Date(event.timestamp), 'PPpp');
|
||||
} catch {
|
||||
return 'Invalid date';
|
||||
}
|
||||
})()
|
||||
: 'No timestamp'
|
||||
}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Chip size="small" label={event.status} variant="outlined" />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', p: 2, textAlign: 'center' }}>
|
||||
{t('security.noRecentActivity')}
|
||||
</Typography>
|
||||
)}
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityStatusCard;
|
||||
640
frontend/src/components/SecurityTestingDashboard.jsx
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Button,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
PlayArrow,
|
||||
Refresh,
|
||||
ArrowBack,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Warning,
|
||||
Shield,
|
||||
ExpandMore,
|
||||
BugReport,
|
||||
Lock,
|
||||
VpnLock,
|
||||
Storage,
|
||||
Code,
|
||||
NetworkCheck
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../utils/api';
|
||||
|
||||
function TabPanel({ children, value, index }) {
|
||||
return value === index ? <Box sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
const SecurityTestingDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// Data states
|
||||
const [defenseLayers, setDefenseLayers] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [testHistory, setTestHistory] = useState([]);
|
||||
const [networkStats, setNetworkStats] = useState(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
// Test selection
|
||||
const [selectedTests, setSelectedTests] = useState({
|
||||
auth: true,
|
||||
sql: true,
|
||||
xss: true,
|
||||
csrf: true,
|
||||
rate: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefenseLayers();
|
||||
fetchTestHistory();
|
||||
fetchNetworkStats();
|
||||
}, []);
|
||||
|
||||
const fetchDefenseLayers = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-testing/defense-layers');
|
||||
setDefenseLayers(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching defense layers:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTestHistory = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-testing/test-history?limit=20');
|
||||
setTestHistory(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching test history:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNetworkStats = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-testing/network-stats');
|
||||
setNetworkStats(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching network stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunPenetrationTests = async () => {
|
||||
setTesting(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const testTypes = Object.keys(selectedTests).filter(key => selectedTests[key]);
|
||||
|
||||
const response = await api.post('/security-testing/penetration-test', {
|
||||
testTypes: testTypes.length === 5 ? ['all'] : testTypes
|
||||
});
|
||||
|
||||
setTestResults(response.data);
|
||||
setSuccess(t('security.testsCompleted'));
|
||||
fetchTestHistory();
|
||||
} catch (err) {
|
||||
setError(t('security.testsFailed'));
|
||||
console.error('Error running penetration tests:', err);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getLayerIcon = (layer) => {
|
||||
switch (layer) {
|
||||
case 'Network Level': return <NetworkCheck />;
|
||||
case 'Server Level': return <Shield />;
|
||||
case 'Application Level': return <Code />;
|
||||
case 'Data Level': return <Storage />;
|
||||
default: return <Security />;
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score) => {
|
||||
if (score >= 90) return 'success';
|
||||
if (score >= 75) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'pass': return <CheckCircle color="success" />;
|
||||
case 'fail': return <ErrorIcon color="error" />;
|
||||
case 'warn': return <Warning color="warning" />;
|
||||
default: return <Warning color="info" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'error';
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={() => navigate('/security')} size="small">
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<BugReport sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('security.securityTesting')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.automatedAndManualTesting')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
fetchDefenseLayers();
|
||||
fetchTestHistory();
|
||||
fetchNetworkStats();
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess('')} sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Overall Security Score */}
|
||||
{defenseLayers && (
|
||||
<Card sx={{ mb: 3, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box sx={{ textAlign: 'center', color: 'white' }}>
|
||||
<Typography variant="h2" fontWeight="bold">
|
||||
{defenseLayers.overall_score}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('security.overallScore')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={defenseLayers.overall_score >= 90 ? 'A' : defenseLayers.overall_score >= 80 ? 'B' : defenseLayers.overall_score >= 70 ? 'C' : 'D'}
|
||||
sx={{ mt: 1, bgcolor: 'white', color: 'primary.main', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={9}>
|
||||
<Box sx={{ color: 'white' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.defenseInDepth')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{[defenseLayers.network, defenseLayers.server, defenseLayers.application, defenseLayers.data].map((layer, index) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={index}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{layer.layer}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={layer.score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'rgba(255,255,255,0.2)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: layer.score >= 80 ? 'success.light' : layer.score >= 60 ? 'warning.light' : 'error.light'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption">
|
||||
{layer.score}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label={t('security.defenseInDepth')} />
|
||||
<Tab label={t('security.penetrationTesting')} />
|
||||
<Tab label={t('security.testHistory')} />
|
||||
<Tab label={t('security.networkSecurity')} />
|
||||
</Tabs>
|
||||
|
||||
{/* Defense-in-Depth Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<CardContent>
|
||||
{defenseLayers && (
|
||||
<Grid container spacing={3}>
|
||||
{[defenseLayers.network, defenseLayers.server, defenseLayers.application, defenseLayers.data].map((layer, layerIndex) => (
|
||||
<Grid item xs={12} key={layerIndex}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{getLayerIcon(layer.layer)}
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight="medium">
|
||||
{layer.layer}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{layer.checks.length} {t('security.checks')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h4" color={getScoreColor(layer.score) + '.main'} fontWeight="bold">
|
||||
{layer.score}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.score')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Checks */}
|
||||
<List>
|
||||
{layer.checks.map((check, checkIndex) => (
|
||||
<ListItem key={checkIndex}>
|
||||
<ListItemIcon>
|
||||
{getStatusIcon(check.status)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={check.name}
|
||||
secondary={check.details}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Recommendations */}
|
||||
{layer.recommendations.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" color="warning.main" gutterBottom>
|
||||
{t('security.recommendations')}:
|
||||
</Typography>
|
||||
{layer.recommendations.map((rec, recIndex) => (
|
||||
<Alert key={recIndex} severity="warning" sx={{ mb: 1 }}>
|
||||
{rec}
|
||||
</Alert>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Penetration Testing Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<CardContent>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('security.penetrationTestingInfo')}
|
||||
</Alert>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.selectTests')}
|
||||
</Typography>
|
||||
<FormGroup row>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.auth} onChange={(e) => setSelectedTests({...selectedTests, auth: e.target.checked})} />}
|
||||
label={t('security.authenticationTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.sql} onChange={(e) => setSelectedTests({...selectedTests, sql: e.target.checked})} />}
|
||||
label={t('security.sqlInjectionTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.xss} onChange={(e) => setSelectedTests({...selectedTests, xss: e.target.checked})} />}
|
||||
label={t('security.xssTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.csrf} onChange={(e) => setSelectedTests({...selectedTests, csrf: e.target.checked})} />}
|
||||
label={t('security.csrfTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.rate} onChange={(e) => setSelectedTests({...selectedTests, rate: e.target.checked})} />}
|
||||
label={t('security.rateLimitTests')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
startIcon={testing ? <CircularProgress size={20} color="inherit" /> : <PlayArrow />}
|
||||
onClick={handleRunPenetrationTests}
|
||||
disabled={testing || Object.values(selectedTests).every(v => !v)}
|
||||
>
|
||||
{testing ? t('security.runningTests') : t('security.runPenetrationTests')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{testResults && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.testResults')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" color="success.main" fontWeight="bold">
|
||||
{testResults.summary.passed}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.passed')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" color="error.main" fontWeight="bold">
|
||||
{testResults.summary.failed}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.failed')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" color="warning.main" fontWeight="bold">
|
||||
{testResults.summary.warnings}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.warnings')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{testResults.tests.map((test, index) => (
|
||||
<Accordion key={index}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
{test.passed ? <CheckCircle color="success" /> : <ErrorIcon color="error" />}
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{test.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={test.severity.toUpperCase()}
|
||||
size="small"
|
||||
color={getSeverityColor(test.severity)}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||
{test.duration_ms}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
<strong>{t('security.findings')}:</strong>
|
||||
</Typography>
|
||||
<List dense>
|
||||
{test.findings.map((finding, fIndex) => (
|
||||
<ListItem key={fIndex}>
|
||||
<ListItemText primary={finding} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{test.recommendations.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mt: 2 }}>
|
||||
<strong>{t('security.recommendations')}:</strong>
|
||||
</Typography>
|
||||
<List dense>
|
||||
{test.recommendations.map((rec, rIndex) => (
|
||||
<ListItem key={rIndex}>
|
||||
<ListItemText primary={rec} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Test History Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<CardContent>
|
||||
{testHistory.length === 0 ? (
|
||||
<Alert severity="info">{t('security.noTestHistory')}</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.timestamp')}</TableCell>
|
||||
<TableCell>{t('security.testType')}</TableCell>
|
||||
<TableCell>{t('security.testName')}</TableCell>
|
||||
<TableCell>{t('security.status')}</TableCell>
|
||||
<TableCell>{t('security.severity')}</TableCell>
|
||||
<TableCell>{t('security.executedBy')}</TableCell>
|
||||
<TableCell>{t('security.duration')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{testHistory.map((test) => (
|
||||
<TableRow key={test.id}>
|
||||
<TableCell>{new Date(test.started_at).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={test.test_type} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{test.test_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={test.status}
|
||||
size="small"
|
||||
color={test.status === 'pass' ? 'success' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={test.severity}
|
||||
size="small"
|
||||
color={getSeverityColor(test.severity)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{test.username || '-'}</TableCell>
|
||||
<TableCell>{test.duration_ms}ms</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Network Security Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<CardContent>
|
||||
{networkStats && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" fontWeight="bold" color="primary">
|
||||
{networkStats.active_connections.count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.activeConnections')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" fontWeight="bold" color="error">
|
||||
{networkStats.blocked_requests.count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.blockedRequests')} (24h)
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" fontWeight="bold" color="warning">
|
||||
{networkStats.rate_limiting.failed_logins}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.failedLogins')} (24h)
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.rateLimitingStats')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.totalRequests')} (24h)
|
||||
</Typography>
|
||||
<Typography variant="h5">
|
||||
{networkStats.rate_limiting.total_requests}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.rateLimited')} (24h)
|
||||
</Typography>
|
||||
<Typography variant="h5" color="warning.main">
|
||||
{networkStats.rate_limiting.rate_limited}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityTestingDashboard;
|
||||
371
frontend/src/components/SessionManagement.jsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DevicesOther,
|
||||
Delete,
|
||||
Refresh,
|
||||
Computer,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const SessionManagement = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useAuthStore();
|
||||
const { notifySecuritySuccess, notifySecurityError, notifySecurityWarning } = useSecurityNotification();
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [terminateDialogOpen, setTerminateDialogOpen] = useState(false);
|
||||
const [terminateAllDialogOpen, setTerminateAllDialogOpen] = useState(false);
|
||||
const [sessionToTerminate, setSessionToTerminate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/sessions/my-sessions', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setSessions(response.data);
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to fetch sessions'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (userAgent) => {
|
||||
if (!userAgent) return <Computer />;
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
return <Smartphone />;
|
||||
}
|
||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
return <Tablet />;
|
||||
}
|
||||
return <Computer />;
|
||||
};
|
||||
|
||||
const getDeviceName = (userAgent) => {
|
||||
if (!userAgent) return 'Unknown Device';
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes('chrome')) return 'Chrome Browser';
|
||||
if (ua.includes('firefox')) return 'Firefox Browser';
|
||||
if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari Browser';
|
||||
if (ua.includes('edge')) return 'Edge Browser';
|
||||
if (ua.includes('mobile')) return 'Mobile Device';
|
||||
if (ua.includes('tablet')) return 'Tablet';
|
||||
return 'Desktop Browser';
|
||||
};
|
||||
|
||||
const getLocation = (ipAddress) => {
|
||||
// In a real app, you might do IP geolocation
|
||||
return ipAddress || 'Unknown Location';
|
||||
};
|
||||
|
||||
const handleTerminateSession = async () => {
|
||||
if (!sessionToTerminate) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/sessions/${sessionToTerminate.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(t('security.terminateSession'));
|
||||
setTerminateDialogOpen(false);
|
||||
setSessionToTerminate(null);
|
||||
fetchSessions();
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to terminate session'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateAllOthers = async () => {
|
||||
try {
|
||||
const response = await axios.post('/api/sessions/terminate-all-others', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(
|
||||
`${response.data.count} ${t('security.terminateSession')}(s)`
|
||||
);
|
||||
setTerminateAllDialogOpen(false);
|
||||
fetchSessions();
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to terminate sessions'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openTerminateDialog = (session) => {
|
||||
setSessionToTerminate(session);
|
||||
setTerminateDialogOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const otherSessions = sessions.filter(s => !s.isCurrent);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DevicesOther color="primary" fontSize="large" />
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('security.activeSessions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchSessions}
|
||||
size="small"
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
{otherSessions.length > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setTerminateAllDialogOpen(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sessions.length > 1 && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('security.multipleDevices', { count: sessions.length })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<Alert severity="warning">
|
||||
{t('security.noRecentActivity')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('device')}</TableCell>
|
||||
<TableCell>{t('security.ipAddress')}</TableCell>
|
||||
<TableCell>{t('lastActive')}</TableCell>
|
||||
<TableCell>{t('created')}</TableCell>
|
||||
<TableCell>{t('security.status')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sessions.map((session) => (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
sx={{
|
||||
backgroundColor: session.isCurrent ? 'action.selected' : 'inherit'
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getDeviceIcon(session.user_agent)}
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={session.isCurrent ? 'bold' : 'normal'}>
|
||||
{getDeviceName(session.user_agent)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{session.user_agent?.substring(0, 50)}...
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{getLocation(session.ip_address)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{session.ip_address}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{session.last_activity
|
||||
? format(new Date(session.last_activity), 'MMM d, yyyy HH:mm')
|
||||
: 'N/A'
|
||||
}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{format(new Date(session.created_at), 'MMM d, yyyy HH:mm')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{session.isCurrent ? (
|
||||
<Chip
|
||||
label={t('current')}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={t('active')}
|
||||
color="success"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{!session.isCurrent && (
|
||||
<Tooltip title={t('security.terminateSession')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => openTerminateDialog(session)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Terminate Single Session Dialog */}
|
||||
<Dialog
|
||||
open={terminateDialogOpen}
|
||||
onClose={() => setTerminateDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning color="warning" />
|
||||
{t('security.terminateSession')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography gutterBottom>
|
||||
Are you sure you want to terminate this session?
|
||||
</Typography>
|
||||
{sessionToTerminate && (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>{t('device')}:</strong> {getDeviceName(sessionToTerminate.user_agent)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>{t('security.ipAddress')}:</strong> {sessionToTerminate.ip_address}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>{t('lastActive')}:</strong> {format(new Date(sessionToTerminate.last_activity || sessionToTerminate.created_at), 'MMM d, yyyy HH:mm')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
This device will be logged out immediately.
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTerminateDialogOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleTerminateSession} color="error" variant="contained">
|
||||
{t('security.terminateSession')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Terminate All Other Sessions Dialog */}
|
||||
<Dialog
|
||||
open={terminateAllDialogOpen}
|
||||
onClose={() => setTerminateAllDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning color="error" />
|
||||
{t('security.terminateAllSessions')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography gutterBottom>
|
||||
Are you sure you want to terminate all other sessions?
|
||||
</Typography>
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
This will log you out from all devices except this one. You'll need to log in again on those devices.
|
||||
</Alert>
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
<strong>{otherSessions.length}</strong> session(s) will be terminated.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTerminateAllDialogOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleTerminateAllOthers} color="error" variant="contained">
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionManagement;
|
||||
165
frontend/src/components/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Box,
|
||||
Typography,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LiveTv,
|
||||
Movie,
|
||||
Theaters,
|
||||
Favorite,
|
||||
Settings,
|
||||
Radio as RadioIcon,
|
||||
BarChart,
|
||||
Security
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import Logo from './Logo';
|
||||
|
||||
const drawerWidth = 200;
|
||||
|
||||
function Sidebar({ open, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'live_tv', icon: <LiveTv fontSize="small" />, path: '/live-tv' },
|
||||
{ text: 'radio', icon: <RadioIcon fontSize="small" />, path: '/radio' },
|
||||
{ text: 'favorite_tv', icon: <Favorite fontSize="small" />, path: '/favorites/tv' },
|
||||
{ text: 'favorite_radio', icon: <Favorite fontSize="small" />, path: '/favorites/radio' },
|
||||
...(user?.role === 'admin' ? [
|
||||
{ text: 'Analytics', icon: <BarChart fontSize="small" />, path: '/stats' },
|
||||
{ text: 'security.title', icon: <Security fontSize="small" />, path: '/security' }
|
||||
] : []),
|
||||
{ text: 'settings.title', icon: <Settings fontSize="small" />, path: '/settings' }
|
||||
];
|
||||
|
||||
const handleNavigate = (path) => {
|
||||
navigate(path);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Logo size={36} />
|
||||
<Typography variant="h6" fontSize="0.95rem" fontWeight="bold" sx={{ mt: 0.5 }}>
|
||||
{t('app_name')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 1.5, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover'
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ width: 28, height: 28, bgcolor: 'primary.main', fontSize: '0.875rem' }}>
|
||||
{user?.username?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Typography variant="body2" fontSize="0.8rem" fontWeight="600" noWrap>
|
||||
{user?.username}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<List sx={{ px: 1.5 }}>
|
||||
{menuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path === '/live-tv' && location.pathname === '/') ||
|
||||
(item.path === '/favorites/tv' && location.pathname === '/favorites');
|
||||
return (
|
||||
<ListItem key={item.text} disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
selected={isActive}
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
minHeight: 36,
|
||||
py: 0.75,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark'
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32, color: isActive ? 'white' : 'inherit' }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t(item.text)}
|
||||
primaryTypographyProps={{ fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile drawer - temporary */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
borderRight: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
|
||||
{/* Desktop drawer - permanent, always visible */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
borderRight: 'none',
|
||||
position: 'relative'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
537
frontend/src/components/TwoFactorSettings.jsx
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Chip,
|
||||
Grid,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
QrCode2,
|
||||
Download,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Check,
|
||||
Close,
|
||||
Refresh
|
||||
} from '@mui/icons-material';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const TwoFactorSettings = () => {
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [setupDialogOpen, setSetupDialogOpen] = useState(false);
|
||||
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
||||
const [backupCodesDialogOpen, setBackupCodesDialogOpen] = useState(false);
|
||||
|
||||
// Setup flow state
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [setupStep, setSetupStep] = useState(1); // 1: QR, 2: Verify, 3: Backup Codes
|
||||
|
||||
// Disable flow state
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Backup codes regeneration
|
||||
const [regenPassword, setRegenPassword] = useState('');
|
||||
const [regenCode, setRegenCode] = useState('');
|
||||
const [existingBackupCodes, setExistingBackupCodes] = useState([]);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/two-factor/status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setStatus(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch 2FA status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const handleStartSetup = async () => {
|
||||
try {
|
||||
setError('');
|
||||
const response = await axios.post(
|
||||
'/api/two-factor/setup',
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setQrCode(response.data.qrCode);
|
||||
setSecret(response.data.secret);
|
||||
setSetupStep(1);
|
||||
setSetupDialogOpen(true);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to start 2FA setup');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyAndEnable = async () => {
|
||||
try {
|
||||
setError('');
|
||||
const response = await axios.post(
|
||||
'/api/two-factor/enable',
|
||||
{ token: verificationCode },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setBackupCodes(response.data.backupCodes);
|
||||
setSetupStep(3);
|
||||
setSuccess('2FA enabled successfully!');
|
||||
await fetchStatus();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Invalid verification code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
try {
|
||||
setError('');
|
||||
await axios.post(
|
||||
'/api/two-factor/disable',
|
||||
{ password: disablePassword, token: disableCode },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setSuccess('2FA disabled successfully');
|
||||
setDisableDialogOpen(false);
|
||||
setDisablePassword('');
|
||||
setDisableCode('');
|
||||
await fetchStatus();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to disable 2FA');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
try {
|
||||
setError('');
|
||||
const response = await axios.post(
|
||||
'/api/two-factor/backup-codes/regenerate',
|
||||
{ password: regenPassword, token: regenCode },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setExistingBackupCodes(response.data.backupCodes);
|
||||
setSuccess('Backup codes regenerated successfully');
|
||||
setRegenPassword('');
|
||||
setRegenCode('');
|
||||
await fetchStatus();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to regenerate backup codes');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackupCodes = () => {
|
||||
const codes = backupCodes.length > 0 ? backupCodes : existingBackupCodes;
|
||||
const text = `StreamFlow - Two-Factor Authentication Backup Codes\n\n` +
|
||||
`Generated: ${new Date().toLocaleString()}\n\n` +
|
||||
`These are your backup codes. Keep them safe!\n` +
|
||||
`Each code can be used only once.\n\n` +
|
||||
codes.map((code, i) => `${i + 1}. ${code}`).join('\n');
|
||||
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'Streamflow-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleCloseSetup = () => {
|
||||
setSetupDialogOpen(false);
|
||||
setVerificationCode('');
|
||||
setSetupStep(1);
|
||||
setBackupCodes([]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{(error || success) && (
|
||||
<Alert
|
||||
severity={error ? 'error' : 'success'}
|
||||
onClose={() => { setError(''); setSuccess(''); }}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{error || success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Security sx={{ fontSize: 40, mr: 2, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
<Chip
|
||||
label={status?.enabled ? 'Enabled' : 'Disabled'}
|
||||
color={status?.enabled ? 'success' : 'default'}
|
||||
icon={status?.enabled ? <Check /> : <Close />}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Two-factor authentication adds an extra layer of security to your account.
|
||||
You'll need to enter a 6-digit code from your authenticator app when logging in.
|
||||
</Typography>
|
||||
|
||||
{!status?.enabled ? (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<AlertTitle>Enable 2FA</AlertTitle>
|
||||
Scan the QR code with an authenticator app like Google Authenticator,
|
||||
Microsoft Authenticator, or Authy.
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<QrCode2 />}
|
||||
onClick={handleStartSetup}
|
||||
size="large"
|
||||
>
|
||||
Enable Two-Factor Authentication
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Backup Codes
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{status.backupCodesUnused} / {status.backupCodesTotal}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Unused codes remaining
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDisableDialogOpen(true)}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => setBackupCodesDialogOpen(true)}
|
||||
>
|
||||
Regenerate Backup Codes
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Setup Dialog */}
|
||||
<Dialog
|
||||
open={setupDialogOpen}
|
||||
onClose={handleCloseSetup}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Enable Two-Factor Authentication - Step {setupStep} of 3
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{setupStep === 1 && (
|
||||
<Box>
|
||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
||||
<svg width="80" height="80" viewBox="0 0 192 192" style={{ marginBottom: '10px' }}>
|
||||
<defs>
|
||||
<linearGradient id="qrGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#3b82f6', stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 72 53 L 72 139 L 139 96 Z" fill="url(#qrGrad)" opacity="0.95" />
|
||||
<path
|
||||
d="M 96 96 C 96 96, 80 83, 72 74 C 64 65, 56 55, 56 53 L 144 96 C 144 96, 141 101, 132 110 C 123 119, 111 131, 98 139"
|
||||
fill="none"
|
||||
stroke="url(#qrGrad)"
|
||||
strokeWidth="4"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'primary.main', mb: 1 }}>
|
||||
StreamFlow IPTV
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" paragraph align="center" color="text.secondary">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.):
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
my: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: '2px solid',
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<img src={qrCode} alt="StreamFlow 2FA QR Code" style={{ maxWidth: '280px', width: '100%' }} />
|
||||
</Box>
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<AlertTitle>Can't scan the code?</AlertTitle>
|
||||
Enter this secret key manually in your authenticator app:<br />
|
||||
<Box component="span" sx={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: 600, userSelect: 'all' }}>
|
||||
{secret}
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{setupStep === 2 && (
|
||||
<Box>
|
||||
<Typography variant="body2" paragraph>
|
||||
Enter the 6-digit code from your authenticator app to verify:
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputProps={{ maxLength: 6, style: { textAlign: 'center', fontSize: '24px', letterSpacing: '8px' } }}
|
||||
sx={{ my: 2 }}
|
||||
/>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{setupStep === 3 && (
|
||||
<Box>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
<AlertTitle>2FA Enabled Successfully!</AlertTitle>
|
||||
Save these backup codes in a safe place.
|
||||
</Alert>
|
||||
<Typography variant="body2" paragraph>
|
||||
Each code can be used only once to log in if you don't have access to your authenticator app.
|
||||
</Typography>
|
||||
<List sx={{ bgcolor: 'background.paper', border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||
{backupCodes.map((code, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText
|
||||
primary={code}
|
||||
primaryTypographyProps={{ fontFamily: 'monospace', fontSize: '18px' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Download />}
|
||||
onClick={handleDownloadBackupCodes}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Download Backup Codes
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{setupStep === 3 ? (
|
||||
<Button onClick={handleCloseSetup} variant="contained">
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleCloseSetup}>Cancel</Button>
|
||||
{setupStep === 1 && (
|
||||
<Button onClick={() => setSetupStep(2)} variant="contained">
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{setupStep === 2 && (
|
||||
<Button
|
||||
onClick={handleVerifyAndEnable}
|
||||
variant="contained"
|
||||
disabled={verificationCode.length !== 6}
|
||||
>
|
||||
Verify & Enable
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Disable Dialog */}
|
||||
<Dialog open={disableDialogOpen} onClose={() => setDisableDialogOpen(false)}>
|
||||
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Disabling 2FA will make your account less secure.
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="2FA Code"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputProps={{ maxLength: 6 }}
|
||||
/>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDisableDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleDisable2FA}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={!disablePassword || disableCode.length !== 6}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Regenerate Backup Codes Dialog */}
|
||||
<Dialog open={backupCodesDialogOpen} onClose={() => setBackupCodesDialogOpen(false)}>
|
||||
<DialogTitle>Regenerate Backup Codes</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
This will invalidate all existing backup codes and generate new ones.
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label="Password"
|
||||
value={regenPassword}
|
||||
onChange={(e) => setRegenPassword(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="2FA Code"
|
||||
value={regenCode}
|
||||
onChange={(e) => setRegenCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputProps={{ maxLength: 6 }}
|
||||
/>
|
||||
{existingBackupCodes.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
New Backup Codes:
|
||||
</Typography>
|
||||
<List sx={{ bgcolor: 'background.paper', border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||
{existingBackupCodes.map((code, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText
|
||||
primary={code}
|
||||
primaryTypographyProps={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Download />}
|
||||
onClick={handleDownloadBackupCodes}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Download Backup Codes
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setBackupCodesDialogOpen(false); setExistingBackupCodes([]); }}>
|
||||
Close
|
||||
</Button>
|
||||
{existingBackupCodes.length === 0 && (
|
||||
<Button
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
disabled={!regenPassword || regenCode.length !== 6}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorSettings;
|
||||
450
frontend/src/components/UserManagement.jsx
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Switch,
|
||||
FormControlLabel
|
||||
} from '@mui/material';
|
||||
import { Add, Edit, Delete, Lock, Check, Close } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const UserManagement = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user: currentUser } = useAuthStore();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [openResetDialog, setOpenResetDialog] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [resetUserId, setResetUserId] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
is_active: true
|
||||
});
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} else {
|
||||
setError('Failed to fetch users');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDialog = (user = null) => {
|
||||
if (user) {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
is_active: user.is_active === 1
|
||||
});
|
||||
} else {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
is_active: true
|
||||
});
|
||||
}
|
||||
setOpenDialog(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingUser(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (editingUser) {
|
||||
// Update user
|
||||
const response = await fetch(`/api/users/${editingUser.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
is_active: formData.is_active
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
handleCloseDialog();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to update user');
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
handleCloseDialog();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to create user');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(editingUser ? 'Failed to update user' : 'Failed to create user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (resetPassword.length < 8) {
|
||||
setError(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${resetUserId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ newPassword: resetPassword })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setOpenResetDialog(false);
|
||||
setResetUserId(null);
|
||||
setResetPassword('');
|
||||
alert('Password reset successfully. User must change password on next login.');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to reset password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to reset password');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId, username) => {
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to delete user');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
if (currentUser?.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{t('settings.userManagement')}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => handleOpenDialog()}
|
||||
>
|
||||
{t('addUser')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('username')}</TableCell>
|
||||
<TableCell>{t('email')}</TableCell>
|
||||
<TableCell>{t('role')}</TableCell>
|
||||
<TableCell>{t('status')}</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.role}
|
||||
size="small"
|
||||
color={user.role === 'admin' ? 'primary' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.is_active ? (
|
||||
<Chip label={t('active')} size="small" color="success" icon={<Check />} />
|
||||
) : (
|
||||
<Chip label={t('inactive')} size="small" color="error" icon={<Close />} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => handleOpenDialog(user)}>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setResetUserId(user.id);
|
||||
setOpenResetDialog(true);
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
<Lock fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(user.id, user.username)}
|
||||
disabled={user.id === currentUser.id}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Create/Edit User Dialog */}
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingUser ? t('editUser') : t('createUser')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('username')}
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
size="small"
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="email"
|
||||
label={t('email')}
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
required
|
||||
/>
|
||||
|
||||
{!editingUser && (
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('password')}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
required
|
||||
helperText="Minimum 8 characters"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||
<InputLabel>{t('role')}</InputLabel>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
label={t('role')}
|
||||
>
|
||||
<MenuItem value="user">{t('user')}</MenuItem>
|
||||
<MenuItem value="admin">{t('admin')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t('active')}
|
||||
/>
|
||||
|
||||
{!editingUser && (
|
||||
<Alert severity="info" sx={{ mt: 2, fontSize: '0.75rem' }}>
|
||||
New users must change their password on first login.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={handleCloseDialog}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !formData.email || (!editingUser && (!formData.username || !formData.password))}
|
||||
>
|
||||
{submitting ? <CircularProgress size={20} /> : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset Password Dialog */}
|
||||
<Dialog open={openResetDialog} onClose={() => setOpenResetDialog(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('resetPassword')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert severity="warning" sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
This will set a temporary password and force the user to change it on next login.
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label="New Password"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
required
|
||||
helperText="Minimum 8 characters"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={() => {
|
||||
setOpenResetDialog(false);
|
||||
setResetPassword('');
|
||||
setError('');
|
||||
}}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleResetPassword}
|
||||
disabled={submitting || resetPassword.length < 8}
|
||||
>
|
||||
{submitting ? <CircularProgress size={20} /> : t('resetPassword')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
491
frontend/src/components/VPNConfigManager.jsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Chip,
|
||||
Stack,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Upload,
|
||||
Delete,
|
||||
PowerSettingsNew,
|
||||
CheckCircle,
|
||||
Public,
|
||||
Router,
|
||||
Info,
|
||||
CloudUpload
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const VPNConfigManager = () => {
|
||||
const { t } = useTranslation();
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const [configs, setConfigs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [configName, setConfigName] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [configToDelete, setConfigToDelete] = useState(null);
|
||||
const [connecting, setConnecting] = useState(null); // configId being connected/disconnected
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/vpn-configs/configs', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConfigs(data.configs || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load configs:', error);
|
||||
setMessage({ type: 'error', text: t('vpnConfig.loadFailed') || 'Failed to load configurations' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (ext === 'conf' || ext === 'ovpn') {
|
||||
setSelectedFile(file);
|
||||
// Auto-fill name from filename
|
||||
const name = file.name.replace(/\.(conf|ovpn)$/, '');
|
||||
setConfigName(name);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: t('vpnConfig.invalidFileType') || 'Only .conf and .ovpn files are supported' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !configName.trim()) {
|
||||
setMessage({ type: 'error', text: t('vpnConfig.nameRequired') || 'Configuration name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('config', selectedFile);
|
||||
formData.append('name', configName.trim());
|
||||
|
||||
const response = await fetch('/api/vpn-configs/configs/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
data = { error: 'Server error' };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('vpnConfig.uploadSuccess') || 'Configuration uploaded successfully!' });
|
||||
setUploadDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setConfigName('');
|
||||
await loadConfigs();
|
||||
} else {
|
||||
const errorMsg = typeof data.error === 'string' ? data.error : t('vpnConfig.uploadFailed') || 'Failed to upload configuration';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error.message || t('vpnConfig.uploadError') || 'Error uploading configuration';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (configId) => {
|
||||
setMessage({ type: '', text: '' });
|
||||
setConnecting(configId);
|
||||
|
||||
try {
|
||||
console.log('[VPNConfigManager] Connecting to config:', configId);
|
||||
|
||||
const response = await fetch(`/api/vpn-configs/configs/${configId}/connect`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[VPNConfigManager] Response status:', response.status);
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
console.error('[VPNConfigManager] Failed to parse response:', e);
|
||||
data = { error: 'Invalid server response' };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[VPNConfigManager] Connected successfully');
|
||||
setMessage({ type: 'success', text: t('vpnConfig.connected') || 'Connected to VPN successfully!' });
|
||||
await loadConfigs();
|
||||
} else {
|
||||
console.error('[VPNConfigManager] Connection failed:', data);
|
||||
const errorMsg = typeof data.error === 'string' ? data.error : t('vpnConfig.connectFailed') || 'Failed to connect';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VPNConfigManager] Connect error:', error);
|
||||
const errorMsg = error.message || t('vpnConfig.connectError') || 'Error connecting to VPN';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
} finally {
|
||||
setConnecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (configId) => {
|
||||
setMessage({ type: '', text: '' });
|
||||
setConnecting(configId);
|
||||
|
||||
try {
|
||||
console.log('[VPNConfigManager] Disconnecting from config:', configId);
|
||||
|
||||
const response = await fetch(`/api/vpn-configs/configs/${configId}/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
console.error('[VPNConfigManager] Failed to parse response:', e);
|
||||
data = { error: 'Invalid server response' };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[VPNConfigManager] Disconnected successfully');
|
||||
setMessage({ type: 'success', text: t('vpnConfig.disconnected') || 'Disconnected from VPN successfully!' });
|
||||
await loadConfigs();
|
||||
} else {
|
||||
console.error('[VPNConfigManager] Disconnect failed:', data);
|
||||
const errorMsg = typeof data.error === 'string' ? data.error : t('vpnConfig.disconnectFailed') || 'Failed to disconnect';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VPNConfigManager] Disconnect error:', error);
|
||||
const errorMsg = error.message || t('vpnConfig.disconnectError') || 'Error disconnecting from VPN';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
} finally {
|
||||
setConnecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/vpn-configs/configs/${configToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('vpnConfig.deleteSuccess') || 'Configuration deleted successfully' });
|
||||
setDeleteDialogOpen(false);
|
||||
setConfigToDelete(null);
|
||||
await loadConfigs();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('vpnConfig.deleteFailed') || 'Failed to delete configuration' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('vpnConfig.deleteError') || 'Error deleting configuration' });
|
||||
}
|
||||
};
|
||||
|
||||
const getCountryFlag = (countryCode) => {
|
||||
const flags = {
|
||||
'US': '🇺🇸', 'NL': '🇳🇱', 'JP': '🇯🇵', 'GB': '🇬🇧',
|
||||
'DE': '🇩🇪', 'FR': '🇫🇷', 'CA': '🇨🇦', 'CH': '🇨🇭',
|
||||
'SE': '🇸🇪', 'RO': '🇷🇴'
|
||||
};
|
||||
return flags[countryCode] || '🌍';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Router /> {t('vpnConfig.title') || 'VPN Configurations'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setUploadDialogOpen(true)}
|
||||
>
|
||||
{t('vpnConfig.uploadConfig') || 'Upload Config'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{message.text && (
|
||||
<Alert severity={message.type} sx={{ mb: 3 }} onClose={() => setMessage({ type: '', text: '' })}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{t('vpnConfig.dockerLimitationWarning')}
|
||||
</Alert>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }} icon={<Info />}>
|
||||
{t('vpnConfig.infoText') || 'Upload VPN configuration files (.conf for WireGuard, .ovpn for OpenVPN) to easily manage multiple VPN connections.'}
|
||||
</Alert>
|
||||
|
||||
{configs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||||
<CloudUpload sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('vpnConfig.noConfigs') || 'No VPN configurations yet'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
||||
{t('vpnConfig.uploadFirst') || 'Upload your first VPN configuration file to get started'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setUploadDialogOpen(true)}
|
||||
>
|
||||
{t('vpnConfig.uploadConfig') || 'Upload Config'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<List>
|
||||
{configs.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body1" fontWeight={config.is_active ? 600 : 400}>
|
||||
{config.name}
|
||||
</Typography>
|
||||
{config.is_active && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<CheckCircle />}
|
||||
label={t('vpnConfig.active') || 'Active'}
|
||||
color="success"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
size="small"
|
||||
label={config.config_type.toUpperCase()}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
secondary={
|
||||
<Stack direction="row" spacing={2} mt={0.5}>
|
||||
{config.country && (
|
||||
<Typography variant="caption" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Public sx={{ fontSize: 14 }} />
|
||||
{getCountryFlag(config.country)} {config.country}
|
||||
</Typography>
|
||||
)}
|
||||
{config.server_name && (
|
||||
<Typography variant="caption" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Router sx={{ fontSize: 14 }} />
|
||||
{config.server_name}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(config.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{config.is_active ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={connecting === config.id ? <CircularProgress size={16} /> : <PowerSettingsNew />}
|
||||
onClick={() => handleDisconnect(config.id)}
|
||||
disabled={connecting === config.id}
|
||||
>
|
||||
{connecting === config.id
|
||||
? (t('vpnConfig.disconnecting') || 'Disconnecting...')
|
||||
: (t('vpnConfig.disconnect') || 'Disconnect')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={connecting === config.id ? <CircularProgress size={16} /> : <PowerSettingsNew />}
|
||||
onClick={() => handleConnect(config.id)}
|
||||
disabled={connecting === config.id}
|
||||
>
|
||||
{connecting === config.id
|
||||
? (t('vpnConfig.connecting') || 'Connecting...')
|
||||
: (t('vpnConfig.connect') || 'Connect')}
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => {
|
||||
setConfigToDelete(config.id);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
disabled={config.is_active || connecting === config.id}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Upload Dialog */}
|
||||
<Dialog
|
||||
open={uploadDialogOpen}
|
||||
onClose={() => !uploading && setUploadDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{t('vpnConfig.uploadConfig') || 'Upload VPN Configuration'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={3} mt={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('vpnConfig.configName') || 'Configuration Name'}
|
||||
value={configName}
|
||||
onChange={(e) => setConfigName(e.target.value)}
|
||||
placeholder="My VPN Config"
|
||||
helperText={t('vpnConfig.nameHelper') || 'A friendly name to identify this configuration'}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<input
|
||||
accept=".conf,.ovpn"
|
||||
style={{ display: 'none' }}
|
||||
id="vpn-config-file"
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<label htmlFor="vpn-config-file">
|
||||
<Button
|
||||
component="span"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<Upload />}
|
||||
>
|
||||
{selectedFile
|
||||
? selectedFile.name
|
||||
: (t('vpnConfig.selectFile') || 'Select .conf or .ovpn file')}
|
||||
</Button>
|
||||
</label>
|
||||
<Typography variant="caption" display="block" mt={1} color="text.secondary">
|
||||
{t('vpnConfig.fileTypes') || 'Supported: WireGuard (.conf) and OpenVPN (.ovpn) configuration files'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{message.text && uploadDialogOpen && (
|
||||
<Alert severity={message.type}>{message.text}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUploadDialogOpen(false)} disabled={uploading}>
|
||||
{t('cancel') || 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
variant="contained"
|
||||
disabled={!selectedFile || !configName.trim() || uploading}
|
||||
startIcon={uploading ? <CircularProgress size={16} /> : <CloudUpload />}
|
||||
>
|
||||
{t('vpnConfig.upload') || 'Upload'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>{t('vpnConfig.deleteConfirmTitle') || 'Delete Configuration?'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
{t('vpnConfig.deleteConfirmText') || 'Are you sure you want to delete this VPN configuration? This action cannot be undone.'}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>{t('cancel') || 'Cancel'}</Button>
|
||||
<Button onClick={handleDelete} color="error" variant="contained">
|
||||
{t('delete') || 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VPNConfigManager;
|
||||
141
frontend/src/components/ValidatedTextField.jsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { TextField, FormHelperText, Box, Chip } from '@mui/material';
|
||||
import { CheckCircle, Error as ErrorIcon, Warning } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
validateUsername,
|
||||
validateEmail,
|
||||
validateUrl,
|
||||
validateTextField,
|
||||
validateInteger
|
||||
} from '../utils/inputValidator';
|
||||
|
||||
/**
|
||||
* ValidatedTextField Component
|
||||
* Provides client-side validation with visual feedback
|
||||
*/
|
||||
const ValidatedTextField = ({
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
validationType,
|
||||
minLength,
|
||||
maxLength,
|
||||
min,
|
||||
max,
|
||||
required = false,
|
||||
showValidation = true,
|
||||
relatedValues = {},
|
||||
...textFieldProps
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [isValid, setIsValid] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched || !value) {
|
||||
setErrors([]);
|
||||
setIsValid(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let validationResult;
|
||||
|
||||
switch (validationType) {
|
||||
case 'username':
|
||||
validationResult = validateUsername(value);
|
||||
break;
|
||||
case 'email':
|
||||
validationResult = validateEmail(value);
|
||||
break;
|
||||
case 'url':
|
||||
validationResult = validateUrl(value);
|
||||
break;
|
||||
case 'integer':
|
||||
validationResult = validateInteger(value, min, max);
|
||||
break;
|
||||
case 'text':
|
||||
default:
|
||||
validationResult = validateTextField(value, minLength, maxLength, required);
|
||||
break;
|
||||
}
|
||||
|
||||
setErrors(validationResult.errors);
|
||||
setIsValid(validationResult.valid);
|
||||
|
||||
// If valid and sanitized value is different, update parent
|
||||
if (validationResult.valid && validationResult.sanitized !== value) {
|
||||
onChange({ target: { value: validationResult.sanitized } });
|
||||
}
|
||||
}, [value, touched, validationType, minLength, maxLength, min, max, required]);
|
||||
|
||||
const handleBlur = (e) => {
|
||||
setTouched(true);
|
||||
if (textFieldProps.onBlur) {
|
||||
textFieldProps.onBlur(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange(e);
|
||||
};
|
||||
|
||||
const getValidationColor = () => {
|
||||
if (!showValidation || !touched || !value) return undefined;
|
||||
return isValid ? 'success' : 'error';
|
||||
};
|
||||
|
||||
const getEndAdornment = () => {
|
||||
if (!showValidation || !touched || !value) return textFieldProps.InputProps?.endAdornment;
|
||||
|
||||
const adornment = (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{isValid ? (
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
) : (
|
||||
<ErrorIcon color="error" fontSize="small" />
|
||||
)}
|
||||
{textFieldProps.InputProps?.endAdornment}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return adornment;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
error={touched && !isValid && errors.length > 0}
|
||||
color={getValidationColor()}
|
||||
InputProps={{
|
||||
...textFieldProps.InputProps,
|
||||
endAdornment: getEndAdornment()
|
||||
}}
|
||||
/>
|
||||
{showValidation && touched && errors.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{errors.map((error, index) => (
|
||||
<FormHelperText key={index} error sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<ErrorIcon fontSize="small" />
|
||||
{error}
|
||||
</FormHelperText>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{showValidation && touched && isValid && (
|
||||
<FormHelperText sx={{ color: 'success.main', display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircle fontSize="small" />
|
||||
{t('security.inputSanitized')}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedTextField;
|
||||
551
frontend/src/components/VideoPlayer.jsx
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Box, Paper, IconButton, Typography, Slider, Tooltip } from '@mui/material';
|
||||
import {
|
||||
PlayArrow,
|
||||
Pause,
|
||||
VolumeUp,
|
||||
VolumeOff,
|
||||
Fullscreen,
|
||||
FiberManualRecord,
|
||||
PictureInPicture,
|
||||
PictureInPictureAlt,
|
||||
Cast,
|
||||
CastConnected
|
||||
} from '@mui/icons-material';
|
||||
import ReactPlayer from 'react-player';
|
||||
import Logo from './Logo';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useChromecast } from '../utils/useChromecast';
|
||||
|
||||
function VideoPlayer({ channel, enablePip = true }) {
|
||||
const playerRef = useRef(null);
|
||||
const videoElementRef = useRef(null);
|
||||
const { token } = useAuthStore();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [pip, setPip] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [pipSupported, setPipSupported] = useState(false);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const hideLogoTimer = useRef(null);
|
||||
|
||||
// Chromecast support
|
||||
const {
|
||||
castAvailable,
|
||||
casting,
|
||||
castMedia,
|
||||
stopCasting,
|
||||
openCastDialog,
|
||||
setCastVolume,
|
||||
setCastMuted
|
||||
} = useChromecast();
|
||||
|
||||
// Auto-hide logo after 2 seconds
|
||||
const resetLogoTimer = () => {
|
||||
setShowLogo(true);
|
||||
if (hideLogoTimer.current) {
|
||||
clearTimeout(hideLogoTimer.current);
|
||||
}
|
||||
if (playing) {
|
||||
hideLogoTimer.current = setTimeout(() => {
|
||||
setShowLogo(false);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Show logo on mouse move
|
||||
const handleMouseMove = () => {
|
||||
if (playing) {
|
||||
resetLogoTimer();
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideLogoTimer.current) {
|
||||
clearTimeout(hideLogoTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// Reset timer when playing changes
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
resetLogoTimer();
|
||||
} else {
|
||||
setShowLogo(true);
|
||||
if (hideLogoTimer.current) {
|
||||
clearTimeout(hideLogoTimer.current);
|
||||
}
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
// Check PiP support on mount
|
||||
useEffect(() => {
|
||||
if (document.pictureInPictureEnabled) {
|
||||
setPipSupported(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for PiP events
|
||||
useEffect(() => {
|
||||
const handlePipEnter = () => setPip(true);
|
||||
const handlePipExit = () => setPip(false);
|
||||
|
||||
if (videoElementRef.current) {
|
||||
videoElementRef.current.addEventListener('enterpictureinpicture', handlePipEnter);
|
||||
videoElementRef.current.addEventListener('leavepictureinpicture', handlePipExit);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (videoElementRef.current) {
|
||||
videoElementRef.current.removeEventListener('enterpictureinpicture', handlePipEnter);
|
||||
videoElementRef.current.removeEventListener('leavepictureinpicture', handlePipExit);
|
||||
}
|
||||
};
|
||||
}, [videoElementRef.current]);
|
||||
|
||||
// Always use proxy to bypass geo-blocking and CORS
|
||||
const getStreamUrl = () => {
|
||||
if (!channel?.id) return '';
|
||||
// Use backend proxy for ALL streams to handle CORS and geo-blocking
|
||||
return `/api/stream/proxy/${channel.id}?token=${token}`;
|
||||
};
|
||||
|
||||
// Get full URL for Chromecast (needs absolute URL)
|
||||
const getFullStreamUrl = () => {
|
||||
const streamUrl = getStreamUrl();
|
||||
if (!streamUrl) return '';
|
||||
// Convert relative URL to absolute
|
||||
return `${window.location.origin}${streamUrl}`;
|
||||
};
|
||||
|
||||
// Get original channel URL for Chromecast (bypasses proxy)
|
||||
const getOriginalChannelUrl = async () => {
|
||||
if (!channel?.id) return null;
|
||||
|
||||
try {
|
||||
// Fetch channel data to get original URL
|
||||
const response = await fetch(`/api/channels/${channel.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const channelData = await response.json();
|
||||
console.log('[Cast] Original channel URL:', channelData.url);
|
||||
return channelData.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Cast] Error fetching channel URL:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle Chromecast
|
||||
const handleCast = async () => {
|
||||
if (casting) {
|
||||
// Stop casting
|
||||
stopCasting();
|
||||
// Resume local playback
|
||||
setPlaying(false);
|
||||
} else {
|
||||
// Start casting
|
||||
if (!channel) return;
|
||||
|
||||
// Pause local playback
|
||||
setPlaying(false);
|
||||
|
||||
// Try to get original URL first (Chromecast can access it directly)
|
||||
const originalUrl = await getOriginalChannelUrl();
|
||||
const castUrl = originalUrl || getFullStreamUrl();
|
||||
|
||||
console.log('[Cast] Casting URL:', castUrl);
|
||||
console.log('[Cast] Channel:', channel.name);
|
||||
console.log('[Cast] Is Radio:', channel.is_radio === 1);
|
||||
|
||||
// Cast the media
|
||||
const success = await castMedia({
|
||||
url: castUrl,
|
||||
title: channel.name || 'TV Channel',
|
||||
subtitle: channel.is_radio === 1 ? 'Radio Station' : 'Live TV',
|
||||
contentType: 'application/x-mpegURL',
|
||||
imageUrl: channel.logo || '',
|
||||
isLive: true
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.log('[Cast] Cast failed, opening device selector');
|
||||
// If casting failed, open device selector
|
||||
openCastDialog();
|
||||
} else {
|
||||
console.log('[Cast] Cast successful');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sync volume with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastVolume) {
|
||||
setCastVolume(volume);
|
||||
}
|
||||
}, [volume, casting, setCastVolume]);
|
||||
|
||||
// Sync mute with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastMuted) {
|
||||
setCastMuted(muted);
|
||||
}
|
||||
}, [muted, casting, setCastMuted]);
|
||||
|
||||
// For radio, use native audio element instead of ReactPlayer
|
||||
if (channel?.is_radio === 1) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'black',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Audio Player for Radio */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Logo size={120} />
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
{channel?.name || 'Radio Station'}
|
||||
</Typography>
|
||||
|
||||
<audio
|
||||
controls
|
||||
autoPlay={playing}
|
||||
src={getStreamUrl()}
|
||||
style={{ width: '80%', maxWidth: '400px' }}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={(e) => {
|
||||
console.error('Radio playback error:', e);
|
||||
setPlaying(false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setPlaying(!playing);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (event, newValue) => {
|
||||
setVolume(newValue / 100);
|
||||
setMuted(newValue === 0);
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
setMuted(!muted);
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
const player = playerRef.current?.wrapper;
|
||||
if (player) {
|
||||
if (player.requestFullscreen) {
|
||||
player.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePip = async () => {
|
||||
if (!pipSupported || !enablePip) return;
|
||||
|
||||
try {
|
||||
const videoElement = playerRef.current?.getInternalPlayer();
|
||||
|
||||
if (!videoElement) {
|
||||
console.warn('Video element not found for PiP');
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.pictureInPictureElement) {
|
||||
// Exit PiP
|
||||
await document.exitPictureInPicture();
|
||||
setPip(false);
|
||||
} else {
|
||||
// Enter PiP
|
||||
await videoElement.requestPictureInPicture();
|
||||
setPip(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PiP error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Store video element reference when player is ready
|
||||
const handleReady = () => {
|
||||
setIsReady(true);
|
||||
const internalPlayer = playerRef.current?.getInternalPlayer();
|
||||
if (internalPlayer) {
|
||||
videoElementRef.current = internalPlayer;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchStart={handleMouseMove}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'black',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Hide player when casting */}
|
||||
{!casting && (
|
||||
<ReactPlayer
|
||||
ref={playerRef}
|
||||
url={getStreamUrl()}
|
||||
playing={playing}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
onReady={handleReady}
|
||||
onError={(error) => {
|
||||
console.error('Video playback error:', error);
|
||||
setPlaying(false);
|
||||
setIsReady(false);
|
||||
}}
|
||||
config={{
|
||||
file: {
|
||||
attributes: {
|
||||
crossOrigin: 'anonymous',
|
||||
disablePictureInPicture: !enablePip
|
||||
},
|
||||
forceHLS: true,
|
||||
hlsOptions: {
|
||||
xhrSetup: function(xhr, url) {
|
||||
xhr.withCredentials = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Casting indicator */}
|
||||
{casting && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.2) 0%, rgba(59, 130, 246, 0.2) 100%)',
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
<CastConnected sx={{ fontSize: 120, color: 'primary.main', opacity: 0.8 }} />
|
||||
<Typography variant="h5" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
Casting to TV
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'white', opacity: 0.7 }}>
|
||||
{channel?.name || 'TV Channel'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Center Logo Play/Pause Button - Auto-hide when playing */}
|
||||
<Box
|
||||
onClick={handlePlayPause}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
opacity: showLogo ? 1 : 0,
|
||||
pointerEvents: showLogo ? 'auto' : 'none',
|
||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translate(-50%, -50%) scale(1.1)'
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translate(-50%, -50%) scale(0.95)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Logo size={playing ? 80 : 120} />
|
||||
{!playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<PlayArrow sx={{ fontSize: 60, color: 'white' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!playing && (
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, textAlign: 'center' }}>
|
||||
{channel?.name || 'Ready to Play'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Semi-transparent overlay when not playing */}
|
||||
{!playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)',
|
||||
zIndex: 5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(180deg, rgba(0,0,0,0.7) 0%, transparent 100%)',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<FiberManualRecord sx={{ color: 'red', fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
{channel?.name}
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<IconButton size="small" sx={{ color: 'white', bgcolor: 'rgba(255,255,255,0.1)' }}>
|
||||
<FiberManualRecord fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, transparent 100%)',
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton onClick={handleToggleMute} sx={{ color: 'white' }}>
|
||||
{muted ? <VolumeOff /> : <VolumeUp />}
|
||||
</IconButton>
|
||||
|
||||
<Slider
|
||||
value={muted ? 0 : volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
sx={{ width: 100, color: 'white' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{castAvailable && (
|
||||
<Tooltip title={casting ? 'Stop Casting' : 'Cast'}>
|
||||
<IconButton
|
||||
onClick={handleCast}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: casting ? 'rgba(168, 85, 247, 0.3)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{casting ? <CastConnected /> : <Cast />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{pipSupported && enablePip && (
|
||||
<Tooltip title={pip ? 'Exit Picture-in-Picture' : 'Picture-in-Picture'}>
|
||||
<IconButton
|
||||
onClick={handlePip}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: pip ? 'rgba(168, 85, 247, 0.3)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{pip ? <PictureInPictureAlt /> : <PictureInPicture />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Fullscreen">
|
||||
<IconButton onClick={handleFullscreen} sx={{ color: 'white' }}>
|
||||
<Fullscreen />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default VideoPlayer;
|
||||
397
frontend/src/components/WireGuardSettings.jsx
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Divider,
|
||||
Chip,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import {
|
||||
VpnKey,
|
||||
Public,
|
||||
NetworkCheck,
|
||||
Router,
|
||||
Dns,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const WireGuardSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState({
|
||||
privateKey: '',
|
||||
serverPublicKey: '',
|
||||
serverEndpoint: '',
|
||||
clientAddress: '',
|
||||
dnsServer: ''
|
||||
});
|
||||
const [status, setStatus] = useState({
|
||||
connected: false,
|
||||
publicIP: '',
|
||||
interface: '',
|
||||
loading: false
|
||||
});
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [configParsed, setConfigParsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 10000); // Check status every 10s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.settings) {
|
||||
setSettings({
|
||||
privateKey: '********', // Don't show actual key
|
||||
serverPublicKey: data.settings.server_public_key || '',
|
||||
serverEndpoint: data.settings.server_endpoint || '',
|
||||
clientAddress: data.settings.client_address || '',
|
||||
dnsServer: data.settings.dns_server || ''
|
||||
});
|
||||
setConfigParsed(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load WireGuard settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/status', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStatus({
|
||||
connected: data.connected,
|
||||
publicIP: data.publicIP || '',
|
||||
interface: data.interface || '',
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check status:', error);
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const parseConfig = (configText) => {
|
||||
const lines = configText.split('\n');
|
||||
const parsed = {
|
||||
privateKey: '',
|
||||
serverPublicKey: '',
|
||||
serverEndpoint: '',
|
||||
clientAddress: '',
|
||||
dnsServer: ''
|
||||
};
|
||||
|
||||
let inInterface = false;
|
||||
let inPeer = false;
|
||||
|
||||
lines.forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '[Interface]') {
|
||||
inInterface = true;
|
||||
inPeer = false;
|
||||
} else if (trimmed === '[Peer]') {
|
||||
inInterface = false;
|
||||
inPeer = true;
|
||||
} else if (trimmed.startsWith('PrivateKey') && inInterface) {
|
||||
parsed.privateKey = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('Address') && inInterface) {
|
||||
parsed.clientAddress = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('DNS') && inInterface) {
|
||||
parsed.dnsServer = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('PublicKey') && inPeer) {
|
||||
parsed.serverPublicKey = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('Endpoint') && inPeer) {
|
||||
parsed.serverEndpoint = trimmed.split('=')[1]?.trim() || '';
|
||||
}
|
||||
});
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const handleConfigPaste = (e) => {
|
||||
const configText = e.target.value;
|
||||
if (configText.includes('[Interface]') && configText.includes('[Peer]')) {
|
||||
const parsed = parseConfig(configText);
|
||||
setSettings(parsed);
|
||||
setConfigParsed(true);
|
||||
setMessage({ type: 'success', text: t('wireguard.configParsed') || 'Configuration parsed successfully!' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setLoading(true);
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('wireguard.settingsSaved') || 'Settings saved successfully!' });
|
||||
setConfigParsed(true);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('wireguard.saveFailed') || 'Failed to save settings' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('wireguard.saveError') || 'Error saving settings' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
setStatus(prev => ({ ...prev, loading: true }));
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/connect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('wireguard.connected') || 'Connected to WireGuard VPN!' });
|
||||
setTimeout(checkStatus, 2000);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('wireguard.connectFailed') || 'Failed to connect' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('wireguard.connectError') || 'Error connecting to VPN' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setStatus(prev => ({ ...prev, loading: true }));
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/disconnect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'info', text: t('wireguard.disconnected') || 'Disconnected from VPN' });
|
||||
setTimeout(checkStatus, 2000);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('wireguard.disconnectFailed') || 'Failed to disconnect' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('wireguard.disconnectError') || 'Error disconnecting from VPN' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<VpnKey /> {t('wireguard.title') || 'WireGuard VPN'}
|
||||
</Typography>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card sx={{ mb: 3, bgcolor: status.connected ? 'success.light' : 'grey.100' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{status.loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : status.connected ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="disabled" />
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{status.connected ? (t('wireguard.status.connected') || 'Connected') : (t('wireguard.status.disconnected') || 'Disconnected')}
|
||||
</Typography>
|
||||
{status.connected && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Public sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
||||
{t('wireguard.publicIP') || 'Public IP'}: {status.publicIP || t('wireguard.checking') || 'Checking...'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Router sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
||||
{t('wireguard.interface') || 'Interface'}: {status.interface}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
{status.connected ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleDisconnect}
|
||||
disabled={status.loading}
|
||||
>
|
||||
{t('wireguard.disconnect') || 'Disconnect'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={handleConnect}
|
||||
disabled={status.loading || !configParsed}
|
||||
>
|
||||
{t('wireguard.connect') || 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Messages */}
|
||||
{message.text && (
|
||||
<Alert severity={message.type} sx={{ mb: 3 }} onClose={() => setMessage({ type: '', text: '' })}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Configuration Card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('wireguard.configuration') || 'Configuration'}
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }} icon={<Info />}>
|
||||
{t('wireguard.pasteConfigHint') || 'Paste your complete WireGuard configuration file below, or enter details manually.'}
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
label={t('wireguard.configFile') || 'WireGuard Config File'}
|
||||
placeholder="[Interface] PrivateKey = ... Address = ... DNS = ... [Peer] PublicKey = ... AllowedIPs = ... Endpoint = ..."
|
||||
onChange={handleConfigPaste}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Chip label={t('wireguard.or') || 'OR'} />
|
||||
</Divider>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('wireguard.privateKey') || 'Private Key'}
|
||||
value={settings.privateKey}
|
||||
onChange={(e) => setSettings({ ...settings, privateKey: e.target.value })}
|
||||
placeholder="base64-encoded private key"
|
||||
InputProps={{
|
||||
startAdornment: <VpnKey sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.serverPublicKey') || 'Server Public Key'}
|
||||
value={settings.serverPublicKey}
|
||||
onChange={(e) => setSettings({ ...settings, serverPublicKey: e.target.value })}
|
||||
placeholder="base64-encoded public key"
|
||||
InputProps={{
|
||||
startAdornment: <VpnKey sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.serverEndpoint') || 'Server Endpoint'}
|
||||
value={settings.serverEndpoint}
|
||||
onChange={(e) => setSettings({ ...settings, serverEndpoint: e.target.value })}
|
||||
placeholder="IP:PORT (e.g., 185.163.110.98:51820)"
|
||||
InputProps={{
|
||||
startAdornment: <NetworkCheck sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.clientAddress') || 'Client Address'}
|
||||
value={settings.clientAddress}
|
||||
onChange={(e) => setSettings({ ...settings, clientAddress: e.target.value })}
|
||||
placeholder="10.2.0.2/32"
|
||||
InputProps={{
|
||||
startAdornment: <Router sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.dnsServer') || 'DNS Server'}
|
||||
value={settings.dnsServer}
|
||||
onChange={(e) => setSettings({ ...settings, dnsServer: e.target.value })}
|
||||
placeholder="10.2.0.1"
|
||||
InputProps={{
|
||||
startAdornment: <Dns sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveSettings}
|
||||
disabled={loading || !settings.privateKey || !settings.serverEndpoint}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : (t('wireguard.saveSettings') || 'Save Settings')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WireGuardSettings;
|
||||
20
frontend/src/i18n.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en.json';
|
||||
import ro from './locales/ro.json';
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ro: { translation: ro }
|
||||
},
|
||||
lng: localStorage.getItem('language') || 'en',
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
38
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vh: 1vh;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
height: -webkit-fill-available;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
907
frontend/src/locales/en.json
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
{
|
||||
"app_name": "StreamFlow",
|
||||
"premium_plan": "Premium Plan",
|
||||
"search": "Search",
|
||||
"live_tv": "Live TV",
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"favorites": "Favorites",
|
||||
"favorite_tv": "Favorite TV",
|
||||
"favorite_radio": "Favorite Radio",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"remember_me": "Remember Password",
|
||||
"forgot_password": "Forgot password?",
|
||||
"dont_have_account": "Don't have an account?",
|
||||
"already_have_account": "Already have an account?",
|
||||
"sign_up": "Sign Up",
|
||||
"sign_in": "Sign In",
|
||||
"login": {
|
||||
"loginHere": "Login Here!",
|
||||
"login": "LOGIN",
|
||||
"success": "Login successful! Welcome back.",
|
||||
"dontHaveAccount": "Don't have an account? Create your account",
|
||||
"createAccountHere": "here!",
|
||||
"registrationDisabled": "Registration is currently disabled. Please contact an administrator to create your account."
|
||||
},
|
||||
"register": {
|
||||
"success": "Registration successful! Welcome aboard.",
|
||||
"failed": "Registration Failed",
|
||||
"error": "Registration failed. Please try again."
|
||||
},
|
||||
"welcome_back": "Welcome back",
|
||||
"create_account": "Create your account",
|
||||
"today": "Today",
|
||||
"up_next": "UP NEXT",
|
||||
"all": "All",
|
||||
"sports": "Sports",
|
||||
"news": "News",
|
||||
"kids": "Kids",
|
||||
"channel": "Channel",
|
||||
"now_playing": "Now Playing",
|
||||
"add_playlist": "Add Playlist",
|
||||
"upload_m3u": "Upload M3U",
|
||||
"playlist_url": "Playlist URL",
|
||||
"playlist_name": "Playlist Name",
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"details": "Details",
|
||||
"no_channels": "No channels available",
|
||||
"no_playlists": "No playlists available",
|
||||
"recording": "Recording",
|
||||
"record": "Record",
|
||||
"schedule_recording": "Schedule Recording",
|
||||
"recordings": "Recordings",
|
||||
"radio": "Radio",
|
||||
"groups": "Groups",
|
||||
"add_to_group": "Add to Group",
|
||||
"create_group": "Create Group",
|
||||
"group_name": "Group Name",
|
||||
"profile": "Profile",
|
||||
"profiles": "Profiles",
|
||||
"add_profile": "Add Profile",
|
||||
"select_profile": "Select Profile",
|
||||
"parental_controls": "Parental Controls",
|
||||
"theme": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"language": "Language",
|
||||
"notifications": "Notifications",
|
||||
"privacy": "Privacy",
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
"quality": "Quality",
|
||||
"auto": "Auto",
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low",
|
||||
"device": "Device",
|
||||
"location": "Location",
|
||||
"lastActive": "Last Active",
|
||||
"created": "Created",
|
||||
"current": "Current",
|
||||
"active": "Active",
|
||||
"actions": "Actions",
|
||||
"refresh": "Refresh",
|
||||
"backToSecurity": "Back to Security",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"playlists": "Playlists",
|
||||
"addPlaylist": "Add Playlist",
|
||||
"noPlaylists": "No playlists available. Add one to get started.",
|
||||
"playlistName": "Playlist Name",
|
||||
"selectFile": "Select File",
|
||||
"m3uLibrary": "M3U Library",
|
||||
"uploadM3u": "Upload M3U",
|
||||
"noM3uFiles": "No M3U files stored. Upload files to manage them separately.",
|
||||
"uploadM3uFile": "Upload M3U File",
|
||||
"fileName": "File Name",
|
||||
"fileNameHelper": "Give this M3U file a name for easy identification",
|
||||
"selectM3uFile": "Select M3U File",
|
||||
"renameM3uFile": "Rename M3U File",
|
||||
"importM3uFile": "Import M3U File",
|
||||
"downloadM3uFile": "Download M3U File",
|
||||
"fileDownloaded": "File downloaded successfully",
|
||||
"failedToDownload": "Failed to download file",
|
||||
"importMessage": "Import this M3U file as a {{type}} playlist?",
|
||||
"importInfo": "The channels from this file will be added to your {{section}} section.",
|
||||
"liveTV": "Live TV",
|
||||
"streaming": "Streaming & Hardware Acceleration",
|
||||
"hwAccelAvailable": "Hardware acceleration available",
|
||||
"hwAccel": "Hardware Acceleration",
|
||||
"hwAccelAuto": "Auto (Recommended)",
|
||||
"hwAccelNone": "Software Only",
|
||||
"hwAccelNotAvailable": "(Not Available)",
|
||||
"hwDevice": "Hardware Device",
|
||||
"hwDeviceHelper": "Usually /dev/dri/renderD128 for Intel/AMD",
|
||||
"encoderPreset": "Encoder Preset",
|
||||
"presetUltrafast": "Ultra Fast (Low Quality)",
|
||||
"presetSuperfast": "Super Fast",
|
||||
"presetVeryfast": "Very Fast (Recommended)",
|
||||
"presetFaster": "Faster",
|
||||
"presetFast": "Fast",
|
||||
"presetMedium": "Medium (Balanced)",
|
||||
"presetSlow": "Slow (High Quality)",
|
||||
"bufferSize": "Buffer Size",
|
||||
"bufferLowLatency": "Low Latency",
|
||||
"bufferSmooth": "Smooth Playback",
|
||||
"maxBitrate": "Max Bitrate",
|
||||
"bitrateLow": "Low",
|
||||
"bitrateHigh": "High Quality",
|
||||
"quickSyncInfo": "Quick Sync enables hardware-accelerated video encoding/decoding for smooth playback with low CPU usage. Auto mode will automatically select the best available method.",
|
||||
"appearance": "Appearance",
|
||||
"darkMode": "Dark Mode",
|
||||
"language": "Language",
|
||||
"selectLanguage": "Select Language",
|
||||
"account": "Account",
|
||||
"logout": "Logout",
|
||||
"userManagement": "User Management",
|
||||
"users": "Users",
|
||||
"addUser": "Add User",
|
||||
"noUsers": "No users found.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"createUser": "Create User",
|
||||
"editUser": "Edit User",
|
||||
"deleteUser": "Delete User",
|
||||
"resetPassword": "Reset Password",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"user": "User",
|
||||
"admin": "Admin",
|
||||
"roleUser": "User",
|
||||
"roleAdmin": "Administrator",
|
||||
"activeAccount": "Active Account",
|
||||
"fetchFailed": "Failed to fetch users",
|
||||
"createFailed": "Failed to create user",
|
||||
"updateFailed": "Failed to update user",
|
||||
"deleteFailed": "Failed to delete user",
|
||||
"passwordResetSuccess": "Password reset successfully. User must change password on next login.",
|
||||
"passwordResetFailed": "Failed to reset password",
|
||||
"deleteConfirm": "Are you sure you want to delete this user? This action cannot be undone.",
|
||||
"newUserInfo": "The new user will be required to change their password on first login.",
|
||||
"passwordHelper": "Minimum 8 characters",
|
||||
"resetPasswordWarning": "This will set a temporary password and force the user to change it on next login.",
|
||||
"changePasswordRequired": "Password Change Required",
|
||||
"changePasswordWarning": "You must change your password to continue.",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordMinLength": "Password must be at least 8 characters",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordChangeSuccess": "Password changed successfully!",
|
||||
"changePasswordFailed": "Password Change Failed"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup & Restore",
|
||||
"description": "Create backups of your playlists, channels, favorites, custom logos, and M3U files. Download backups or restore them anytime.",
|
||||
"create": "Create Backup",
|
||||
"upload": "Upload Backup",
|
||||
"restore": "Restore",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"backup": "Backup",
|
||||
"created": "Created",
|
||||
"noBackups": "No backups available. Create your first backup to secure your data.",
|
||||
"createSuccess": "Backup created successfully!",
|
||||
"createError": "Failed to create backup. Please try again.",
|
||||
"deleteSuccess": "Backup deleted successfully!",
|
||||
"deleteError": "Failed to delete backup. Please try again.",
|
||||
"restoreSuccess": "Backup restored successfully! Restored",
|
||||
"restoreError": "Failed to restore backup. Please try again.",
|
||||
"uploadSuccess": "Backup uploaded successfully!",
|
||||
"uploadError": "Failed to upload backup. Please try again.",
|
||||
"fetchError": "Failed to load backups.",
|
||||
"deleteTitle": "Delete Backup?",
|
||||
"deleteConfirm": "Are you sure you want to delete this backup? This action cannot be undone.",
|
||||
"restoreTitle": "Restore Backup?",
|
||||
"restoreConfirm": "This will restore your playlists, channels, favorites, and M3U files from this backup.",
|
||||
"restoreWarning": "Current data with the same names will not be duplicated. The page will reload after restoration.",
|
||||
"uploading": "Uploading"
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh",
|
||||
"upload": "Upload",
|
||||
"rename": "Rename",
|
||||
"import": "Import",
|
||||
"tv": "TV",
|
||||
"radio": "Radio",
|
||||
"back": "Back"
|
||||
},
|
||||
"audio": {
|
||||
"select_station": "Select a Station",
|
||||
"now_playing": "Now Playing",
|
||||
"casting": "Casting to Device",
|
||||
"visualizer_type": "Visualizer {{count}}",
|
||||
"no_metadata": "No song information available",
|
||||
"loading": "Loading...",
|
||||
"buffering": "Buffering..."
|
||||
},
|
||||
"twoFactor": {
|
||||
"title": "Two-Factor Authentication",
|
||||
"enterCode": "Enter the 6-digit code from your authenticator app",
|
||||
"enterBackupCode": "Enter the backup code",
|
||||
"backupCodePlaceholder": "Backup Code",
|
||||
"codePlaceholder": "000000",
|
||||
"useBackupCode": "Use backup code instead",
|
||||
"useAuthenticatorCode": "Use authenticator code",
|
||||
"verify": "Verify",
|
||||
"verifySuccess": "2FA verification successful!",
|
||||
"verifyFailed": "2FA Verification Failed",
|
||||
"backToLogin": "Back to login",
|
||||
"invalidCode": "Invalid verification code. Please try again.",
|
||||
"codeRequired": "Verification code required"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"passwordPolicy": "Password Policy",
|
||||
"accountLockout": "Account Lockout",
|
||||
"passwordExpiry": "Password Expiry",
|
||||
"securityStatus": "Security Status",
|
||||
"auditLog": "Audit Log",
|
||||
"passwordStrength": "Password Strength",
|
||||
"veryWeak": "Very Weak",
|
||||
"weak": "Weak",
|
||||
"good": "Good",
|
||||
"strong": "Strong",
|
||||
"veryStrong": "Very Strong",
|
||||
"requirements": "Requirements",
|
||||
"minLength": "At least 12 characters",
|
||||
"uppercase": "At least one uppercase letter",
|
||||
"lowercase": "At least one lowercase letter",
|
||||
"number": "At least one number",
|
||||
"special": "At least one special character",
|
||||
"noCommon": "Not a common password",
|
||||
"noUsername": "Does not contain username",
|
||||
"accountLocked": "Account Locked",
|
||||
"accountLockedMsg": "Your account has been locked due to multiple failed login attempts.",
|
||||
"remainingTime": "Time remaining: {{minutes}} minutes",
|
||||
"tryAgainAt": "Try again at {{time}}",
|
||||
"passwordExpired": "Password Expired",
|
||||
"passwordExpiredMsg": "Your password has expired. Please change it to continue.",
|
||||
"passwordExpiresIn": "Password expires in {{days}} days",
|
||||
"passwordExpiringSoon": "Your password will expire soon",
|
||||
"changeNow": "Change Now",
|
||||
"remindLater": "Remind Later",
|
||||
"twoFactorEnabled": "Two-Factor Authentication Enabled",
|
||||
"passwordAge": "Password Age: {{days}} days",
|
||||
"lastLogin": "Last Login",
|
||||
"activeSessions": "Active Sessions",
|
||||
"failedAttempts": "Failed Login Attempts",
|
||||
"recentActivity": "Recent Activity",
|
||||
"securityEvents": "Security Events",
|
||||
"viewDetails": "View Details",
|
||||
"eventType": "Event Type",
|
||||
"status": "Status",
|
||||
"timestamp": "Timestamp",
|
||||
"ipAddress": "IP Address",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"pending": "Pending",
|
||||
"loginAttempt": "Login Attempt",
|
||||
"passwordChange": "Password Change",
|
||||
"twoFactorVerification": "2FA Verification",
|
||||
"sessionCreated": "Session Created",
|
||||
"tokenIssued": "Token Issued",
|
||||
"tokenRefreshed": "Token Refreshed",
|
||||
"tokenRevoked": "Token Revoked",
|
||||
"privilegeChange": "Privilege Change",
|
||||
"permissionGranted": "Permission Granted",
|
||||
"permissionRevoked": "Permission Revoked",
|
||||
"accountStatusChanged": "Account Status Changed",
|
||||
"roleChanged": "Role Changed",
|
||||
"accountActivated": "Account Activated",
|
||||
"accountDeactivated": "Account Deactivated",
|
||||
"adminActivity": "Admin Activity",
|
||||
"sensitiveDataAccess": "Sensitive Data Access",
|
||||
"userCreated": "User Created",
|
||||
"userDeleted": "User Deleted",
|
||||
"accountUnlocked": "Account Unlocked",
|
||||
"passwordResetByAdmin": "Password Reset by Admin",
|
||||
"forceLogout": "Force Logout",
|
||||
"adminAction": "Admin Action",
|
||||
"dataType": "Data Type",
|
||||
"accessMethod": "Access Method",
|
||||
"userList": "User List",
|
||||
"userDetails": "User Details",
|
||||
"vpnConfigs": "VPN Configurations",
|
||||
"auditLogs": "Audit Logs",
|
||||
"records": "Records",
|
||||
"scope": "Scope",
|
||||
"noRecentActivity": "No recent security activity",
|
||||
"securityDashboard": "Security Dashboard",
|
||||
"allUsers": "All Users",
|
||||
"lockedAccounts": "Locked Accounts",
|
||||
"recentFailures": "Recent Failures",
|
||||
"passwordReuse": "Cannot reuse any of your last 5 passwords",
|
||||
"passwordPolicyError": "Password does not meet requirements",
|
||||
"strengthTooWeak": "Password strength is too weak",
|
||||
"unlockAccount": "Unlock Account",
|
||||
"viewAuditLog": "View Audit Log",
|
||||
"exportAuditLog": "Export Audit Log",
|
||||
"deviceInformation": "Device Information",
|
||||
"deviceType": "Device Type",
|
||||
"operatingSystem": "Operating System",
|
||||
"browser": "Browser",
|
||||
"tokenDetails": "Token Details",
|
||||
"tokenType": "Token Type",
|
||||
"purpose": "Purpose",
|
||||
"expiresIn": "Expires In",
|
||||
"reason": "Reason",
|
||||
"privilegeChangeDetails": "Privilege Change Details",
|
||||
"affectedUser": "Affected User",
|
||||
"previousRole": "Previous Role",
|
||||
"newRole": "New Role",
|
||||
"changedBy": "Changed By",
|
||||
"accountStatusDetails": "Account Status Details",
|
||||
"previousStatus": "Previous Status",
|
||||
"newStatus": "New Status",
|
||||
"userAgent": "User Agent",
|
||||
"rawDetails": "Raw Details",
|
||||
"noEvents": "No security events found",
|
||||
"loadingEvents": "Loading security events...",
|
||||
"sessionTimeout": "Session Timeout",
|
||||
"sessionExpired": "Your session has expired. Please login again.",
|
||||
"multipleDevices": "You have {{count}} active sessions",
|
||||
"terminateSession": "Terminate Session",
|
||||
"terminateAllSessions": "Terminate All Other Sessions",
|
||||
"inputValidation": "Input Validation",
|
||||
"invalidInput": "Invalid Input",
|
||||
"validationFailed": "Validation Failed",
|
||||
"invalidUsername": "Invalid username format",
|
||||
"invalidEmail": "Invalid email format",
|
||||
"invalidUrl": "Invalid URL format",
|
||||
"fieldRequired": "This field is required",
|
||||
"fieldTooShort": "This field is too short",
|
||||
"fieldTooLong": "This field is too long",
|
||||
"invalidCharacters": "Contains invalid characters",
|
||||
"invalidFileType": "Invalid file type",
|
||||
"fileTooLarge": "File size exceeds maximum limit",
|
||||
"securityAlert": "Security Alert",
|
||||
"suspiciousActivity": "Suspicious Activity Detected",
|
||||
"inputSanitized": "Input has been sanitized for security",
|
||||
"xssAttemptBlocked": "Potential XSS attempt blocked",
|
||||
"sqlInjectionBlocked": "Potential SQL injection blocked",
|
||||
"unauthorizedAccess": "Unauthorized access attempt",
|
||||
"rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"invalidToken": "Invalid or expired authentication token",
|
||||
"csrfDetected": "CSRF attempt detected",
|
||||
"permissionDenied": "Permission denied",
|
||||
"securityCheckFailed": "Security check failed",
|
||||
"ipAddress": "IP Address",
|
||||
"status": "Status",
|
||||
"noRecentActivity": "No recent activity found",
|
||||
"monitoring": "Security Monitoring",
|
||||
"overview": "Overview",
|
||||
"dependencies": "Dependencies",
|
||||
"securityHeaders": "Security Headers",
|
||||
"manageHttpHeaders": "Manage HTTP Security Headers",
|
||||
"backend": "Backend",
|
||||
"frontend": "Frontend",
|
||||
"totalVulnerabilities": "Total Vulnerabilities",
|
||||
"activeSessions": "Active Sessions",
|
||||
"failedLogins": "Failed Logins",
|
||||
"scanVulnerabilities": "Scan Vulnerabilities",
|
||||
"noVulnerabilities": "No vulnerabilities found",
|
||||
"clickToScanVulnerabilities": "Click 'Scan Vulnerabilities' to check for security issues",
|
||||
"action": "Action",
|
||||
"eventDetails": "Event Details",
|
||||
"recentEvents": "Recent Events",
|
||||
"lastOccurrence": "Last Occurrence",
|
||||
"securityRecommendations": "Security Recommendations",
|
||||
"moreRecommendations": "more recommendations",
|
||||
"recommendedAction": "Recommended Action",
|
||||
"noRecommendations": "All security checks passed! No recommendations at this time.",
|
||||
"cspDashboard": "Content Security Policy Dashboard",
|
||||
"cspPolicyStatus": "CSP Policy Status",
|
||||
"mode": "Mode",
|
||||
"enforcing": "Enforcing",
|
||||
"reportOnly": "Report Only",
|
||||
"totalViolations": "Total Violations",
|
||||
"policyDirectives": "Policy Directives",
|
||||
"violations": "Violations",
|
||||
"statistics": "Statistics",
|
||||
"policy": "Policy",
|
||||
"recentViolations": "Recent Violations",
|
||||
"clearOlderThan": "Clear Older Than",
|
||||
"cspViolationsCleared": "CSP violations cleared",
|
||||
"noViolations": "No CSP violations found. Your application is secure!",
|
||||
"timestamp": "Timestamp",
|
||||
"violatedDirective": "Violated Directive",
|
||||
"blockedUri": "Blocked URI",
|
||||
"sourceFile": "Source File",
|
||||
"viewDetails": "View Details",
|
||||
"violationStatistics": "Violation Statistics",
|
||||
"byDirective": "By Directive",
|
||||
"byBlockedUri": "By Blocked URI",
|
||||
"noData": "No data available",
|
||||
"directive": "Directive",
|
||||
"allowedSources": "Allowed Sources",
|
||||
"currentCspPolicy": "Current CSP Policy",
|
||||
"cspEnforcedDescription": "CSP is currently being enforced. Violations will block resources from loading.",
|
||||
"cspReportOnlyDescription": "CSP is in report-only mode. Violations are logged but resources are not blocked.",
|
||||
"violationDetails": "Violation Details",
|
||||
"documentUri": "Document URI",
|
||||
"lineNumber": "Line Number",
|
||||
"columnNumber": "Column Number",
|
||||
"userAgent": "User Agent",
|
||||
"adminAccessRequired": "Admin access required to view this page",
|
||||
"rbacDashboard": "RBAC & Permissions",
|
||||
"currentConfig": "Current Configuration",
|
||||
"currentConfigDescription": "This is the currently active security headers configuration running on the server.",
|
||||
"environment": "Environment",
|
||||
"contentSecurityPolicy": "Content Security Policy",
|
||||
"otherHeaders": "Other Security Headers",
|
||||
"editor": "Configuration Editor",
|
||||
"savedConfigs": "Saved Configurations",
|
||||
"recommendations": "Recommendations",
|
||||
"history": "History",
|
||||
"loadPreset": "Load Preset",
|
||||
"testConfig": "Test Configuration",
|
||||
"saveConfig": "Save Configuration",
|
||||
"editorWarning": "Changes in the editor are not applied until you save and activate a configuration. Server restart required.",
|
||||
"cspDirectives": "CSP Directives",
|
||||
"jsonArrayFormat": "Enter as JSON array, e.g., ['self', 'https:'] ",
|
||||
"hstsConfiguration": "HSTS Configuration",
|
||||
"enableHSTS": "Enable HSTS",
|
||||
"maxAge": "Max Age (seconds)",
|
||||
"includeSubdomains": "Include Subdomains",
|
||||
"preload": "Preload",
|
||||
"referrerPolicy": "Referrer Policy",
|
||||
"xFrameOptions": "X-Frame-Options",
|
||||
"noSavedConfigs": "No saved configurations. Create and save configurations from the editor.",
|
||||
"active": "Active",
|
||||
"created": "Created",
|
||||
"loadToEditor": "Load to Editor",
|
||||
"apply": "Apply",
|
||||
"configLoaded": "Configuration loaded into editor",
|
||||
"saveConfiguration": "Save Configuration",
|
||||
"configurationName": "Configuration Name",
|
||||
"configNameHelper": "Give this configuration a memorable name",
|
||||
"testResults": "Test Results",
|
||||
"grade": "Grade",
|
||||
"passed": "Passed",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors",
|
||||
"selectPreset": "Select Security Preset",
|
||||
"preset": "Preset",
|
||||
"securityScore": "Security Score",
|
||||
"securitySummary": "Security Summary",
|
||||
"critical": "Critical",
|
||||
"info": "Info",
|
||||
"failedToLoad": "Failed to load security configuration",
|
||||
"testFailed": "Failed to test configuration",
|
||||
"nameRequired": "Configuration name is required",
|
||||
"configSaved": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"confirmApply": "Apply this configuration? Server restart will be required.",
|
||||
"configAppliedRestart": "Configuration applied successfully. Please restart the server to activate changes.",
|
||||
"configApplied": "Configuration applied successfully",
|
||||
"applyFailed": "Failed to apply configuration",
|
||||
"securityTesting": "Security Testing",
|
||||
"automatedAndManualTesting": "Automated & Manual Testing",
|
||||
"overallScore": "Overall Score",
|
||||
"defenseInDepth": "Defense-in-Depth Layers",
|
||||
"networkLayer": "Network Layer",
|
||||
"serverLayer": "Server Layer",
|
||||
"applicationLayer": "Application Layer",
|
||||
"dataLayer": "Data Layer",
|
||||
"score": "Score",
|
||||
"checks": "Checks",
|
||||
"penetrationTesting": "Penetration Testing",
|
||||
"penetrationTestingInfo": "These automated tests attempt to find security vulnerabilities by simulating attacks. All tests are safe and do not harm your application.",
|
||||
"selectTests": "Select Tests to Run",
|
||||
"authenticationTests": "Authentication Tests",
|
||||
"sqlInjectionTests": "SQL Injection Tests",
|
||||
"xssTests": "XSS Tests",
|
||||
"csrfTests": "CSRF Tests",
|
||||
"rateLimitTests": "Rate Limiting Tests",
|
||||
"runPenetrationTests": "Run Penetration Tests",
|
||||
"runningTests": "Running Tests...",
|
||||
"testsCompleted": "Tests completed successfully",
|
||||
"testsFailed": "Failed to run tests",
|
||||
"findings": "Findings",
|
||||
"testHistory": "Test History",
|
||||
"noTestHistory": "No test history available. Run penetration tests to see results here.",
|
||||
"testType": "Test Type",
|
||||
"testName": "Test Name",
|
||||
"severity": "Severity",
|
||||
"executedBy": "Executed By",
|
||||
"duration": "Duration",
|
||||
"networkSecurity": "Network Security",
|
||||
"activeConnections": "Active Connections",
|
||||
"blockedRequests": "Blocked Requests",
|
||||
"failedLogins": "Failed Logins",
|
||||
"rateLimitingStats": "Rate Limiting Statistics",
|
||||
"totalRequests": "Total Requests",
|
||||
"rateLimited": "Rate Limited",
|
||||
"failed": "Failed",
|
||||
"confirmDelete": "Are you sure you want to delete this configuration?",
|
||||
"configDeleted": "Configuration deleted successfully",
|
||||
"deleteFailed": "Failed to delete configuration",
|
||||
"presetApplied": "Preset '{{preset}}' loaded into editor",
|
||||
"noHistory": "No configuration history available",
|
||||
"timestamp": "Timestamp",
|
||||
"action": "Action",
|
||||
"configuration": "Configuration",
|
||||
"changedBy": "Changed By"
|
||||
},
|
||||
"logManagement": {
|
||||
"title": "Log Management",
|
||||
"subtitle": "CWE-53 Compliance: Automated retention, archival, and integrity verification",
|
||||
"totalLogs": "Total Logs",
|
||||
"archives": "Archives",
|
||||
"retention": "Retention Policy",
|
||||
"days": "days",
|
||||
"integrity": "Integrity",
|
||||
"protected": "Protected",
|
||||
"manualCleanup": "Manual Cleanup",
|
||||
"verifyIntegrity": "Verify Integrity",
|
||||
"archivesList": "Log Archives",
|
||||
"noArchives": "No archives available",
|
||||
"filename": "Filename",
|
||||
"size": "Size",
|
||||
"created": "Created",
|
||||
"cleanupWarning": "This will delete logs older than the specified retention period. Archives will be created before deletion.",
|
||||
"retentionDays": "Retention Days",
|
||||
"retentionHelp": "Minimum: 7 days, Maximum: 365 days",
|
||||
"performCleanup": "Perform Cleanup",
|
||||
"integrityResults": "Integrity Verification Results",
|
||||
"verified": "Verified",
|
||||
"tampered": "Tampered",
|
||||
"securityAlert": "Security Alert",
|
||||
"allLogsVerified": "All logs passed integrity verification. No tampering detected."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Data Encryption",
|
||||
"subtitle": "CWE-311 Compliance: Encrypt sensitive data at rest using AES-256-GCM",
|
||||
"status": "Encryption Status",
|
||||
"secure": "Secure",
|
||||
"defaultKey": "Default Key",
|
||||
"algorithm": "Algorithm",
|
||||
"keySize": "Key Size",
|
||||
"coverage": "Encryption Coverage",
|
||||
"encrypted": "Encrypted",
|
||||
"dataTypes": "Sensitive Data Types",
|
||||
"dataType": "Data Type",
|
||||
"total": "Total",
|
||||
"percentage": "Coverage",
|
||||
"vpnConfigs": "VPN Configurations",
|
||||
"settings": "User Settings",
|
||||
"twoFactorSecrets": "2FA Secrets",
|
||||
"apiTokens": "API Tokens",
|
||||
"actions": "Encryption Actions",
|
||||
"scanButton": "Scan Database",
|
||||
"migrateButton": "Migrate to Encrypted",
|
||||
"verifyButton": "Verify Integrity",
|
||||
"actionsHelp": "Scan for unencrypted sensitive data, migrate to encrypted format, or verify encryption integrity.",
|
||||
"scanResults": "Scan Results",
|
||||
"noIssuesFound": "All sensitive data is encrypted. No action needed.",
|
||||
"foundIssues": "Found {{count}} unencrypted items that should be protected.",
|
||||
"unencryptedCount": "Unencrypted items",
|
||||
"recommendation": "Recommendation",
|
||||
"migrateNow": "Migrate Now",
|
||||
"confirmMigration": "Confirm Data Migration",
|
||||
"migrationWarning": "This operation will encrypt all unencrypted sensitive data. This process is irreversible.",
|
||||
"migrationDescription": "The migration process will:",
|
||||
"migrationStep1": "Scan the database for unencrypted sensitive data",
|
||||
"migrationStep2": "Encrypt each item using AES-256-GCM with unique IVs",
|
||||
"migrationStep3": "Update the database with encrypted values",
|
||||
"migrating": "Migrating...",
|
||||
"startMigration": "Start Migration",
|
||||
"migrationComplete": "Migration completed successfully. {{count}} items encrypted.",
|
||||
"verificationResults": "Verification Results",
|
||||
"tested": "Tested",
|
||||
"valid": "Valid",
|
||||
"invalid": "Invalid",
|
||||
"invalidDataFound": "Some encrypted data could not be decrypted. This may indicate corruption.",
|
||||
"allDataValid": "All tested data decrypted successfully.",
|
||||
"recommendations": "Security Recommendations",
|
||||
"fetchError": "Failed to fetch encryption status",
|
||||
"scanError": "Failed to scan database",
|
||||
"scanComplete": "Database scan completed",
|
||||
"issuesFound": "Found {{count}} security issues",
|
||||
"migrationError": "Migration failed",
|
||||
"verifyError": "Verification failed",
|
||||
"verifyComplete": "Verification completed successfully",
|
||||
"statusError": "Failed to load encryption status"
|
||||
},
|
||||
"rbac": {
|
||||
"dashboard": "Role-Based Access Control",
|
||||
"rolesAndPermissions": "Roles & Permissions",
|
||||
"userRoles": "User Roles",
|
||||
"auditLog": "Audit Log",
|
||||
"statistics": "Statistics",
|
||||
"myPermissions": "My Permissions",
|
||||
"roles": "Roles",
|
||||
"createRole": "Create Role",
|
||||
"editRole": "Edit Role",
|
||||
"roleName": "Role Name",
|
||||
"roleKey": "Role Key",
|
||||
"roleKeyHelp": "Lowercase letters and underscores only (e.g., content_manager)",
|
||||
"description": "Description",
|
||||
"permissions": "Permissions",
|
||||
"permissionsCount": "Permissions",
|
||||
"type": "Type",
|
||||
"system": "System",
|
||||
"custom": "Custom",
|
||||
"roleCreated": "Role created successfully",
|
||||
"roleUpdated": "Role updated successfully",
|
||||
"roleDeleted": "Role deleted successfully",
|
||||
"confirmDeleteRole": "Are you sure you want to delete this role? This action cannot be undone.",
|
||||
"selectPermissions": "Select Permissions",
|
||||
"selected": "selected",
|
||||
"manageUserRoles": "Manage User Roles",
|
||||
"changeRole": "Change Role",
|
||||
"assignRole": "Assign Role",
|
||||
"assigningRoleTo": "Assigning role to",
|
||||
"roleAssigned": "Role assigned successfully",
|
||||
"permissionAuditLog": "Permission Audit Log",
|
||||
"filterByAction": "Filter by Action",
|
||||
"target": "Target",
|
||||
"changes": "Changes",
|
||||
"rbacStatistics": "RBAC Statistics",
|
||||
"roleDistribution": "Role Distribution",
|
||||
"recentActions": "Recent Actions (Last 30 Days)",
|
||||
"times": "times",
|
||||
"totalPermissions": "Total Permissions",
|
||||
"totalRoles": "Total Roles",
|
||||
"yourPermissions": "Your Permissions",
|
||||
"yourRole": "Your Role",
|
||||
"permissionsGranted": "Permissions Granted",
|
||||
"permissionsList": "Permissions List"
|
||||
},
|
||||
"device": "Device",
|
||||
"location": "Location",
|
||||
"lastActive": "Last Active",
|
||||
"created": "Created",
|
||||
"current": "Current",
|
||||
"active": "Active",
|
||||
"actions": "Actions",
|
||||
"refresh": "Refresh",
|
||||
"days": "days",
|
||||
"count": "Count",
|
||||
"clear": "Clear",
|
||||
"users": "Users",
|
||||
"all": "All",
|
||||
"apply": "Apply",
|
||||
"assign": "Assign",
|
||||
"wireguard": {
|
||||
"title": "WireGuard VPN",
|
||||
"connected": "Connected to WireGuard VPN",
|
||||
"disconnected": "Disconnected from WireGuard VPN",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"configuration": "Configuration",
|
||||
"configFile": "WireGuard Config File",
|
||||
"pasteConfigHint": "Paste your complete WireGuard configuration file below, or enter details manually.",
|
||||
"or": "OR",
|
||||
"privateKey": "Private Key",
|
||||
"serverPublicKey": "Server Public Key",
|
||||
"serverEndpoint": "Server Endpoint",
|
||||
"clientAddress": "Client Address",
|
||||
"dnsServer": "DNS Server",
|
||||
"saveSettings": "Save Settings",
|
||||
"settingsSaved": "Settings saved successfully!",
|
||||
"saveFailed": "Failed to save settings",
|
||||
"saveError": "Error saving settings",
|
||||
"connectFailed": "Failed to connect",
|
||||
"connectError": "Error connecting to VPN",
|
||||
"disconnectFailed": "Failed to disconnect",
|
||||
"disconnectError": "Error disconnecting from VPN",
|
||||
"configParsed": "Configuration parsed successfully!",
|
||||
"publicIP": "Public IP",
|
||||
"interface": "Interface",
|
||||
"checking": "Checking...",
|
||||
"status": {
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected"
|
||||
}
|
||||
},
|
||||
"vpnConfig": {
|
||||
"title": "VPN Configurations",
|
||||
"uploadConfig": "Upload Config",
|
||||
"configName": "Configuration Name",
|
||||
"nameHelper": "A friendly name to identify this configuration",
|
||||
"nameRequired": "Configuration name is required",
|
||||
"selectFile": "Select .conf or .ovpn file",
|
||||
"fileTypes": "Supported: WireGuard (.conf) and OpenVPN (.ovpn) configuration files",
|
||||
"invalidFileType": "Only .conf and .ovpn files are supported",
|
||||
"upload": "Upload",
|
||||
"uploadSuccess": "Configuration uploaded successfully!",
|
||||
"uploadFailed": "Failed to upload configuration",
|
||||
"uploadError": "Error uploading configuration",
|
||||
"loadFailed": "Failed to load configurations",
|
||||
"noConfigs": "No VPN configurations yet",
|
||||
"uploadFirst": "Upload your first VPN configuration file to get started",
|
||||
"infoText": "Upload VPN configuration files (.conf for WireGuard, .ovpn for OpenVPN) to easily manage multiple VPN connections.",
|
||||
"active": "Active",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"connected": "Connected to VPN successfully!",
|
||||
"connectFailed": "Failed to connect",
|
||||
"connectError": "Error connecting to VPN",
|
||||
"dockerLimitationWarning": "VPN connections require host network mode and are not supported in Docker containers. Please use the desktop application for VPN functionality.",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"disconnected": "Disconnected from VPN successfully!",
|
||||
"disconnectFailed": "Failed to disconnect",
|
||||
"disconnectError": "Error disconnecting from VPN",
|
||||
"deleteConfirmTitle": "Delete Configuration?",
|
||||
"deleteConfirmText": "Are you sure you want to delete this VPN configuration? This action cannot be undone.",
|
||||
"deleteSuccess": "Configuration deleted successfully",
|
||||
"deleteFailed": "Failed to delete configuration",
|
||||
"deleteError": "Error deleting configuration"
|
||||
},
|
||||
"errors": {
|
||||
"general": {
|
||||
"unexpected": "An unexpected error occurred",
|
||||
"tryAgain": "Please try again",
|
||||
"contactSupport": "If the problem persists, please contact support"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network Error",
|
||||
"message": "Network error. Please check your connection and try again.",
|
||||
"timeout": "Request timeout. Please try again.",
|
||||
"offline": "You appear to be offline. Please check your internet connection."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication Error",
|
||||
"required": "Authentication required. Please log in again.",
|
||||
"invalid": "Invalid or expired authentication token.",
|
||||
"failed": "Authentication failed. Please check your credentials.",
|
||||
"sessionExpired": "Your session has expired. Please log in again."
|
||||
},
|
||||
"permission": {
|
||||
"title": "Permission Denied",
|
||||
"message": "You do not have permission to perform this action.",
|
||||
"adminRequired": "This action requires administrator privileges.",
|
||||
"contactAdmin": "Please contact an administrator for access."
|
||||
},
|
||||
"validation": {
|
||||
"title": "Validation Error",
|
||||
"invalidInput": "Invalid input. Please check your data.",
|
||||
"requiredField": "This field is required.",
|
||||
"invalidFormat": "Invalid format. Please check your input.",
|
||||
"tooLarge": "The file or data is too large.",
|
||||
"tooSmall": "The value is too small."
|
||||
},
|
||||
"server": {
|
||||
"title": "Server Error",
|
||||
"message": "Server error. Please try again later.",
|
||||
"unavailable": "Service temporarily unavailable. Please try again later.",
|
||||
"maintenance": "The server is currently undergoing maintenance.",
|
||||
"overloaded": "The server is currently overloaded. Please try again in a few moments."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Not Found",
|
||||
"message": "The requested resource was not found.",
|
||||
"page": "Page not found",
|
||||
"resource": "Resource not found"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "Conflict",
|
||||
"message": "This operation conflicts with existing data.",
|
||||
"duplicate": "This item already exists.",
|
||||
"outdated": "The data has been modified by another user. Please refresh and try again."
|
||||
},
|
||||
"rateLimit": {
|
||||
"title": "Too Many Requests",
|
||||
"message": "Too many requests. Please wait a moment and try again.",
|
||||
"slowDown": "Please slow down and try again in a few seconds."
|
||||
}
|
||||
},
|
||||
"siem": {
|
||||
"title": "Security Intelligence",
|
||||
"threatScore": "Threat Score",
|
||||
"alerts": "Alerts",
|
||||
"anomalies": "Anomalies",
|
||||
"threats": "Threat Intelligence",
|
||||
"logs": "Security Logs",
|
||||
"severity": "Severity",
|
||||
"level": "Level",
|
||||
"source": "Source",
|
||||
"category": "Category",
|
||||
"message": "Message",
|
||||
"time": "Time",
|
||||
"type": "Type",
|
||||
"description": "Description",
|
||||
"confidence": "Confidence",
|
||||
"indicator": "Indicator",
|
||||
"threatLevel": "Threat Level",
|
||||
"occurrences": "Occurrences",
|
||||
"lastSeen": "Last Seen",
|
||||
"verifyIntegrity": "Verify Integrity",
|
||||
"alertAcknowledged": "Alert acknowledged successfully",
|
||||
"alertAcknowledgeFailed": "Failed to acknowledge alert",
|
||||
"alertResolved": "Alert resolved successfully",
|
||||
"alertResolveFailed": "Failed to resolve alert",
|
||||
"anomalyResolved": "Anomaly resolved successfully",
|
||||
"anomalyResolveFailed": "Failed to resolve anomaly",
|
||||
"exportSuccess": "Logs exported successfully",
|
||||
"exportFailed": "Failed to export logs",
|
||||
"integrityVerified": "Log integrity verified: {{verified}} logs validated",
|
||||
"integrityCompromised": "WARNING: {{tampered}} of {{total}} logs have been tampered with!",
|
||||
"integrityCheckFailed": "Failed to verify log integrity",
|
||||
"acknowledge": "Acknowledge",
|
||||
"resolve": "Resolve",
|
||||
"viewDetails": "View Details",
|
||||
"alertDetails": "Alert Details",
|
||||
"anomalyDetails": "Anomaly Details",
|
||||
"resolutionNotes": "Resolution Notes",
|
||||
"resolutionNotesPlaceholder": "Enter resolution notes...",
|
||||
"criticalAnomalies": "Critical Anomalies",
|
||||
"highAnomalies": "High Priority Anomalies",
|
||||
"mediumAnomalies": "Medium Priority Anomalies",
|
||||
"lowAnomalies": "Low Priority Anomalies"
|
||||
},
|
||||
"securityConfig": {
|
||||
"title": "Security Configuration",
|
||||
"thresholds": "Notification Thresholds",
|
||||
"signatures": "Risk Signatures",
|
||||
"protocols": "Response Protocols",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"autoBlock": "Auto Block",
|
||||
"autoExecute": "Auto Execute",
|
||||
"addThreshold": "Add Threshold",
|
||||
"editThreshold": "Edit Threshold",
|
||||
"addSignature": "Add Signature",
|
||||
"editSignature": "Edit Signature",
|
||||
"addProtocol": "Add Protocol",
|
||||
"editProtocol": "Edit Protocol",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"patternType": "Pattern Type",
|
||||
"metricName": "Metric Name",
|
||||
"operator": "Operator",
|
||||
"thresholdValue": "Threshold Value",
|
||||
"timeWindow": "Time Window (minutes)",
|
||||
"severity": "Severity",
|
||||
"signatureType": "Signature Type",
|
||||
"matchType": "Match Type",
|
||||
"pattern": "Pattern",
|
||||
"patternHelp": "Enter regex pattern or match string",
|
||||
"threatLevel": "Threat Level",
|
||||
"confidence": "Confidence",
|
||||
"triggerType": "Trigger Type",
|
||||
"actionsCount": "Actions",
|
||||
"actions": "actions",
|
||||
"cooldown": "Cooldown",
|
||||
"cooldownMinutes": "Cooldown (minutes)",
|
||||
"condition": "Condition",
|
||||
"status": "Status",
|
||||
"thresholdSaved": "Threshold saved successfully",
|
||||
"signatureSaved": "Signature saved successfully",
|
||||
"protocolSaved": "Protocol saved successfully",
|
||||
"deleted": "Deleted successfully",
|
||||
"deleteWarning": "Are you sure you want to delete this item? This action cannot be undone.",
|
||||
"protocolWarning": "Response protocols are powerful automation tools. Configure them carefully to avoid unintended consequences."
|
||||
}
|
||||
}
|
||||
898
frontend/src/locales/ro.json
Normal file
|
|
@ -0,0 +1,898 @@
|
|||
{
|
||||
"app_name": "StreamFlow",
|
||||
"premium_plan": "Plan Premium",
|
||||
"search": "Căutare",
|
||||
"live_tv": "TV Live",
|
||||
"movies": "Filme",
|
||||
"series": "Seriale",
|
||||
"favorites": "Favorite",
|
||||
"favorite_tv": "TV Favorite",
|
||||
"favorite_radio": "Radio Favorite",
|
||||
"settings": "Setări",
|
||||
"logout": "Deconectare",
|
||||
"login": "Autentificare",
|
||||
"register": "Înregistrare",
|
||||
"username": "Nume utilizator",
|
||||
"email": "Email",
|
||||
"password": "Parolă",
|
||||
"remember_me": "Ține Parola Minte",
|
||||
"forgot_password": "Ai uitat parola?",
|
||||
"dont_have_account": "Nu ai un cont?",
|
||||
"already_have_account": "Ai deja un cont?",
|
||||
"sign_up": "Înregistrează-te",
|
||||
"sign_in": "Autentifică-te",
|
||||
"login": {
|
||||
"loginHere": "Autentificare Aici!",
|
||||
"login": "AUTENTIFICARE",
|
||||
"success": "Autentificare reușită! Bine ai revenit.",
|
||||
"dontHaveAccount": "Nu ai un cont? Creează-ți contul",
|
||||
"createAccountHere": "aici!",
|
||||
"registrationDisabled": "Înregistrarea este momentan dezactivată. Te rugăm să contactezi un administrator pentru a-ți crea contul."
|
||||
},
|
||||
"register": {
|
||||
"success": "Înregistrare reușită! Bine ai venit.",
|
||||
"failed": "Înregistrarea a eșuat",
|
||||
"error": "Înregistrarea a eșuat. Te rugăm să încerci din nou."
|
||||
},
|
||||
"welcome_back": "Bine ai revenit",
|
||||
"create_account": "Creează-ți contul",
|
||||
"today": "Astăzi",
|
||||
"up_next": "URMEAZĂ",
|
||||
"all": "Toate",
|
||||
"sports": "Sport",
|
||||
"news": "Știri",
|
||||
"kids": "Copii",
|
||||
"channel": "Canal",
|
||||
"now_playing": "Redare Acum",
|
||||
"add_playlist": "Adaugă Playlist",
|
||||
"upload_m3u": "Încarcă M3U",
|
||||
"playlist_url": "URL Playlist",
|
||||
"playlist_name": "Nume Playlist",
|
||||
"add": "Adaugă",
|
||||
"cancel": "Anulează",
|
||||
"delete": "Șterge",
|
||||
"edit": "Editează",
|
||||
"save": "Salvează",
|
||||
"close": "Închide",
|
||||
"loading": "Se încarcă...",
|
||||
"error": "Eroare",
|
||||
"success": "Succes",
|
||||
"details": "Detalii",
|
||||
"no_channels": "Niciun canal disponibil",
|
||||
"no_playlists": "Nicio listă de redare disponibilă",
|
||||
"recording": "Înregistrare",
|
||||
"record": "Înregistrează",
|
||||
"schedule_recording": "Programează Înregistrare",
|
||||
"recordings": "Înregistrări",
|
||||
"radio": "Radio",
|
||||
"groups": "Grupuri",
|
||||
"add_to_group": "Adaugă în Grup",
|
||||
"create_group": "Creează Grup",
|
||||
"group_name": "Nume Grup",
|
||||
"profile": "Profil",
|
||||
"profiles": "Profile",
|
||||
"add_profile": "Adaugă Profil",
|
||||
"select_profile": "Selectează Profil",
|
||||
"parental_controls": "Control Parental",
|
||||
"theme": "Temă",
|
||||
"light": "Luminoasă",
|
||||
"dark": "Întunecată",
|
||||
"language": "Limbă",
|
||||
"notifications": "Notificări",
|
||||
"privacy": "Confidențialitate",
|
||||
"about": "Despre",
|
||||
"version": "Versiune",
|
||||
"quality": "Calitate",
|
||||
"auto": "Auto",
|
||||
"high": "Înaltă",
|
||||
"medium": "Medie",
|
||||
"low": "Scăzută",
|
||||
"backToSecurity": "Înapoi la Securitate",
|
||||
"enabled": "Activat",
|
||||
"disabled": "Dezactivat",
|
||||
"settings": {
|
||||
"title": "Setări",
|
||||
"playlists": "Liste de Redare",
|
||||
"addPlaylist": "Adaugă Playlist",
|
||||
"noPlaylists": "Nu există liste de redare. Adaugă una pentru a începe.",
|
||||
"playlistName": "Nume Playlist",
|
||||
"selectFile": "Selectează Fișier",
|
||||
"m3uLibrary": "Bibliotecă M3U",
|
||||
"uploadM3u": "Încarcă M3U",
|
||||
"noM3uFiles": "Nu există fișiere M3U stocate. Încarcă fișiere pentru a le gestiona separat.",
|
||||
"uploadM3uFile": "Încarcă Fișier M3U",
|
||||
"fileName": "Nume Fișier",
|
||||
"fileNameHelper": "Dă acestui fișier M3U un nume pentru identificare ușoară",
|
||||
"selectM3uFile": "Selectează Fișier M3U",
|
||||
"renameM3uFile": "Redenumește Fișier M3U",
|
||||
"importM3uFile": "Importă Fișier M3U",
|
||||
"downloadM3uFile": "Descarcă Fișier M3U",
|
||||
"fileDownloaded": "Fișierul a fost descărcat cu succes",
|
||||
"failedToDownload": "Eroare la descărcarea fișierului",
|
||||
"importMessage": "Importă acest fișier M3U ca playlist {{type}}?",
|
||||
"importInfo": "Canalele din acest fișier vor fi adăugate în secțiunea {{section}}.",
|
||||
"liveTV": "TV Live",
|
||||
"streaming": "Streaming & Accelerare Hardware",
|
||||
"hwAccelAvailable": "Accelerare hardware disponibilă",
|
||||
"hwAccel": "Accelerare Hardware",
|
||||
"hwAccelAuto": "Auto (Recomandat)",
|
||||
"hwAccelNone": "Doar Software",
|
||||
"hwAccelNotAvailable": "(Nu e disponibil)",
|
||||
"hwDevice": "Dispozitiv Hardware",
|
||||
"hwDeviceHelper": "De obicei /dev/dri/renderD128 pentru Intel/AMD",
|
||||
"encoderPreset": "Preset Encoder",
|
||||
"presetUltrafast": "Ultra Rapid (Calitate Scăzută)",
|
||||
"presetSuperfast": "Super Rapid",
|
||||
"presetVeryfast": "Foarte Rapid (Recomandat)",
|
||||
"presetFaster": "Mai Rapid",
|
||||
"presetFast": "Rapid",
|
||||
"presetMedium": "Mediu (Echilibrat)",
|
||||
"presetSlow": "Lent (Calitate Înaltă)",
|
||||
"bufferSize": "Dimensiune Buffer",
|
||||
"bufferLowLatency": "Latență Scăzută",
|
||||
"bufferSmooth": "Redare Fluidă",
|
||||
"maxBitrate": "Bitrate Maxim",
|
||||
"bitrateLow": "Scăzut",
|
||||
"bitrateHigh": "Calitate Înaltă",
|
||||
"quickSyncInfo": "Quick Sync permite encodare/decodare video accelerată hardware pentru redare fluidă cu utilizare scăzută a CPU. Modul Auto va selecta automat cea mai bună metodă disponibilă.",
|
||||
"appearance": "Aspect",
|
||||
"darkMode": "Mod Întunecat",
|
||||
"language": "Limbă",
|
||||
"selectLanguage": "Selectează Limba",
|
||||
"account": "Cont",
|
||||
"logout": "Deconectare",
|
||||
"userManagement": "Gestionare Utilizatori",
|
||||
"users": "Utilizatori",
|
||||
"addUser": "Adaugă Utilizator",
|
||||
"noUsers": "Nu s-au găsit utilizatori.",
|
||||
"username": "Nume utilizator",
|
||||
"email": "Email",
|
||||
"password": "Parolă",
|
||||
"role": "Rol",
|
||||
"status": "Status",
|
||||
"actions": "Acțiuni",
|
||||
"createUser": "Creează Utilizator",
|
||||
"editUser": "Editează Utilizator",
|
||||
"deleteUser": "Șterge Utilizator",
|
||||
"resetPassword": "Resetează Parola",
|
||||
"active": "Activ",
|
||||
"inactive": "Inactiv",
|
||||
"user": "Utilizator",
|
||||
"admin": "Administrator",
|
||||
"roleUser": "Utilizator",
|
||||
"roleAdmin": "Administrator",
|
||||
"activeAccount": "Cont Activ",
|
||||
"fetchFailed": "Eroare la încărcarea utilizatorilor",
|
||||
"createFailed": "Eroare la crearea utilizatorului",
|
||||
"updateFailed": "Eroare la actualizarea utilizatorului",
|
||||
"deleteFailed": "Eroare la ștergerea utilizatorului",
|
||||
"passwordResetSuccess": "Parola a fost resetată cu succes. Utilizatorul trebuie să schimbe parola la următoarea autentificare.",
|
||||
"passwordResetFailed": "Eroare la resetarea parolei",
|
||||
"deleteConfirm": "Ești sigur că vrei să ștergi acest utilizator? Această acțiune nu poate fi anulată.",
|
||||
"newUserInfo": "Noul utilizator va trebui să își schimbe parola la prima autentificare.",
|
||||
"passwordHelper": "Minim 8 caractere",
|
||||
"resetPasswordWarning": "Aceasta va seta o parolă temporară și va forța utilizatorul să o schimbe la următoarea autentificare.",
|
||||
"changePasswordRequired": "Schimbare Parolă Necesară",
|
||||
"changePasswordWarning": "Trebuie să îți schimbi parola pentru a continua.",
|
||||
"currentPassword": "Parola Curentă",
|
||||
"newPassword": "Parola Nouă",
|
||||
"confirmPassword": "Confirmă Parola",
|
||||
"passwordMinLength": "Parola trebuie să aibă cel puțin 8 caractere",
|
||||
"passwordTooShort": "Parola trebuie să aibă cel puțin 8 caractere",
|
||||
"passwordsDoNotMatch": "Parolele nu se potrivesc",
|
||||
"changePassword": "Schimbă Parola",
|
||||
"passwordChangeFailed": "Eroare la schimbarea parolei",
|
||||
"passwordChangeSuccess": "Parola a fost schimbată cu succes!",
|
||||
"changePasswordFailed": "Schimbarea Parolei a Eșuat"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup și Restaurare",
|
||||
"description": "Creează copii de rezervă ale playlist-urilor, canalelor, favoritelor, logo-urilor personalizate și fișierelor M3U. Descarcă backup-urile sau restaurează-le oricând.",
|
||||
"create": "Creează Backup",
|
||||
"upload": "Încarcă Backup",
|
||||
"restore": "Restaurează",
|
||||
"download": "Descarcă",
|
||||
"delete": "Șterge",
|
||||
"backup": "Backup",
|
||||
"created": "Creat",
|
||||
"noBackups": "Nu există backup-uri disponibile. Creează primul backup pentru a-ți securiza datele.",
|
||||
"createSuccess": "Backup creat cu succes!",
|
||||
"createError": "Eroare la crearea backup-ului. Te rugăm să încerci din nou.",
|
||||
"deleteSuccess": "Backup șters cu succes!",
|
||||
"deleteError": "Eroare la ștergerea backup-ului. Te rugăm să încerci din nou.",
|
||||
"restoreSuccess": "Backup restaurat cu succes! Restaurate",
|
||||
"restoreError": "Eroare la restaurarea backup-ului. Te rugăm să încerci din nou.",
|
||||
"uploadSuccess": "Backup încărcat cu succes!",
|
||||
"uploadError": "Eroare la încărcarea backup-ului. Te rugăm să încerci din nou.",
|
||||
"fetchError": "Eroare la încărcarea backup-urilor.",
|
||||
"deleteTitle": "Ștergi Backup-ul?",
|
||||
"deleteConfirm": "Ești sigur că vrei să ștergi acest backup? Această acțiune nu poate fi anulată.",
|
||||
"restoreTitle": "Restaurezi Backup-ul?",
|
||||
"restoreConfirm": "Aceasta va restaura playlist-urile, canalele, favoritele și fișierele M3U din acest backup.",
|
||||
"restoreWarning": "Datele curente cu aceleași nume nu vor fi duplicate. Pagina se va reîncărca după restaurare.",
|
||||
"uploading": "Se încarcă"
|
||||
},
|
||||
"common": {
|
||||
"add": "Adaugă",
|
||||
"cancel": "Anulează",
|
||||
"delete": "Șterge",
|
||||
"save": "Salvează",
|
||||
"close": "Închide",
|
||||
"refresh": "Reîmprospătează",
|
||||
"upload": "Încarcă",
|
||||
"rename": "Redenumește",
|
||||
"import": "Importă",
|
||||
"tv": "TV",
|
||||
"radio": "Radio",
|
||||
"back": "Înapoi"
|
||||
},
|
||||
"audio": {
|
||||
"select_station": "Selectează un Post",
|
||||
"now_playing": "Acum se redă",
|
||||
"casting": "Se transmite la dispozitiv",
|
||||
"visualizer_type": "Vizualizator {{count}}",
|
||||
"no_metadata": "Informații despre melodie indisponibile",
|
||||
"loading": "Se încarcă...",
|
||||
"buffering": "Se buferizează..."
|
||||
},
|
||||
"twoFactor": {
|
||||
"title": "Autentificare Cu Doi Factori",
|
||||
"enterCode": "Introduceți codul de 6 cifre din aplicația de autentificare",
|
||||
"enterBackupCode": "Introduceți codul de rezervă",
|
||||
"backupCodePlaceholder": "Cod de Rezervă",
|
||||
"codePlaceholder": "000000",
|
||||
"useBackupCode": "Folosește codul de rezervă",
|
||||
"useAuthenticatorCode": "Folosește codul de autentificare",
|
||||
"verify": "Verifică",
|
||||
"verifySuccess": "Verificare 2FA reușită!",
|
||||
"verifyFailed": "Verificarea 2FA a eșuat",
|
||||
"backToLogin": "Înapoi la autentificare",
|
||||
"invalidCode": "Cod de verificare invalid. Te rugăm să încerci din nou.",
|
||||
"codeRequired": "Cod de verificare necesar"
|
||||
},
|
||||
"security": {
|
||||
"title": "Securitate",
|
||||
"passwordPolicy": "Politica Parolelor",
|
||||
"accountLockout": "Blocare Cont",
|
||||
"passwordExpiry": "Expirare Parolă",
|
||||
"securityStatus": "Status Securitate",
|
||||
"auditLog": "Jurnal Audit",
|
||||
"passwordStrength": "Puterea Parolei",
|
||||
"veryWeak": "Foarte Slabă",
|
||||
"weak": "Slabă",
|
||||
"good": "Bună",
|
||||
"strong": "Puternică",
|
||||
"veryStrong": "Foarte Puternică",
|
||||
"requirements": "Cerințe",
|
||||
"minLength": "Cel puțin 12 caractere",
|
||||
"uppercase": "Cel puțin o literă mare",
|
||||
"lowercase": "Cel puțin o literă mică",
|
||||
"number": "Cel puțin o cifră",
|
||||
"special": "Cel puțin un caracter special",
|
||||
"noCommon": "Nu este o parolă comună",
|
||||
"noUsername": "Nu conține numele de utilizator",
|
||||
"accountLocked": "Cont Blocat",
|
||||
"accountLockedMsg": "Contul tău a fost blocat din cauza mai multor încercări eșuate de autentificare.",
|
||||
"remainingTime": "Timp rămas: {{minutes}} minute",
|
||||
"tryAgainAt": "Încearcă din nou la {{time}}",
|
||||
"passwordExpired": "Parolă Expirată",
|
||||
"passwordExpiredMsg": "Parola ta a expirat. Te rugăm să o schimbi pentru a continua.",
|
||||
"passwordExpiresIn": "Parola expiră în {{days}} zile",
|
||||
"passwordExpiringSoon": "Parola ta va expira în curând",
|
||||
"changeNow": "Schimbă Acum",
|
||||
"remindLater": "Amintește Mai Târziu",
|
||||
"twoFactorEnabled": "Autentificare Cu Doi Factori Activată",
|
||||
"passwordAge": "Vechimea Parolei: {{days}} zile",
|
||||
"lastLogin": "Ultima Autentificare",
|
||||
"activeSessions": "Sesiuni Active",
|
||||
"failedAttempts": "Încercări Eșuate de Autentificare",
|
||||
"recentActivity": "Activitate Recentă",
|
||||
"securityEvents": "Evenimente de Securitate",
|
||||
"viewDetails": "Vezi Detalii",
|
||||
"eventType": "Tip Eveniment",
|
||||
"status": "Status",
|
||||
"timestamp": "Marcaj Temporal",
|
||||
"ipAddress": "Adresă IP",
|
||||
"success": "Succes",
|
||||
"failed": "Eșuat",
|
||||
"pending": "În Așteptare",
|
||||
"loginAttempt": "Încercare de Autentificare",
|
||||
"passwordChange": "Schimbare Parolă",
|
||||
"twoFactorVerification": "Verificare 2FA",
|
||||
"sessionCreated": "Sesiune Creată",
|
||||
"tokenIssued": "Token Emis",
|
||||
"tokenRefreshed": "Token Reîmprospătat",
|
||||
"tokenRevoked": "Token Revocat",
|
||||
"privilegeChange": "Schimbare Privilegii",
|
||||
"permissionGranted": "Permisiune Acordată",
|
||||
"permissionRevoked": "Permisiune Revocată",
|
||||
"accountStatusChanged": "Status Cont Schimbat",
|
||||
"roleChanged": "Rol Schimbat",
|
||||
"accountActivated": "Cont Activat",
|
||||
"accountDeactivated": "Cont Dezactivat",
|
||||
"adminActivity": "Activitate Admin",
|
||||
"sensitiveDataAccess": "Acces Date Sensibile",
|
||||
"userCreated": "Utilizator Creat",
|
||||
"userDeleted": "Utilizator Șters",
|
||||
"accountUnlocked": "Cont Deblocat",
|
||||
"passwordResetByAdmin": "Parolă Resetată de Admin",
|
||||
"forceLogout": "Deconectare Forțată",
|
||||
"adminAction": "Acțiune Admin",
|
||||
"dataType": "Tip Date",
|
||||
"accessMethod": "Metodă Acces",
|
||||
"userList": "Listă Utilizatori",
|
||||
"userDetails": "Detalii Utilizator",
|
||||
"vpnConfigs": "Configurări VPN",
|
||||
"auditLogs": "Jurnale Audit",
|
||||
"records": "Înregistrări",
|
||||
"scope": "Domeniu",
|
||||
"noRecentActivity": "Nicio activitate de securitate recentă",
|
||||
"securityDashboard": "Panou de Securitate",
|
||||
"allUsers": "Toți Utilizatorii",
|
||||
"lockedAccounts": "Conturi Blocate",
|
||||
"recentFailures": "Eșecuri Recente",
|
||||
"passwordReuse": "Nu poți refolosi niciuna din ultimele 5 parole",
|
||||
"passwordPolicyError": "Parola nu îndeplinește cerințele",
|
||||
"strengthTooWeak": "Puterea parolei este prea slabă",
|
||||
"unlockAccount": "Deblochează Cont",
|
||||
"viewAuditLog": "Vezi Jurnal Audit",
|
||||
"exportAuditLog": "Exportă Jurnal Audit",
|
||||
"deviceInformation": "Informații Dispozitiv",
|
||||
"deviceType": "Tip Dispozitiv",
|
||||
"operatingSystem": "Sistem de Operare",
|
||||
"browser": "Browser",
|
||||
"tokenDetails": "Detalii Token",
|
||||
"tokenType": "Tip Token",
|
||||
"purpose": "Scop",
|
||||
"expiresIn": "Expiră În",
|
||||
"reason": "Motiv",
|
||||
"privilegeChangeDetails": "Detalii Schimbare Privilegii",
|
||||
"affectedUser": "Utilizator Afectat",
|
||||
"previousRole": "Rol Anterior",
|
||||
"newRole": "Rol Nou",
|
||||
"changedBy": "Schimbat De",
|
||||
"accountStatusDetails": "Detalii Status Cont",
|
||||
"previousStatus": "Status Anterior",
|
||||
"newStatus": "Status Nou",
|
||||
"userAgent": "Agent Utilizator",
|
||||
"rawDetails": "Detalii Brute",
|
||||
"noEvents": "Nu s-au găsit evenimente de securitate",
|
||||
"loadingEvents": "Se încarcă evenimentele de securitate...",
|
||||
"sessionTimeout": "Expirare Sesiune",
|
||||
"sessionExpired": "Sesiunea ta a expirat. Te rugăm să te autentifici din nou.",
|
||||
"multipleDevices": "Ai {{count}} sesiuni active",
|
||||
"terminateSession": "Închide Sesiune",
|
||||
"terminateAllSessions": "Închide Toate Celelalte Sesiuni",
|
||||
"inputValidation": "Validare Input",
|
||||
"invalidInput": "Input Invalid",
|
||||
"validationFailed": "Validare Eșuată",
|
||||
"invalidUsername": "Format invalid nume utilizator",
|
||||
"invalidEmail": "Format invalid email",
|
||||
"invalidUrl": "Format invalid URL",
|
||||
"fieldRequired": "Acest câmp este obligatoriu",
|
||||
"fieldTooShort": "Acest câmp este prea scurt",
|
||||
"fieldTooLong": "Acest câmp este prea lung",
|
||||
"invalidCharacters": "Conține caractere invalide",
|
||||
"invalidFileType": "Tip de fișier invalid",
|
||||
"fileTooLarge": "Dimensiunea fișierului depășește limita maximă",
|
||||
"securityAlert": "Alertă de Securitate",
|
||||
"suspiciousActivity": "Activitate Suspectă Detectată",
|
||||
"inputSanitized": "Input-ul a fost igienizat pentru securitate",
|
||||
"xssAttemptBlocked": "Încercare potențială XSS blocată",
|
||||
"sqlInjectionBlocked": "Încercare potențială SQL injection blocată",
|
||||
"unauthorizedAccess": "Încercare de acces neautorizată",
|
||||
"rateLimitExceeded": "Limită de rată depășită. Te rugăm să încerci din nou mai târziu.",
|
||||
"invalidToken": "Token de autentificare invalid sau expirat",
|
||||
"csrfDetected": "Încercare CSRF detectată",
|
||||
"permissionDenied": "Permisiune refuzată",
|
||||
"securityCheckFailed": "Verificare de securitate eșuată",
|
||||
"ipAddress": "Adresă IP",
|
||||
"status": "Stare",
|
||||
"monitoring": "Monitorizare Securitate",
|
||||
"overview": "Prezentare Generală",
|
||||
"dependencies": "Dependențe",
|
||||
"securityHeaders": "Anteturi de Securitate",
|
||||
"manageHttpHeaders": "Gestionează Anteteurile HTTP de Securitate",
|
||||
"backend": "Backend",
|
||||
"frontend": "Frontend",
|
||||
"totalVulnerabilities": "Total Vulnerabilități",
|
||||
"activeSessions": "Sesiuni Active",
|
||||
"failedLogins": "Autentificări Eșuate",
|
||||
"scanVulnerabilities": "Scanează Vulnerabilități",
|
||||
"noVulnerabilities": "Nu s-au găsit vulnerabilități",
|
||||
"clickToScanVulnerabilities": "Apăsați 'Scanează Vulnerabilități' pentru a verifica probleme de securitate",
|
||||
"action": "Acțiune",
|
||||
"eventDetails": "Detalii Eveniment",
|
||||
"recentEvents": "Evenimente Recente",
|
||||
"lastOccurrence": "Ultima Apariție",
|
||||
"securityRecommendations": "Recomandări de Securitate",
|
||||
"moreRecommendations": "recomandări suplimentare",
|
||||
"recommendedAction": "Acțiune Recomandată",
|
||||
"noRecommendations": "Toate verificările de securitate au trecut! Nu există recomandări în acest moment.",
|
||||
"cspDashboard": "Panou Politică de Securitate a Conținutului",
|
||||
"cspPolicyStatus": "Stare Politică CSP",
|
||||
"mode": "Mod",
|
||||
"enforcing": "Activ",
|
||||
"reportOnly": "Doar Raportare",
|
||||
"totalViolations": "Violări Totale",
|
||||
"policyDirectives": "Directive Politică",
|
||||
"violations": "Violări",
|
||||
"statistics": "Statistici",
|
||||
"policy": "Politică",
|
||||
"recentViolations": "Violări Recente",
|
||||
"clearOlderThan": "Șterge Mai Vechi De",
|
||||
"cspViolationsCleared": "Violări CSP șterse",
|
||||
"noViolations": "Nu s-au găsit violări CSP. Aplicația ta este sigură!",
|
||||
"timestamp": "Dată/Oră",
|
||||
"violatedDirective": "Directivă Violată",
|
||||
"blockedUri": "URI Blocat",
|
||||
"sourceFile": "Fișier Sursă",
|
||||
"viewDetails": "Vezi Detalii",
|
||||
"violationStatistics": "Statistici Violări",
|
||||
"byDirective": "Pe Directive",
|
||||
"byBlockedUri": "Pe URI Blocat",
|
||||
"noData": "Nu există date disponibile",
|
||||
"directive": "Directivă",
|
||||
"allowedSources": "Surse Permise",
|
||||
"currentCspPolicy": "Politică CSP Curentă",
|
||||
"cspEnforcedDescription": "CSP este activat în prezent. Violările vor bloca încărcarea resurselor.",
|
||||
"cspReportOnlyDescription": "CSP este în modul doar-raportare. Violările sunt înregistrate dar resursele nu sunt blocate.",
|
||||
"violationDetails": "Detalii Violare",
|
||||
"documentUri": "URI Document",
|
||||
"lineNumber": "Număr Linie",
|
||||
"columnNumber": "Număr Coloană",
|
||||
"userAgent": "Agent Utilizator",
|
||||
"adminAccessRequired": "Este necesară permisiunea de administrator pentru a vizualiza această pagină",
|
||||
"rbacDashboard": "RBAC & Permisiuni",
|
||||
"currentConfig": "Configurație Curentă",
|
||||
"currentConfigDescription": "Aceasta este configurația activă a anteteurilor de securitate care rulează pe server.",
|
||||
"environment": "Mediu",
|
||||
"contentSecurityPolicy": "Politica de Securitate a Conținutului",
|
||||
"otherHeaders": "Alte Anteturi de Securitate",
|
||||
"editor": "Editor de Configurație",
|
||||
"savedConfigs": "Configurații Salvate",
|
||||
"recommendations": "Recomandări",
|
||||
"history": "Istoric",
|
||||
"loadPreset": "Încarcă Presetare",
|
||||
"testConfig": "Testează Configurația",
|
||||
"saveConfig": "Salvează Configurația",
|
||||
"editorWarning": "Modificările din editor nu sunt aplicate până când salvezi și activezi o configurație. Este necesară repornirea serverului.",
|
||||
"cspDirectives": "Directive CSP",
|
||||
"jsonArrayFormat": "Introdu ca array JSON, ex: ['self', 'https:']",
|
||||
"hstsConfiguration": "Configurație HSTS",
|
||||
"enableHSTS": "Activează HSTS",
|
||||
"maxAge": "Durata Maximă (secunde)",
|
||||
"includeSubdomains": "Include Subdomeniile",
|
||||
"preload": "Preîncărcare",
|
||||
"referrerPolicy": "Politica Referrer",
|
||||
"xFrameOptions": "Opțiuni X-Frame",
|
||||
"noSavedConfigs": "Nu există configurații salvate. Creează și salvează configurații din editor.",
|
||||
"active": "Activă",
|
||||
"created": "Creat",
|
||||
"loadToEditor": "Încarcă în Editor",
|
||||
"apply": "Aplică",
|
||||
"configLoaded": "Configurație încărcată în editor",
|
||||
"saveConfiguration": "Salvează Configurația",
|
||||
"configurationName": "Nume Configurație",
|
||||
"configNameHelper": "Dă acestei configurații un nume memorabil",
|
||||
"testResults": "Rezultate Test",
|
||||
"grade": "Notă",
|
||||
"passed": "Trecut",
|
||||
"warnings": "Avertismente",
|
||||
"errors": "Erori",
|
||||
"selectPreset": "Selectează Presetare de Securitate",
|
||||
"preset": "Presetare",
|
||||
"securityScore": "Scor Securitate",
|
||||
"securitySummary": "Rezumat Securitate",
|
||||
"critical": "Critic",
|
||||
"info": "Info",
|
||||
"failedToLoad": "Eșec la încărcarea configurației de securitate",
|
||||
"testFailed": "Eșec la testarea configurației",
|
||||
"nameRequired": "Numele configurației este necesar",
|
||||
"configSaved": "Configurație salvată cu succes",
|
||||
"saveFailed": "Eșec la salvarea configurației",
|
||||
"confirmApply": "Aplici această configurație? Va fi necesară repornirea serverului.",
|
||||
"configAppliedRestart": "Configurație aplicată cu succes. Te rugăm să repornești serverul pentru a activa modificările.",
|
||||
"configApplied": "Configurație aplicată cu succes",
|
||||
"applyFailed": "Eșec la aplicarea configurației",
|
||||
"securityTesting": "Testare Securitate",
|
||||
"automatedAndManualTesting": "Testare Automată și Manuală",
|
||||
"overallScore": "Scor General",
|
||||
"defenseInDepth": "Apărare în Adâncime",
|
||||
"networkLayer": "Nivel Rețea",
|
||||
"serverLayer": "Nivel Server",
|
||||
"applicationLayer": "Nivel Aplicație",
|
||||
"dataLayer": "Nivel Date",
|
||||
"score": "Scor",
|
||||
"checks": "Verificări",
|
||||
"penetrationTesting": "Testare Penetrare",
|
||||
"penetrationTestingInfo": "Aceste teste automate încearcă să găsească vulnerabilități de securitate prin simularea atacurilor. Toate testele sunt sigure și nu dăunează aplicației.",
|
||||
"selectTests": "Selectați Testele de Rulat",
|
||||
"authenticationTests": "Teste Autentificare",
|
||||
"sqlInjectionTests": "Teste Injecție SQL",
|
||||
"xssTests": "Teste XSS",
|
||||
"csrfTests": "Teste CSRF",
|
||||
"rateLimitTests": "Teste Limitare Rată",
|
||||
"runPenetrationTests": "Rulează Testele de Penetrare",
|
||||
"runningTests": "Se Rulează Testele...",
|
||||
"testsCompleted": "Teste finalizate cu succes",
|
||||
"testsFailed": "Eșec la rularea testelor",
|
||||
"findings": "Descoperiri",
|
||||
"testHistory": "Istoric Teste",
|
||||
"noTestHistory": "Nu există istoric de teste. Rulați teste de penetrare pentru a vedea rezultate aici.",
|
||||
"testType": "Tip Test",
|
||||
"testName": "Nume Test",
|
||||
"severity": "Severitate",
|
||||
"executedBy": "Executat De",
|
||||
"duration": "Durată",
|
||||
"networkSecurity": "Securitate Rețea",
|
||||
"activeConnections": "Conexiuni Active",
|
||||
"blockedRequests": "Cereri Blocate",
|
||||
"failedLogins": "Autentificări Eșuate",
|
||||
"rateLimitingStats": "Statistici Limitare Rată",
|
||||
"totalRequests": "Total Cereri",
|
||||
"rateLimited": "Limitate Rată",
|
||||
"failed": "Eșuat",
|
||||
"confirmDelete": "Ești sigur că vrei să ștergi această configurație?",
|
||||
"configDeleted": "Configurație ștearsă cu succes",
|
||||
"deleteFailed": "Eșec la ștergerea configurației",
|
||||
"presetApplied": "Presetarea '{{preset}}' încărcată în editor",
|
||||
"noHistory": "Nu există istoric de configurații disponibil",
|
||||
"timestamp": "Marcaj Temporal",
|
||||
"action": "Acțiune",
|
||||
"configuration": "Configurație",
|
||||
"changedBy": "Modificat De"
|
||||
},
|
||||
"logManagement": {
|
||||
"title": "Gestionare Jurnale",
|
||||
"subtitle": "Conformitate CWE-53: Retenție automată, arhivare și verificare integritate",
|
||||
"totalLogs": "Total Jurnale",
|
||||
"archives": "Arhive",
|
||||
"retention": "Politică de Retenție",
|
||||
"days": "zile",
|
||||
"integrity": "Integritate",
|
||||
"protected": "Protejat",
|
||||
"manualCleanup": "Curățare Manuală",
|
||||
"verifyIntegrity": "Verifică Integritatea",
|
||||
"archivesList": "Arhive Jurnale",
|
||||
"noArchives": "Nu există arhive disponibile",
|
||||
"filename": "Nume fișier",
|
||||
"size": "Dimensiune",
|
||||
"created": "Creat",
|
||||
"cleanupWarning": "Acest lucru va șterge jurnalele mai vechi decât perioada de retenție specificată. Arhivele vor fi create înainte de ștergere.",
|
||||
"retentionDays": "Zile Retenție",
|
||||
"retentionHelp": "Minimum: 7 zile, Maximum: 365 zile",
|
||||
"performCleanup": "Efectuează Curățare",
|
||||
"integrityResults": "Rezultate Verificare Integritate",
|
||||
"verified": "Verificate",
|
||||
"tampered": "Compromise",
|
||||
"securityAlert": "Alertă de Securitate",
|
||||
"allLogsVerified": "Toate jurnalele au trecut verificarea integrității. Nu s-a detectat nicio manipulare."
|
||||
},
|
||||
"encryption": {
|
||||
"title": "Criptare Date",
|
||||
"subtitle": "Conformitate CWE-311: Criptare date sensibile cu AES-256-GCM",
|
||||
"status": "Stare Criptare",
|
||||
"secure": "Securizat",
|
||||
"defaultKey": "Cheie Implicită",
|
||||
"algorithm": "Algoritm",
|
||||
"keySize": "Dimensiune Cheie",
|
||||
"coverage": "Acoperire Criptare",
|
||||
"encrypted": "Criptat",
|
||||
"dataTypes": "Tipuri Date Sensibile",
|
||||
"dataType": "Tip Date",
|
||||
"total": "Total",
|
||||
"percentage": "Acoperire",
|
||||
"vpnConfigs": "Configurații VPN",
|
||||
"settings": "Setări Utilizator",
|
||||
"twoFactorSecrets": "Secrete 2FA",
|
||||
"apiTokens": "Tokenuri API",
|
||||
"actions": "Acțiuni Criptare",
|
||||
"scanButton": "Scanează Baza de Date",
|
||||
"migrateButton": "Migrează la Criptat",
|
||||
"verifyButton": "Verifică Integritatea",
|
||||
"actionsHelp": "Scanați pentru date sensibile necriptate, migrați la format criptat sau verificați integritatea criptării.",
|
||||
"scanResults": "Rezultate Scanare",
|
||||
"noIssuesFound": "Toate datele sensibile sunt criptate. Nu este necesară nicio acțiune.",
|
||||
"foundIssues": "S-au găsit {{count}} elemente necriptate care ar trebui protejate.",
|
||||
"unencryptedCount": "Elemente necriptate",
|
||||
"recommendation": "Recomandare",
|
||||
"migrateNow": "Migrează Acum",
|
||||
"confirmMigration": "Confirmă Migrarea Datelor",
|
||||
"migrationWarning": "Această operațiune va cripta toate datele sensibile necriptate. Acest proces este ireversibil.",
|
||||
"migrationDescription": "Procesul de migrare va:",
|
||||
"migrationStep1": "Scana baza de date pentru date sensibile necriptate",
|
||||
"migrationStep2": "Cripta fiecare element folosind AES-256-GCM cu IV-uri unice",
|
||||
"migrationStep3": "Actualiza baza de date cu valorile criptate",
|
||||
"migrating": "Se migrează...",
|
||||
"startMigration": "Pornește Migrarea",
|
||||
"migrationComplete": "Migrare finalizată cu succes. {{count}} elemente criptate.",
|
||||
"verificationResults": "Rezultate Verificare",
|
||||
"tested": "Testate",
|
||||
"valid": "Valide",
|
||||
"invalid": "Invalide",
|
||||
"invalidDataFound": "Unele date criptate nu au putut fi decriptate. Aceasta poate indica corupție.",
|
||||
"allDataValid": "Toate datele testate au fost decriptate cu succes.",
|
||||
"recommendations": "Recomandări Securitate",
|
||||
"fetchError": "Eșec la obținerea stării criptării",
|
||||
"scanError": "Eșec la scanarea bazei de date",
|
||||
"scanComplete": "Scanare bază de date finalizată",
|
||||
"issuesFound": "S-au găsit {{count}} probleme de securitate",
|
||||
"migrationError": "Migrare eșuată",
|
||||
"verifyError": "Verificare eșuată",
|
||||
"verifyComplete": "Verificare finalizată cu succes",
|
||||
"statusError": "Eșec la încărcarea stării criptării"
|
||||
},
|
||||
"rbac": {
|
||||
"dashboard": "Control Acces Bazat pe Roluri",
|
||||
"rolesAndPermissions": "Roluri & Permisiuni",
|
||||
"userRoles": "Roluri Utilizatori",
|
||||
"auditLog": "Jurnal Audit",
|
||||
"statistics": "Statistici",
|
||||
"myPermissions": "Permisiunile Mele",
|
||||
"roles": "Roluri",
|
||||
"createRole": "Creează Rol",
|
||||
"editRole": "Editează Rol",
|
||||
"roleName": "Nume Rol",
|
||||
"roleKey": "Cheie Rol",
|
||||
"roleKeyHelp": "Doar litere mici și underscore (ex: gestionar_continut)",
|
||||
"description": "Descriere",
|
||||
"permissions": "Permisiuni",
|
||||
"permissionsCount": "Permisiuni",
|
||||
"type": "Tip",
|
||||
"system": "Sistem",
|
||||
"custom": "Personalizat",
|
||||
"roleCreated": "Rol creat cu succes",
|
||||
"roleUpdated": "Rol actualizat cu succes",
|
||||
"roleDeleted": "Rol șters cu succes",
|
||||
"confirmDeleteRole": "Sigur doriți să ștergeți acest rol? Această acțiune nu poate fi anulată.",
|
||||
"selectPermissions": "Selectează Permisiuni",
|
||||
"selected": "selectate",
|
||||
"manageUserRoles": "Gestionează Roluri Utilizatori",
|
||||
"changeRole": "Schimbă Rol",
|
||||
"assignRole": "Atribuie Rol",
|
||||
"assigningRoleTo": "Atribuire rol pentru",
|
||||
"roleAssigned": "Rol atribuit cu succes",
|
||||
"permissionAuditLog": "Jurnal Audit Permisiuni",
|
||||
"filterByAction": "Filtrează după Acțiune",
|
||||
"target": "Țintă",
|
||||
"changes": "Modificări",
|
||||
"rbacStatistics": "Statistici RBAC",
|
||||
"roleDistribution": "Distribuție Roluri",
|
||||
"recentActions": "Acțiuni Recente (Ultimele 30 Zile)",
|
||||
"times": "ori",
|
||||
"totalPermissions": "Total Permisiuni",
|
||||
"totalRoles": "Total Roluri",
|
||||
"yourPermissions": "Permisiunile Tale",
|
||||
"yourRole": "Rolul Tău",
|
||||
"permissionsGranted": "Permisiuni Acordate",
|
||||
"permissionsList": "Listă Permisiuni"
|
||||
},
|
||||
"device": "Dispozitiv",
|
||||
"location": "Locație",
|
||||
"lastActive": "Ultima Activitate",
|
||||
"created": "Creat",
|
||||
"current": "Actual",
|
||||
"active": "Activ",
|
||||
"actions": "Acțiuni",
|
||||
"refresh": "Reîmprospătare",
|
||||
"days": "zile",
|
||||
"count": "Număr",
|
||||
"clear": "Șterge",
|
||||
"users": "Utilizatori",
|
||||
"all": "Toate",
|
||||
"apply": "Aplică",
|
||||
"assign": "Atribuie",
|
||||
"wireguard": {
|
||||
"title": "VPN WireGuard",
|
||||
"connected": "Conectat la VPN WireGuard",
|
||||
"disconnected": "Deconectat de la VPN WireGuard",
|
||||
"connect": "Conectează",
|
||||
"disconnect": "Deconectează",
|
||||
"configuration": "Configurație",
|
||||
"configFile": "Fișier de Configurare WireGuard",
|
||||
"pasteConfigHint": "Lipește fișierul tău complet de configurare WireGuard mai jos, sau introdu detaliile manual.",
|
||||
"or": "SAU",
|
||||
"privateKey": "Cheie Privată",
|
||||
"serverPublicKey": "Cheie Publică Server",
|
||||
"serverEndpoint": "Endpoint Server",
|
||||
"clientAddress": "Adresă Client",
|
||||
"dnsServer": "Server DNS",
|
||||
"saveSettings": "Salvează Setările",
|
||||
"settingsSaved": "Setările au fost salvate cu succes!",
|
||||
"saveFailed": "Salvarea setărilor a eșuat",
|
||||
"saveError": "Eroare la salvarea setărilor",
|
||||
"connectFailed": "Conectarea a eșuat",
|
||||
"connectError": "Eroare la conectarea la VPN",
|
||||
"disconnectFailed": "Deconectarea a eșuat",
|
||||
"disconnectError": "Eroare la deconectarea de la VPN",
|
||||
"configParsed": "Configurația a fost parsată cu succes!",
|
||||
"publicIP": "IP Public",
|
||||
"interface": "Interfață",
|
||||
"checking": "Verificare...",
|
||||
"status": {
|
||||
"connected": "Conectat",
|
||||
"disconnected": "Deconectat"
|
||||
}
|
||||
},
|
||||
"vpnConfig": {
|
||||
"title": "Configurații VPN",
|
||||
"uploadConfig": "Încarcă Configurație",
|
||||
"configName": "Nume Configurație",
|
||||
"nameHelper": "Un nume prietenos pentru a identifica această configurație",
|
||||
"nameRequired": "Numele configurației este obligatoriu",
|
||||
"selectFile": "Selectează fișier .conf sau .ovpn",
|
||||
"fileTypes": "Acceptate: Fișiere de configurare WireGuard (.conf) și OpenVPN (.ovpn)",
|
||||
"invalidFileType": "Doar fișierele .conf și .ovpn sunt acceptate",
|
||||
"upload": "Încarcă",
|
||||
"uploadSuccess": "Configurația a fost încărcată cu succes!",
|
||||
"uploadFailed": "Încărcarea configurației a eșuat",
|
||||
"uploadError": "Eroare la încărcarea configurației",
|
||||
"loadFailed": "Încărcarea configurațiilor a eșuat",
|
||||
"noConfigs": "Nu există încă configurații VPN",
|
||||
"uploadFirst": "Încarcă primul tău fișier de configurare VPN pentru a începe",
|
||||
"infoText": "Încarcă fișiere de configurare VPN (.conf pentru WireGuard, .ovpn pentru OpenVPN) pentru a gestiona ușor multiple conexiuni VPN.",
|
||||
"active": "Activ",
|
||||
"connect": "Conectează",
|
||||
"connecting": "Se conectează...",
|
||||
"connected": "Conectat la VPN cu succes!",
|
||||
"connectFailed": "Conectarea a eșuat",
|
||||
"connectError": "Eroare la conectarea la VPN",
|
||||
"dockerLimitationWarning": "Conexiunile VPN necesită modul de rețea gazdă și nu sunt suportate în containerele Docker. Te rugăm să folosești aplicația desktop pentru funcționalitatea VPN.",
|
||||
"disconnect": "Deconectează",
|
||||
"disconnecting": "Se deconectează...",
|
||||
"disconnected": "Deconectat de la VPN cu succes!",
|
||||
"disconnectFailed": "Deconectarea a eșuat",
|
||||
"disconnectError": "Eroare la deconectarea de la VPN",
|
||||
"deleteConfirmTitle": "Ștergi Configurația?",
|
||||
"deleteConfirmText": "Ești sigur că vrei să ștergi această configurație VPN? Această acțiune nu poate fi anulată.",
|
||||
"deleteSuccess": "Configurația a fost ștearsă cu succes",
|
||||
"deleteFailed": "Ștergerea configurației a eșuat",
|
||||
"deleteError": "Eroare la ștergerea configurației"
|
||||
},
|
||||
"errors": {
|
||||
"general": {
|
||||
"unexpected": "A apărut o eroare neașteptată",
|
||||
"tryAgain": "Vă rugăm să încercați din nou",
|
||||
"contactSupport": "Dacă problema persistă, vă rugăm să contactați suportul"
|
||||
},
|
||||
"network": {
|
||||
"title": "Eroare de Rețea",
|
||||
"message": "Eroare de rețea. Verificați conexiunea și încercați din nou.",
|
||||
"timeout": "Timeout la cerere. Vă rugăm să încercați din nou.",
|
||||
"offline": "Păreți să fiți offline. Verificați conexiunea la internet."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Eroare de Autentificare",
|
||||
"required": "Autentificare necesară. Vă rugăm să vă autentificați din nou.",
|
||||
"invalid": "Token de autentificare invalid sau expirat.",
|
||||
"failed": "Autentificarea a eșuat. Verificați credențialele.",
|
||||
"sessionExpired": "Sesiunea dvs. a expirat. Vă rugăm să vă autentificați din nou."
|
||||
},
|
||||
"permission": {
|
||||
"title": "Permisiune Refuzată",
|
||||
"message": "Nu aveți permisiunea de a efectua această acțiune.",
|
||||
"adminRequired": "Această acțiune necesită privilegii de administrator.",
|
||||
"contactAdmin": "Vă rugăm să contactați un administrator pentru acces."
|
||||
},
|
||||
"validation": {
|
||||
"title": "Eroare de Validare",
|
||||
"invalidInput": "Date invalide. Verificați datele introduse.",
|
||||
"requiredField": "Acest câmp este obligatoriu.",
|
||||
"invalidFormat": "Format invalid. Verificați datele introduse.",
|
||||
"tooLarge": "Fișierul sau datele sunt prea mari.",
|
||||
"tooSmall": "Valoarea este prea mică."
|
||||
},
|
||||
"server": {
|
||||
"title": "Eroare de Server",
|
||||
"message": "Eroare de server. Încercați din nou mai târziu.",
|
||||
"unavailable": "Serviciu temporar indisponibil. Încercați din nou mai târziu.",
|
||||
"maintenance": "Serverul este în prezent în mentenanță.",
|
||||
"overloaded": "Serverul este momentan supraîncărcat. Încercați din nou în câteva momente."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Nu a Fost Găsit",
|
||||
"message": "Resursa solicitată nu a fost găsită.",
|
||||
"page": "Pagină negăsită",
|
||||
"resource": "Resursă negăsită"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "Conflict",
|
||||
"message": "Această operațiune este în conflict cu datele existente.",
|
||||
"duplicate": "Acest element există deja.",
|
||||
"outdated": "Datele au fost modificate de un alt utilizator. Reîmprospătați și încercați din nou."
|
||||
},
|
||||
"rateLimit": {
|
||||
"title": "Prea Multe Cereri",
|
||||
"message": "Prea multe cereri. Așteptați un moment și încercați din nou.",
|
||||
"slowDown": "Vă rugăm să încetiniți și să încercați din nou în câteva secunde."
|
||||
}
|
||||
},
|
||||
"siem": {
|
||||
"title": "Inteligență de Securitate",
|
||||
"threatScore": "Scor Amenințări",
|
||||
"alerts": "Alerte",
|
||||
"anomalies": "Anomalii",
|
||||
"threats": "Inteligență Amenințări",
|
||||
"logs": "Jurnale Securitate",
|
||||
"severity": "Severitate",
|
||||
"level": "Nivel",
|
||||
"source": "Sursă",
|
||||
"category": "Categorie",
|
||||
"message": "Mesaj",
|
||||
"time": "Timp",
|
||||
"type": "Tip",
|
||||
"description": "Descriere",
|
||||
"confidence": "Încredere",
|
||||
"indicator": "Indicator",
|
||||
"threatLevel": "Nivel Amenințare",
|
||||
"occurrences": "Apariții",
|
||||
"lastSeen": "Ultima Observare",
|
||||
"verifyIntegrity": "Verifică Integritatea",
|
||||
"alertAcknowledged": "Alertă confirmată cu succes",
|
||||
"alertAcknowledgeFailed": "Eșec la confirmarea alertei",
|
||||
"alertResolved": "Alertă rezolvată cu succes",
|
||||
"alertResolveFailed": "Eșec la rezolvarea alertei",
|
||||
"anomalyResolved": "Anomalie rezolvată cu succes",
|
||||
"anomalyResolveFailed": "Eșec la rezolvarea anomaliei",
|
||||
"exportSuccess": "Jurnale exportate cu succes",
|
||||
"exportFailed": "Eșec la exportarea jurnalelor",
|
||||
"integrityVerified": "Integritate jurnal verificată: {{verified}} jurnale validate",
|
||||
"integrityCompromised": "ATENȚIE: {{tampered}} din {{total}} jurnale au fost compromise!",
|
||||
"integrityCheckFailed": "Eșec la verificarea integrității jurnalelor",
|
||||
"acknowledge": "Confirmă",
|
||||
"resolve": "Rezolvă",
|
||||
"viewDetails": "Vezi Detalii",
|
||||
"alertDetails": "Detalii Alertă",
|
||||
"anomalyDetails": "Detalii Anomalie",
|
||||
"resolutionNotes": "Note Rezolvare",
|
||||
"resolutionNotesPlaceholder": "Introduceți note de rezolvare...",
|
||||
"criticalAnomalies": "Anomalii Critice",
|
||||
"highAnomalies": "Anomalii Prioritate Înaltă",
|
||||
"mediumAnomalies": "Anomalii Prioritate Medie",
|
||||
"lowAnomalies": "Anomalii Prioritate Scăzută"
|
||||
},
|
||||
"securityConfig": {
|
||||
"title": "Configurare Securitate",
|
||||
"thresholds": "Praguri de Notificare",
|
||||
"signatures": "Semnături de Risc",
|
||||
"protocols": "Protocoale de Răspuns",
|
||||
"enabled": "Activat",
|
||||
"disabled": "Dezactivat",
|
||||
"autoBlock": "Blocare Automată",
|
||||
"autoExecute": "Executare Automată",
|
||||
"addThreshold": "Adaugă Prag",
|
||||
"editThreshold": "Editează Prag",
|
||||
"addSignature": "Adaugă Semnătură",
|
||||
"editSignature": "Editează Semnătură",
|
||||
"addProtocol": "Adaugă Protocol",
|
||||
"editProtocol": "Editează Protocol",
|
||||
"name": "Nume",
|
||||
"description": "Descriere",
|
||||
"patternType": "Tip Model",
|
||||
"metricName": "Nume Metrică",
|
||||
"operator": "Operator",
|
||||
"thresholdValue": "Valoare Prag",
|
||||
"timeWindow": "Fereastră Timp (minute)",
|
||||
"severity": "Severitate",
|
||||
"signatureType": "Tip Semnătură",
|
||||
"matchType": "Tip Potrivire",
|
||||
"pattern": "Model",
|
||||
"patternHelp": "Introduceți model regex sau șir de potrivire",
|
||||
"threatLevel": "Nivel Amenințare",
|
||||
"confidence": "Încredere",
|
||||
"triggerType": "Tip Declanșator",
|
||||
"actionsCount": "Acțiuni",
|
||||
"actions": "acțiuni",
|
||||
"cooldown": "Timp Răcire",
|
||||
"cooldownMinutes": "Timp Răcire (minute)",
|
||||
"condition": "Condiție",
|
||||
"status": "Status",
|
||||
"thresholdSaved": "Prag salvat cu succes",
|
||||
"signatureSaved": "Semnătură salvată cu succes",
|
||||
"protocolSaved": "Protocol salvat cu succes",
|
||||
"deleted": "Șters cu succes",
|
||||
"deleteWarning": "Sigur doriți să ștergeți acest element? Această acțiune nu poate fi anulată.",
|
||||
"protocolWarning": "Protocoalele de răspuns sunt instrumente puternice de automatizare. Configurați-le cu atenție pentru a evita consecințe nedorite."
|
||||
}
|
||||
}
|
||||
63
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { ErrorNotificationProvider } from './components/ErrorNotificationProvider';
|
||||
import './i18n';
|
||||
import './index.css';
|
||||
import './api/axiosConfig'; // Setup axios interceptors
|
||||
|
||||
// Global error handlers (CWE-391 protection)
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
event.preventDefault(); // Prevent default browser console error
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
}
|
||||
|
||||
// Log to error tracking service in production
|
||||
// trackError(event.reason);
|
||||
});
|
||||
|
||||
// Handle global errors
|
||||
window.addEventListener('error', (event) => {
|
||||
event.preventDefault(); // Prevent default browser console error
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Global error:', event.error || event.message);
|
||||
}
|
||||
|
||||
// Log to error tracking service in production
|
||||
// trackError(event.error || event.message);
|
||||
});
|
||||
|
||||
// Register Service Worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then((registration) => {
|
||||
// Service worker registered successfully
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('SW registered:', registration);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// Service worker registration failed - log only in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('SW registration failed:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ErrorNotificationProvider>
|
||||
<App />
|
||||
</ErrorNotificationProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
52
frontend/src/pages/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box } from '@mui/material';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import Header from '../components/Header';
|
||||
|
||||
function Dashboard() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const handleMenuClick = () => {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
};
|
||||
|
||||
const handleSidebarClose = () => {
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
minHeight: '100vh',
|
||||
minHeight: '-webkit-fill-available',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Sidebar open={sidebarOpen} onClose={handleSidebarClose} />
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
width: { xs: '100%', md: 'calc(100% - 200px)' },
|
||||
height: '100%'
|
||||
}}>
|
||||
<Header onMenuClick={handleMenuClick} />
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
p: { xs: 1, sm: 2 },
|
||||
height: '100%'
|
||||
}}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
190
frontend/src/pages/Favorites.jsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
Grid,
|
||||
IconButton,
|
||||
Chip,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { PlayArrow, Delete, Favorite as FavoriteIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import Logo from '../components/Logo';
|
||||
|
||||
const Favorites = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { type = 'tv' } = useParams(); // tv or radio
|
||||
const { token } = useAuthStore();
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFavorites();
|
||||
}, [type]);
|
||||
|
||||
const fetchFavorites = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`/api/favorites?isRadio=${type === 'radio'}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFavorites(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch favorites:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFavorite = async (channelId) => {
|
||||
try {
|
||||
await axios.delete(`/api/favorites/${channelId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFavorites(favorites.filter(fav => fav.id !== channelId));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = (channel) => {
|
||||
if (type === 'radio') {
|
||||
navigate('/radio', { state: { autoPlayChannel: channel } });
|
||||
} else {
|
||||
navigate('/live-tv', { state: { autoPlayChannel: channel } });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<FavoriteIcon sx={{ color: 'primary.main', fontSize: 32 }} />
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{type === 'radio' ? t('favorite_radio') : t('favorite_tv')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${favorites.length} ${favorites.length === 1 ? 'channel' : 'channels'}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{favorites.length === 0 ? (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
No favorite {type === 'radio' ? 'radio stations' : 'TV channels'} yet.
|
||||
Start adding channels to your favorites from the {type === 'radio' ? 'Radio' : 'Live TV'} page!
|
||||
</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{favorites.map((channel) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={channel.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 4
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Remove Favorite Button */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRemoveFavorite(channel.id)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: 'error.main',
|
||||
zIndex: 1,
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.8)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<CardMedia
|
||||
sx={{
|
||||
height: 140,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{channel.logo ? (
|
||||
<img
|
||||
src={channel.logo.startsWith('http') ? channel.logo : `/logos/${channel.logo}`}
|
||||
alt={channel.name}
|
||||
style={{ maxWidth: '80%', maxHeight: '80%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Logo size={60} />
|
||||
)}
|
||||
</CardMedia>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1, pb: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight="bold"
|
||||
noWrap
|
||||
title={channel.name}
|
||||
>
|
||||
{channel.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{channel.group_name || 'No Category'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<Box sx={{ p: 1, pt: 0, textAlign: 'center' }}>
|
||||
<IconButton
|
||||
onClick={() => handlePlay(channel)}
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark'
|
||||
},
|
||||
width: '100%',
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
<PlayArrow />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Favorites;
|
||||
|
||||
531
frontend/src/pages/LiveTV.jsx
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Box, Grid, Typography, Chip, CircularProgress, Card, CardMedia, CardContent, Button, IconButton, ToggleButton, ToggleButtonGroup, Menu, MenuItem, ListItemIcon, ListItemText, Dialog, DialogTitle, DialogContent, DialogActions, DialogContentText } from '@mui/material';
|
||||
import { Add, Refresh, Edit, Tv, GridView, Favorite, FavoriteBorder, Star, StarBorder, Delete } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { getProxiedLogoUrl } from '../utils/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import VideoPlayer from '../components/VideoPlayer';
|
||||
import Logo from '../components/Logo';
|
||||
import ChannelLogoManager from '../components/ChannelLogoManager';
|
||||
import MultiScreen from '../components/MultiScreen';
|
||||
import { showChannelNotification, requestNotificationPermission } from '../utils/notifications';
|
||||
|
||||
function LiveTV() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { token } = useAuthStore();
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedChannel, setSelectedChannel] = useState(null);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [logoManagerOpen, setLogoManagerOpen] = useState(false);
|
||||
const [channelToEdit, setChannelToEdit] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('single'); // 'single' or 'multi'
|
||||
const [favorites, setFavorites] = useState(new Set());
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [contextChannel, setContextChannel] = useState(null);
|
||||
const [longPressTimer, setLongPressTimer] = useState(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [channelToDelete, setChannelToDelete] = useState(null);
|
||||
const playerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchChannels();
|
||||
fetchFavorites();
|
||||
// Request notification permission
|
||||
requestNotificationPermission();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const fetchFavorites = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/favorites?isRadio=false', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const favIds = new Set(response.data.map(fav => fav.id));
|
||||
setFavorites(favIds);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch favorites:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (channelId, event) => {
|
||||
if (event) event.stopPropagation();
|
||||
const isFavorite = favorites.has(channelId);
|
||||
|
||||
try {
|
||||
if (isFavorite) {
|
||||
await axios.delete(`/api/favorites/${channelId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFavorites(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(channelId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
await axios.post(`/api/favorites/${channelId}`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFavorites(prev => new Set([...prev, channelId]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event, channel) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
mouseX: event.clientX - 2,
|
||||
mouseY: event.clientY - 4,
|
||||
});
|
||||
setContextChannel(channel);
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
setContextChannel(null);
|
||||
};
|
||||
|
||||
const handleContextFavorite = () => {
|
||||
if (contextChannel) {
|
||||
handleToggleFavorite(contextChannel.id, null);
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async () => {
|
||||
if (!channelToDelete) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/channels/${channelToDelete.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
// Remove from channels list
|
||||
setChannels(prev => prev.filter(ch => ch.id !== channelToDelete.id));
|
||||
|
||||
// Remove from favorites if present
|
||||
setFavorites(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(channelToDelete.id);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Clear selected channel if it was deleted
|
||||
if (selectedChannel?.id === channelToDelete.id) {
|
||||
setSelectedChannel(channels.find(ch => ch.id !== channelToDelete.id) || null);
|
||||
}
|
||||
|
||||
setDeleteDialogOpen(false);
|
||||
setChannelToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel:', error);
|
||||
alert('Failed to delete channel. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextDelete = () => {
|
||||
if (contextChannel) {
|
||||
setChannelToDelete(contextChannel);
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleLongPressStart = (channel) => {
|
||||
const timer = setTimeout(() => {
|
||||
// Vibrate if supported (for mobile)
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
setContextChannel(channel);
|
||||
setContextMenu({ mouseX: 0, mouseY: 0 }); // Show menu in center for touch
|
||||
}, 500); // 500ms for long press
|
||||
setLongPressTimer(timer);
|
||||
};
|
||||
|
||||
const handleLongPressEnd = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
setLongPressTimer(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle auto-play from search
|
||||
useEffect(() => {
|
||||
if (location.state?.autoPlayChannel && channels.length > 0) {
|
||||
const channel = channels.find(ch => ch.id === location.state.autoPlayChannel.id);
|
||||
if (channel) {
|
||||
setSelectedChannel(channel);
|
||||
setViewMode('single');
|
||||
// Show notification for now playing
|
||||
showChannelNotification(channel, 'tv');
|
||||
// Scroll to player
|
||||
setTimeout(() => {
|
||||
playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
// Clear the state
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, channels, navigate, location.pathname]);
|
||||
|
||||
// Handle group selection from search
|
||||
useEffect(() => {
|
||||
if (location.state?.selectedGroup) {
|
||||
setFilter(location.state.selectedGroup);
|
||||
// Clear the state
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, navigate, location.pathname]);
|
||||
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/channels?isRadio=false', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setChannels(response.data);
|
||||
if (response.data.length > 0) {
|
||||
setSelectedChannel(response.data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channels:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (newFilter) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
const filteredChannels = channels.filter((channel) => {
|
||||
if (filter === 'all') return true;
|
||||
return channel.group_name?.toLowerCase().includes(filter.toLowerCase());
|
||||
});
|
||||
|
||||
const handleChannelClick = (channel) => {
|
||||
setSelectedChannel(channel);
|
||||
// Show notification for now playing
|
||||
showChannelNotification(channel, 'tv');
|
||||
// Auto-scroll to player after brief delay for state update
|
||||
setTimeout(() => {
|
||||
playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleLogoEdit = (channel, event) => {
|
||||
event.stopPropagation();
|
||||
setChannelToEdit(channel);
|
||||
setLogoManagerOpen(true);
|
||||
};
|
||||
|
||||
const handleLogoUpdated = (updatedChannel) => {
|
||||
// Update channels list
|
||||
setChannels(channels.map(ch =>
|
||||
ch.id === updatedChannel.id ? updatedChannel : ch
|
||||
));
|
||||
// Update selected channel if it's the one being edited
|
||||
if (selectedChannel?.id === updatedChannel.id) {
|
||||
setSelectedChannel(updatedChannel);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('live_tv')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(e, newMode) => newMode && setViewMode(newMode)}
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<ToggleButton value="single">
|
||||
<Tv fontSize="small" sx={{ mr: 0.5 }} />
|
||||
Single
|
||||
</ToggleButton>
|
||||
<ToggleButton value="multi">
|
||||
<GridView fontSize="small" sx={{ mr: 0.5 }} />
|
||||
Multi
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<IconButton size="small" onClick={fetchChannels}>
|
||||
<Refresh fontSize="small" />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
{t('add_playlist')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{viewMode === 'multi' ? (
|
||||
<MultiScreen channels={filteredChannels} />
|
||||
) : (
|
||||
<>
|
||||
{selectedChannel && (
|
||||
<Box ref={playerRef} sx={{ mb: 2, scrollMarginTop: '20px', position: 'relative' }}>
|
||||
<VideoPlayer channel={selectedChannel} />
|
||||
{/* Favorite Star Button for Playing Channel */}
|
||||
<IconButton
|
||||
onClick={() => handleToggleFavorite(selectedChannel.id, null)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.7)',
|
||||
color: favorites.has(selectedChannel.id) ? 'warning.main' : 'white',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.9)',
|
||||
transform: 'scale(1.1)'
|
||||
},
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{favorites.has(selectedChannel.id) ? (
|
||||
<Star fontSize="large" />
|
||||
) : (
|
||||
<StarBorder fontSize="large" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 2, display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
{['all', 'news', 'sports', 'movies', 'kids'].map((filterOption) => (
|
||||
<Chip
|
||||
key={filterOption}
|
||||
label={t(filterOption)}
|
||||
size="small"
|
||||
onClick={() => handleFilterChange(filterOption)}
|
||||
color={filter === filterOption ? 'primary' : 'default'}
|
||||
sx={{ textTransform: 'capitalize', fontSize: '0.75rem' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={1.5}>
|
||||
{filteredChannels.map((channel) => (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2} key={channel.id}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 4
|
||||
},
|
||||
'&:hover .logo-edit-btn': {
|
||||
opacity: 1
|
||||
},
|
||||
border: selectedChannel?.id === channel.id ? 2 : 0,
|
||||
borderColor: 'primary.main'
|
||||
}}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
onContextMenu={(e) => handleContextMenu(e, channel)}
|
||||
onTouchStart={() => handleLongPressStart(channel)}
|
||||
onTouchEnd={handleLongPressEnd}
|
||||
onTouchMove={handleLongPressEnd}
|
||||
onMouseDown={() => handleLongPressStart(channel)}
|
||||
onMouseUp={handleLongPressEnd}
|
||||
onMouseLeave={handleLongPressEnd}
|
||||
>
|
||||
{/* Favorite Button */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => handleToggleFavorite(channel.id, e)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 4,
|
||||
zIndex: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: favorites.has(channel.id) ? 'error.main' : 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }
|
||||
}}
|
||||
>
|
||||
{favorites.has(channel.id) ? (
|
||||
<Favorite fontSize="small" />
|
||||
) : (
|
||||
<FavoriteBorder fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Edit Logo Button */}
|
||||
<IconButton
|
||||
className="logo-edit-btn"
|
||||
size="small"
|
||||
onClick={(e) => handleLogoEdit(channel, e)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
zIndex: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }
|
||||
}}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
height: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default',
|
||||
p: 1
|
||||
}}
|
||||
>
|
||||
{channel.logo ? (
|
||||
<>
|
||||
<Box
|
||||
component="img"
|
||||
src={getProxiedLogoUrl(channel.logo)}
|
||||
alt={channel.name}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block'
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.log(`[Logo] Failed to load: ${channel.logo}`);
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextElementSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'none', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Logo size={56} />
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Logo size={56} />
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="caption" fontWeight="600" noWrap display="block">
|
||||
{channel.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap display="block" fontSize="0.7rem">
|
||||
{channel.group_name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 6 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{t('no_channels')}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/settings')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{t('add_playlist')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Channel Logo Manager Dialog */}
|
||||
<ChannelLogoManager
|
||||
open={logoManagerOpen}
|
||||
onClose={() => setLogoManagerOpen(false)}
|
||||
channel={channelToEdit}
|
||||
onLogoUpdated={handleLogoUpdated}
|
||||
/>
|
||||
|
||||
{/* Context Menu for Favorites */}
|
||||
<Menu
|
||||
open={contextMenu !== null}
|
||||
onClose={handleCloseContextMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
contextMenu !== null
|
||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MenuItem onClick={handleContextFavorite}>
|
||||
<ListItemIcon>
|
||||
{contextChannel && favorites.has(contextChannel.id) ? (
|
||||
<Favorite fontSize="small" color="error" />
|
||||
) : (
|
||||
<FavoriteBorder fontSize="small" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{contextChannel && favorites.has(contextChannel.id)
|
||||
? 'Remove from Favorites'
|
||||
: 'Add to Favorites'}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleContextDelete}>
|
||||
<ListItemIcon>
|
||||
<Delete fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete Channel</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Delete Channel</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete <strong>{channelToDelete?.name}</strong>? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDeleteChannel} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveTV;
|
||||
511
frontend/src/pages/Login.jsx
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff, PersonOutline, LockOutlined, ArrowForward } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
||||
import { useErrorNotification } from '../components/ErrorNotificationProvider';
|
||||
|
||||
function Login() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const mustChangePassword = useAuthStore((state) => state.mustChangePassword);
|
||||
const { showError, showWarning, showSuccess } = useErrorNotification();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [require2FA, setRequire2FA] = useState(false);
|
||||
const [tempToken, setTempToken] = useState('');
|
||||
const [twoFACode, setTwoFACode] = useState('');
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', formData);
|
||||
|
||||
// Check if account is locked
|
||||
if (response.data.locked) {
|
||||
showError(
|
||||
new Error(t('security.accountLockedMsg') + ' ' + t('security.remainingTime', { minutes: response.data.remainingMinutes })),
|
||||
{ title: t('errors.auth.title'), duration: 8000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if password has expired
|
||||
if (response.data.passwordExpired) {
|
||||
showError(
|
||||
new Error(t('security.passwordExpiredMsg')),
|
||||
{ title: t('errors.auth.title'), duration: 8000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
if (response.data.require2FA) {
|
||||
setRequire2FA(true);
|
||||
setTempToken(response.data.tempToken);
|
||||
// Show password warning if present
|
||||
if (response.data.passwordWarning) {
|
||||
showWarning(response.data.passwordWarning, {
|
||||
title: t('errors.auth.title'),
|
||||
duration: 10000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
login(response.data.user, response.data.token);
|
||||
|
||||
// Show password expiry warning if present
|
||||
if (response.data.passwordWarning) {
|
||||
showWarning(response.data.passwordWarning, {
|
||||
title: t('errors.auth.title'),
|
||||
duration: 10000
|
||||
});
|
||||
} else {
|
||||
// Show success message only if no warnings
|
||||
showSuccess(t('login.success') || 'Login successful', {
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
// If password change is not required, navigate to home
|
||||
if (!response.data.user.must_change_password) {
|
||||
navigate('/');
|
||||
}
|
||||
// Otherwise, ChangePasswordDialog will show automatically
|
||||
} catch (err) {
|
||||
// Handle account lockout
|
||||
if (err.response?.status === 423) {
|
||||
showError(err, {
|
||||
title: t('errors.auth.title'),
|
||||
defaultMessage: err.response.data.error + ' ' + t('security.remainingTime', { minutes: err.response.data.remainingMinutes }),
|
||||
duration: 8000
|
||||
});
|
||||
}
|
||||
// Handle password expiry
|
||||
else if (err.response?.status === 403 && err.response.data.passwordExpired) {
|
||||
showError(err, {
|
||||
title: t('errors.auth.title'),
|
||||
defaultMessage: t('security.passwordExpiredMsg'),
|
||||
duration: 8000
|
||||
});
|
||||
}
|
||||
// Handle other errors
|
||||
else {
|
||||
showError(err, {
|
||||
title: t('errors.auth.title'),
|
||||
defaultMessage: t('error') || 'Login failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify2FA = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/verify-2fa', {
|
||||
tempToken,
|
||||
code: twoFACode
|
||||
});
|
||||
|
||||
login(response.data.user, response.data.token);
|
||||
|
||||
showSuccess(t('twoFactor.verifySuccess') || '2FA verification successful', {
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
// If password change is not required, navigate to home
|
||||
if (!response.data.user.must_change_password) {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err, {
|
||||
title: t('twoFactor.verifyFailed') || '2FA Verification Failed',
|
||||
defaultMessage: t('twoFactor.invalidCode') || 'Invalid verification code'
|
||||
});
|
||||
setTwoFACode('');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-submit when code is complete
|
||||
useEffect(() => {
|
||||
if (require2FA && twoFACode.length === (useBackupCode ? 8 : 6)) {
|
||||
handleVerify2FA();
|
||||
}
|
||||
}, [twoFACode, require2FA, useBackupCode]);
|
||||
|
||||
const handlePasswordChanged = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
background: '#000000'
|
||||
}}
|
||||
>
|
||||
{/* Left Side - Black with Logo */}
|
||||
<Grid container sx={{ height: '100vh' }}>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={6}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#000000',
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="200"
|
||||
height="200"
|
||||
viewBox="0 0 192 192"
|
||||
style={{ marginBottom: '20px' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="loginGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#3b82f6', stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M 72 53 L 72 139 L 139 96 Z"
|
||||
fill="url(#loginGrad)"
|
||||
opacity="0.95"
|
||||
/>
|
||||
<path
|
||||
d="M 96 96 C 96 96, 80 83, 72 74 C 64 65, 56 55, 56 53 L 144 96 C 144 96, 141 101, 132 110 C 123 119, 111 131, 98 139"
|
||||
fill="none"
|
||||
stroke="url(#loginGrad)"
|
||||
strokeWidth="4"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px'
|
||||
}}
|
||||
>
|
||||
StreamFlow
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Right Side - Light Grey with Login Form */}
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={6}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%)',
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 450,
|
||||
width: '100%',
|
||||
px: 4
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#2d3748',
|
||||
fontWeight: 700,
|
||||
mb: 4
|
||||
}}
|
||||
>
|
||||
{t('login.loginHere')}
|
||||
</Typography>
|
||||
|
||||
{!require2FA ? (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Username Field */}
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder={t('username')}
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
autoFocus
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '12px',
|
||||
backgroundColor: '#f7fafc',
|
||||
'& fieldset': {
|
||||
borderColor: '#e2e8f0'
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#cbd5e0'
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#a855f7'
|
||||
}
|
||||
},
|
||||
'& .MuiOutlinedInput-input': {
|
||||
color: '#2d3748',
|
||||
'&::placeholder': {
|
||||
color: '#a0aec0',
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<PersonOutline sx={{ color: '#718096' }} />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder={t('password')}
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '12px',
|
||||
backgroundColor: '#f7fafc',
|
||||
'& fieldset': {
|
||||
borderColor: '#e2e8f0'
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#cbd5e0'
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#a855f7'
|
||||
}
|
||||
},
|
||||
'& .MuiOutlinedInput-input': {
|
||||
color: '#2d3748',
|
||||
'&::placeholder': {
|
||||
color: '#a0aec0',
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<LockOutlined sx={{ color: '#718096' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
sx={{ color: '#718096' }}
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Remember Password */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
size="small"
|
||||
sx={{
|
||||
color: '#cbd5e0',
|
||||
'&.Mui-checked': {
|
||||
color: '#a855f7'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" sx={{ color: '#718096', fontSize: '0.875rem' }}>
|
||||
{t('remember_me')}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{/* Login Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
endIcon={<ArrowForward />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderRadius: '12px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
||||
color: '#ffffff',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #1a202c 0%, #000000 100%)',
|
||||
boxShadow: '0 6px 12px rgba(0, 0, 0, 0.15)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('login.login')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerify2FA}>
|
||||
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{useBackupCode ? t('twoFactor.enterBackupCode') : t('twoFactor.enterCode')}
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder={useBackupCode ? t('twoFactor.backupCodePlaceholder') : t('twoFactor.codePlaceholder')}
|
||||
value={twoFACode}
|
||||
onChange={(e) => setTwoFACode(useBackupCode ? e.target.value.toUpperCase() : e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
required
|
||||
autoFocus
|
||||
inputProps={{
|
||||
maxLength: useBackupCode ? 8 : 6,
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
fontSize: useBackupCode ? '18px' : '24px',
|
||||
letterSpacing: useBackupCode ? '4px' : '8px',
|
||||
fontFamily: 'monospace',
|
||||
color: '#2d3748'
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '12px',
|
||||
backgroundColor: '#f7fafc',
|
||||
'& fieldset': {
|
||||
borderColor: '#e2e8f0'
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#cbd5e0'
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#a855f7'
|
||||
}
|
||||
},
|
||||
'& .MuiOutlinedInput-input': {
|
||||
color: '#2d3748'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => { setUseBackupCode(!useBackupCode); setTwoFACode(''); }}
|
||||
sx={{ mb: 3, color: '#718096', textTransform: 'none' }}
|
||||
>
|
||||
{useBackupCode ? t('twoFactor.useAuthenticatorCode') : t('twoFactor.useBackupCode')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={useBackupCode ? twoFACode.length !== 8 : twoFACode.length !== 6}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderRadius: '12px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
||||
color: '#ffffff',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #1a202c 0%, #000000 100%)',
|
||||
boxShadow: '0 6px 12px rgba(0, 0, 0, 0.15)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('twoFactor.verify')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => { setRequire2FA(false); setTwoFACode(''); setUseBackupCode(false); }}
|
||||
sx={{
|
||||
mt: 2,
|
||||
color: '#718096',
|
||||
textTransform: 'none'
|
||||
}}
|
||||
>
|
||||
{t('twoFactor.backToLogin')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Show password change dialog if required */}
|
||||
{mustChangePassword && (
|
||||
<ChangePasswordDialog onPasswordChanged={handlePasswordChanged} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
44
frontend/src/pages/Movies.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import { Box, Container, Typography, Button, Grid, Card, CardMedia, CardContent, Chip } from '@mui/material';
|
||||
import { Settings, PlayArrow } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Logo from '../components/Logo';
|
||||
|
||||
const Movies = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5">{t('movies')}</Typography>
|
||||
<Button size="small" startIcon={<Settings fontSize="small" />} onClick={() => navigate('/settings')}>
|
||||
Settings
|
||||
</Button>
|
||||
</Box>
|
||||
<Grid container spacing={1.5}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2} key={i}>
|
||||
<Card sx={{ cursor: 'pointer', '&:hover': { transform: 'scale(1.02)' }, transition: 'transform 0.2s' }}>
|
||||
<Box sx={{ height: 180, bgcolor: 'background.default', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Logo size={80} />
|
||||
</Box>
|
||||
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>Movie Title {i}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">2024 • 120 min</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip label="Action" size="small" sx={{ height: 20, fontSize: '0.65rem' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Movies;
|
||||
605
frontend/src/pages/Radio.jsx
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Box, Grid, Typography, Chip, CircularProgress, Card, CardMedia, CardContent, Button, IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Dialog, DialogTitle, DialogContent, DialogActions, DialogContentText } from '@mui/material';
|
||||
import { Add, Refresh, Edit, Favorite, FavoriteBorder, Star, StarBorder, Delete } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { getProxiedLogoUrl } from '../utils/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import AudioPlayer from '../components/AudioPlayer';
|
||||
import Logo from '../components/Logo';
|
||||
import ChannelLogoManager from '../components/ChannelLogoManager';
|
||||
import { showChannelNotification, requestNotificationPermission } from '../utils/notifications';
|
||||
|
||||
const Radio = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { token } = useAuthStore();
|
||||
const playerRef = useRef(null);
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedChannel, setSelectedChannel] = useState(null);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [logoManagerOpen, setLogoManagerOpen] = useState(false);
|
||||
const [channelToEdit, setChannelToEdit] = useState(null);
|
||||
const [favorites, setFavorites] = useState(new Set());
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [contextChannel, setContextChannel] = useState(null);
|
||||
const [longPressTimer, setLongPressTimer] = useState(null);
|
||||
const [nowPlaying, setNowPlaying] = useState(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [channelToDelete, setChannelToDelete] = useState(null);
|
||||
const metadataIntervalRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchChannels();
|
||||
fetchFavorites();
|
||||
// Request notification permission
|
||||
requestNotificationPermission();
|
||||
}
|
||||
|
||||
// Cleanup polling on unmount
|
||||
return () => {
|
||||
if (metadataIntervalRef.current) {
|
||||
clearInterval(metadataIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// Poll for now playing metadata when channel is selected
|
||||
useEffect(() => {
|
||||
// Clear previous interval
|
||||
if (metadataIntervalRef.current) {
|
||||
clearInterval(metadataIntervalRef.current);
|
||||
}
|
||||
|
||||
if (selectedChannel) {
|
||||
// Fetch immediately
|
||||
fetchNowPlaying(selectedChannel.id);
|
||||
|
||||
// Then poll every 10 seconds
|
||||
metadataIntervalRef.current = setInterval(() => {
|
||||
fetchNowPlaying(selectedChannel.id);
|
||||
}, 10000);
|
||||
} else {
|
||||
setNowPlaying(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (metadataIntervalRef.current) {
|
||||
clearInterval(metadataIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [selectedChannel]);
|
||||
|
||||
const fetchNowPlaying = async (channelId) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/metadata/radio/${channelId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setNowPlaying(response.data);
|
||||
} catch (error) {
|
||||
// Silently fail - metadata might not be available for all streams
|
||||
console.debug('Metadata fetch failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFavorites = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/favorites?isRadio=true', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const favIds = new Set(response.data.map(fav => fav.id));
|
||||
setFavorites(favIds);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch favorites:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (channelId, event) => {
|
||||
if (event) event.stopPropagation();
|
||||
const isFavorite = favorites.has(channelId);
|
||||
|
||||
try {
|
||||
if (isFavorite) {
|
||||
await axios.delete(`/api/favorites/${channelId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFavorites(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(channelId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
await axios.post(`/api/favorites/${channelId}`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setFavorites(prev => new Set([...prev, channelId]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event, channel) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
mouseX: event.clientX - 2,
|
||||
mouseY: event.clientY - 4,
|
||||
});
|
||||
setContextChannel(channel);
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
setContextChannel(null);
|
||||
};
|
||||
|
||||
const handleContextFavorite = () => {
|
||||
if (contextChannel) {
|
||||
handleToggleFavorite(contextChannel.id, null);
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async () => {
|
||||
if (!channelToDelete) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/channels/${channelToDelete.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
// Remove from channels list
|
||||
setChannels(prev => prev.filter(ch => ch.id !== channelToDelete.id));
|
||||
|
||||
// Remove from favorites if present
|
||||
setFavorites(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(channelToDelete.id);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Clear selected channel if it was deleted
|
||||
if (selectedChannel?.id === channelToDelete.id) {
|
||||
setSelectedChannel(channels.find(ch => ch.id !== channelToDelete.id) || null);
|
||||
}
|
||||
|
||||
setDeleteDialogOpen(false);
|
||||
setChannelToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel:', error);
|
||||
alert('Failed to delete channel. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextDelete = () => {
|
||||
if (contextChannel) {
|
||||
setChannelToDelete(contextChannel);
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleLongPressStart = (channel) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
setContextChannel(channel);
|
||||
setContextMenu({ mouseX: 0, mouseY: 0 });
|
||||
}, 500);
|
||||
setLongPressTimer(timer);
|
||||
};
|
||||
|
||||
const handleLongPressEnd = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
setLongPressTimer(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle auto-play from search
|
||||
useEffect(() => {
|
||||
if (location.state?.autoPlayChannel && channels.length > 0) {
|
||||
const channel = channels.find(ch => ch.id === location.state.autoPlayChannel.id);
|
||||
if (channel) {
|
||||
setSelectedChannel(channel);
|
||||
// Show notification for now playing
|
||||
showChannelNotification(channel, 'radio');
|
||||
// Scroll to player
|
||||
setTimeout(() => {
|
||||
playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
// Clear the state
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, channels, navigate, location.pathname]);
|
||||
|
||||
// Handle group selection from search
|
||||
useEffect(() => {
|
||||
if (location.state?.selectedGroup) {
|
||||
setFilter(location.state.selectedGroup);
|
||||
// Clear the state
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, navigate, location.pathname]);
|
||||
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/channels?isRadio=true', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setChannels(response.data);
|
||||
if (response.data.length > 0 && !selectedChannel) {
|
||||
setSelectedChannel(response.data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch radio channels:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (newFilter) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
const filteredChannels = channels.filter((channel) => {
|
||||
if (filter === 'all') return true;
|
||||
return channel.group_name?.toLowerCase().includes(filter.toLowerCase());
|
||||
});
|
||||
|
||||
const handleChannelClick = (channel) => {
|
||||
setSelectedChannel(channel);
|
||||
// Show notification for now playing
|
||||
showChannelNotification(channel, 'radio');
|
||||
// Auto-scroll to player after brief delay for state update
|
||||
setTimeout(() => {
|
||||
playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleChannelSelect = (channel) => {
|
||||
if (channel) {
|
||||
handleChannelClick(channel);
|
||||
}
|
||||
};
|
||||
|
||||
const getNextChannel = () => {
|
||||
if (!selectedChannel || filteredChannels.length === 0) return null;
|
||||
const currentIndex = filteredChannels.findIndex(ch => ch.id === selectedChannel.id);
|
||||
const nextIndex = (currentIndex + 1) % filteredChannels.length;
|
||||
return filteredChannels[nextIndex];
|
||||
};
|
||||
|
||||
const getPreviousChannel = () => {
|
||||
if (!selectedChannel || filteredChannels.length === 0) return null;
|
||||
const currentIndex = filteredChannels.findIndex(ch => ch.id === selectedChannel.id);
|
||||
const previousIndex = currentIndex === 0 ? filteredChannels.length - 1 : currentIndex - 1;
|
||||
return filteredChannels[previousIndex];
|
||||
};
|
||||
|
||||
const handleLogoEdit = (channel, event) => {
|
||||
event.stopPropagation();
|
||||
setChannelToEdit(channel);
|
||||
setLogoManagerOpen(true);
|
||||
};
|
||||
|
||||
const handleLogoUpdated = (updatedChannel) => {
|
||||
setChannels(channels.map(ch =>
|
||||
ch.id === updatedChannel.id ? updatedChannel : ch
|
||||
));
|
||||
if (selectedChannel?.id === updatedChannel.id) {
|
||||
setSelectedChannel(updatedChannel);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('radio')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton size="small" onClick={fetchChannels}>
|
||||
<Refresh fontSize="small" />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
{t('add_playlist')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedChannel && (
|
||||
<Box ref={playerRef} sx={{ mb: 2, scrollMarginTop: '20px', position: 'relative' }}>
|
||||
<AudioPlayer
|
||||
station={selectedChannel}
|
||||
onNext={() => handleChannelSelect(getNextChannel())}
|
||||
onPrevious={() => handleChannelSelect(getPreviousChannel())}
|
||||
/>
|
||||
{/* Favorite Star Button for Playing Channel */}
|
||||
<IconButton
|
||||
onClick={() => handleToggleFavorite(selectedChannel.id, null)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.7)',
|
||||
color: favorites.has(selectedChannel.id) ? 'warning.main' : 'white',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.9)',
|
||||
transform: 'scale(1.1)'
|
||||
},
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{favorites.has(selectedChannel.id) ? (
|
||||
<Star fontSize="large" />
|
||||
) : (
|
||||
<StarBorder fontSize="large" />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Now Playing Information */}
|
||||
{nowPlaying && (nowPlaying.song || nowPlaying.artist || nowPlaying.title) && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
{t('now_playing')}
|
||||
</Typography>
|
||||
{nowPlaying.artist && nowPlaying.title ? (
|
||||
<>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{nowPlaying.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{nowPlaying.artist}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{nowPlaying.song}
|
||||
</Typography>
|
||||
)}
|
||||
{(nowPlaying.genre || nowPlaying.bitrate) && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}>
|
||||
{nowPlaying.genre && (
|
||||
<Chip label={nowPlaying.genre} size="small" variant="outlined" />
|
||||
)}
|
||||
{nowPlaying.bitrate && (
|
||||
<Chip label={nowPlaying.bitrate} size="small" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 2, display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
{['all', 'music', 'news', 'talk', 'sports'].map((filterOption) => (
|
||||
<Chip
|
||||
key={filterOption}
|
||||
label={t(filterOption) || filterOption}
|
||||
size="small"
|
||||
onClick={() => handleFilterChange(filterOption)}
|
||||
color={filter === filterOption ? 'primary' : 'default'}
|
||||
sx={{ textTransform: 'capitalize', fontSize: '0.75rem' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={1.5}>
|
||||
{filteredChannels.map((channel) => (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2} key={channel.id}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 4
|
||||
},
|
||||
'&:hover .logo-edit-btn': {
|
||||
opacity: 1
|
||||
},
|
||||
border: selectedChannel?.id === channel.id ? 2 : 0,
|
||||
borderColor: 'primary.main'
|
||||
}}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
onContextMenu={(e) => handleContextMenu(e, channel)}
|
||||
onTouchStart={() => handleLongPressStart(channel)}
|
||||
onTouchEnd={handleLongPressEnd}
|
||||
onTouchMove={handleLongPressEnd}
|
||||
onMouseDown={() => handleLongPressStart(channel)}
|
||||
onMouseUp={handleLongPressEnd}
|
||||
onMouseLeave={handleLongPressEnd}
|
||||
>
|
||||
{/* Favorite Button */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => handleToggleFavorite(channel.id, e)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 4,
|
||||
zIndex: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: favorites.has(channel.id) ? 'error.main' : 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }
|
||||
}}
|
||||
>
|
||||
{favorites.has(channel.id) ? (
|
||||
<Favorite fontSize="small" />
|
||||
) : (
|
||||
<FavoriteBorder fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
className="logo-edit-btn"
|
||||
size="small"
|
||||
onClick={(e) => handleLogoEdit(channel, e)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
zIndex: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }
|
||||
}}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
{channel.logo ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="100"
|
||||
image={getProxiedLogoUrl(channel.logo)}
|
||||
alt={channel.name}
|
||||
crossOrigin="anonymous"
|
||||
sx={{ objectFit: 'contain', bgcolor: 'background.default', p: 1 }}
|
||||
onError={(e) => {
|
||||
console.log(`[Logo] Failed to load: ${channel.logo}`);
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Box
|
||||
sx={{
|
||||
height: 100,
|
||||
display: channel.logo ? 'none' : 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<Logo size={56} />
|
||||
</Box>
|
||||
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="caption" fontWeight="600" noWrap display="block">
|
||||
{channel.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap display="block" fontSize="0.7rem">
|
||||
{channel.group_name || 'Radio'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 6 }}>
|
||||
<Typography variant="body1" color="text.secondary" gutterBottom>
|
||||
{t('no_channels')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/settings')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{t('add_playlist')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ChannelLogoManager
|
||||
open={logoManagerOpen}
|
||||
onClose={() => {
|
||||
setLogoManagerOpen(false);
|
||||
setChannelToEdit(null);
|
||||
}}
|
||||
channel={channelToEdit}
|
||||
onLogoUpdated={handleLogoUpdated}
|
||||
/>
|
||||
|
||||
{/* Context Menu for Favorites */}
|
||||
<Menu
|
||||
open={contextMenu !== null}
|
||||
onClose={handleCloseContextMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
contextMenu !== null
|
||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MenuItem onClick={handleContextFavorite}>
|
||||
<ListItemIcon>
|
||||
{contextChannel && favorites.has(contextChannel.id) ? (
|
||||
<Favorite fontSize="small" color="error" />
|
||||
) : (
|
||||
<FavoriteBorder fontSize="small" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{contextChannel && favorites.has(contextChannel.id)
|
||||
? 'Remove from Favorites'
|
||||
: 'Add to Favorites'}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleContextDelete}>
|
||||
<ListItemIcon>
|
||||
<Delete fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete Channel</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Delete Channel</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete <strong>{channelToDelete?.name}</strong>? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDeleteChannel} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Radio;
|
||||
184
frontend/src/pages/Register.jsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff, LiveTv } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import PasswordStrengthMeter from '../components/PasswordStrengthMeter';
|
||||
import { useErrorNotification } from '../components/ErrorNotificationProvider';
|
||||
|
||||
function Register() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const { showError, showSuccess } = useErrorNotification();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/register', formData);
|
||||
login(response.data.user, response.data.token);
|
||||
|
||||
showSuccess(t('register.success') || 'Registration successful! Welcome aboard.', {
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
// Handle password policy errors with detailed feedback
|
||||
if (err.response?.data?.details) {
|
||||
showError(
|
||||
new Error(err.response.data.details.join('. ')),
|
||||
{
|
||||
title: t('errors.validation.title'),
|
||||
duration: 10000
|
||||
}
|
||||
);
|
||||
} else {
|
||||
showError(err, {
|
||||
title: t('register.failed') || 'Registration Failed',
|
||||
defaultMessage: t('register.error') || 'Registration failed. Please try again.'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: 'linear-gradient(135deg, #1e3c72 0%, #2a5298 100%)'
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2.5,
|
||||
maxWidth: 400,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? 'rgba(255, 255, 255, 0.95)'
|
||||
: 'rgba(26, 32, 44, 0.95)',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||
<LiveTv sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
|
||||
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
||||
{t('app_name')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('premium_plan')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" fontWeight="600" gutterBottom sx={{ mb: 2 }}>
|
||||
{t('create_account')}
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('username')}
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
margin="normal"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('email')}
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('password')}
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
margin="normal"
|
||||
required
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<PasswordStrengthMeter
|
||||
password={formData.password}
|
||||
username={formData.username}
|
||||
email={formData.email}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="medium"
|
||||
sx={{ mt: 2, mb: 1.5 }}
|
||||
>
|
||||
{t('sign_up')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('already_have_account')}{' '}
|
||||
<Link to="/login" style={{ textDecoration: 'none', color: '#2196F3' }}>
|
||||
{t('sign_in')}
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Register;
|
||||
768
frontend/src/pages/SecurityConfigDashboard.jsx
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box, Container, Typography, Card, CardContent, Grid, Table, TableBody, TableCell,
|
||||
TableContainer, TableHead, TableRow, Paper, Chip, CircularProgress, Alert, IconButton,
|
||||
Tabs, Tab, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
||||
Select, MenuItem, FormControl, InputLabel, Switch, FormControlLabel, Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Settings, Add, Edit, Delete, Refresh, Warning, CheckCircle, Security, PlayArrow
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
function TabPanel({ children, value, index }) {
|
||||
return value === index ? <Box sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
const SecurityConfigDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// Data states
|
||||
const [thresholds, setThresholds] = useState([]);
|
||||
const [signatures, setSignatures] = useState([]);
|
||||
const [protocols, setProtocols] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
// Dialog states
|
||||
const [thresholdDialog, setThresholdDialog] = useState({ open: false, mode: 'add', data: null });
|
||||
const [signatureDialog, setSignatureDialog] = useState({ open: false, mode: 'add', data: null });
|
||||
const [protocolDialog, setProtocolDialog] = useState({ open: false, mode: 'add', data: null });
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, type: null, id: null });
|
||||
|
||||
// Redirect non-admins
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && user?.role === 'admin') {
|
||||
fetchAllData();
|
||||
}
|
||||
}, [token, user]);
|
||||
|
||||
const fetchAllData = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [thresholdsRes, signaturesRes, protocolsRes, dashboardRes] = await Promise.all([
|
||||
axios.get('/api/security-config/thresholds', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
axios.get('/api/security-config/signatures', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
axios.get('/api/security-config/protocols', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
axios.get('/api/security-config/dashboard', { headers: { Authorization: `Bearer ${token}` } })
|
||||
]);
|
||||
|
||||
setThresholds(thresholdsRes.data.data);
|
||||
setSignatures(signaturesRes.data.data);
|
||||
setProtocols(protocolsRes.data.data);
|
||||
setStats(dashboardRes.data.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveThreshold = async () => {
|
||||
try {
|
||||
const url = thresholdDialog.mode === 'add'
|
||||
? '/api/security-config/thresholds'
|
||||
: `/api/security-config/thresholds/${thresholdDialog.data.threshold_id}`;
|
||||
|
||||
const method = thresholdDialog.mode === 'add' ? 'post' : 'put';
|
||||
|
||||
await axios[method](url, thresholdDialog.data, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('securityConfig.thresholdSaved'));
|
||||
setThresholdDialog({ open: false, mode: 'add', data: null });
|
||||
fetchAllData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSignature = async () => {
|
||||
try {
|
||||
const url = signatureDialog.mode === 'add'
|
||||
? '/api/security-config/signatures'
|
||||
: `/api/security-config/signatures/${signatureDialog.data.signature_id}`;
|
||||
|
||||
const method = signatureDialog.mode === 'add' ? 'post' : 'put';
|
||||
|
||||
await axios[method](url, signatureDialog.data, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('securityConfig.signatureSaved'));
|
||||
setSignatureDialog({ open: false, mode: 'add', data: null });
|
||||
fetchAllData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProtocol = async () => {
|
||||
try {
|
||||
const url = protocolDialog.mode === 'add'
|
||||
? '/api/security-config/protocols'
|
||||
: `/api/security-config/protocols/${protocolDialog.data.protocol_id}`;
|
||||
|
||||
const method = protocolDialog.mode === 'add' ? 'post' : 'put';
|
||||
|
||||
await axios[method](url, protocolDialog.data, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('securityConfig.protocolSaved'));
|
||||
setProtocolDialog({ open: false, mode: 'add', data: null });
|
||||
fetchAllData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const urls = {
|
||||
threshold: `/api/security-config/thresholds/${deleteDialog.id}`,
|
||||
signature: `/api/security-config/signatures/${deleteDialog.id}`,
|
||||
protocol: `/api/security-config/protocols/${deleteDialog.id}`
|
||||
};
|
||||
|
||||
await axios.delete(urls[deleteDialog.type], {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('securityConfig.deleted'));
|
||||
setDeleteDialog({ open: false, type: null, id: null });
|
||||
fetchAllData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
const colors = { critical: 'error', high: 'warning', medium: 'info', low: 'success' };
|
||||
return colors[severity] || 'default';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '80vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Settings fontSize="large" color="primary" />
|
||||
<Typography variant="h4">{t('securityConfig.title')}</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<Refresh />} onClick={fetchAllData}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError('')} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" onClose={() => setSuccess('')} sx={{ mb: 2 }}>{success}</Alert>}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
{stats && (
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>{t('securityConfig.thresholds')}</Typography>
|
||||
<Typography variant="h3" color="primary">{stats.thresholds.total}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{stats.thresholds.enabled} {t('securityConfig.enabled')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>{t('securityConfig.signatures')}</Typography>
|
||||
<Typography variant="h3" color="warning.main">{stats.signatures.total}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{stats.signatures.auto_block_enabled} {t('securityConfig.autoBlock')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>{t('securityConfig.protocols')}</Typography>
|
||||
<Typography variant="h3" color="error.main">{stats.protocols.total}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{stats.protocols.auto_execute_enabled} {t('securityConfig.autoExecute')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} variant="fullWidth">
|
||||
<Tab label={t('securityConfig.thresholds')} />
|
||||
<Tab label={t('securityConfig.signatures')} />
|
||||
<Tab label={t('securityConfig.protocols')} />
|
||||
</Tabs>
|
||||
|
||||
{/* Thresholds Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setThresholdDialog({
|
||||
open: true,
|
||||
mode: 'add',
|
||||
data: {
|
||||
name: '', description: '', pattern_type: 'brute_force_attack',
|
||||
metric_name: 'failed_login_count', operator: '>=', threshold_value: 5,
|
||||
time_window_minutes: 10, severity: 'high', enabled: true
|
||||
}
|
||||
})}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{t('securityConfig.addThreshold')}
|
||||
</Button>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('securityConfig.name')}</TableCell>
|
||||
<TableCell>{t('securityConfig.patternType')}</TableCell>
|
||||
<TableCell>{t('securityConfig.condition')}</TableCell>
|
||||
<TableCell>{t('securityConfig.timeWindow')}</TableCell>
|
||||
<TableCell>{t('securityConfig.severity')}</TableCell>
|
||||
<TableCell>{t('securityConfig.status')}</TableCell>
|
||||
<TableCell>{t('common.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{thresholds.map((threshold) => (
|
||||
<TableRow key={threshold.id}>
|
||||
<TableCell>{threshold.name}</TableCell>
|
||||
<TableCell><Chip label={threshold.pattern_type} size="small" /></TableCell>
|
||||
<TableCell>
|
||||
{threshold.metric_name} {threshold.operator} {threshold.threshold_value}
|
||||
</TableCell>
|
||||
<TableCell>{threshold.time_window_minutes} min</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={threshold.severity} color={getSeverityColor(threshold.severity)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={threshold.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
color={threshold.enabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => setThresholdDialog({ open: true, mode: 'edit', data: threshold })}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setDeleteDialog({ open: true, type: 'threshold', id: threshold.threshold_id })}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Signatures Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setSignatureDialog({
|
||||
open: true,
|
||||
mode: 'add',
|
||||
data: {
|
||||
name: '', description: '', signature_type: 'attack_pattern',
|
||||
pattern: '', match_type: 'regex', threat_level: 'high',
|
||||
confidence: 0.8, enabled: true, auto_block: false
|
||||
}
|
||||
})}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{t('securityConfig.addSignature')}
|
||||
</Button>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('securityConfig.name')}</TableCell>
|
||||
<TableCell>{t('securityConfig.signatureType')}</TableCell>
|
||||
<TableCell>{t('securityConfig.matchType')}</TableCell>
|
||||
<TableCell>{t('securityConfig.threatLevel')}</TableCell>
|
||||
<TableCell>{t('securityConfig.confidence')}</TableCell>
|
||||
<TableCell>{t('securityConfig.autoBlock')}</TableCell>
|
||||
<TableCell>{t('securityConfig.status')}</TableCell>
|
||||
<TableCell>{t('common.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{signatures.map((signature) => (
|
||||
<TableRow key={signature.id}>
|
||||
<TableCell>{signature.name}</TableCell>
|
||||
<TableCell><Chip label={signature.signature_type} size="small" /></TableCell>
|
||||
<TableCell>{signature.match_type}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={signature.threat_level} color={getSeverityColor(signature.threat_level)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{(signature.confidence * 100).toFixed(0)}%</TableCell>
|
||||
<TableCell>
|
||||
{signature.auto_block ? <CheckCircle color="warning" /> : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={signature.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
color={signature.enabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => setSignatureDialog({ open: true, mode: 'edit', data: signature })}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setDeleteDialog({ open: true, type: 'signature', id: signature.signature_id })}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Protocols Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setProtocolDialog({
|
||||
open: true,
|
||||
mode: 'add',
|
||||
data: {
|
||||
name: '', description: '', trigger_type: 'anomaly',
|
||||
trigger_condition: {}, actions: [],
|
||||
severity: 'high', enabled: true, auto_execute: false, cooldown_minutes: 60
|
||||
}
|
||||
})}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{t('securityConfig.addProtocol')}
|
||||
</Button>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('securityConfig.name')}</TableCell>
|
||||
<TableCell>{t('securityConfig.triggerType')}</TableCell>
|
||||
<TableCell>{t('securityConfig.actionsCount')}</TableCell>
|
||||
<TableCell>{t('securityConfig.severity')}</TableCell>
|
||||
<TableCell>{t('securityConfig.autoExecute')}</TableCell>
|
||||
<TableCell>{t('securityConfig.cooldown')}</TableCell>
|
||||
<TableCell>{t('securityConfig.status')}</TableCell>
|
||||
<TableCell>{t('common.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{protocols.map((protocol) => {
|
||||
const actions = typeof protocol.actions === 'string' ? JSON.parse(protocol.actions) : protocol.actions;
|
||||
return (
|
||||
<TableRow key={protocol.id}>
|
||||
<TableCell>{protocol.name}</TableCell>
|
||||
<TableCell><Chip label={protocol.trigger_type} size="small" /></TableCell>
|
||||
<TableCell>{actions.length} {t('securityConfig.actions')}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={protocol.severity} color={getSeverityColor(protocol.severity)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{protocol.auto_execute ? <PlayArrow color="success" /> : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{protocol.cooldown_minutes} min</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={protocol.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
color={protocol.enabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => setProtocolDialog({ open: true, mode: 'edit', data: protocol })}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setDeleteDialog({ open: true, type: 'protocol', id: protocol.protocol_id })}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
|
||||
{/* Threshold Dialog */}
|
||||
<Dialog open={thresholdDialog.open} onClose={() => setThresholdDialog({ open: false, mode: 'add', data: null })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{thresholdDialog.mode === 'add' ? t('securityConfig.addThreshold') : t('securityConfig.editThreshold')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.name')}
|
||||
value={thresholdDialog.data?.name || ''}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, name: e.target.value } })}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.description')}
|
||||
multiline
|
||||
rows={2}
|
||||
value={thresholdDialog.data?.description || ''}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, description: e.target.value } })}
|
||||
/>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('securityConfig.patternType')}</InputLabel>
|
||||
<Select
|
||||
value={thresholdDialog.data?.pattern_type || 'brute_force_attack'}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, pattern_type: e.target.value } })}
|
||||
>
|
||||
<MenuItem value="brute_force_attack">Brute Force Attack</MenuItem>
|
||||
<MenuItem value="credential_stuffing">Credential Stuffing</MenuItem>
|
||||
<MenuItem value="privilege_escalation">Privilege Escalation</MenuItem>
|
||||
<MenuItem value="suspicious_ip">Suspicious IP</MenuItem>
|
||||
<MenuItem value="data_exfiltration">Data Exfiltration</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.metricName')}
|
||||
value={thresholdDialog.data?.metric_name || ''}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, metric_name: e.target.value } })}
|
||||
/>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('securityConfig.operator')}</InputLabel>
|
||||
<Select
|
||||
value={thresholdDialog.data?.operator || '>='}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, operator: e.target.value } })}
|
||||
>
|
||||
<MenuItem value=">=">>=</MenuItem>
|
||||
<MenuItem value=">">></MenuItem>
|
||||
<MenuItem value="<="><=</MenuItem>
|
||||
<MenuItem value="<"><</MenuItem>
|
||||
<MenuItem value="==">==</MenuItem>
|
||||
<MenuItem value="!=">!=</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type="number"
|
||||
label={t('securityConfig.thresholdValue')}
|
||||
value={thresholdDialog.data?.threshold_value || 0}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, threshold_value: parseInt(e.target.value) } })}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type="number"
|
||||
label={t('securityConfig.timeWindow')}
|
||||
value={thresholdDialog.data?.time_window_minutes || 30}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, time_window_minutes: parseInt(e.target.value) } })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('securityConfig.severity')}</InputLabel>
|
||||
<Select
|
||||
value={thresholdDialog.data?.severity || 'high'}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, severity: e.target.value } })}
|
||||
>
|
||||
<MenuItem value="low">Low</MenuItem>
|
||||
<MenuItem value="medium">Medium</MenuItem>
|
||||
<MenuItem value="high">High</MenuItem>
|
||||
<MenuItem value="critical">Critical</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={thresholdDialog.data?.enabled || false}
|
||||
onChange={(e) => setThresholdDialog({ ...thresholdDialog, data: { ...thresholdDialog.data, enabled: e.target.checked } })}
|
||||
/>
|
||||
}
|
||||
label={t('common.enabled')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setThresholdDialog({ open: false, mode: 'add', data: null })}>{t('common.cancel')}</Button>
|
||||
<Button variant="contained" onClick={handleSaveThreshold}>{t('common.save')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Signature Dialog */}
|
||||
<Dialog open={signatureDialog.open} onClose={() => setSignatureDialog({ open: false, mode: 'add', data: null })} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{signatureDialog.mode === 'add' ? t('securityConfig.addSignature') : t('securityConfig.editSignature')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.name')}
|
||||
value={signatureDialog.data?.name || ''}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, name: e.target.value } })}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.description')}
|
||||
multiline
|
||||
rows={2}
|
||||
value={signatureDialog.data?.description || ''}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, description: e.target.value } })}
|
||||
/>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('securityConfig.signatureType')}</InputLabel>
|
||||
<Select
|
||||
value={signatureDialog.data?.signature_type || 'attack_pattern'}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, signature_type: e.target.value } })}
|
||||
>
|
||||
<MenuItem value="ip_address">IP Address</MenuItem>
|
||||
<MenuItem value="user_agent">User Agent</MenuItem>
|
||||
<MenuItem value="attack_pattern">Attack Pattern</MenuItem>
|
||||
<MenuItem value="behavior">Behavior</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('securityConfig.matchType')}</InputLabel>
|
||||
<Select
|
||||
value={signatureDialog.data?.match_type || 'regex'}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, match_type: e.target.value } })}
|
||||
>
|
||||
<MenuItem value="regex">Regex</MenuItem>
|
||||
<MenuItem value="regex_case_insensitive">Regex (Case Insensitive)</MenuItem>
|
||||
<MenuItem value="exact">Exact Match</MenuItem>
|
||||
<MenuItem value="contains">Contains</MenuItem>
|
||||
<MenuItem value="custom">Custom</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.pattern')}
|
||||
multiline
|
||||
rows={3}
|
||||
value={signatureDialog.data?.pattern || ''}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, pattern: e.target.value } })}
|
||||
helperText={t('securityConfig.patternHelp')}
|
||||
/>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('securityConfig.threatLevel')}</InputLabel>
|
||||
<Select
|
||||
value={signatureDialog.data?.threat_level || 'high'}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, threat_level: e.target.value } })}
|
||||
>
|
||||
<MenuItem value="low">Low</MenuItem>
|
||||
<MenuItem value="medium">Medium</MenuItem>
|
||||
<MenuItem value="high">High</MenuItem>
|
||||
<MenuItem value="critical">Critical</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type="number"
|
||||
label={t('securityConfig.confidence')}
|
||||
value={signatureDialog.data?.confidence || 0.8}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, confidence: parseFloat(e.target.value) } })}
|
||||
inputProps={{ min: 0, max: 1, step: 0.1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={signatureDialog.data?.auto_block || false}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, auto_block: e.target.checked } })}
|
||||
/>
|
||||
}
|
||||
label={t('securityConfig.autoBlock')}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={signatureDialog.data?.enabled || false}
|
||||
onChange={(e) => setSignatureDialog({ ...signatureDialog, data: { ...signatureDialog.data, enabled: e.target.checked } })}
|
||||
/>
|
||||
}
|
||||
label={t('common.enabled')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSignatureDialog({ open: false, mode: 'add', data: null })}>{t('common.cancel')}</Button>
|
||||
<Button variant="contained" onClick={handleSaveSignature}>{t('common.save')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Protocol Dialog - Simplified */}
|
||||
<Dialog open={protocolDialog.open} onClose={() => setProtocolDialog({ open: false, mode: 'add', data: null })} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{protocolDialog.mode === 'add' ? t('securityConfig.addProtocol') : t('securityConfig.editProtocol')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('securityConfig.protocolWarning')}
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.name')}
|
||||
value={protocolDialog.data?.name || ''}
|
||||
onChange={(e) => setProtocolDialog({ ...protocolDialog, data: { ...protocolDialog.data, name: e.target.value } })}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label={t('securityConfig.description')}
|
||||
multiline
|
||||
rows={2}
|
||||
value={protocolDialog.data?.description || ''}
|
||||
onChange={(e) => setProtocolDialog({ ...protocolDialog, data: { ...protocolDialog.data, description: e.target.value } })}
|
||||
/>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('securityConfig.severity')}</InputLabel>
|
||||
<Select
|
||||
value={protocolDialog.data?.severity || 'high'}
|
||||
onChange={(e) => setProtocolDialog({ ...protocolDialog, data: { ...protocolDialog.data, severity: e.target.value } })}
|
||||
>
|
||||
<MenuItem value="low">Low</MenuItem>
|
||||
<MenuItem value="medium">Medium</MenuItem>
|
||||
<MenuItem value="high">High</MenuItem>
|
||||
<MenuItem value="critical">Critical</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type="number"
|
||||
label={t('securityConfig.cooldownMinutes')}
|
||||
value={protocolDialog.data?.cooldown_minutes || 60}
|
||||
onChange={(e) => setProtocolDialog({ ...protocolDialog, data: { ...protocolDialog.data, cooldown_minutes: parseInt(e.target.value) } })}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={protocolDialog.data?.auto_execute || false}
|
||||
onChange={(e) => setProtocolDialog({ ...protocolDialog, data: { ...protocolDialog.data, auto_execute: e.target.checked } })}
|
||||
/>
|
||||
}
|
||||
label={t('securityConfig.autoExecute')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={protocolDialog.data?.enabled || false}
|
||||
onChange={(e) => setProtocolDialog({ ...protocolDialog, data: { ...protocolDialog.data, enabled: e.target.checked } })}
|
||||
/>
|
||||
}
|
||||
label={t('common.enabled')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setProtocolDialog({ open: false, mode: 'add', data: null })}>{t('common.cancel')}</Button>
|
||||
<Button variant="contained" onClick={handleSaveProtocol}>{t('common.save')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, type: null, id: null })}>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{t('securityConfig.deleteWarning')}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, type: null, id: null })}>{t('common.cancel')}</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDelete}>{t('common.delete')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityConfigDashboard;
|
||||
594
frontend/src/pages/SecurityDashboard.jsx
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
Lock,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
ErrorOutline,
|
||||
Refresh,
|
||||
LockOpen,
|
||||
AdminPanelSettings,
|
||||
BugReport,
|
||||
Settings
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
function TabPanel({ children, value, index }) {
|
||||
return value === index ? <Box sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
const SecurityDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [securityData, setSecurityData] = useState({
|
||||
lockedAccounts: [],
|
||||
recentFailures: [],
|
||||
securityEvents: [],
|
||||
stats: {
|
||||
totalUsers: 0,
|
||||
lockedAccounts: 0,
|
||||
failedAttempts24h: 0,
|
||||
passwordChanges24h: 0
|
||||
}
|
||||
});
|
||||
const [auditLog, setAuditLog] = useState([]);
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
|
||||
// Redirect non-admins
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && user?.role === 'admin') {
|
||||
fetchSecurityData();
|
||||
}
|
||||
}, [token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabValue === 2 && token && user?.role === 'admin') {
|
||||
fetchAuditLog();
|
||||
}
|
||||
}, [tabValue, token, user]);
|
||||
|
||||
const fetchSecurityData = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
// Fetch user data
|
||||
const usersResponse = await axios.get('/api/users', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
// Fetch security status from new monitoring API
|
||||
const statusResponse = await axios.get('/api/security-monitor/status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => null);
|
||||
|
||||
const users = usersResponse.data;
|
||||
const lockedUsers = users.filter(u => u.locked_until && new Date(u.locked_until) > new Date());
|
||||
const usersWithFailures = users.filter(u => u.failed_login_attempts > 0);
|
||||
|
||||
// Get password changes from audit log if available
|
||||
let passwordChanges24h = 0;
|
||||
if (statusResponse?.data?.systemHealth) {
|
||||
passwordChanges24h = statusResponse.data.systemHealth.passwordChanges || 0;
|
||||
}
|
||||
|
||||
setSecurityData({
|
||||
lockedAccounts: lockedUsers,
|
||||
recentFailures: usersWithFailures,
|
||||
securityEvents: statusResponse?.data?.auditSummary || [],
|
||||
stats: {
|
||||
totalUsers: users.length,
|
||||
lockedAccounts: lockedUsers.length,
|
||||
failedAttempts24h: usersWithFailures.reduce((sum, u) => sum + u.failed_login_attempts, 0),
|
||||
passwordChanges24h
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// Only show error if not authentication related
|
||||
if (err.response?.status !== 401) {
|
||||
setError(err.response?.data?.error || 'Failed to load security data');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAuditLog = async () => {
|
||||
setAuditLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/security-monitor/audit-log?limit=50', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setAuditLog(response.data.logs || []);
|
||||
} catch (err) {
|
||||
// Silently fail for authentication errors
|
||||
if (err.response?.status !== 401) {
|
||||
console.error('Error fetching audit log:', err);
|
||||
}
|
||||
setAuditLog([]);
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockAccount = async (userId) => {
|
||||
try {
|
||||
await axios.post(`/api/users/${userId}/unlock`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
fetchSecurityData();
|
||||
} catch (err) {
|
||||
setError('Failed to unlock account');
|
||||
}
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security color="primary" fontSize="large" />
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('security.securityDashboard')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={fetchSecurityData} disabled={loading}>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Security />}
|
||||
onClick={() => navigate('/security/monitor')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('security.monitoring')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Lock />}
|
||||
onClick={() => navigate('/security/headers')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('security.securityHeaders')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Security />}
|
||||
onClick={() => navigate('/security/csp')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('security.cspDashboard')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<AdminPanelSettings />}
|
||||
onClick={() => navigate('/security/rbac')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('security.rbacDashboard')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<BugReport />}
|
||||
onClick={() => navigate('/security/testing')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('security.securityTesting')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<Security />}
|
||||
onClick={() => navigate('/security/intelligence')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('siem.title')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="warning"
|
||||
startIcon={<Settings />}
|
||||
onClick={() => navigate('/security/config')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('securityConfig.title')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="info"
|
||||
startIcon={<AdminPanelSettings />}
|
||||
onClick={() => navigate('/security/logs')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('logManagement.title')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon={<Lock />}
|
||||
onClick={() => navigate('/security/encryption')}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('encryption.title')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.allUsers')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{securityData.stats.totalUsers}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CheckCircle sx={{ fontSize: 48, color: 'success.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.lockedAccounts')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold" color="error.main">
|
||||
{securityData.stats.lockedAccounts}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Lock sx={{ fontSize: 48, color: 'error.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.recentFailures')} (24h)
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold" color="warning.main">
|
||||
{securityData.stats.failedAttempts24h}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Warning sx={{ fontSize: 48, color: 'warning.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.passwordChange')} (24h)
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold" color="info.main">
|
||||
{securityData.stats.passwordChanges24h}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Security sx={{ fontSize: 48, color: 'info.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label={t('security.lockedAccounts')} icon={<Lock />} iconPosition="start" />
|
||||
<Tab label={t('security.recentFailures')} icon={<Warning />} iconPosition="start" />
|
||||
<Tab label={t('security.auditLog')} icon={<Security />} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{/* Locked Accounts Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
{securityData.lockedAccounts.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
No locked accounts
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('settings.username')}</TableCell>
|
||||
<TableCell>{t('settings.email')}</TableCell>
|
||||
<TableCell>{t('security.failedAttempts')}</TableCell>
|
||||
<TableCell>{t('security.accountLocked')}</TableCell>
|
||||
<TableCell>{t('settings.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{securityData.lockedAccounts.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.failed_login_attempts || 0}
|
||||
color="error"
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.locked_until ? format(new Date(user.locked_until), 'PPpp') : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<LockOpen />}
|
||||
onClick={() => handleUnlockAccount(user.id)}
|
||||
>
|
||||
{t('security.unlockAccount')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Recent Failures Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
{securityData.recentFailures.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
No recent failed attempts
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('settings.username')}</TableCell>
|
||||
<TableCell>{t('settings.email')}</TableCell>
|
||||
<TableCell>{t('security.failedAttempts')}</TableCell>
|
||||
<TableCell>{t('security.lastLogin')}</TableCell>
|
||||
<TableCell>{t('settings.status')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{securityData.recentFailures.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.failed_login_attempts || 0}
|
||||
color={user.failed_login_attempts >= 3 ? 'error' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.last_failed_login ? format(new Date(user.last_failed_login), 'PPpp') : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.locked_until && new Date(user.locked_until) > new Date() ? 'Locked' : 'Active'}
|
||||
color={user.locked_until && new Date(user.locked_until) > new Date() ? 'error' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Audit Log Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{auditLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : auditLog.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Security sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary" gutterBottom>
|
||||
{t('security.noEvents')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchAuditLog}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 2 }}>
|
||||
<Typography variant="h6">
|
||||
{t('security.recentEvents')} ({auditLog.length})
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => navigate('/security/monitor')}
|
||||
>
|
||||
{t('security.viewDetails')}
|
||||
</Button>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.timestamp')}</TableCell>
|
||||
<TableCell>{t('security.eventType')}</TableCell>
|
||||
<TableCell>{t('security.status')}</TableCell>
|
||||
<TableCell>{t('security.ipAddress')}</TableCell>
|
||||
<TableCell>{t('details')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{auditLog.slice(0, 20).map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>
|
||||
{format(new Date(log.timestamp), 'MMM dd, HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={log.action}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={log.result}
|
||||
color={
|
||||
log.result === 'success' ? 'success' :
|
||||
log.result === 'failed' ? 'error' :
|
||||
'warning'
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{log.ip_address || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.details && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{JSON.parse(log.details).username ||
|
||||
JSON.parse(log.details).reason ||
|
||||
'See details'}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => navigate('/security/monitor')}
|
||||
>
|
||||
{t('security.viewDetails')} →
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</TabPanel>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityDashboard;
|
||||
733
frontend/src/pages/SecurityIntelligenceDashboard.jsx
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
/**
|
||||
* Security Intelligence Dashboard
|
||||
* Real-time SIEM monitoring, anomaly detection, and threat intelligence
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
TextField,
|
||||
LinearProgress,
|
||||
Badge,
|
||||
Tabs,
|
||||
Tab
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
Warning,
|
||||
Error as ErrorIcon,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Refresh,
|
||||
Download,
|
||||
ArrowBack,
|
||||
Timeline,
|
||||
Shield,
|
||||
BugReport,
|
||||
Visibility,
|
||||
Assessment,
|
||||
Notifications,
|
||||
CheckCircleOutline,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSecurityNotification } from '../components/SecurityNotificationProvider';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const TabPanel = ({ children, value, index }) => (
|
||||
<div role="tabpanel" hidden={value !== index}>
|
||||
{value === index && <Box sx={{ py: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SecurityIntelligenceDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuthStore();
|
||||
const { notifySecuritySuccess, notifySecurityError } = useSecurityNotification();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [dashboardData, setDashboardData] = useState(null);
|
||||
const [anomalies, setAnomalies] = useState([]);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [threats, setThreats] = useState([]);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [detailsDialog, setDetailsDialog] = useState(false);
|
||||
const [refreshInterval, setRefreshInterval] = useState(null);
|
||||
|
||||
// Fetch dashboard data
|
||||
const fetchDashboardData = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/siem/dashboard', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setDashboardData(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch anomalies
|
||||
const fetchAnomalies = useCallback(async (status = 'open') => {
|
||||
try {
|
||||
const response = await axios.get('/api/siem/anomalies', {
|
||||
params: { status, limit: 100 },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setAnomalies(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch anomalies:', error);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch alerts
|
||||
const fetchAlerts = useCallback(async (status = 'active') => {
|
||||
try {
|
||||
const response = await axios.get('/api/siem/alerts', {
|
||||
params: { status, limit: 100 },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setAlerts(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch alerts:', error);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch threat intelligence
|
||||
const fetchThreats = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/siem/threats', {
|
||||
params: { limit: 50 },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setThreats(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch threats:', error);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch logs
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/siem/logs', {
|
||||
params: { limit: 50 },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setLogs(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch logs:', error);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Acknowledge alert
|
||||
const handleAcknowledgeAlert = async (alertId) => {
|
||||
try {
|
||||
await axios.post(`/api/siem/alerts/${alertId}/acknowledge`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(t('siem.alertAcknowledged'));
|
||||
fetchAlerts();
|
||||
} catch (error) {
|
||||
notifySecurityError(t('siem.alertAcknowledgeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve alert
|
||||
const handleResolveAlert = async (alertId, notes) => {
|
||||
try {
|
||||
await axios.post(`/api/siem/alerts/${alertId}/resolve`,
|
||||
{ notes },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
notifySecuritySuccess(t('siem.alertResolved'));
|
||||
fetchAlerts();
|
||||
setDetailsDialog(false);
|
||||
} catch (error) {
|
||||
notifySecurityError(t('siem.alertResolveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve anomaly
|
||||
const handleResolveAnomaly = async (anomalyId, notes) => {
|
||||
try {
|
||||
await axios.post(`/api/siem/anomalies/${anomalyId}/resolve`,
|
||||
{ notes },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
notifySecuritySuccess(t('siem.anomalyResolved'));
|
||||
fetchAnomalies();
|
||||
setDetailsDialog(false);
|
||||
} catch (error) {
|
||||
notifySecurityError(t('siem.anomalyResolveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Export logs
|
||||
const handleExport = async (format = 'json') => {
|
||||
try {
|
||||
const response = await axios.get('/api/siem/export', {
|
||||
params: { format },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `siem_export_${Date.now()}.${format}`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
notifySecuritySuccess(t('siem.exportSuccess'));
|
||||
} catch (error) {
|
||||
notifySecurityError(t('siem.exportFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify log integrity
|
||||
const handleVerifyIntegrity = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.post('/api/siem/logs/verify', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const { verified, tampered, total } = response.data.data;
|
||||
if (tampered > 0) {
|
||||
notifySecurityError(t('siem.integrityCompromised', { tampered, total }));
|
||||
} else {
|
||||
notifySecuritySuccess(t('siem.integrityVerified', { verified }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
notifySecurityError(t('siem.integrityCheckFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
fetchDashboardData(),
|
||||
fetchAnomalies(),
|
||||
fetchAlerts(),
|
||||
fetchThreats(),
|
||||
fetchLogs()
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
// Auto-refresh every 60 seconds
|
||||
const interval = setInterval(() => {
|
||||
fetchDashboardData();
|
||||
fetchAnomalies();
|
||||
fetchAlerts();
|
||||
}, 60000);
|
||||
|
||||
setRefreshInterval(interval);
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [fetchDashboardData, fetchAnomalies, fetchAlerts, fetchThreats, fetchLogs]);
|
||||
|
||||
// Get severity color
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'error';
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Get threat score color
|
||||
const getThreatScoreColor = (score) => {
|
||||
if (score >= 80) return 'error';
|
||||
if (score >= 50) return 'warning';
|
||||
if (score >= 20) return 'info';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
if (loading && !dashboardData) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '80vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ py: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={() => navigate('/security')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Shield sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Typography variant="h4" component="h1">
|
||||
{t('siem.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
fetchDashboardData();
|
||||
fetchAnomalies();
|
||||
fetchAlerts();
|
||||
fetchThreats();
|
||||
fetchLogs();
|
||||
}}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<CheckCircle />}
|
||||
onClick={handleVerifyIntegrity}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('siem.verifyIntegrity')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
onClick={() => handleExport('csv')}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Threat Score */}
|
||||
{dashboardData && (
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{t('siem.threatScore')}
|
||||
</Typography>
|
||||
<Assessment color={getThreatScoreColor(dashboardData.threatScore)} />
|
||||
</Box>
|
||||
<Typography variant="h3" sx={{ mt: 2, color: `${getThreatScoreColor(dashboardData.threatScore)}.main` }}>
|
||||
{dashboardData.threatScore}/100
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={dashboardData.threatScore}
|
||||
color={getThreatScoreColor(dashboardData.threatScore)}
|
||||
sx={{ mt: 2, height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Anomaly Stats */}
|
||||
{['critical', 'high', 'medium', 'low'].map((severity) => (
|
||||
<Grid item xs={12} md={2.25} key={severity}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t(`siem.${severity}Anomalies`)}
|
||||
</Typography>
|
||||
<Typography variant="h4" color={`${getSeverityColor(severity)}.main`} sx={{ mt: 1 }}>
|
||||
{dashboardData.anomalyStats?.[severity] || 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} variant="fullWidth">
|
||||
<Tab icon={<Badge badgeContent={alerts.length} color="error"><Warning /></Badge>} label={t('siem.alerts')} />
|
||||
<Tab icon={<Badge badgeContent={anomalies.length} color="warning"><BugReport /></Badge>} label={t('siem.anomalies')} />
|
||||
<Tab icon={<Security />} label={t('siem.threats')} />
|
||||
<Tab icon={<Timeline />} label={t('siem.logs')} />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Alerts Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('siem.severity')}</TableCell>
|
||||
<TableCell>{t('siem.title')}</TableCell>
|
||||
<TableCell>{t('siem.description')}</TableCell>
|
||||
<TableCell>{t('siem.time')}</TableCell>
|
||||
<TableCell>{t('common.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{alerts.map((alert) => (
|
||||
<TableRow key={alert.alert_id} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={alert.severity.toUpperCase()}
|
||||
color={getSeverityColor(alert.severity)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{alert.title}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: 400 }}>
|
||||
{alert.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{format(new Date(alert.created_at), 'PPpp')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={t('siem.acknowledge')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleAcknowledgeAlert(alert.alert_id)}
|
||||
>
|
||||
<CheckCircleOutline />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('siem.viewDetails')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedItem({ ...alert, type: 'alert' });
|
||||
setDetailsDialog(true);
|
||||
}}
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Anomalies Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('siem.severity')}</TableCell>
|
||||
<TableCell>{t('siem.type')}</TableCell>
|
||||
<TableCell>{t('siem.description')}</TableCell>
|
||||
<TableCell>{t('siem.confidence')}</TableCell>
|
||||
<TableCell>{t('siem.time')}</TableCell>
|
||||
<TableCell>{t('common.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{anomalies.map((anomaly) => (
|
||||
<TableRow key={anomaly.anomaly_id} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={anomaly.severity.toUpperCase()}
|
||||
color={getSeverityColor(anomaly.severity)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={anomaly.type.replace(/_/g, ' ')} variant="outlined" size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: 400 }}>
|
||||
{anomaly.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{(anomaly.confidence * 100).toFixed(0)}%
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{format(new Date(anomaly.created_at), 'PPpp')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={t('siem.viewDetails')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedItem({ ...anomaly, type: 'anomaly' });
|
||||
setDetailsDialog(true);
|
||||
}}
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Threats Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('siem.threatLevel')}</TableCell>
|
||||
<TableCell>{t('siem.indicator')}</TableCell>
|
||||
<TableCell>{t('siem.type')}</TableCell>
|
||||
<TableCell>{t('siem.description')}</TableCell>
|
||||
<TableCell>{t('siem.occurrences')}</TableCell>
|
||||
<TableCell>{t('siem.lastSeen')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{threats.map((threat) => (
|
||||
<TableRow key={threat.id} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={threat.threat_level.toUpperCase()}
|
||||
color={getSeverityColor(threat.threat_level)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{threat.indicator}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={threat.indicator_type.toUpperCase()} variant="outlined" size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: 300 }}>
|
||||
{threat.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{threat.occurrence_count}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{format(new Date(threat.last_seen), 'PPpp')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Logs Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('siem.level')}</TableCell>
|
||||
<TableCell>{t('siem.source')}</TableCell>
|
||||
<TableCell>{t('siem.category')}</TableCell>
|
||||
<TableCell>{t('siem.message')}</TableCell>
|
||||
<TableCell>{t('siem.time')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.log_id} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={log.level.toUpperCase()}
|
||||
color={getSeverityColor(log.level === 'error' ? 'high' : log.level === 'warn' ? 'medium' : 'low')}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={log.source} variant="outlined" size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption">{log.category}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: 500 }}>
|
||||
{log.message}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{format(new Date(log.timestamp), 'PPpp')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Details Dialog */}
|
||||
<Dialog open={detailsDialog} onClose={() => setDetailsDialog(false)} maxWidth="md" fullWidth>
|
||||
{selectedItem && (
|
||||
<>
|
||||
<DialogTitle>
|
||||
{selectedItem.type === 'alert' ? t('siem.alertDetails') : t('siem.anomalyDetails')}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('siem.severity')}
|
||||
secondary={
|
||||
<Chip
|
||||
label={selectedItem.severity.toUpperCase()}
|
||||
color={getSeverityColor(selectedItem.severity)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={selectedItem.type === 'alert' ? t('siem.title') : t('siem.type')}
|
||||
secondary={selectedItem.title || selectedItem.type}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('siem.description')}
|
||||
secondary={selectedItem.description}
|
||||
/>
|
||||
</ListItem>
|
||||
{selectedItem.confidence && (
|
||||
<>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('siem.confidence')}
|
||||
secondary={`${(selectedItem.confidence * 100).toFixed(0)}%`}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('siem.time')}
|
||||
secondary={format(new Date(selectedItem.created_at), 'PPPppp')}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label={t('siem.resolutionNotes')}
|
||||
placeholder={t('siem.resolutionNotesPlaceholder')}
|
||||
sx={{ mt: 2 }}
|
||||
id="resolution-notes"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailsDialog(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const notes = document.getElementById('resolution-notes').value;
|
||||
if (selectedItem.type === 'alert') {
|
||||
handleResolveAlert(selectedItem.alert_id, notes);
|
||||
} else {
|
||||
handleResolveAnomaly(selectedItem.anomaly_id, notes);
|
||||
}
|
||||
}}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t('siem.resolve')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityIntelligenceDashboard;
|
||||
1062
frontend/src/pages/SecurityMonitor.jsx
Normal file
46
frontend/src/pages/Series.jsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
import { Box, Container, Typography, Button, Grid, Card, CardMedia, CardContent, Chip, Badge } from '@mui/material';
|
||||
import { Settings, PlayArrow } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Logo from '../components/Logo';
|
||||
|
||||
const Series = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5">{t('series')}</Typography>
|
||||
<Button size="small" startIcon={<Settings fontSize="small" />} onClick={() => navigate('/settings')}>
|
||||
Settings
|
||||
</Button>
|
||||
</Box>
|
||||
<Grid container spacing={1.5}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2} key={i}>
|
||||
<Card sx={{ cursor: 'pointer', '&:hover': { transform: 'scale(1.02)' }, transition: 'transform 0.2s' }}>
|
||||
<Badge badgeContent={`S${i}E${i * 2}`} color="primary" sx={{ '& .MuiBadge-badge': { fontSize: '0.65rem', height: 18, minWidth: 18 } }}>
|
||||
<Box sx={{ width: '100%', height: 180, bgcolor: 'background.default', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Logo size={80} />
|
||||
</Box>
|
||||
</Badge>
|
||||
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>Series Title {i}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{i} Seasons</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Chip label="Drama" size="small" sx={{ height: 20, fontSize: '0.65rem' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Series;
|
||||
936
frontend/src/pages/Settings.jsx
Normal file
|
|
@ -0,0 +1,936 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { Delete, Add, Upload, Link as LinkIcon, Edit, CloudUpload, Tv, Radio, Download } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import UserManagement from '../components/UserManagement';
|
||||
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
||||
import TwoFactorSettings from '../components/TwoFactorSettings';
|
||||
import VPNConfigManager from '../components/VPNConfigManager';
|
||||
import BackupRestore from '../components/BackupRestore';
|
||||
import SecurityStatusCard from '../components/SecurityStatusCard';
|
||||
import SessionManagement from '../components/SessionManagement';
|
||||
|
||||
const Settings = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { mode, toggleTheme } = useThemeStore();
|
||||
const { logout, token, user } = useAuthStore();
|
||||
const [playlists, setPlaylists] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [playlistUrl, setPlaylistUrl] = useState('');
|
||||
const [playlistName, setPlaylistName] = useState('');
|
||||
const [playlistUsername, setPlaylistUsername] = useState('');
|
||||
const [playlistPassword, setPlaylistPassword] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// M3U Library state
|
||||
const [m3uFiles, setM3uFiles] = useState([]);
|
||||
const [openM3uDialog, setOpenM3uDialog] = useState(false);
|
||||
const [m3uFileName, setM3uFileName] = useState('');
|
||||
const [selectedM3uFile, setSelectedM3uFile] = useState(null);
|
||||
const [uploadingM3u, setUploadingM3u] = useState(false);
|
||||
const [openImportDialog, setOpenImportDialog] = useState(false);
|
||||
const [importFileId, setImportFileId] = useState(null);
|
||||
const [importType, setImportType] = useState('tv'); // 'tv' or 'radio'
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [renameFileId, setRenameFileId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
// Stream settings state
|
||||
const [streamSettings, setStreamSettings] = useState({
|
||||
hwaccel: 'auto',
|
||||
hwaccel_device: '/dev/dri/renderD128',
|
||||
codec: 'h264',
|
||||
preset: 'veryfast',
|
||||
buffer_size: '2M',
|
||||
max_bitrate: '8M'
|
||||
});
|
||||
const [hwCapabilities, setHwCapabilities] = useState({
|
||||
quicksync: false,
|
||||
nvenc: false,
|
||||
vaapi: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists();
|
||||
fetchM3uFiles();
|
||||
fetchStreamSettings();
|
||||
fetchHwCapabilities();
|
||||
}, []);
|
||||
|
||||
const fetchPlaylists = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/playlists', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPlaylists(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load playlists');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = (event) => {
|
||||
i18n.changeLanguage(event.target.value);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleDeletePlaylist = async (id) => {
|
||||
try {
|
||||
const response = await fetch(`/api/playlists/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
fetchPlaylists();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete playlist');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPlaylist = async () => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
if (tabValue === 0) {
|
||||
// URL method
|
||||
const response = await fetch('http://localhost:12345/api/playlists/url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: playlistUrl,
|
||||
name: playlistName,
|
||||
username: playlistUsername,
|
||||
password: playlistPassword,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
setOpenDialog(false);
|
||||
resetForm();
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
setError('Failed to add playlist from URL');
|
||||
}
|
||||
} else {
|
||||
// File upload
|
||||
const formData = new FormData();
|
||||
formData.append('playlist', selectedFile);
|
||||
if (playlistName) formData.append('name', playlistName);
|
||||
|
||||
const response = await fetch('/api/playlists/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (response.ok) {
|
||||
setOpenDialog(false);
|
||||
resetForm();
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
setError('Failed to upload playlist');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to add playlist');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setPlaylistUrl('');
|
||||
setPlaylistName('');
|
||||
setPlaylistUsername('');
|
||||
setPlaylistPassword('');
|
||||
setSelectedFile(null);
|
||||
setTabValue(0);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// M3U Library functions
|
||||
const fetchM3uFiles = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/m3u-files', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setM3uFiles(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load M3U files:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Stream settings functions
|
||||
const fetchStreamSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/stream_settings', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStreamSettings(data.value || streamSettings);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Using default stream settings');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHwCapabilities = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stream/capabilities', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHwCapabilities(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hardware capabilities');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStreamSettingChange = async (key, value) => {
|
||||
const newSettings = { ...streamSettings, [key]: value };
|
||||
setStreamSettings(newSettings);
|
||||
|
||||
try {
|
||||
await fetch('/api/settings/stream_settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ value: streamSettings }),
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Failed to update stream settings');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleUploadM3u = async () => {
|
||||
if (!selectedM3uFile || !m3uFileName.trim()) {
|
||||
setError('Please select a file and provide a name');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingM3u(true);
|
||||
setError('');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('m3u', selectedM3uFile);
|
||||
formData.append('name', m3uFileName);
|
||||
|
||||
const response = await fetch('/api/m3u-files/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setOpenM3uDialog(false);
|
||||
resetM3uForm();
|
||||
fetchM3uFiles();
|
||||
} else {
|
||||
setError('Failed to upload M3U file');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to upload M3U file');
|
||||
} finally {
|
||||
setUploadingM3u(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameM3u = async () => {
|
||||
if (!renameValue.trim()) {
|
||||
setError('Please provide a name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/m3u-files/${renameFileId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name: newM3uFileName }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setRenameDialogOpen(false);
|
||||
setRenameFileId(null);
|
||||
setRenameValue('');
|
||||
fetchM3uFiles();
|
||||
} else {
|
||||
setError('Failed to rename M3U file');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to rename M3U file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteM3u = async (id) => {
|
||||
try {
|
||||
const response = await fetch(`/api/m3u-files/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchM3uFiles();
|
||||
} else {
|
||||
setError('Failed to delete M3U file');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete M3U file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadM3u = async (id, filename) => {
|
||||
try {
|
||||
const response = await fetch(`/api/m3u-files/${id}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
setSuccess(t('settings.fileDownloaded'));
|
||||
} else {
|
||||
setError(t('settings.failedToDownload'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Download error:', err);
|
||||
setError(t('settings.failedToDownload'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportM3u = async () => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setOpenImportDialog(false); // Close modal immediately
|
||||
setImportFileId(null);
|
||||
|
||||
try {
|
||||
console.log('Importing M3U file:', importFileId, 'as type:', importType);
|
||||
setSuccess('Importing channels, please wait...');
|
||||
|
||||
const response = await fetch(`/api/m3u-files/${importFileId}/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ type: importType }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Import response:', data);
|
||||
|
||||
if (response.ok) {
|
||||
fetchPlaylists();
|
||||
setSuccess(`✓ Successfully imported ${data.channels_added} ${importType === 'radio' ? 'radio stations' : 'TV channels'}`);
|
||||
setTimeout(() => setSuccess(''), 5000); // Clear after 5 seconds
|
||||
} else {
|
||||
setError(data.error || 'Failed to import M3U file');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
setError('Failed to import M3U file. Please try again.');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const resetM3uForm = () => {
|
||||
setM3uFileName('');
|
||||
setSelectedM3uFile(null);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
{t('settings.title')}
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>{success}</Alert>}
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
{t('settings.playlists')}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setOpenDialog(true)}
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
{t('settings.addPlaylist')}
|
||||
</Button>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : playlists.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
{t('settings.noPlaylists')}
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense disablePadding>
|
||||
{playlists.map((playlist) => (
|
||||
<React.Fragment key={playlist.id}>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<ListItemText
|
||||
primary={playlist.name}
|
||||
secondary={`${playlist.channel_count || 0} channels`}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={() => handleDeletePlaylist(playlist.id)}
|
||||
color="error"
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{t('settings.m3uLibrary')}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setOpenM3uDialog(true)}
|
||||
>
|
||||
{t('settings.uploadM3u')}
|
||||
</Button>
|
||||
</Box>
|
||||
{m3uFiles.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
{t('settings.noM3uFiles')}
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense disablePadding>
|
||||
{m3uFiles.map((file) => (
|
||||
<React.Fragment key={file.id}>
|
||||
<ListItem sx={{ px: 0, flexWrap: 'wrap' }}>
|
||||
<ListItemText
|
||||
primary={file.name}
|
||||
secondary={`${formatFileSize(file.size)} • ${formatDate(file.created_at)}`}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 500 }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDownloadM3u(file.id, file.original_filename || `${file.name}.m3u`)}
|
||||
title={t('settings.downloadM3uFile')}
|
||||
>
|
||||
<Download fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRenameFileId(file.id);
|
||||
setRenameValue(file.name);
|
||||
setRenameDialogOpen(true);
|
||||
}}
|
||||
title="Rename"
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Tv />}
|
||||
onClick={() => {
|
||||
setImportFileId(file.id);
|
||||
setImportType('tv');
|
||||
setOpenImportDialog(true);
|
||||
}}
|
||||
sx={{ minWidth: 'auto', px: 1 }}
|
||||
>
|
||||
{t('common.tv')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Radio />}
|
||||
onClick={() => {
|
||||
setImportFileId(file.id);
|
||||
setImportType('radio');
|
||||
setOpenImportDialog(true);
|
||||
}}
|
||||
sx={{ minWidth: 'auto', px: 1 }}
|
||||
>
|
||||
{t('common.radio')}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteM3u(file.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
{t('settings.streaming')}
|
||||
</Typography>
|
||||
|
||||
{Object.values(hwCapabilities).some(v => v) && (
|
||||
<Alert severity="success" sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
{t('settings.hwAccelAvailable')}: {
|
||||
[
|
||||
hwCapabilities.quicksync && 'Intel Quick Sync',
|
||||
hwCapabilities.nvenc && 'NVIDIA NVENC',
|
||||
hwCapabilities.vaapi && 'VAAPI'
|
||||
].filter(Boolean).join(', ')
|
||||
}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 1.5 }}>
|
||||
<InputLabel>{t('settings.hwAccel')}</InputLabel>
|
||||
<Select
|
||||
value={streamSettings.hwaccel}
|
||||
onChange={(e) => handleStreamSettingChange('hwaccel', e.target.value)}
|
||||
label={t('settings.hwAccel')}
|
||||
>
|
||||
<MenuItem value="auto">{t('settings.hwAccelAuto')}</MenuItem>
|
||||
<MenuItem value="quicksync" disabled={!hwCapabilities.quicksync}>
|
||||
Intel Quick Sync {!hwCapabilities.quicksync && t('settings.hwAccelNotAvailable')}
|
||||
</MenuItem>
|
||||
<MenuItem value="vaapi" disabled={!hwCapabilities.vaapi}>
|
||||
VAAPI {!hwCapabilities.vaapi && t('settings.hwAccelNotAvailable')}
|
||||
</MenuItem>
|
||||
<MenuItem value="nvenc" disabled={!hwCapabilities.nvenc}>
|
||||
NVIDIA NVENC {!hwCapabilities.nvenc && t('settings.hwAccelNotAvailable')}
|
||||
</MenuItem>
|
||||
<MenuItem value="none">{t('settings.hwAccelNone')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{streamSettings.hwaccel !== 'none' && streamSettings.hwaccel !== 'nvenc' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.hwDevice')}
|
||||
value={streamSettings.hwaccel_device}
|
||||
onChange={(e) => handleStreamSettingChange('hwaccel_device', e.target.value)}
|
||||
sx={{ mb: 1.5 }}
|
||||
helperText={t('settings.hwDeviceHelper')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 1.5 }}>
|
||||
<InputLabel>{t('settings.encoderPreset')}</InputLabel>
|
||||
<Select
|
||||
value={streamSettings.preset}
|
||||
onChange={(e) => handleStreamSettingChange('preset', e.target.value)}
|
||||
label={t('settings.encoderPreset')}
|
||||
>
|
||||
<MenuItem value="ultrafast">{t('settings.presetUltrafast')}</MenuItem>
|
||||
<MenuItem value="superfast">{t('settings.presetSuperfast')}</MenuItem>
|
||||
<MenuItem value="veryfast">{t('settings.presetVeryfast')}</MenuItem>
|
||||
<MenuItem value="faster">{t('settings.presetFaster')}</MenuItem>
|
||||
<MenuItem value="fast">{t('settings.presetFast')}</MenuItem>
|
||||
<MenuItem value="medium">{t('settings.presetMedium')}</MenuItem>
|
||||
<MenuItem value="slow">{t('settings.presetSlow')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 1.5 }}>
|
||||
<InputLabel>{t('settings.bufferSize')}</InputLabel>
|
||||
<Select
|
||||
value={streamSettings.buffer_size}
|
||||
onChange={(e) => handleStreamSettingChange('buffer_size', e.target.value)}
|
||||
label={t('settings.bufferSize')}
|
||||
>
|
||||
<MenuItem value="512K">512 KB ({t('settings.bufferLowLatency')})</MenuItem>
|
||||
<MenuItem value="1M">1 MB</MenuItem>
|
||||
<MenuItem value="2M">2 MB ({t('settings.hwAccelAuto').split(' ')[0]})</MenuItem>
|
||||
<MenuItem value="4M">4 MB</MenuItem>
|
||||
<MenuItem value="8M">8 MB ({t('settings.bufferSmooth')})</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('settings.maxBitrate')}</InputLabel>
|
||||
<Select
|
||||
value={streamSettings.max_bitrate}
|
||||
onChange={(e) => handleStreamSettingChange('max_bitrate', e.target.value)}
|
||||
label={t('settings.maxBitrate')}
|
||||
>
|
||||
<MenuItem value="2M">2 Mbps ({t('settings.bitrateLow')})</MenuItem>
|
||||
<MenuItem value="4M">4 Mbps</MenuItem>
|
||||
<MenuItem value="8M">8 Mbps ({t('settings.hwAccelAuto').split(' ')[0]})</MenuItem>
|
||||
<MenuItem value="12M">12 Mbps</MenuItem>
|
||||
<MenuItem value="20M">20 Mbps ({t('settings.bitrateHigh')})</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2, fontSize: '0.75rem' }}>
|
||||
{t('settings.quickSyncInfo')}
|
||||
</Alert>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
{t('settings.appearance')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={mode === 'dark'} onChange={toggleTheme} size="small" />}
|
||||
label={<Typography variant="body2">{t('settings.darkMode')}</Typography>}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
{t('settings.language')}
|
||||
</Typography>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('settings.selectLanguage')}</InputLabel>
|
||||
<Select value={i18n.language} onChange={handleLanguageChange} label={t('settings.selectLanguage')}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="ro">Română</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<SecurityStatusCard />
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<TwoFactorSettings />
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<SessionManagement />
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<VPNConfigManager />
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<BackupRestore />
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 600 }}>
|
||||
{t('settings.account')}
|
||||
</Typography>
|
||||
<Button size="small" variant="outlined" color="error" onClick={handleLogout}>
|
||||
{t('settings.logout')}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Dialog open={openDialog} onClose={() => { setOpenDialog(false); resetForm(); }} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6">{t('settings.addPlaylist')}</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ mb: 2 }}>
|
||||
<Tab icon={<LinkIcon />} label="URL" iconPosition="start" sx={{ minHeight: 40, fontSize: '0.8125rem' }} />
|
||||
<Tab icon={<Upload />} label="Upload" iconPosition="start" sx={{ minHeight: 40, fontSize: '0.8125rem' }} />
|
||||
</Tabs>
|
||||
|
||||
{tabValue === 0 ? (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Playlist URL"
|
||||
value={playlistUrl}
|
||||
onChange={(e) => setPlaylistUrl(e.target.value)}
|
||||
sx={{ mb: 1.5 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.playlistName')}
|
||||
value={playlistName}
|
||||
onChange={(e) => setPlaylistName(e.target.value)}
|
||||
sx={{ mb: 1.5 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Username (optional)"
|
||||
value={playlistUsername}
|
||||
onChange={(e) => setPlaylistUsername(e.target.value)}
|
||||
sx={{ mb: 1.5 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type="password"
|
||||
label="Password (optional)"
|
||||
value={playlistPassword}
|
||||
onChange={(e) => setPlaylistPassword(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mb: 1.5, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{selectedFile ? selectedFile.name : t('settings.selectFile')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".m3u,.m3u8"
|
||||
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||
/>
|
||||
</Button>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.playlistName')}
|
||||
value={playlistName}
|
||||
onChange={(e) => setPlaylistName(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={() => { setOpenDialog(false); resetForm(); }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleAddPlaylist}
|
||||
disabled={submitting || (tabValue === 0 ? !playlistUrl : !selectedFile)}
|
||||
>
|
||||
{submitting ? <CircularProgress size={20} /> : t('common.add')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* M3U Upload Dialog */}
|
||||
<Dialog open={openM3uDialog} onClose={() => { setOpenM3uDialog(false); resetM3uForm(); }} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6">{t('settings.uploadM3uFile')}</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.fileName')}
|
||||
value={m3uFileName}
|
||||
onChange={(e) => setM3uFileName(e.target.value)}
|
||||
sx={{ mb: 1.5, mt: 1 }}
|
||||
helperText={t('settings.fileNameHelper')}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
fullWidth
|
||||
size="small"
|
||||
startIcon={<Upload />}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{selectedM3uFile ? selectedM3uFile.name : t('settings.selectM3uFile')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".m3u,.m3u8"
|
||||
onChange={(e) => setSelectedM3uFile(e.target.files[0])}
|
||||
/>
|
||||
</Button>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={() => { setOpenM3uDialog(false); resetM3uForm(); }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleUploadM3u}
|
||||
disabled={uploadingM3u || !selectedM3uFile || !m3uFileName.trim()}
|
||||
>
|
||||
{uploadingM3u ? <CircularProgress size={20} /> : t('common.upload')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
<Dialog open={renameDialogOpen} onClose={() => { setRenameDialogOpen(false); setRenameFileId(null); setRenameValue(''); }} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6">{t('settings.renameM3uFile')}</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.fileName')}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
sx={{ mt: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={() => { setRenameDialogOpen(false); setRenameFileId(null); setRenameValue(''); }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleRenameM3u}
|
||||
disabled={!renameValue.trim()}
|
||||
>
|
||||
{t('common.rename')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Import Dialog */}
|
||||
<Dialog open={openImportDialog} onClose={() => { setOpenImportDialog(false); setImportFileId(null); }} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6">{t('settings.importM3uFile')}</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{t('settings.importMessage', { type: importType === 'tv' ? t('common.tv') : t('common.radio') })}
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ fontSize: '0.75rem' }}>
|
||||
{t('settings.importInfo', { section: importType === 'tv' ? t('settings.liveTV') : t('common.radio') })}
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={() => { setOpenImportDialog(false); setImportFileId(null); }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleImportM3u}
|
||||
>
|
||||
{t('common.import')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* User Management Section - Admin Only */}
|
||||
{user?.role === 'admin' && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontSize: '1rem', fontWeight: 600 }}>
|
||||
{t('settings.userManagement')}
|
||||
</Typography>
|
||||
<UserManagement />
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
396
frontend/src/pages/Stats.jsx
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
Tabs,
|
||||
Tab
|
||||
} from '@mui/material';
|
||||
import {
|
||||
People,
|
||||
Tv,
|
||||
Radio,
|
||||
PlaylistPlay,
|
||||
TrendingUp,
|
||||
Speed,
|
||||
HealthAndSafety,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import axios from 'axios';
|
||||
|
||||
function TabPanel({ children, value, index, ...other }) {
|
||||
return (
|
||||
<div hidden={value !== index} {...other}>
|
||||
{value === index && <Box sx={{ py: 2 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Stats = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user } = useAuthStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [topChannels, setTopChannels] = useState([]);
|
||||
const [usageByHour, setUsageByHour] = useState([]);
|
||||
const [trends, setTrends] = useState([]);
|
||||
const [userActivity, setUserActivity] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === 'admin') {
|
||||
fetchAllStats();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchAllStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [overviewRes, channelsRes, hourlyRes, trendsRes, usersRes] = await Promise.all([
|
||||
axios.get('/api/stats/overview', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
axios.get('/api/stats/top-channels?limit=15', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
axios.get('/api/stats/usage-by-hour?days=7', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
axios.get('/api/stats/trends?days=14', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
axios.get('/api/stats/user-activity?days=30', { headers: { Authorization: `Bearer ${token}` } })
|
||||
]);
|
||||
|
||||
setOverview(overviewRes.data);
|
||||
setTopChannels(channelsRes.data);
|
||||
setUsageByHour(hourlyRes.data);
|
||||
setTrends(trendsRes.data);
|
||||
setUserActivity(usersRes.data);
|
||||
} catch (err) {
|
||||
setError('Failed to load statistics');
|
||||
console.error('Stats fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '0m';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="error">Admin access required to view statistics.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
||||
Analytics Dashboard
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Overview Cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
Total Users
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{overview?.totalUsers || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
<People sx={{ fontSize: 48, color: 'primary.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
TV Channels
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{overview?.totalTvChannels || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tv sx={{ fontSize: 48, color: 'success.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
Radio Channels
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{overview?.totalRadioChannels || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Radio sx={{ fontSize: 48, color: 'info.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
Total Views
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{overview?.totalWatchHistory?.toLocaleString() || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TrendingUp sx={{ fontSize: 48, color: 'warning.main', opacity: 0.3 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Channel Health */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<HealthAndSafety sx={{ mr: 1 }} />
|
||||
<Typography variant="h6">Channel Health Status</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CheckCircle sx={{ color: 'success.main', mr: 1 }} />
|
||||
<Typography>Healthy</Typography>
|
||||
</Box>
|
||||
<Chip label={overview?.channelHealth?.healthy || 0} color="success" size="small" />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Warning sx={{ color: 'warning.main', mr: 1 }} />
|
||||
<Typography>Degraded</Typography>
|
||||
</Box>
|
||||
<Chip label={overview?.channelHealth?.degraded || 0} color="warning" size="small" />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ErrorIcon sx={{ color: 'error.main', mr: 1 }} />
|
||||
<Typography>Dead</Typography>
|
||||
</Box>
|
||||
<Chip label={overview?.channelHealth?.dead || 0} color="error" size="small" />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Resources */}
|
||||
{overview?.system && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Speed sx={{ mr: 1 }} />
|
||||
<Typography variant="h6">System Resources</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">Memory Usage</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5 }}>
|
||||
<Box sx={{ flexGrow: 1, mr: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(1 - (overview.system.freeMemory / overview.system.totalMemory)) * 100}
|
||||
sx={{ height: 8, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2">
|
||||
{formatBytes(overview.system.totalMemory - overview.system.freeMemory)} / {formatBytes(overview.system.totalMemory)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">Server Uptime</Typography>
|
||||
<Typography variant="h6">
|
||||
{Math.floor(overview.system.uptime / 3600)}h {Math.floor((overview.system.uptime % 3600) / 60)}m
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs for detailed stats */}
|
||||
<Card>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)}>
|
||||
<Tab label="Top Channels" />
|
||||
<Tab label="Usage Patterns" />
|
||||
<Tab label="User Activity" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Top Channels */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Channel</TableCell>
|
||||
<TableCell>Group</TableCell>
|
||||
<TableCell align="right">Views</TableCell>
|
||||
<TableCell align="right">Unique Users</TableCell>
|
||||
<TableCell align="right">Total Duration</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topChannels.map((channel, index) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" fontWeight={index < 3 ? 'bold' : 'normal'}>
|
||||
#{index + 1} {channel.name}
|
||||
</Typography>
|
||||
{channel.is_radio && <Chip label="Radio" size="small" sx={{ ml: 1 }} />}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{channel.group_name || '-'}</TableCell>
|
||||
<TableCell align="right">{channel.watch_count}</TableCell>
|
||||
<TableCell align="right">{channel.unique_users}</TableCell>
|
||||
<TableCell align="right">{formatDuration(channel.total_duration)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Usage Patterns */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Typography variant="subtitle2" gutterBottom>Peak Usage Hours (Last 7 Days)</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Hour</TableCell>
|
||||
<TableCell align="right">View Count</TableCell>
|
||||
<TableCell align="right">Unique Users</TableCell>
|
||||
<TableCell>Activity</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{usageByHour.map((hour) => (
|
||||
<TableRow key={hour.hour}>
|
||||
<TableCell>{hour.hour}:00</TableCell>
|
||||
<TableCell align="right">{hour.view_count}</TableCell>
|
||||
<TableCell align="right">{hour.unique_users}</TableCell>
|
||||
<TableCell>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(hour.view_count / Math.max(...usageByHour.map(h => h.view_count))) * 100}
|
||||
sx={{ height: 6, borderRadius: 1 }}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* User Activity */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Typography variant="subtitle2" gutterBottom>User Activity (Last 30 Days)</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell align="right">Watch Count</TableCell>
|
||||
<TableCell align="right">Total Duration</TableCell>
|
||||
<TableCell>Last Active</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{userActivity.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell align="right">{user.watch_count || 0}</TableCell>
|
||||
<TableCell align="right">{formatDuration(user.total_duration)}</TableCell>
|
||||
<TableCell>
|
||||
{user.last_active ? new Date(user.last_active).toLocaleDateString() : 'Never'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
25
frontend/src/store/authStore.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export const useAuthStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
login: (user, token) => set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: user.must_change_password || false
|
||||
}),
|
||||
logout: () => set({ user: null, token: null, isAuthenticated: false, mustChangePassword: false }),
|
||||
updateUser: (user) => set({ user, mustChangePassword: user.must_change_password || false }),
|
||||
clearPasswordFlag: () => set({ mustChangePassword: false })
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage'
|
||||
}
|
||||
)
|
||||
);
|
||||
15
frontend/src/store/themeStore.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export const useThemeStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
mode: 'dark',
|
||||
toggleTheme: () => set((state) => ({ mode: state.mode === 'light' ? 'dark' : 'light' })),
|
||||
setTheme: (mode) => set({ mode })
|
||||
}),
|
||||
{
|
||||
name: 'theme-storage'
|
||||
}
|
||||
)
|
||||
);
|
||||
311
frontend/src/theme.js
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#a855f7',
|
||||
light: '#c084fc',
|
||||
dark: '#9333ea'
|
||||
},
|
||||
secondary: {
|
||||
main: '#3b82f6',
|
||||
light: '#60a5fa',
|
||||
dark: '#2563eb'
|
||||
},
|
||||
background: {
|
||||
default: '#F5F7FA',
|
||||
paper: 'rgba(255, 255, 255, 0.95)'
|
||||
},
|
||||
text: {
|
||||
primary: '#1A202C',
|
||||
secondary: '#718096'
|
||||
}
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12
|
||||
},
|
||||
typography: {
|
||||
fontSize: 13,
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif',
|
||||
h1: { fontSize: '1.75rem', fontWeight: 700, lineHeight: 1.2 },
|
||||
h2: { fontSize: '1.5rem', fontWeight: 700, lineHeight: 1.2 },
|
||||
h3: { fontSize: '1.25rem', fontWeight: 600, lineHeight: 1.3 },
|
||||
h4: { fontSize: '1.125rem', fontWeight: 600, lineHeight: 1.3 },
|
||||
h5: { fontSize: '1rem', fontWeight: 600, lineHeight: 1.4 },
|
||||
h6: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.4 },
|
||||
body1: { fontSize: '0.875rem', lineHeight: 1.5 },
|
||||
body2: { fontSize: '0.8125rem', lineHeight: 1.5 },
|
||||
button: { fontSize: '0.8125rem', fontWeight: 600, textTransform: 'none' },
|
||||
caption: { fontSize: '0.75rem', lineHeight: 1.4 }
|
||||
},
|
||||
spacing: 8,
|
||||
components: {
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: '0 4px 16px 0 rgba(31, 38, 135, 0.12)'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '6px 16px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
minHeight: 32
|
||||
},
|
||||
contained: {
|
||||
boxShadow: '0 2px 8px rgba(33, 150, 243, 0.25)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(33, 150, 243, 0.35)'
|
||||
}
|
||||
},
|
||||
small: {
|
||||
padding: '4px 12px',
|
||||
fontSize: '0.75rem',
|
||||
minHeight: 28
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 6,
|
||||
backdropFilter: 'blur(10px)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)'
|
||||
}
|
||||
},
|
||||
small: {
|
||||
padding: 4
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 4px 16px 0 rgba(31, 38, 135, 0.12)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: 28,
|
||||
fontSize: '0.75rem'
|
||||
},
|
||||
small: {
|
||||
height: 24,
|
||||
fontSize: '0.6875rem'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiInputBase-root': {
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiListItem: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiListItemText: {
|
||||
styleOverrides: {
|
||||
primary: {
|
||||
fontSize: '0.875rem'
|
||||
},
|
||||
secondary: {
|
||||
fontSize: '0.75rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif',
|
||||
h1: {
|
||||
fontWeight: 700
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 700
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 600
|
||||
},
|
||||
button: {
|
||||
fontWeight: 600
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#a855f7',
|
||||
light: '#c084fc',
|
||||
dark: '#9333ea'
|
||||
},
|
||||
secondary: {
|
||||
main: '#3b82f6',
|
||||
light: '#60a5fa',
|
||||
dark: '#2563eb'
|
||||
},
|
||||
background: {
|
||||
default: '#0F1419',
|
||||
paper: 'rgba(26, 32, 44, 0.95)'
|
||||
},
|
||||
text: {
|
||||
primary: '#F7FAFC',
|
||||
secondary: '#A0AEC0'
|
||||
}
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12
|
||||
},
|
||||
typography: {
|
||||
fontSize: 13,
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif',
|
||||
h1: { fontSize: '1.75rem', fontWeight: 700, lineHeight: 1.2 },
|
||||
h2: { fontSize: '1.5rem', fontWeight: 700, lineHeight: 1.2 },
|
||||
h3: { fontSize: '1.25rem', fontWeight: 600, lineHeight: 1.3 },
|
||||
h4: { fontSize: '1.125rem', fontWeight: 600, lineHeight: 1.3 },
|
||||
h5: { fontSize: '1rem', fontWeight: 600, lineHeight: 1.4 },
|
||||
h6: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.4 },
|
||||
body1: { fontSize: '0.875rem', lineHeight: 1.5 },
|
||||
body2: { fontSize: '0.8125rem', lineHeight: 1.5 },
|
||||
button: { fontSize: '0.8125rem', fontWeight: 600, textTransform: 'none' },
|
||||
caption: { fontSize: '0.75rem', lineHeight: 1.4 }
|
||||
},
|
||||
spacing: 8,
|
||||
components: {
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: '0 4px 16px 0 rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '6px 16px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
minHeight: 32
|
||||
},
|
||||
contained: {
|
||||
boxShadow: '0 2px 8px rgba(33, 150, 243, 0.25)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(33, 150, 243, 0.35)'
|
||||
}
|
||||
},
|
||||
small: {
|
||||
padding: '4px 12px',
|
||||
fontSize: '0.75rem',
|
||||
minHeight: 28
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 6,
|
||||
backdropFilter: 'blur(10px)',
|
||||
backgroundColor: 'rgba(26, 32, 44, 0.8)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(26, 32, 44, 0.95)'
|
||||
}
|
||||
},
|
||||
small: {
|
||||
padding: 4
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 4px 16px 0 rgba(0, 0, 0, 0.3)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
backgroundColor: 'rgba(26, 32, 44, 0.95)'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: 28,
|
||||
fontSize: '0.75rem'
|
||||
},
|
||||
small: {
|
||||
height: 24,
|
||||
fontSize: '0.6875rem'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiInputBase-root': {
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiListItem: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiListItemText: {
|
||||
styleOverrides: {
|
||||
primary: {
|
||||
fontSize: '0.875rem'
|
||||
},
|
||||
secondary: {
|
||||
fontSize: '0.75rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif',
|
||||
h1: {
|
||||
fontWeight: 700
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 700
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 600
|
||||
},
|
||||
button: {
|
||||
fontWeight: 600
|
||||
}
|
||||
}
|
||||
});
|
||||
74
frontend/src/utils/api.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import axios from 'axios';
|
||||
import { getErrorMessage, isAuthError, handleError } from './errorHandler';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000 // 30 second timeout
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
try {
|
||||
const { state } = JSON.parse(authStorage);
|
||||
if (state?.token) {
|
||||
config.headers.Authorization = `Bearer ${state.token}`;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle JSON parse errors - don't expose to user
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to parse auth token from storage');
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// Log error in development only
|
||||
handleError(error, 'API Request');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle errors securely
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle authentication errors
|
||||
if (isAuthError(error)) {
|
||||
// Clear auth and redirect to login
|
||||
localStorage.removeItem('auth-storage');
|
||||
|
||||
// Only redirect if not already on login page
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// Log error (sanitized in production)
|
||||
handleError(error, 'API Response');
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Utility function to proxy external logo URLs
|
||||
export const getProxiedLogoUrl = (logoUrl) => {
|
||||
if (!logoUrl) return logoUrl;
|
||||
|
||||
// Check if it's an external URL (starts with http:// or https://)
|
||||
if (logoUrl.startsWith('http://') || logoUrl.startsWith('https://')) {
|
||||
// Proxy external URLs through our backend to handle CORS
|
||||
return `/api/logo-proxy?url=${encodeURIComponent(logoUrl)}`;
|
||||
}
|
||||
|
||||
// Return local URLs as-is
|
||||
return logoUrl;
|
||||
};
|
||||
|
||||
export default api;
|
||||
293
frontend/src/utils/errorHandler.js
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Frontend Error Handler Utility
|
||||
* Provides user-friendly error messages and secure error handling
|
||||
* Prevents exposure of technical details to users
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from API response
|
||||
*
|
||||
* @param {Error} error - Axios error object
|
||||
* @param {string} defaultMessage - Default message if error cannot be parsed
|
||||
* @returns {string} User-friendly error message
|
||||
*/
|
||||
export function getErrorMessage(error, defaultMessage = 'An unexpected error occurred') {
|
||||
// Check if error response exists
|
||||
if (error?.response?.data) {
|
||||
const { data } = error.response;
|
||||
|
||||
// Server returned a structured error
|
||||
if (data.error) {
|
||||
return typeof data.error === 'string' ? data.error : data.error.message || defaultMessage;
|
||||
}
|
||||
|
||||
// Server returned validation errors array
|
||||
if (data.errors && Array.isArray(data.errors)) {
|
||||
return data.errors.map(e => e.msg || e.message).join(', ');
|
||||
}
|
||||
|
||||
// Server returned a message
|
||||
if (data.message) {
|
||||
return data.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Network error
|
||||
if (error?.message === 'Network Error' || error?.code === 'ERR_NETWORK') {
|
||||
return 'Network error. Please check your connection and try again.';
|
||||
}
|
||||
|
||||
// Timeout error
|
||||
if (error?.code === 'ECONNABORTED' || error?.message?.includes('timeout')) {
|
||||
return 'Request timeout. Please try again.';
|
||||
}
|
||||
|
||||
// HTTP status-based messages
|
||||
if (error?.response?.status) {
|
||||
const status = error.response.status;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'Invalid request. Please check your input.';
|
||||
case 401:
|
||||
return 'Authentication required. Please log in again.';
|
||||
case 403:
|
||||
return 'You do not have permission to perform this action.';
|
||||
case 404:
|
||||
return 'Resource not found.';
|
||||
case 409:
|
||||
return 'This operation conflicts with existing data.';
|
||||
case 422:
|
||||
return 'Unable to process your request. Please check your input.';
|
||||
case 429:
|
||||
return 'Too many requests. Please wait a moment and try again.';
|
||||
case 500:
|
||||
return 'Server error. Please try again later.';
|
||||
case 502:
|
||||
return 'Bad gateway. The server is temporarily unavailable.';
|
||||
case 503:
|
||||
return 'Service temporarily unavailable. Please try again later.';
|
||||
case 504:
|
||||
return 'Gateway timeout. Please try again.';
|
||||
default:
|
||||
return defaultMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error message as fallback
|
||||
return error?.message || defaultMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is an authentication error
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @returns {boolean} True if authentication error
|
||||
*/
|
||||
export function isAuthError(error) {
|
||||
return error?.response?.status === 401 ||
|
||||
error?.response?.data?.code === 'UNAUTHORIZED' ||
|
||||
error?.response?.data?.code === 'AUTH_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a permission error
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @returns {boolean} True if permission error
|
||||
*/
|
||||
export function isPermissionError(error) {
|
||||
return error?.response?.status === 403 ||
|
||||
error?.response?.data?.code === 'FORBIDDEN' ||
|
||||
error?.response?.data?.code === 'PERMISSION_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a validation error
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @returns {boolean} True if validation error
|
||||
*/
|
||||
export function isValidationError(error) {
|
||||
return error?.response?.status === 400 ||
|
||||
error?.response?.status === 422 ||
|
||||
error?.response?.data?.code === 'VALIDATION_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a network error
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @returns {boolean} True if network error
|
||||
*/
|
||||
export function isNetworkError(error) {
|
||||
return error?.message === 'Network Error' ||
|
||||
error?.code === 'ERR_NETWORK' ||
|
||||
error?.code === 'ECONNABORTED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type classification
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @returns {string} Error type: 'auth', 'permission', 'validation', 'network', 'server', 'unknown'
|
||||
*/
|
||||
export function getErrorType(error) {
|
||||
if (isAuthError(error)) return 'auth';
|
||||
if (isPermissionError(error)) return 'permission';
|
||||
if (isValidationError(error)) return 'validation';
|
||||
if (isNetworkError(error)) return 'network';
|
||||
if (error?.response?.status >= 500) return 'server';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error severity level
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @returns {string} Severity: 'error', 'warning', 'info'
|
||||
*/
|
||||
export function getErrorSeverity(error) {
|
||||
const status = error?.response?.status;
|
||||
|
||||
if (!status) return 'error';
|
||||
|
||||
if (status === 401 || status === 403) return 'warning';
|
||||
if (status >= 400 && status < 500) return 'info';
|
||||
if (status >= 500) return 'error';
|
||||
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for display in UI
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} options - Formatting options
|
||||
* @returns {Object} Formatted error object
|
||||
*/
|
||||
export function formatError(error, options = {}) {
|
||||
const {
|
||||
defaultMessage = 'An unexpected error occurred',
|
||||
includeCode = false,
|
||||
includeType = false
|
||||
} = options;
|
||||
|
||||
const message = getErrorMessage(error, defaultMessage);
|
||||
const type = getErrorType(error);
|
||||
const severity = getErrorSeverity(error);
|
||||
|
||||
const formatted = {
|
||||
message,
|
||||
severity,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (includeCode && error?.response?.data?.code) {
|
||||
formatted.code = error.response.data.code;
|
||||
}
|
||||
|
||||
if (includeType) {
|
||||
formatted.type = type;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error with automatic logging
|
||||
* Useful for global error boundary
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @param {string} context - Context where error occurred
|
||||
*/
|
||||
export function handleError(error, context = 'Application') {
|
||||
// Log to console in development only
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error(`[${context}]`, error);
|
||||
}
|
||||
|
||||
// You can add additional error tracking here (e.g., Sentry, LogRocket)
|
||||
// trackError(error, { context });
|
||||
|
||||
return formatError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error notification object for UI
|
||||
*
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} options - Options
|
||||
* @returns {Object} Notification object
|
||||
*/
|
||||
export function createErrorNotification(error, options = {}) {
|
||||
const {
|
||||
title = 'Error',
|
||||
defaultMessage = 'An unexpected error occurred',
|
||||
autoHide = true,
|
||||
duration = 5000
|
||||
} = options;
|
||||
|
||||
const formatted = formatError(error, { defaultMessage });
|
||||
|
||||
return {
|
||||
title,
|
||||
message: formatted.message,
|
||||
type: 'error',
|
||||
severity: formatted.severity,
|
||||
autoHide,
|
||||
duration,
|
||||
timestamp: formatted.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry helper for failed requests
|
||||
*
|
||||
* @param {Function} fn - Async function to retry
|
||||
* @param {Object} options - Retry options
|
||||
* @returns {Promise} Result of function
|
||||
*/
|
||||
export async function retryRequest(fn, options = {}) {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
shouldRetry = (error) => isNetworkError(error) || error?.response?.status >= 500
|
||||
} = options;
|
||||
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Don't retry if error shouldn't be retried
|
||||
if (!shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Don't wait after last attempt
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export default {
|
||||
getErrorMessage,
|
||||
isAuthError,
|
||||
isPermissionError,
|
||||
isValidationError,
|
||||
isNetworkError,
|
||||
getErrorType,
|
||||
getErrorSeverity,
|
||||
formatError,
|
||||
handleError,
|
||||
createErrorNotification,
|
||||
retryRequest
|
||||
};
|
||||
345
frontend/src/utils/inputValidator.js
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* Frontend Input Sanitization Utility
|
||||
* Provides client-side input validation and sanitization
|
||||
* NOTE: This is for UX only - server-side validation is the real security layer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sanitize string to prevent XSS
|
||||
*/
|
||||
export function sanitizeString(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
// Remove HTML tags
|
||||
const temp = document.createElement('div');
|
||||
temp.textContent = str;
|
||||
let sanitized = temp.innerHTML;
|
||||
|
||||
// Remove script-related content
|
||||
sanitized = sanitized.replace(/javascript:/gi, '');
|
||||
sanitized = sanitized.replace(/on\w+\s*=/gi, '');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize username
|
||||
*/
|
||||
export function validateUsername(username) {
|
||||
const errors = [];
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
errors.push('Username is required');
|
||||
return { valid: false, errors, sanitized: '' };
|
||||
}
|
||||
|
||||
const trimmed = username.trim();
|
||||
|
||||
if (trimmed.length < 3) {
|
||||
errors.push('Username must be at least 3 characters');
|
||||
}
|
||||
|
||||
if (trimmed.length > 50) {
|
||||
errors.push('Username must not exceed 50 characters');
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
errors.push('Username can only contain letters, numbers, hyphens, and underscores');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
sanitized: sanitizeString(trimmed)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email
|
||||
*/
|
||||
export function validateEmail(email) {
|
||||
const errors = [];
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
errors.push('Email is required');
|
||||
return { valid: false, errors, sanitized: '' };
|
||||
}
|
||||
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
|
||||
// Basic email regex
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(trimmed)) {
|
||||
errors.push('Invalid email format');
|
||||
}
|
||||
|
||||
if (trimmed.length > 255) {
|
||||
errors.push('Email must not exceed 255 characters');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
sanitized: sanitizeString(trimmed)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL
|
||||
*/
|
||||
export function validateUrl(url) {
|
||||
const errors = [];
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
errors.push('URL is required');
|
||||
return { valid: false, errors, sanitized: '' };
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
|
||||
try {
|
||||
const urlObj = new URL(trimmed);
|
||||
const allowedProtocols = ['http:', 'https:', 'rtmp:', 'rtsp:', 'udp:', 'rtp:'];
|
||||
|
||||
if (!allowedProtocols.includes(urlObj.protocol)) {
|
||||
errors.push('Invalid URL protocol');
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('Invalid URL format');
|
||||
}
|
||||
|
||||
if (trimmed.length > 2048) {
|
||||
errors.push('URL must not exceed 2048 characters');
|
||||
}
|
||||
|
||||
if (trimmed.includes('javascript:')) {
|
||||
errors.push('URL contains invalid content');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
sanitized: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate text field
|
||||
*/
|
||||
export function validateTextField(value, minLength = 1, maxLength = 1000, required = true) {
|
||||
const errors = [];
|
||||
|
||||
if (!value || value === '') {
|
||||
if (required) {
|
||||
errors.push('This field is required');
|
||||
return { valid: false, errors, sanitized: '' };
|
||||
}
|
||||
return { valid: true, errors: [], sanitized: '' };
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
errors.push('Must be a string');
|
||||
return { valid: false, errors, sanitized: '' };
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (required && trimmed.length < minLength) {
|
||||
errors.push(`Must be at least ${minLength} character${minLength > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
if (trimmed.length > maxLength) {
|
||||
errors.push(`Must not exceed ${maxLength} characters`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
sanitized: sanitizeString(trimmed)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate integer
|
||||
*/
|
||||
export function validateInteger(value, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) {
|
||||
const errors = [];
|
||||
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
if (isNaN(num)) {
|
||||
errors.push('Must be a valid number');
|
||||
return { valid: false, errors, sanitized: null };
|
||||
}
|
||||
|
||||
if (num < min) {
|
||||
errors.push(`Must be at least ${min}`);
|
||||
}
|
||||
|
||||
if (num > max) {
|
||||
errors.push(`Must not exceed ${max}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
sanitized: num
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize form data object
|
||||
*/
|
||||
export function sanitizeFormData(data) {
|
||||
const sanitized = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string') {
|
||||
sanitized[key] = sanitizeString(value);
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
sanitized[key] = value;
|
||||
} else if (value === null || value === undefined) {
|
||||
sanitized[key] = value;
|
||||
} else if (typeof value === 'object') {
|
||||
sanitized[key] = sanitizeFormData(value);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file upload
|
||||
*/
|
||||
export function validateFile(file, allowedTypes = [], maxSizeMB = 50) {
|
||||
const errors = [];
|
||||
|
||||
if (!file) {
|
||||
errors.push('File is required');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
errors.push(`File size must not exceed ${maxSizeMB}MB`);
|
||||
}
|
||||
|
||||
// Check file type if restrictions exist
|
||||
if (allowedTypes.length > 0) {
|
||||
const fileExt = file.name.split('.').pop().toLowerCase();
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
const isAllowed = allowedTypes.some(type => {
|
||||
return mimeType.includes(type) || fileExt === type;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
errors.push(`File type not allowed. Allowed types: ${allowedTypes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check filename
|
||||
const filename = file.name;
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
errors.push('Invalid filename');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for display
|
||||
*/
|
||||
export function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
|
||||
return text.replace(/[&<>"']/g, char => map[char]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength (client-side check)
|
||||
*/
|
||||
export function validatePassword(password, username = '', email = '') {
|
||||
const errors = [];
|
||||
const requirements = {
|
||||
minLength: false,
|
||||
uppercase: false,
|
||||
lowercase: false,
|
||||
number: false,
|
||||
special: false,
|
||||
noUsername: true,
|
||||
noEmail: true
|
||||
};
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
errors.push('Password is required');
|
||||
return { valid: false, errors, requirements, strength: 0 };
|
||||
}
|
||||
|
||||
// Check requirements
|
||||
requirements.minLength = password.length >= 12;
|
||||
requirements.uppercase = /[A-Z]/.test(password);
|
||||
requirements.lowercase = /[a-z]/.test(password);
|
||||
requirements.number = /[0-9]/.test(password);
|
||||
requirements.special = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
|
||||
|
||||
if (username && password.toLowerCase().includes(username.toLowerCase())) {
|
||||
requirements.noUsername = false;
|
||||
errors.push('Password cannot contain username');
|
||||
}
|
||||
|
||||
if (email && password.toLowerCase().includes(email.split('@')[0].toLowerCase())) {
|
||||
requirements.noEmail = false;
|
||||
errors.push('Password cannot contain email');
|
||||
}
|
||||
|
||||
// Calculate strength score
|
||||
let strength = 0;
|
||||
if (requirements.minLength) strength += 20;
|
||||
if (requirements.uppercase) strength += 15;
|
||||
if (requirements.lowercase) strength += 15;
|
||||
if (requirements.number) strength += 15;
|
||||
if (requirements.special) strength += 15;
|
||||
if (password.length > 15) strength += 10;
|
||||
if (password.length > 20) strength += 10;
|
||||
if (!requirements.noUsername || !requirements.noEmail) strength -= 30;
|
||||
|
||||
strength = Math.max(0, Math.min(100, strength));
|
||||
|
||||
// Add errors for missing requirements
|
||||
if (!requirements.minLength) errors.push('Password must be at least 12 characters');
|
||||
if (!requirements.uppercase) errors.push('Password must contain at least one uppercase letter');
|
||||
if (!requirements.lowercase) errors.push('Password must contain at least one lowercase letter');
|
||||
if (!requirements.number) errors.push('Password must contain at least one number');
|
||||
if (!requirements.special) errors.push('Password must contain at least one special character');
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
requirements,
|
||||
strength
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
sanitizeString,
|
||||
validateUsername,
|
||||
validateEmail,
|
||||
validateUrl,
|
||||
validateTextField,
|
||||
validateInteger,
|
||||
sanitizeFormData,
|
||||
validateFile,
|
||||
escapeHtml,
|
||||
validatePassword
|
||||
};
|
||||
92
frontend/src/utils/notifications.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Browser notification utilities for StreamFlow
|
||||
*/
|
||||
|
||||
/**
|
||||
* Request notification permission from the user
|
||||
*/
|
||||
export const requestNotificationPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('This browser does not support notifications');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'denied') {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === 'granted';
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a notification for the currently playing channel
|
||||
* @param {Object} channel - The channel object
|
||||
* @param {string} type - 'tv' or 'radio'
|
||||
*/
|
||||
export const showChannelNotification = async (channel, type = 'tv') => {
|
||||
if (!channel) return;
|
||||
|
||||
// Request permission if not already granted
|
||||
const hasPermission = await requestNotificationPermission();
|
||||
if (!hasPermission) return;
|
||||
|
||||
try {
|
||||
// Use service worker notification if available (better for PWA)
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
await registration.showNotification('StreamFlow IPTV', {
|
||||
body: `Now playing: ${channel.name}`,
|
||||
icon: '/icons/icon-192x192.svg',
|
||||
badge: '/icons/icon-72x72.svg',
|
||||
tag: 'now-playing',
|
||||
renotify: true,
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
url: type === 'tv' ? '/live-tv' : '/radio',
|
||||
channelId: channel.id
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'open',
|
||||
title: 'Open Player'
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
// Fallback to regular notification
|
||||
const notification = new Notification('StreamFlow IPTV', {
|
||||
body: `Now playing: ${channel.name}`,
|
||||
icon: '/icons/icon-192x192.svg',
|
||||
badge: '/icons/icon-72x72.svg',
|
||||
tag: 'now-playing',
|
||||
renotify: true
|
||||
});
|
||||
|
||||
// Auto-close after 4 seconds
|
||||
setTimeout(() => notification.close(), 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to show notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if notifications are supported and enabled
|
||||
*/
|
||||
export const isNotificationSupported = () => {
|
||||
return 'Notification' in window;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current notification permission status
|
||||
*/
|
||||
export const getNotificationPermission = () => {
|
||||
if (!isNotificationSupported()) return 'unsupported';
|
||||
return Notification.permission;
|
||||
};
|
||||
387
frontend/src/utils/useChromecast.js
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for managing Chromecast functionality
|
||||
* Supports both audio and video casting
|
||||
*/
|
||||
export const useChromecast = () => {
|
||||
const [castAvailable, setCastAvailable] = useState(false);
|
||||
const [casting, setCasting] = useState(false);
|
||||
const [castSession, setCastSession] = useState(null);
|
||||
const [currentMedia, setCurrentMedia] = useState(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Initialize Cast API
|
||||
useEffect(() => {
|
||||
const initializeCast = () => {
|
||||
try {
|
||||
console.log('[Chromecast] Initializing Cast API...');
|
||||
|
||||
if (!window.chrome?.cast?.isAvailable) {
|
||||
console.log('[Chromecast] Cast API not yet available');
|
||||
return;
|
||||
}
|
||||
|
||||
const castContext = window.cast.framework.CastContext.getInstance();
|
||||
|
||||
// Set Cast options
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
resumeSavedSession: true
|
||||
});
|
||||
|
||||
console.log('[Chromecast] Cast options set');
|
||||
|
||||
// Listen for Cast state changes
|
||||
castContext.addEventListener(
|
||||
window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,
|
||||
(event) => {
|
||||
console.log('[Chromecast] Cast state changed:', event.castState);
|
||||
const isConnected = event.castState === window.cast.framework.CastState.CONNECTED;
|
||||
setCasting(isConnected);
|
||||
|
||||
// Always show button once initialized, regardless of device availability
|
||||
setCastAvailable(true);
|
||||
|
||||
if (isConnected) {
|
||||
const session = castContext.getCurrentSession();
|
||||
setCastSession(session);
|
||||
} else {
|
||||
setCastSession(null);
|
||||
setCurrentMedia(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Always make cast available once SDK is loaded (button will handle device selection)
|
||||
setCastAvailable(true);
|
||||
|
||||
const currentState = castContext.getCastState();
|
||||
console.log('[Chromecast] Current cast state:', currentState);
|
||||
|
||||
const isConnected = currentState === window.cast.framework.CastState.CONNECTED;
|
||||
setCasting(isConnected);
|
||||
|
||||
if (isConnected) {
|
||||
setCastSession(castContext.getCurrentSession());
|
||||
}
|
||||
|
||||
setInitialized(true);
|
||||
console.log('[Chromecast] Initialization complete');
|
||||
} catch (error) {
|
||||
console.error('[Chromecast] Initialization error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for Cast API to load
|
||||
let checkInterval = null;
|
||||
let checkCount = 0;
|
||||
const maxChecks = 10;
|
||||
|
||||
const checkCastAvailability = () => {
|
||||
checkCount++;
|
||||
if (window.chrome?.cast?.isAvailable) {
|
||||
console.log('[Chromecast] Cast SDK loaded');
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
}
|
||||
initializeCast();
|
||||
} else if (checkCount >= maxChecks) {
|
||||
console.log('[Chromecast] Cast SDK not available after max attempts');
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Try immediately
|
||||
checkCastAvailability();
|
||||
|
||||
// Set up callback for when SDK loads
|
||||
window['__onGCastApiAvailable'] = (isAvailable) => {
|
||||
console.log('[Chromecast] SDK availability callback:', isAvailable);
|
||||
if (isAvailable) {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
}
|
||||
initializeCast();
|
||||
}
|
||||
};
|
||||
|
||||
// Check periodically but with limit
|
||||
if (!window.chrome?.cast?.isAvailable) {
|
||||
checkInterval = setInterval(checkCastAvailability, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cast media to Chromecast device
|
||||
* @param {Object} options - Media options
|
||||
* @param {string} options.url - Media URL
|
||||
* @param {string} options.title - Media title
|
||||
* @param {string} options.subtitle - Media subtitle (optional)
|
||||
* @param {string} options.contentType - MIME type (e.g., 'audio/mpeg', 'video/mp4', 'application/x-mpegURL')
|
||||
* @param {string} options.imageUrl - Thumbnail image URL (optional)
|
||||
* @param {boolean} options.isLive - Whether the stream is live (default: true)
|
||||
*/
|
||||
const castMedia = useCallback(async (options) => {
|
||||
if (!initialized) {
|
||||
console.warn('[Chromecast] Cast not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
subtitle = '',
|
||||
contentType = 'application/x-mpegURL',
|
||||
imageUrl = '',
|
||||
isLive = true
|
||||
} = options;
|
||||
|
||||
console.log('[Chromecast] castMedia called with:', { url, title, contentType, isLive });
|
||||
|
||||
try {
|
||||
const castContext = window.cast.framework.CastContext.getInstance();
|
||||
let session = castContext.getCurrentSession();
|
||||
|
||||
// If no session, request one first
|
||||
if (!session) {
|
||||
console.log('[Chromecast] No active session, requesting session...');
|
||||
try {
|
||||
await castContext.requestSession();
|
||||
session = castContext.getCurrentSession();
|
||||
|
||||
if (!session) {
|
||||
console.error('[Chromecast] Failed to get session after request');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[Chromecast] Session established');
|
||||
setCastSession(session);
|
||||
} catch (sessionError) {
|
||||
console.error('[Chromecast] Session request failed:', sessionError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Chromecast] Creating media info for URL:', url);
|
||||
|
||||
const mediaInfo = new window.chrome.cast.media.MediaInfo(url, contentType);
|
||||
|
||||
// Set metadata
|
||||
const metadata = new window.chrome.cast.media.GenericMediaMetadata();
|
||||
metadata.title = title;
|
||||
|
||||
if (subtitle) {
|
||||
metadata.subtitle = subtitle;
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
metadata.images = [new window.chrome.cast.Image(imageUrl)];
|
||||
}
|
||||
|
||||
mediaInfo.metadata = metadata;
|
||||
mediaInfo.streamType = isLive
|
||||
? window.chrome.cast.media.StreamType.LIVE
|
||||
: window.chrome.cast.media.StreamType.BUFFERED;
|
||||
|
||||
console.log('[Chromecast] Media info created:', {
|
||||
url: mediaInfo.contentId,
|
||||
contentType: mediaInfo.contentType,
|
||||
streamType: mediaInfo.streamType
|
||||
});
|
||||
|
||||
// Create load request
|
||||
const request = new window.chrome.cast.media.LoadRequest(mediaInfo);
|
||||
request.autoplay = true;
|
||||
request.currentTime = 0;
|
||||
|
||||
// Set adaptive bitrate settings for better streaming
|
||||
if (isLive) {
|
||||
// For live streams, optimize for lower latency
|
||||
request.customData = {
|
||||
lowLatency: true
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[Chromecast] Loading media...');
|
||||
|
||||
// Load media
|
||||
const loadResult = await session.loadMedia(request);
|
||||
|
||||
console.log('[Chromecast] Media loaded successfully:', loadResult);
|
||||
|
||||
// Listen for media status updates
|
||||
const mediaSession = session.getMediaSession();
|
||||
if (mediaSession) {
|
||||
mediaSession.addUpdateListener((isAlive) => {
|
||||
if (isAlive) {
|
||||
const state = mediaSession.playerState;
|
||||
console.log('[Chromecast] Player state:', state);
|
||||
|
||||
if (state === window.chrome.cast.media.PlayerState.BUFFERING) {
|
||||
console.log('[Chromecast] Buffering...');
|
||||
} else if (state === window.chrome.cast.media.PlayerState.PLAYING) {
|
||||
console.log('[Chromecast] Playing');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store current media info
|
||||
setCurrentMedia({
|
||||
title,
|
||||
subtitle,
|
||||
url,
|
||||
contentType,
|
||||
isLive
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Chromecast] Error casting media:', error);
|
||||
console.error('[Chromecast] Error details:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
description: error.description
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [initialized, castSession]);
|
||||
|
||||
/**
|
||||
* Stop casting and disconnect
|
||||
*/
|
||||
const stopCasting = useCallback(() => {
|
||||
if (!initialized) {
|
||||
console.warn('[Chromecast] Cannot stop - not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Chromecast] Stopping cast...');
|
||||
const castContext = window.cast.framework.CastContext.getInstance();
|
||||
const session = castContext.getCurrentSession();
|
||||
|
||||
if (session) {
|
||||
console.log('[Chromecast] Ending session...');
|
||||
// Stop media first
|
||||
const media = session.getMediaSession();
|
||||
if (media) {
|
||||
console.log('[Chromecast] Stopping media...');
|
||||
media.stop(new window.chrome.cast.media.StopRequest(),
|
||||
() => console.log('[Chromecast] Media stopped'),
|
||||
(error) => console.error('[Chromecast] Media stop error:', error)
|
||||
);
|
||||
}
|
||||
|
||||
// Then end the session
|
||||
session.endSession(true);
|
||||
console.log('[Chromecast] Session ended');
|
||||
} else {
|
||||
console.log('[Chromecast] No active session to stop');
|
||||
}
|
||||
|
||||
setCasting(false);
|
||||
setCastSession(null);
|
||||
setCurrentMedia(null);
|
||||
} catch (error) {
|
||||
console.error('[Chromecast] Error stopping cast:', error);
|
||||
// Force reset state even on error
|
||||
setCasting(false);
|
||||
setCastSession(null);
|
||||
setCurrentMedia(null);
|
||||
}
|
||||
}, [initialized]);
|
||||
|
||||
/**
|
||||
* Open cast dialog to select device
|
||||
*/
|
||||
const openCastDialog = useCallback(() => {
|
||||
if (!initialized) return;
|
||||
|
||||
try {
|
||||
const castContext = window.cast.framework.CastContext.getInstance();
|
||||
castContext.requestSession();
|
||||
} catch (error) {
|
||||
console.error('Error opening cast dialog:', error);
|
||||
}
|
||||
}, [initialized]);
|
||||
|
||||
/**
|
||||
* Control playback (play/pause)
|
||||
*/
|
||||
const togglePlayback = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
|
||||
try {
|
||||
const mediaSession = castSession.getMediaSession();
|
||||
if (!mediaSession) return;
|
||||
|
||||
if (mediaSession.playerState === window.chrome.cast.media.PlayerState.PLAYING) {
|
||||
await mediaSession.pause();
|
||||
} else {
|
||||
await mediaSession.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling playback:', error);
|
||||
}
|
||||
}, [castSession]);
|
||||
|
||||
/**
|
||||
* Set volume on cast device
|
||||
* @param {number} volume - Volume level (0-1)
|
||||
*/
|
||||
const setCastVolume = useCallback(async (volume) => {
|
||||
if (!castSession) return;
|
||||
|
||||
try {
|
||||
await castSession.setVolume(volume);
|
||||
} catch (error) {
|
||||
console.error('Error setting volume:', error);
|
||||
}
|
||||
}, [castSession]);
|
||||
|
||||
/**
|
||||
* Mute/unmute cast device
|
||||
* @param {boolean} muted - Mute state
|
||||
*/
|
||||
const setCastMuted = useCallback(async (muted) => {
|
||||
if (!castSession) return;
|
||||
|
||||
try {
|
||||
await castSession.setMute(muted);
|
||||
} catch (error) {
|
||||
console.error('Error setting mute:', error);
|
||||
}
|
||||
}, [castSession]);
|
||||
|
||||
return {
|
||||
// State
|
||||
castAvailable,
|
||||
casting,
|
||||
currentMedia,
|
||||
initialized,
|
||||
|
||||
// Actions
|
||||
castMedia,
|
||||
stopCasting,
|
||||
openCastDialog,
|
||||
togglePlayback,
|
||||
setCastVolume,
|
||||
setCastMuted
|
||||
};
|
||||
};
|
||||
|
||||
export default useChromecast;
|
||||
27
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:12345',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
'mui-vendor': ['@mui/material', '@mui/icons-material']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||