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

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;