Initial commit: StreamFlow IPTV platform

This commit is contained in:
aiulian25 2025-12-17 00:42:43 +00:00
commit 73a8ae9ffd
1240 changed files with 278451 additions and 0 deletions

41
frontend/.eslintrc.js Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

40
frontend/package.json Normal file
View 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"
}
}

View 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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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" }]
}
]
}

View 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

View 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

View 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...');
}

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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]&#10;PrivateKey = ...&#10;Address = ...&#10;DNS = ...&#10;&#10;[Peer]&#10;PublicKey = ...&#10;AllowedIPs = ...&#10;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
View 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
View 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;
}

View 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."
}
}

View 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
View 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>
);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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=">=">&gt;=</MenuItem>
<MenuItem value=">">&gt;</MenuItem>
<MenuItem value="<=">&lt;=</MenuItem>
<MenuItem value="<">&lt;</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;

View 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;

View 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;

File diff suppressed because it is too large Load diff

View 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;

View 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;

View 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;

View 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'
}
)
);

View 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
View 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
View 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;

View 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
};

View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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
};

View 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;
};

View 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
View 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']
}
}
}
}
});