Initial commit: StreamFlow IPTV platform
This commit is contained in:
commit
73a8ae9ffd
1240 changed files with 278451 additions and 0 deletions
704
frontend/src/components/AudioPlayer.jsx
Normal file
704
frontend/src/components/AudioPlayer.jsx
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Box, Paper, IconButton, Typography, Slider, LinearProgress, Tooltip, Fade, Chip } from '@mui/material';
|
||||
import {
|
||||
PlayArrow,
|
||||
Pause,
|
||||
VolumeUp,
|
||||
VolumeOff,
|
||||
SkipNext,
|
||||
SkipPrevious,
|
||||
Cast,
|
||||
CastConnected,
|
||||
MusicNote,
|
||||
GraphicEq
|
||||
} from '@mui/icons-material';
|
||||
import ReactPlayer from 'react-player';
|
||||
import Logo from './Logo';
|
||||
import AudioVisualizer from './AudioVisualizer';
|
||||
import { useChromecast } from '../utils/useChromecast';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
|
||||
function AudioPlayer({ station, onNext, onPrevious }) {
|
||||
const playerRef = useRef(null);
|
||||
const audioElementRef = useRef(null);
|
||||
const metadataIntervalRef = useRef(null);
|
||||
const { token } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [visualizerType, setVisualizerType] = useState(0);
|
||||
const [metadata, setMetadata] = useState(null);
|
||||
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const hideControlsTimer = useRef(null);
|
||||
|
||||
// Chromecast support
|
||||
const {
|
||||
castAvailable,
|
||||
casting,
|
||||
castMedia,
|
||||
stopCasting,
|
||||
openCastDialog,
|
||||
setCastVolume,
|
||||
setCastMuted
|
||||
} = useChromecast();
|
||||
|
||||
// Auto-hide controls after 2 seconds when playing
|
||||
const resetControlsTimer = () => {
|
||||
setShowControls(true);
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
if (playing) {
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset timer when playing changes
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
resetControlsTimer();
|
||||
} else {
|
||||
setShowControls(true);
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
// Fetch metadata for radio station
|
||||
const fetchMetadata = async () => {
|
||||
if (!station?.id || !playing) return;
|
||||
|
||||
try {
|
||||
setLoadingMetadata(true);
|
||||
const response = await axios.get(`/api/metadata/radio/${station.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setMetadata(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Metadata] Error fetching metadata:', error);
|
||||
setMetadata(null);
|
||||
} finally {
|
||||
setLoadingMetadata(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll metadata when playing
|
||||
useEffect(() => {
|
||||
if (playing && station?.id) {
|
||||
// Fetch immediately
|
||||
fetchMetadata();
|
||||
|
||||
// Poll every 10 seconds
|
||||
metadataIntervalRef.current = setInterval(fetchMetadata, 10000);
|
||||
} else {
|
||||
// Clear interval when not playing
|
||||
if (metadataIntervalRef.current) {
|
||||
clearInterval(metadataIntervalRef.current);
|
||||
metadataIntervalRef.current = null;
|
||||
}
|
||||
setMetadata(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (metadataIntervalRef.current) {
|
||||
clearInterval(metadataIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [playing, station?.id, token]);
|
||||
|
||||
// Cycle through visualizers every 15 seconds when playing
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
|
||||
const cycleInterval = setInterval(() => {
|
||||
setVisualizerType(prev => (prev + 1) % 10);
|
||||
}, 15000);
|
||||
|
||||
return () => clearInterval(cycleInterval);
|
||||
}, [playing]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setPlaying(!playing);
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
const handleVolumeChange = (event, newValue) => {
|
||||
setVolume(newValue / 100);
|
||||
setMuted(newValue === 0);
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
setMuted(!muted);
|
||||
};
|
||||
|
||||
// Get stream URL with token
|
||||
const getStreamUrl = () => {
|
||||
if (!station?.id) return station?.url || '';
|
||||
// Use backend proxy for radio streams
|
||||
return `/api/stream/proxy/${station.id}?token=${token}`;
|
||||
};
|
||||
|
||||
// Get full URL for Chromecast (needs absolute URL)
|
||||
const getFullStreamUrl = () => {
|
||||
const streamUrl = station?.url || getStreamUrl();
|
||||
if (!streamUrl) return '';
|
||||
// If it's already an absolute URL, use it; otherwise make it absolute
|
||||
if (streamUrl.startsWith('http')) {
|
||||
return streamUrl;
|
||||
}
|
||||
return `${window.location.origin}${streamUrl}`;
|
||||
};
|
||||
|
||||
// Get original station URL for Chromecast (bypasses proxy)
|
||||
const getOriginalStationUrl = async () => {
|
||||
if (!station?.id) return null;
|
||||
|
||||
try {
|
||||
// Fetch station data to get original URL
|
||||
const response = await fetch(`/api/radio/${station.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const stationData = await response.json();
|
||||
console.log('[Cast] Original station URL:', stationData.url);
|
||||
return stationData.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Cast] Error fetching station URL:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle Chromecast
|
||||
const handleCast = async () => {
|
||||
if (casting) {
|
||||
// Stop casting
|
||||
stopCasting();
|
||||
// Resume local playback
|
||||
setPlaying(false);
|
||||
} else {
|
||||
// Start casting
|
||||
if (!station) return;
|
||||
|
||||
// Pause local playback
|
||||
setPlaying(false);
|
||||
|
||||
// Try to get original URL first (Chromecast can access it directly)
|
||||
const originalUrl = await getOriginalStationUrl();
|
||||
const castUrl = originalUrl || getFullStreamUrl();
|
||||
|
||||
console.log('[Cast] Casting URL:', castUrl);
|
||||
console.log('[Cast] Station:', station.name);
|
||||
|
||||
// Detect content type from URL
|
||||
let contentType = 'audio/mpeg';
|
||||
if (castUrl.includes('.m3u8')) {
|
||||
contentType = 'application/x-mpegURL';
|
||||
} else if (castUrl.includes('.aac')) {
|
||||
contentType = 'audio/aac';
|
||||
}
|
||||
|
||||
console.log('[Cast] Content type:', contentType);
|
||||
|
||||
// Cast the media
|
||||
const success = await castMedia({
|
||||
url: castUrl,
|
||||
title: station.name || 'Radio Station',
|
||||
subtitle: station.genre || 'Live Radio',
|
||||
contentType: contentType,
|
||||
imageUrl: station.logo || '',
|
||||
isLive: true
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.log('[Cast] Cast failed, opening device selector');
|
||||
// If casting failed, open device selector
|
||||
openCastDialog();
|
||||
} else {
|
||||
console.log('[Cast] Cast successful');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sync volume with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastVolume) {
|
||||
setCastVolume(volume);
|
||||
}
|
||||
}, [volume, casting, setCastVolume]);
|
||||
|
||||
// Sync mute with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastMuted) {
|
||||
setCastMuted(muted);
|
||||
}
|
||||
}, [muted, casting, setCastMuted]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: { xs: 300, md: 180 },
|
||||
maxHeight: { xs: 'none', md: 250 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{/* Audio Player - hidden visually but accessible for visualizer */}
|
||||
{!casting && (
|
||||
<Box sx={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
||||
<ReactPlayer
|
||||
ref={playerRef}
|
||||
url={getStreamUrl()}
|
||||
playing={playing}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
width="100%"
|
||||
height="100%"
|
||||
progressInterval={5000}
|
||||
onReady={() => {
|
||||
setIsReady(true);
|
||||
setBuffering(false);
|
||||
}}
|
||||
onPlay={() => {
|
||||
setBuffering(false);
|
||||
// Capture audio element when actually playing
|
||||
if (playerRef.current && !audioElementRef.current) {
|
||||
const player = playerRef.current.getInternalPlayer();
|
||||
if (player && (player.tagName === 'AUDIO' || player.tagName === 'VIDEO')) {
|
||||
audioElementRef.current = player;
|
||||
console.log('[AudioPlayer] Audio element captured for visualizer');
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBuffer={() => setBuffering(true)}
|
||||
onBufferEnd={() => setBuffering(false)}
|
||||
config={{
|
||||
file: {
|
||||
forceAudio: true,
|
||||
attributes: {
|
||||
crossOrigin: 'anonymous'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Visual Display */}
|
||||
<Box
|
||||
onMouseMove={resetControlsTimer}
|
||||
onTouchStart={resetControlsTimer}
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Audio Visualizer Background */}
|
||||
{playing && audioElementRef.current && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<AudioVisualizer
|
||||
audioElement={audioElementRef.current}
|
||||
isPlaying={playing && !casting}
|
||||
visualizerType={visualizerType}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content Overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: playing
|
||||
? 'linear-gradient(135deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.5) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(59, 130, 246, 0.05) 100%)',
|
||||
p: { xs: 4, md: 1.5 },
|
||||
gap: { xs: 2, md: 0.5 }
|
||||
}}
|
||||
>
|
||||
{/* Buffering/Loading Indicator */}
|
||||
{(buffering || (playing && !isReady)) && (
|
||||
<Fade in={true}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 3,
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
|
||||
{t('audio.loading', 'Loading...')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Visualizer Type Indicator */}
|
||||
{playing && !buffering && isReady && (
|
||||
<Fade in={showControls}>
|
||||
<Chip
|
||||
icon={<GraphicEq />}
|
||||
label={t('audio.visualizer_type', { count: visualizerType + 1, defaultValue: `Visualizer ${visualizerType + 1}` })}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Logo as Play/Pause Button */}
|
||||
<Box
|
||||
onClick={handlePlayPause}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
opacity: showControls ? 1 : 0.3,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
opacity: 1
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)'
|
||||
},
|
||||
'& > *': {
|
||||
width: { xs: playing ? 80 : 120, md: playing ? 50 : 60 },
|
||||
height: { xs: playing ? 80 : 120, md: playing ? 50 : 60 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Logo size={playing ? 80 : 120} />
|
||||
{/* Buffering overlay on logo */}
|
||||
{buffering && playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: '3px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<PlayArrow sx={{ fontSize: { xs: 60, md: 40 }, color: 'white' }} />
|
||||
</Box>
|
||||
)}
|
||||
{playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '50%'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pause sx={{ fontSize: { xs: 40, md: 30 }, color: 'white' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Station Info */}
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
fontWeight={600}
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontSize: { xs: '1.5rem', md: '1.1rem' },
|
||||
mb: { xs: 1, md: 0.5 },
|
||||
color: playing ? 'white' : 'text.primary',
|
||||
textShadow: playing ? '0 2px 10px rgba(0,0,0,0.5)' : 'none'
|
||||
}}
|
||||
>
|
||||
{station?.name || t('audio.select_station', 'Select a Station')}
|
||||
</Typography>
|
||||
{station?.genre && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: playing ? 'rgba(255,255,255,0.8)' : 'text.secondary',
|
||||
textShadow: playing ? '0 1px 5px rgba(0,0,0,0.5)' : 'none'
|
||||
}}
|
||||
>
|
||||
{station.genre}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Now Playing Metadata */}
|
||||
{metadata && playing && (metadata.title || metadata.artist || (metadata.song && !metadata.song.match(/^\w+_aacp?_\d+k?$/i))) && (
|
||||
<Fade in={true}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 1, md: 0.5 },
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
px: { xs: 3, md: 2 },
|
||||
py: { xs: 1.5, md: 0.75 },
|
||||
borderRadius: 3,
|
||||
maxWidth: '90%'
|
||||
}}
|
||||
>
|
||||
<MusicNote sx={{ color: 'white', fontSize: { xs: 20, md: 16 } }} />
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
mb: { xs: 0.5, md: 0.25 },
|
||||
fontSize: { xs: '0.875rem', md: '0.75rem' }
|
||||
}}
|
||||
>
|
||||
{t('audio.now_playing', 'Now Playing')}
|
||||
</Typography>
|
||||
{metadata.title && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '1rem', md: '0.875rem' }
|
||||
}}
|
||||
>
|
||||
{metadata.title}
|
||||
</Typography>
|
||||
)}
|
||||
{metadata.artist && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: { xs: '0.875rem', md: '0.75rem' }
|
||||
}}
|
||||
>
|
||||
{metadata.artist}
|
||||
</Typography>
|
||||
)}
|
||||
{!metadata.title && !metadata.artist && metadata.streamTitle && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{metadata.streamTitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Casting Indicator */}
|
||||
{casting && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CastConnected sx={{ color: 'white', fontSize: 20 }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 1px 5px rgba(0,0,0,0.5)'
|
||||
}}
|
||||
>
|
||||
{t('audio.casting', 'Casting to Device')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation Controls */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton
|
||||
onClick={onPrevious}
|
||||
disabled={!onPrevious}
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
<SkipPrevious />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ width: 56 }} /> {/* Spacer where play button was */}
|
||||
|
||||
<IconButton
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
<SkipNext />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Volume Control */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%', maxWidth: 300 }}>
|
||||
<IconButton size="small" onClick={handleToggleMute} sx={{ color: 'text.primary' }}>
|
||||
{muted ? <VolumeOff /> : <VolumeUp />}
|
||||
</IconButton>
|
||||
|
||||
<Slider
|
||||
value={muted ? 0 : volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
{castAvailable && (
|
||||
<Tooltip title={casting ? 'Stop Casting' : 'Cast'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCast}
|
||||
sx={{
|
||||
color: casting ? 'primary.main' : 'text.primary',
|
||||
bgcolor: casting ? 'rgba(168, 85, 247, 0.1)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{casting ? <CastConnected /> : <Cast />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Live Indicator */}
|
||||
{playing && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'error.main',
|
||||
animation: 'blink 1.5s ease-in-out infinite',
|
||||
'@keyframes blink': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.3 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600}>
|
||||
LIVE
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{playing && !isReady && (
|
||||
<LinearProgress sx={{ position: 'absolute', top: 0, left: 0, right: 0 }} />
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioPlayer;
|
||||
434
frontend/src/components/AudioVisualizer.jsx
Normal file
434
frontend/src/components/AudioVisualizer.jsx
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { Box, IconButton, Tooltip } from '@mui/material';
|
||||
import { Audiotrack } from '@mui/icons-material';
|
||||
|
||||
/**
|
||||
* AudioVisualizer Component
|
||||
* Provides 10 different audio visualizations for radio playback
|
||||
*/
|
||||
const AudioVisualizer = ({ audioElement, isPlaying, visualizerType = 0 }) => {
|
||||
const canvasRef = useRef(null);
|
||||
const animationRef = useRef(null);
|
||||
const audioContextRef = useRef(null);
|
||||
const analyserRef = useRef(null);
|
||||
const dataArrayRef = useRef(null);
|
||||
const sourceRef = useRef(null);
|
||||
const connectedElementRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[AudioVisualizer] Props:', { audioElement, isPlaying, visualizerType });
|
||||
|
||||
if (!audioElement || !isPlaying) {
|
||||
console.log('[AudioVisualizer] Not rendering - no audio element or not playing');
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
// Clear canvas
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
console.log('[AudioVisualizer] No canvas element');
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas size
|
||||
const updateCanvasSize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
};
|
||||
updateCanvasSize();
|
||||
|
||||
// Initialize Web Audio API (only once per audio element)
|
||||
if (!audioContextRef.current) {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
audioContextRef.current = new AudioContext();
|
||||
analyserRef.current = audioContextRef.current.createAnalyser();
|
||||
analyserRef.current.fftSize = 128; // Smaller for better performance
|
||||
analyserRef.current.smoothingTimeConstant = 0.8;
|
||||
|
||||
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||
dataArrayRef.current = new Uint8Array(bufferLength);
|
||||
} catch (error) {
|
||||
console.error('[AudioVisualizer] Error initializing audio context:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create media source only once per audio element
|
||||
// Check if this is the same element we already connected
|
||||
const needsNewSource = !sourceRef.current || connectedElementRef.current !== audioElement;
|
||||
|
||||
if (needsNewSource && audioContextRef.current) {
|
||||
// Clean up old source if switching elements
|
||||
if (sourceRef.current && connectedElementRef.current !== audioElement) {
|
||||
try {
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
connectedElementRef.current = null;
|
||||
} catch (e) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[AudioVisualizer] Creating media element source');
|
||||
sourceRef.current = audioContextRef.current.createMediaElementSource(audioElement);
|
||||
sourceRef.current.connect(analyserRef.current);
|
||||
analyserRef.current.connect(audioContextRef.current.destination);
|
||||
connectedElementRef.current = audioElement;
|
||||
console.log('[AudioVisualizer] Audio context initialized successfully');
|
||||
} catch (error) {
|
||||
// If element is already connected, it means we already set it up
|
||||
console.log('[AudioVisualizer] Element already connected, reusing existing connection');
|
||||
// Don't return, continue with existing setup if available
|
||||
if (!analyserRef.current || !audioContextRef.current) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resume audio context if suspended
|
||||
if (audioContextRef.current.state === 'suspended') {
|
||||
audioContextRef.current.resume();
|
||||
}
|
||||
|
||||
const analyser = analyserRef.current;
|
||||
const dataArray = dataArrayRef.current;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
|
||||
// Visualization functions
|
||||
const visualizers = [
|
||||
// 0: Classic Bars
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||
let barHeight;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
barHeight = (dataArray[i] / 255) * canvas.height * 0.8;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`;
|
||||
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
|
||||
|
||||
x += barWidth + 1;
|
||||
}
|
||||
},
|
||||
|
||||
// 1: Circular Spectrum
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const radius = Math.min(centerX, centerY) * 0.6;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const angle = (i / bufferLength) * Math.PI * 2;
|
||||
const barHeight = (dataArray[i] / 255) * radius * 0.8;
|
||||
|
||||
const x1 = centerX + Math.cos(angle) * radius;
|
||||
const y1 = centerY + Math.sin(angle) * radius;
|
||||
const x2 = centerX + Math.cos(angle) * (radius + barHeight);
|
||||
const y2 = centerY + Math.sin(angle) * (radius + barHeight);
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.strokeStyle = `hsl(${hue}, 70%, 50%)`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
// 2: Waveform
|
||||
() => {
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = '#a855f7';
|
||||
ctx.beginPath();
|
||||
|
||||
const sliceWidth = canvas.width / bufferLength;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const v = dataArray[i] / 128.0;
|
||||
const y = (v * canvas.height) / 2;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
ctx.lineTo(canvas.width, canvas.height / 2);
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
// 3: Symmetric Bars
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * canvas.height * 0.4;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`;
|
||||
|
||||
// Top bars
|
||||
ctx.fillRect(x, canvas.height / 2 - barHeight, barWidth, barHeight);
|
||||
// Bottom bars (mirrored)
|
||||
ctx.fillRect(x, canvas.height / 2, barWidth, barHeight);
|
||||
|
||||
x += barWidth + 1;
|
||||
}
|
||||
},
|
||||
|
||||
// 4: Particles
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let i = 0; i < bufferLength; i += 2) {
|
||||
const value = dataArray[i];
|
||||
if (value > 100) {
|
||||
const x = (i / bufferLength) * canvas.width;
|
||||
const y = Math.random() * canvas.height;
|
||||
const size = (value / 255) * 5;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.fillStyle = `hsla(${hue}, 70%, 50%, 0.8)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 5: Frequency Rings
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const maxRadius = Math.min(centerX, centerY) * 0.9;
|
||||
|
||||
for (let i = 0; i < bufferLength; i += 4) {
|
||||
const value = dataArray[i];
|
||||
const radius = (value / 255) * maxRadius;
|
||||
|
||||
const hue = (i / bufferLength) * 360;
|
||||
ctx.strokeStyle = `hsla(${hue}, 70%, 50%, 0.5)`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
// 6: Line Spectrum
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const x = (i / bufferLength) * canvas.width;
|
||||
const y = canvas.height - (dataArray[i] / 255) * canvas.height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
gradient.addColorStop(0, '#a855f7');
|
||||
gradient.addColorStop(0.5, '#3b82f6');
|
||||
gradient.addColorStop(1, '#10b981');
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
// 7: Radial Bars
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const baseRadius = Math.min(centerX, centerY) * 0.3;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const angle = (i / bufferLength) * Math.PI * 2;
|
||||
const barHeight = (dataArray[i] / 255) * baseRadius;
|
||||
|
||||
const innerX = centerX + Math.cos(angle) * baseRadius;
|
||||
const innerY = centerY + Math.sin(angle) * baseRadius;
|
||||
const outerX = centerX + Math.cos(angle) * (baseRadius + barHeight);
|
||||
const outerY = centerY + Math.sin(angle) * (baseRadius + barHeight);
|
||||
|
||||
const gradient = ctx.createLinearGradient(innerX, innerY, outerX, outerY);
|
||||
gradient.addColorStop(0, '#a855f7');
|
||||
gradient.addColorStop(1, '#3b82f6');
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(innerX, innerY);
|
||||
ctx.lineTo(outerX, outerY);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
// 8: Block Grid
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const cols = 16;
|
||||
const rows = 8;
|
||||
const blockWidth = canvas.width / cols;
|
||||
const blockHeight = canvas.height / rows;
|
||||
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const dataIndex = Math.floor((i / cols) * bufferLength);
|
||||
const value = dataArray[dataIndex];
|
||||
const activedRows = Math.floor((value / 255) * rows);
|
||||
|
||||
for (let j = 0; j < activedRows; j++) {
|
||||
const hue = (i / cols) * 360;
|
||||
const brightness = 50 + (j / rows) * 30;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, ${brightness}%)`;
|
||||
ctx.fillRect(
|
||||
i * blockWidth + 1,
|
||||
canvas.height - (j + 1) * blockHeight + 1,
|
||||
blockWidth - 2,
|
||||
blockHeight - 2
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 9: Spiral
|
||||
() => {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
let radius = 10;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const angle = (i / bufferLength) * Math.PI * 8;
|
||||
const value = dataArray[i];
|
||||
radius += (value / 255) * 2;
|
||||
|
||||
const x = centerX + Math.cos(angle) * radius;
|
||||
const y = centerY + Math.sin(angle) * radius;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
||||
gradient.addColorStop(0, '#a855f7');
|
||||
gradient.addColorStop(0.5, '#3b82f6');
|
||||
gradient.addColorStop(1, '#10b981');
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.stroke();
|
||||
}
|
||||
];
|
||||
|
||||
// Animation loop
|
||||
const draw = () => {
|
||||
visualizers[visualizerType]();
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [audioElement, isPlaying, visualizerType]);
|
||||
|
||||
// Cleanup audio context on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isPlaying) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(59, 130, 246, 0.05) 100%)'
|
||||
}}
|
||||
>
|
||||
<Audiotrack sx={{ fontSize: 80, color: 'text.disabled', opacity: 0.3 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioVisualizer;
|
||||
385
frontend/src/components/BackupRestore.jsx
Normal file
385
frontend/src/components/BackupRestore.jsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
DialogContentText,
|
||||
Chip,
|
||||
Divider,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Backup as BackupIcon,
|
||||
Restore as RestoreIcon,
|
||||
Delete as DeleteIcon,
|
||||
Download as DownloadIcon,
|
||||
CloudUpload as UploadIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const BackupRestore = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useAuthStore();
|
||||
const [backups, setBackups] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
const [selectedBackup, setSelectedBackup] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackups();
|
||||
}, []);
|
||||
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/backup/list', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setBackups(response.data);
|
||||
} catch (err) {
|
||||
setError(t('backup.fetchError'));
|
||||
console.error('Failed to fetch backups:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
try {
|
||||
setCreating(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const response = await axios.post('/api/backup/create', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('backup.createSuccess'));
|
||||
fetchBackups();
|
||||
} catch (err) {
|
||||
setError(t('backup.createError'));
|
||||
console.error('Failed to create backup:', err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackup = (backup) => {
|
||||
window.open(`/api/backup/download/${backup.filename}?token=${token}`, '_blank');
|
||||
};
|
||||
|
||||
const handleDeleteBackup = async () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/backup/${selectedBackup.filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setSuccess(t('backup.deleteSuccess'));
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedBackup(null);
|
||||
fetchBackups();
|
||||
} catch (err) {
|
||||
setError(t('backup.deleteError'));
|
||||
console.error('Failed to delete backup:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
try {
|
||||
setRestoring(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const response = await axios.post(
|
||||
`/api/backup/restore/${selectedBackup.filename}`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setSuccess(
|
||||
`${t('backup.restoreSuccess')}: ${response.data.stats.playlists} playlists, ${response.data.stats.channels} channels, ${response.data.stats.favorites} favorites`
|
||||
);
|
||||
setRestoreDialogOpen(false);
|
||||
setSelectedBackup(null);
|
||||
|
||||
// Refresh page after 2 seconds to show restored data
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (err) {
|
||||
setError(t('backup.restoreError'));
|
||||
console.error('Failed to restore backup:', err);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadBackup = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setUploadProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
|
||||
await axios.post('/api/backup/upload', formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
setUploadProgress(percentCompleted);
|
||||
}
|
||||
});
|
||||
|
||||
setSuccess(t('backup.uploadSuccess'));
|
||||
fetchBackups();
|
||||
} catch (err) {
|
||||
setError(t('backup.uploadError'));
|
||||
console.error('Failed to upload backup:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setUploadProgress(0);
|
||||
event.target.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1rem', fontWeight: 600 }}>
|
||||
{t('backup.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="label"
|
||||
startIcon={<UploadIcon fontSize="small" />}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('backup.upload')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".zip"
|
||||
onChange={handleUploadBackup}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleCreateBackup}
|
||||
startIcon={creating ? <CircularProgress size={16} /> : <BackupIcon fontSize="small" />}
|
||||
disabled={creating || loading}
|
||||
>
|
||||
{t('backup.create')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{uploadProgress > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
||||
{t('backup.uploading')}: {uploadProgress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess('')} sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert severity="info" icon={<InfoIcon fontSize="small" />} sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
{t('backup.description')}
|
||||
</Alert>
|
||||
|
||||
{loading && !uploadProgress ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : backups.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('backup.noBackups')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{backups.map((backup, index) => (
|
||||
<React.Fragment key={backup.filename}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem
|
||||
sx={{
|
||||
px: 0,
|
||||
py: 1.5,
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
alignItems: { xs: 'flex-start', sm: 'center' }
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{t('backup.backup')} #{backups.length - index}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={formatFileSize(backup.size)}
|
||||
size="small"
|
||||
sx={{ height: '20px', fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('backup.created')}: {formatDate(backup.created)}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: { xs: 1, sm: 0 } }}
|
||||
/>
|
||||
<ListItemSecondaryAction sx={{ position: { xs: 'relative', sm: 'absolute' }, right: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDownloadBackup(backup)}
|
||||
title={t('backup.download')}
|
||||
>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedBackup(backup);
|
||||
setRestoreDialogOpen(true);
|
||||
}}
|
||||
title={t('backup.restore')}
|
||||
color="primary"
|
||||
>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedBackup(backup);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
title={t('backup.delete')}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('backup.deleteTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('backup.deleteConfirm')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)} size="small">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteBackup} color="error" variant="contained" size="small">
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Restore Confirmation Dialog */}
|
||||
<Dialog open={restoreDialogOpen} onClose={() => !restoring && setRestoreDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('backup.restoreTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('backup.restoreConfirm')}
|
||||
</DialogContentText>
|
||||
<Alert severity="warning" sx={{ mt: 2, fontSize: '0.75rem' }}>
|
||||
{t('backup.restoreWarning')}
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRestoreDialogOpen(false)} size="small" disabled={restoring}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRestoreBackup}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={restoring}
|
||||
startIcon={restoring ? <CircularProgress size={16} /> : <RestoreIcon fontSize="small" />}
|
||||
>
|
||||
{t('backup.restore')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupRestore;
|
||||
571
frontend/src/components/CSPDashboard.jsx
Normal file
571
frontend/src/components/CSPDashboard.jsx
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
BugReport,
|
||||
Refresh,
|
||||
Delete,
|
||||
Visibility,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Policy,
|
||||
Shield,
|
||||
Lock,
|
||||
VpnLock,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const CSPDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token, user } = useAuthStore();
|
||||
const { notifySecuritySuccess, notifySecurityError } = useSecurityNotification();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// CSP State
|
||||
const [cspStats, setCspStats] = useState(null);
|
||||
const [cspViolations, setCspViolations] = useState([]);
|
||||
const [cspPolicy, setCspPolicy] = useState(null);
|
||||
const [violationDialogOpen, setViolationDialogOpen] = useState(false);
|
||||
const [selectedViolation, setSelectedViolation] = useState(null);
|
||||
const [clearDays, setClearDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === 'admin') {
|
||||
fetchCSPData();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchCSPData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, violationsRes, policyRes] = await Promise.all([
|
||||
axios.get('/api/csp/stats?days=7', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get('/api/csp/violations?limit=50', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get('/api/csp/policy', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
setCspStats(statsRes.data);
|
||||
setCspViolations(violationsRes.data.violations);
|
||||
setCspPolicy(policyRes.data);
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to fetch CSP data'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearViolations = async () => {
|
||||
try {
|
||||
const response = await axios.delete(`/api/csp/violations?days=${clearDays}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(
|
||||
`${response.data.deleted} ${t('security.cspViolationsCleared')}`
|
||||
);
|
||||
fetchCSPData();
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to clear violations'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewViolation = (violation) => {
|
||||
setSelectedViolation(violation);
|
||||
setViolationDialogOpen(true);
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Alert severity="warning" sx={{ mt: 4 }}>
|
||||
{t('security.adminAccessRequired')}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ py: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Security fontSize="large" color="primary" />
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('security.cspDashboard')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/security')}
|
||||
>
|
||||
{t('backToSecurity')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchCSPData}
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* CSP Policy Status */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Shield color="primary" />
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('security.cspPolicyStatus')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Policy color="primary" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('security.mode')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={cspPolicy?.mode === 'enforce' ? t('security.enforcing') : t('security.reportOnly')}
|
||||
color={cspPolicy?.mode === 'enforce' ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<BugReport color="error" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('security.totalViolations')} (7 {t('days')})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{cspStats?.total || 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Lock color="success" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('security.policyDirectives')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{cspPolicy ? Object.keys(cspPolicy.policy).length : 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Tabs */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)}>
|
||||
<Tab label={t('security.violations')} icon={<Warning />} iconPosition="start" />
|
||||
<Tab label={t('security.statistics')} icon={<BugReport />} iconPosition="start" />
|
||||
<Tab label={t('security.policy')} icon={<Policy />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Tab Content */}
|
||||
{tabValue === 0 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('security.recentViolations')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>{t('security.clearOlderThan')}</InputLabel>
|
||||
<Select
|
||||
value={clearDays}
|
||||
onChange={(e) => setClearDays(e.target.value)}
|
||||
label={t('security.clearOlderThan')}
|
||||
>
|
||||
<MenuItem value={7}>7 {t('days')}</MenuItem>
|
||||
<MenuItem value={14}>14 {t('days')}</MenuItem>
|
||||
<MenuItem value={30}>30 {t('days')}</MenuItem>
|
||||
<MenuItem value={90}>90 {t('days')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<Delete />}
|
||||
onClick={handleClearViolations}
|
||||
>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{cspViolations.length === 0 ? (
|
||||
<Alert severity="success" icon={<CheckCircle />}>
|
||||
{t('security.noViolations')}
|
||||
</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.timestamp')}</TableCell>
|
||||
<TableCell>{t('security.violatedDirective')}</TableCell>
|
||||
<TableCell>{t('security.blockedUri')}</TableCell>
|
||||
<TableCell>{t('security.sourceFile')}</TableCell>
|
||||
<TableCell>{t('security.ipAddress')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cspViolations.map((violation) => (
|
||||
<TableRow key={violation.id}>
|
||||
<TableCell>
|
||||
{format(new Date(violation.created_at), 'MMM d, HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={violation.violated_directive}
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 200, display: 'block' }}>
|
||||
{violation.blocked_uri || 'N/A'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 200, display: 'block' }}>
|
||||
{violation.source_file || 'N/A'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption">
|
||||
{violation.ip_address}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('security.viewDetails')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleViewViolation(violation)}
|
||||
>
|
||||
<Visibility />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
{t('security.violationStatistics')}
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{/* By Directive */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('security.byDirective')}
|
||||
</Typography>
|
||||
{cspStats?.byDirective?.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.directive')}</TableCell>
|
||||
<TableCell align="right">{t('count')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cspStats.byDirective.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>
|
||||
<Chip label={item.violated_directive} size="small" color="error" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="h6">{item.count}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">{t('security.noData')}</Alert>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* By URI */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('security.byBlockedUri')}
|
||||
</Typography>
|
||||
{cspStats?.byUri?.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.blockedUri')}</TableCell>
|
||||
<TableCell align="right">{t('count')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cspStats.byUri.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 300, display: 'block' }}>
|
||||
{item.blocked_uri || 'inline'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="h6">{item.count}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">{t('security.noData')}</Alert>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{tabValue === 2 && cspPolicy && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
{t('security.currentCspPolicy')}
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{cspPolicy.mode === 'enforce'
|
||||
? t('security.cspEnforcedDescription')
|
||||
: t('security.cspReportOnlyDescription')}
|
||||
</Alert>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.directive')}</TableCell>
|
||||
<TableCell>{t('security.allowedSources')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(cspPolicy.policy).map(([directive, sources]) => (
|
||||
sources && (
|
||||
<TableRow key={directive}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{directive}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{Array.isArray(sources) ? sources.map((source, idx) => (
|
||||
<Chip key={idx} label={source} size="small" variant="outlined" />
|
||||
)) : (
|
||||
<Chip label={sources.toString()} size="small" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Violation Details Dialog */}
|
||||
<Dialog
|
||||
open={violationDialogOpen}
|
||||
onClose={() => setViolationDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ErrorIcon color="error" />
|
||||
{t('security.violationDetails')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedViolation && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.timestamp')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{format(new Date(selectedViolation.created_at), 'PPpp')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.violatedDirective')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
<Chip label={selectedViolation.violated_directive} color="error" size="small" />
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.blockedUri')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
|
||||
{selectedViolation.blocked_uri || 'inline'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.sourceFile')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
|
||||
{selectedViolation.source_file || 'N/A'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
{selectedViolation.line_number && (
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.lineNumber')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedViolation.line_number}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{selectedViolation.column_number && (
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.columnNumber')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedViolation.column_number}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.documentUri')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
|
||||
{selectedViolation.document_uri}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.ipAddress')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{selectedViolation.ip_address}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.userAgent')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
|
||||
{selectedViolation.user_agent}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setViolationDialogOpen(false)}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CSPDashboard;
|
||||
180
frontend/src/components/ChangePasswordDialog.jsx
Normal file
180
frontend/src/components/ChangePasswordDialog.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import PasswordStrengthMeter from './PasswordStrengthMeter';
|
||||
import { useErrorNotification } from './ErrorNotificationProvider';
|
||||
|
||||
const ChangePasswordDialog = ({ open, onClose, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token, clearPasswordFlag } = useAuthStore();
|
||||
const { showError, showSuccess } = useErrorNotification();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsDoNotMatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
clearPasswordFlag();
|
||||
|
||||
// Show success notification
|
||||
showSuccess(t('auth.passwordChangeSuccess') || 'Password changed successfully!', {
|
||||
duration: 4000
|
||||
});
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
if (onClose) onClose();
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
// Handle detailed password policy errors
|
||||
if (data.details) {
|
||||
setError(data.details.join('. '));
|
||||
// Also show as notification for better visibility
|
||||
showError(
|
||||
new Error(data.details.join('. ')),
|
||||
{
|
||||
title: t('errors.validation.title'),
|
||||
duration: 10000
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setError(data.error || t('auth.passwordChangeFailed'));
|
||||
showError(
|
||||
new Error(data.error || t('auth.passwordChangeFailed')),
|
||||
{
|
||||
title: t('auth.changePasswordFailed') || 'Password Change Failed',
|
||||
duration: 6000
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('auth.passwordChangeFailed'));
|
||||
showError(err, {
|
||||
title: t('auth.changePasswordFailed') || 'Password Change Failed',
|
||||
defaultMessage: t('auth.passwordChangeFailed'),
|
||||
duration: 6000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={null}
|
||||
disableEscapeKeyDown
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{t('auth.changePasswordRequired')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('auth.changePasswordWarning')}
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('auth.currentPassword')}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
size="small"
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('auth.newPassword')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('auth.confirmPassword')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
size="small"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : t('auth.changePassword')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordDialog;
|
||||
232
frontend/src/components/ChannelLogoManager.jsx
Normal file
232
frontend/src/components/ChannelLogoManager.jsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import { CloudUpload, Delete, Close } from '@mui/icons-material';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import Logo from './Logo';
|
||||
|
||||
function ChannelLogoManager({ open, onClose, channel, onLogoUpdated }) {
|
||||
const { token } = useAuthStore();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [preview, setPreview] = useState(null);
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size (5MB max)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.match(/image\/(jpeg|jpg|png|gif|svg\+xml|webp)/)) {
|
||||
setError('Only image files are allowed (JPG, PNG, GIF, SVG, WebP)');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setUploading(true);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setPreview(e.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload to server
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/channels/${channel.id}/logo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
onLogoUpdated({ ...channel, logo: data.logoUrl });
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setPreview(null);
|
||||
}, 1000);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to upload logo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLogo = async () => {
|
||||
setDeleting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/channels/${channel.id}/logo`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onLogoUpdated({ ...channel, logo: null });
|
||||
onClose();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to delete logo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentLogo = preview || channel?.logo;
|
||||
const hasCustomLogo = channel?.custom_logo || preview;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">Manage Channel Logo</Typography>
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{channel?.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Current Logo Preview */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 200,
|
||||
bgcolor: 'background.default',
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
position: 'relative',
|
||||
border: '2px dashed',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<CircularProgress />
|
||||
) : currentLogo ? (
|
||||
<img
|
||||
src={currentLogo}
|
||||
alt={channel?.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!currentLogo && !uploading && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Logo size={80} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
No logo uploaded
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Upload Instructions */}
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 2 }}>
|
||||
• Supported formats: JPG, PNG, GIF, SVG, WebP<br />
|
||||
• Maximum size: 5MB<br />
|
||||
• Recommended: Square images (1:1 ratio)
|
||||
</Typography>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexDirection: 'column' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
startIcon={<CloudUpload />}
|
||||
disabled={uploading || deleting}
|
||||
fullWidth
|
||||
>
|
||||
{hasCustomLogo ? 'Replace Logo' : 'Upload Logo'}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/svg+xml,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{hasCustomLogo && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={deleting ? <CircularProgress size={20} /> : <Delete />}
|
||||
onClick={handleDeleteLogo}
|
||||
disabled={uploading || deleting}
|
||||
fullWidth
|
||||
>
|
||||
Delete Custom Logo
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasCustomLogo && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block', textAlign: 'center' }}>
|
||||
Custom logo will be used instead of the official channel logo
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={uploading || deleting}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelLogoManager;
|
||||
612
frontend/src/components/EncryptionManagementDashboard.jsx
Normal file
612
frontend/src/components/EncryptionManagementDashboard.jsx
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Security,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Info,
|
||||
VpnKey,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
Scanner,
|
||||
Refresh,
|
||||
VerifiedUser,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useErrorNotification } from './ErrorNotificationProvider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function EncryptionManagementDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const { showError, showSuccess, showWarning } = useErrorNotification();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [scanResults, setScanResults] = useState(null);
|
||||
const [verifyResults, setVerifyResults] = useState(null);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
// Dialogs
|
||||
const [scanDialogOpen, setScanDialogOpen] = useState(false);
|
||||
const [migrateDialogOpen, setMigrateDialogOpen] = useState(false);
|
||||
const [verifyDialogOpen, setVerifyDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEncryptionStatus();
|
||||
}, []);
|
||||
|
||||
const fetchEncryptionStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/encryption/status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setStatus(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.fetchError'));
|
||||
console.error('Error fetching encryption status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
setScanning(true);
|
||||
const response = await axios.get('/api/encryption/scan', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setScanResults(response.data.data);
|
||||
setScanDialogOpen(true);
|
||||
|
||||
if (response.data.data.totalIssues === 0) {
|
||||
showSuccess(t('encryption.scanComplete'));
|
||||
} else {
|
||||
showWarning(t('encryption.issuesFound', { count: response.data.data.totalIssues }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.scanError'));
|
||||
console.error('Error scanning:', error);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrate = async () => {
|
||||
try {
|
||||
setMigrating(true);
|
||||
const response = await axios.post('/api/encryption/migrate', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('encryption.migrationComplete', { count: response.data.data.totalMigrated }));
|
||||
setMigrateDialogOpen(false);
|
||||
|
||||
// Refresh status
|
||||
await fetchEncryptionStatus();
|
||||
|
||||
// Re-scan to show updated results
|
||||
await handleScan();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.migrationError'));
|
||||
console.error('Error migrating:', error);
|
||||
} finally {
|
||||
setMigrating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
try {
|
||||
setVerifying(true);
|
||||
const response = await axios.post('/api/encryption/verify', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setVerifyResults(response.data.data);
|
||||
setVerifyDialogOpen(true);
|
||||
showSuccess(t('encryption.verifyComplete'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('encryption.verifyError'));
|
||||
console.error('Error verifying:', error);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{t('encryption.statusError')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
if (status === 'secure') return 'success';
|
||||
if (status === 'default-key') return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getEncryptionPercentage = (stats) => {
|
||||
if (!stats) return 0;
|
||||
const total = Object.values(stats).reduce((sum, s) => sum + (s.total || 0), 0);
|
||||
const encrypted = Object.values(stats).reduce((sum, s) => sum + (s.encrypted || 0), 0);
|
||||
return total > 0 ? Math.round((encrypted / total) * 100) : 100;
|
||||
};
|
||||
|
||||
const encryptionPercentage = getEncryptionPercentage(status.statistics);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header with Back Button */}
|
||||
<Box mb={3}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/security')}
|
||||
variant="text"
|
||||
color="primary"
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ ml: 1 }}>
|
||||
<Shield sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchEncryptionStatus}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ ml: 10 }}>
|
||||
{t('encryption.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Warning if default key is being used */}
|
||||
{status.status === 'default-key' && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }} icon={<Warning />}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{status.warning}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{t('encryption.recommendations')}:
|
||||
</Typography>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||
{status.recommendations.map((rec, idx) => (
|
||||
<li key={idx}><Typography variant="body2">{rec}</Typography></li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status Cards */}
|
||||
<Grid container spacing={3} mb={3}>
|
||||
{/* Encryption Status */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<Lock sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="h6">
|
||||
{t('encryption.status')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
label={status.status === 'secure' ? t('encryption.secure') : t('encryption.defaultKey')}
|
||||
color={getStatusColor(status.status)}
|
||||
icon={status.status === 'secure' ? <CheckCircle /> : <Warning />}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{t('encryption.algorithm')}: <strong>{status.algorithm}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('encryption.keySize')}: <strong>{status.keySize} bits</strong>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Encryption Coverage */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<VerifiedUser sx={{ mr: 1, color: 'success.main' }} />
|
||||
<Typography variant="h6">
|
||||
{t('encryption.coverage')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" mb={1}>
|
||||
<Typography variant="h3" color="primary">
|
||||
{encryptionPercentage}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" ml={1}>
|
||||
{t('encryption.encrypted')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={encryptionPercentage}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
||||
color={encryptionPercentage === 100 ? 'success' : 'warning'}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{status.statistics && (
|
||||
<>
|
||||
{Object.entries(status.statistics).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
{t(`encryption.${key}`)}: {value.encrypted}/{value.total}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Statistics Table */}
|
||||
{status.statistics && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<VpnKey sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.dataTypes')}
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('encryption.dataType')}</TableCell>
|
||||
<TableCell align="right">{t('encryption.total')}</TableCell>
|
||||
<TableCell align="right">{t('encryption.encrypted')}</TableCell>
|
||||
<TableCell align="right">{t('encryption.percentage')}</TableCell>
|
||||
<TableCell align="center">{t('encryption.status')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(status.statistics).map(([key, value]) => {
|
||||
const percentage = value.total > 0
|
||||
? Math.round((value.encrypted / value.total) * 100)
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
{t(`encryption.${key}`)}
|
||||
</TableCell>
|
||||
<TableCell align="right">{value.total}</TableCell>
|
||||
<TableCell align="right">{value.encrypted}</TableCell>
|
||||
<TableCell align="right">{percentage}%</TableCell>
|
||||
<TableCell align="center">
|
||||
{percentage === 100 ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Warning color="warning" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<SettingsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.actions')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={scanning ? <CircularProgress size={20} /> : <Scanner />}
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
>
|
||||
{t('encryption.scanButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
color="warning"
|
||||
startIcon={migrating ? <CircularProgress size={20} /> : <Lock />}
|
||||
onClick={() => setMigrateDialogOpen(true)}
|
||||
disabled={migrating || encryptionPercentage === 100}
|
||||
>
|
||||
{t('encryption.migrateButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={verifying ? <CircularProgress size={20} /> : <VerifiedUser />}
|
||||
onClick={handleVerify}
|
||||
disabled={verifying}
|
||||
>
|
||||
{t('encryption.verifyButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" mt={2}>
|
||||
<Info sx={{ fontSize: 16, verticalAlign: 'middle', mr: 0.5 }} />
|
||||
{t('encryption.actionsHelp')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scan Results Dialog */}
|
||||
<Dialog
|
||||
open={scanDialogOpen}
|
||||
onClose={() => setScanDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Scanner sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.scanResults')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{scanResults && (
|
||||
<>
|
||||
{scanResults.findings.length === 0 ? (
|
||||
<Alert severity="success" icon={<CheckCircle />}>
|
||||
{t('encryption.noIssuesFound')}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('encryption.foundIssues', { count: scanResults.totalIssues })}
|
||||
</Alert>
|
||||
|
||||
<List>
|
||||
{scanResults.findings.map((finding, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Warning color={finding.severity === 'high' ? 'error' : 'warning'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={`${finding.table}.${finding.field}`}
|
||||
secondary={
|
||||
<>
|
||||
<Typography component="span" variant="body2">
|
||||
{finding.description}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography component="span" variant="caption" color="error">
|
||||
{t('encryption.unencryptedCount')}: {finding.count}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label={finding.severity}
|
||||
color={finding.severity === 'high' ? 'error' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
{idx < scanResults.findings.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" mt={2}>
|
||||
<strong>{t('encryption.recommendation')}:</strong> {scanResults.recommendation}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setScanDialogOpen(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
{scanResults && scanResults.findings.length > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => {
|
||||
setScanDialogOpen(false);
|
||||
setMigrateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('encryption.migrateNow')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Migrate Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={migrateDialogOpen}
|
||||
onClose={() => !migrating && setMigrateDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Lock sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.confirmMigration')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('encryption.migrationWarning')}
|
||||
</Alert>
|
||||
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{t('encryption.migrationDescription')}
|
||||
</Typography>
|
||||
|
||||
<ul style={{ marginTop: '8px' }}>
|
||||
<li><Typography variant="body2">{t('encryption.migrationStep1')}</Typography></li>
|
||||
<li><Typography variant="body2">{t('encryption.migrationStep2')}</Typography></li>
|
||||
<li><Typography variant="body2">{t('encryption.migrationStep3')}</Typography></li>
|
||||
</ul>
|
||||
|
||||
{migrating && (
|
||||
<Box mt={2}>
|
||||
<LinearProgress />
|
||||
<Typography variant="body2" color="textSecondary" align="center" mt={1}>
|
||||
{t('encryption.migrating')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setMigrateDialogOpen(false)} disabled={migrating}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={handleMigrate}
|
||||
disabled={migrating}
|
||||
>
|
||||
{migrating ? t('encryption.migrating') : t('encryption.startMigration')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Verify Results Dialog */}
|
||||
<Dialog
|
||||
open={verifyDialogOpen}
|
||||
onClose={() => setVerifyDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<VerifiedUser sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{t('encryption.verificationResults')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{verifyResults && (
|
||||
<>
|
||||
{Object.entries(verifyResults).map(([key, value]) => (
|
||||
<Box key={key} mb={2}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t(`encryption.${key}`)}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('encryption.tested')}: {value.tested}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="body2" color="success.main">
|
||||
{t('encryption.valid')}: {value.valid}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="body2" color="error.main">
|
||||
{t('encryption.invalid')}: {value.invalid}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{value.invalid > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{t('encryption.invalidDataFound')}
|
||||
</Alert>
|
||||
)}
|
||||
{value.tested > 0 && value.invalid === 0 && (
|
||||
<Alert severity="success" sx={{ mt: 1 }}>
|
||||
{t('encryption.allDataValid')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setVerifyDialogOpen(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default EncryptionManagementDashboard;
|
||||
121
frontend/src/components/ErrorBoundary.jsx
Normal file
121
frontend/src/components/ErrorBoundary.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import { Box, Button, Container, Paper, Typography } from '@mui/material';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Error Boundary Component
|
||||
* Catches JavaScript errors anywhere in the child component tree
|
||||
* Prevents application crashes and displays user-friendly error messages
|
||||
*
|
||||
* Security: Does NOT expose stack traces or technical details to users
|
||||
*/
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
errorCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log error details (only in development)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Error Boundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
// Update error count
|
||||
this.setState(prevState => ({
|
||||
errorCount: prevState.errorCount + 1
|
||||
}));
|
||||
|
||||
// You can log to an error reporting service here
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
errorCount: 0
|
||||
});
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <ErrorFallback onReset={this.handleReset} onReload={this.handleReload} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Fallback Component
|
||||
* Displays a user-friendly error message with recovery options
|
||||
*/
|
||||
function ErrorFallback({ onReset, onReload }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ mt: 8 }}>
|
||||
<Paper elevation={3} sx={{ p: 4, textAlign: 'center' }}>
|
||||
<ErrorOutlineIcon
|
||||
sx={{
|
||||
fontSize: 80,
|
||||
color: 'error.main',
|
||||
mb: 2
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="h4" gutterBottom color="error">
|
||||
{t('errors.general.unexpected')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('errors.general.tryAgain')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onReset}
|
||||
color="primary"
|
||||
>
|
||||
{t('errors.general.tryAgain')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onReload}
|
||||
color="primary"
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ mt: 3 }}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t('errors.general.contactSupport')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
178
frontend/src/components/ErrorNotificationProvider.jsx
Normal file
178
frontend/src/components/ErrorNotificationProvider.jsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { Snackbar, Alert, AlertTitle, IconButton } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getErrorMessage, getErrorType, getErrorSeverity } from '../utils/errorHandler';
|
||||
|
||||
const ErrorNotificationContext = createContext(null);
|
||||
|
||||
/**
|
||||
* Error Notification Provider
|
||||
* Provides global error notification functionality throughout the app
|
||||
*/
|
||||
export function ErrorNotificationProvider({ children }) {
|
||||
const { t } = useTranslation();
|
||||
const [notification, setNotification] = useState(null);
|
||||
|
||||
/**
|
||||
* Show error notification from error object
|
||||
*/
|
||||
const showError = useCallback((error, options = {}) => {
|
||||
const {
|
||||
title,
|
||||
defaultMessage = t('errors.general.unexpected'),
|
||||
duration = 6000
|
||||
} = options;
|
||||
|
||||
const message = getErrorMessage(error, defaultMessage);
|
||||
const type = getErrorType(error);
|
||||
const severity = getErrorSeverity(error);
|
||||
|
||||
// Get translated title based on error type
|
||||
const errorTitle = title || getErrorTitle(type, t);
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title: errorTitle,
|
||||
severity,
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
/**
|
||||
* Show success notification
|
||||
*/
|
||||
const showSuccess = useCallback((message, options = {}) => {
|
||||
const { title, duration = 4000 } = options;
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title,
|
||||
severity: 'success',
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Show warning notification
|
||||
*/
|
||||
const showWarning = useCallback((message, options = {}) => {
|
||||
const { title, duration = 5000 } = options;
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title,
|
||||
severity: 'warning',
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Show info notification
|
||||
*/
|
||||
const showInfo = useCallback((message, options = {}) => {
|
||||
const { title, duration = 4000 } = options;
|
||||
|
||||
setNotification({
|
||||
message,
|
||||
title,
|
||||
severity: 'info',
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear notification
|
||||
*/
|
||||
const clearNotification = useCallback(() => {
|
||||
setNotification(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = (event, reason) => {
|
||||
if (reason === 'clickaway') {
|
||||
return;
|
||||
}
|
||||
clearNotification();
|
||||
};
|
||||
|
||||
const value = {
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
showInfo,
|
||||
clearNotification
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
{notification && (
|
||||
<Snackbar
|
||||
open={true}
|
||||
autoHideDuration={notification.duration}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ mt: 8 }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleClose}
|
||||
severity={notification.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%', maxWidth: 500 }}
|
||||
action={
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="close"
|
||||
color="inherit"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{notification.title && (
|
||||
<AlertTitle>{notification.title}</AlertTitle>
|
||||
)}
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
)}
|
||||
</ErrorNotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error title based on error type
|
||||
*/
|
||||
function getErrorTitle(type, t) {
|
||||
const titles = {
|
||||
auth: t('errors.auth.title'),
|
||||
permission: t('errors.permission.title'),
|
||||
validation: t('errors.validation.title'),
|
||||
network: t('errors.network.title'),
|
||||
server: t('errors.server.title'),
|
||||
unknown: t('error')
|
||||
};
|
||||
|
||||
return titles[type] || titles.unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use error notifications
|
||||
*/
|
||||
export function useErrorNotification() {
|
||||
const context = useContext(ErrorNotificationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useErrorNotification must be used within ErrorNotificationProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export default ErrorNotificationProvider;
|
||||
410
frontend/src/components/GlobalSearch.jsx
Normal file
410
frontend/src/components/GlobalSearch.jsx
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
InputBase,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Typography,
|
||||
Chip,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Popper,
|
||||
ClickAwayListener
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Tv,
|
||||
Radio as RadioIcon,
|
||||
Person,
|
||||
Settings as SettingsIcon,
|
||||
Folder,
|
||||
Close,
|
||||
PlayArrow
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Logo from './Logo';
|
||||
import api from '../utils/api';
|
||||
|
||||
// Debounce hook
|
||||
function useDebounce(value, delay) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function GlobalSearch({ onChannelSelect }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
// Search function
|
||||
const performSearch = useCallback(async (query) => {
|
||||
if (!query || query.trim().length < 2) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/search', {
|
||||
params: { q: query }
|
||||
});
|
||||
setResults(response.data);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
setResults(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Perform search when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearch) {
|
||||
performSearch(debouncedSearch);
|
||||
} else {
|
||||
setResults(null);
|
||||
}
|
||||
}, [debouncedSearch, performSearch]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
setOpen(value.length >= 2);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchQuery('');
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleChannelClick = (channel) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
if (onChannelSelect) {
|
||||
onChannelSelect(channel);
|
||||
}
|
||||
// Navigate to appropriate page
|
||||
if (channel.is_radio === 1) {
|
||||
navigate('/radio', { state: { autoPlayChannel: channel }, replace: false });
|
||||
} else {
|
||||
navigate('/live-tv', { state: { autoPlayChannel: channel }, replace: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserClick = (user) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
navigate('/settings?tab=users', { state: { selectedUserId: user.id } });
|
||||
};
|
||||
|
||||
const handleSettingClick = (setting) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
navigate(setting.path);
|
||||
};
|
||||
|
||||
const handleGroupClick = (group) => {
|
||||
setSearchQuery('');
|
||||
setOpen(false);
|
||||
if (group.is_radio === 1) {
|
||||
navigate('/radio', { state: { selectedGroup: group.name } });
|
||||
} else {
|
||||
navigate('/live', { state: { selectedGroup: group.name } });
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = results && (
|
||||
results.channels?.length > 0 ||
|
||||
results.radio?.length > 0 ||
|
||||
results.users?.length > 0 ||
|
||||
results.settings?.length > 0 ||
|
||||
results.groups?.length > 0
|
||||
);
|
||||
|
||||
const getCategoryIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'channel': return <Tv fontSize="small" />;
|
||||
case 'radio': return <RadioIcon fontSize="small" />;
|
||||
case 'user': return <Person fontSize="small" />;
|
||||
case 'setting': return <SettingsIcon fontSize="small" />;
|
||||
case 'group': return <Folder fontSize="small" />;
|
||||
default: return <SearchIcon fontSize="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, maxWidth: 600, position: 'relative' }}>
|
||||
<Box
|
||||
ref={anchorRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1.5,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
transition: 'all 0.2s',
|
||||
border: open ? 2 : 0,
|
||||
borderColor: 'primary.main'
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ color: 'text.secondary', mr: 1, fontSize: 20 }} />
|
||||
<InputBase
|
||||
ref={inputRef}
|
||||
placeholder={t('search') || 'Search channels, settings, users...'}
|
||||
fullWidth
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
sx={{ color: 'text.primary', fontSize: '0.875rem' }}
|
||||
onFocus={() => searchQuery.length >= 2 && setOpen(true)}
|
||||
/>
|
||||
{(loading || searchQuery) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 1 }}>
|
||||
{loading && <CircularProgress size={16} sx={{ mr: 1 }} />}
|
||||
{searchQuery && (
|
||||
<IconButton size="small" onClick={handleClear}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Popper
|
||||
open={open && (hasResults || (searchQuery.length >= 2 && !loading))}
|
||||
anchorEl={anchorRef.current}
|
||||
placement="bottom-start"
|
||||
style={{ width: anchorRef.current?.offsetWidth, zIndex: 1300 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
mt: 1,
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
{!hasResults && !loading && searchQuery.length >= 2 && (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<SearchIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
|
||||
<Typography color="text.secondary">
|
||||
No results found for "{searchQuery}"
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* TV Channels */}
|
||||
{results?.channels?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
TV CHANNELS ({results.channels.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.channels.map((channel) => (
|
||||
<ListItem key={`channel-${channel.id}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleChannelClick(channel)}>
|
||||
<ListItemAvatar>
|
||||
{channel.logo ? (
|
||||
<Avatar
|
||||
src={channel.logo.startsWith('http') ? channel.logo : `/logos/${channel.logo}`}
|
||||
variant="rounded"
|
||||
sx={{ width: 40, height: 40 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar variant="rounded" sx={{ width: 40, height: 40 }}>
|
||||
<Logo size={24} />
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{channel.name}
|
||||
<PlayArrow sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
</Box>
|
||||
}
|
||||
secondary={channel.group_name}
|
||||
/>
|
||||
<Chip icon={getCategoryIcon('channel')} label="TV" size="small" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Radio Stations */}
|
||||
{results?.radio?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
RADIO STATIONS ({results.radio.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.radio.map((channel) => (
|
||||
<ListItem key={`radio-${channel.id}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleChannelClick(channel)}>
|
||||
<ListItemAvatar>
|
||||
{channel.logo ? (
|
||||
<Avatar
|
||||
src={channel.logo.startsWith('http') ? channel.logo : `/logos/${channel.logo}`}
|
||||
variant="rounded"
|
||||
sx={{ width: 40, height: 40 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar variant="rounded" sx={{ width: 40, height: 40 }}>
|
||||
<Logo size={24} />
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{channel.name}
|
||||
<PlayArrow sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
</Box>
|
||||
}
|
||||
secondary={channel.group_name}
|
||||
/>
|
||||
<Chip icon={getCategoryIcon('radio')} label="Radio" size="small" color="secondary" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
{results?.groups?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
GROUPS ({results.groups.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.groups.map((group, index) => (
|
||||
<ListItem key={`group-${index}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleGroupClick(group)}>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
{getCategoryIcon('group')}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={group.name}
|
||||
secondary={group.is_radio === 1 ? 'Radio Group' : 'TV Group'}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
{results?.settings?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
PAGES & SETTINGS ({results.settings.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.settings.map((setting) => (
|
||||
<ListItem key={setting.id} disablePadding>
|
||||
<ListItemButton onClick={() => handleSettingClick(setting)}>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
{getCategoryIcon('setting')}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={setting.name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Users (Admin only) */}
|
||||
{results?.users?.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" fontWeight="bold" color="primary">
|
||||
USERS ({results.users.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
{results.users.map((user) => (
|
||||
<ListItem key={`user-${user.id}`} disablePadding>
|
||||
<ListItemButton onClick={() => handleUserClick(user)}>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
{getCategoryIcon('user')}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
secondary={user.email}
|
||||
/>
|
||||
<Chip
|
||||
label={user.role}
|
||||
size="small"
|
||||
color={user.role === 'admin' ? 'error' : 'default'}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalSearch;
|
||||
105
frontend/src/components/Header.jsx
Normal file
105
frontend/src/components/Header.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Logout,
|
||||
Menu as MenuIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
|
||||
function Header({ onMenuClick }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const { user } = useAuthStore();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
const handleMenuOpen = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'transparent',
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Toolbar variant="dense" sx={{ minHeight: 48 }}>
|
||||
<IconButton
|
||||
onClick={onMenuClick}
|
||||
sx={{
|
||||
mr: 2,
|
||||
p: 0.5,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<GlobalSearch />
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user?.username?.[0]?.toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout sx={{ mr: 1 }} fontSize="small" />
|
||||
{t('logout')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
504
frontend/src/components/LogManagementDashboard.jsx
Normal file
504
frontend/src/components/LogManagementDashboard.jsx
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Archive,
|
||||
Delete,
|
||||
Download,
|
||||
Security,
|
||||
VerifiedUser,
|
||||
Warning,
|
||||
CleaningServices,
|
||||
Storage,
|
||||
Refresh,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useErrorNotification } from './ErrorNotificationProvider';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const LogManagementDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const { showError, showSuccess, showWarning } = useErrorNotification();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statistics, setStatistics] = useState(null);
|
||||
const [archives, setArchives] = useState([]);
|
||||
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false);
|
||||
const [retentionDays, setRetentionDays] = useState(90);
|
||||
const [verifyDialogOpen, setVerifyDialogOpen] = useState(false);
|
||||
const [integrityResult, setIntegrityResult] = useState(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'admin') {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, navigate]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, archivesRes] = await Promise.all([
|
||||
axios.get('/api/log-management/statistics', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}),
|
||||
axios.get('/api/log-management/archives', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
setStatistics(statsRes.data.data);
|
||||
setArchives(archivesRes.data.data);
|
||||
} catch (err) {
|
||||
if (err.response?.status !== 401) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to load log management data'
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualCleanup = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await axios.post('/api/log-management/cleanup',
|
||||
{ retentionDays },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
showSuccess(response.data.message, { duration: 5000 });
|
||||
setCleanupDialogOpen(false);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to perform cleanup'
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyIntegrity = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await axios.post('/api/log-management/verify-integrity', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setIntegrityResult(response.data.data);
|
||||
setVerifyDialogOpen(true);
|
||||
|
||||
if (response.data.alert) {
|
||||
showWarning(response.data.message, {
|
||||
title: 'Security Alert',
|
||||
duration: 10000
|
||||
});
|
||||
} else {
|
||||
showSuccess(response.data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to verify log integrity'
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadArchive = (filename) => {
|
||||
window.location.href = `/api/log-management/archives/download/${filename}?token=${token}`;
|
||||
};
|
||||
|
||||
const handleDeleteArchive = async (filename) => {
|
||||
if (!confirm(`Delete archive "${filename}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/log-management/archives/${filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
showSuccess('Archive deleted successfully');
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
showError(err, {
|
||||
title: t('errors.general.title'),
|
||||
defaultMessage: 'Failed to delete archive'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const totalLogs = statistics?.sources?.reduce((sum, s) => sum + s.total, 0) || 0;
|
||||
const archiveSize = statistics?.archives?.totalSizeMB || 0;
|
||||
const archiveCount = statistics?.archives?.count || 0;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header with Back Button */}
|
||||
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton
|
||||
onClick={() => navigate('/security')}
|
||||
color="primary"
|
||||
size="large"
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Archive sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('logManagement.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('logManagement.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchData}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<strong>CWE-53 Compliance:</strong> Automated log retention, archival, and integrity verification are active.
|
||||
Logs are preserved securely with tamper detection and encrypted archives.
|
||||
</Alert>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.totalLogs')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{totalLogs.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Storage sx={{ fontSize: 40, color: 'primary.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.archives')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{archiveCount}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{archiveSize} MB
|
||||
</Typography>
|
||||
</Box>
|
||||
<Archive sx={{ fontSize: 40, color: 'success.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.retention')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
90
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.days')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CleaningServices sx={{ fontSize: 40, color: 'warning.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.integrity')}
|
||||
</Typography>
|
||||
<Typography variant="h4" fontWeight="bold" color="success.main">
|
||||
✓
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.protected')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<VerifiedUser sx={{ fontSize: 40, color: 'success.main', opacity: 0.5 }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="warning"
|
||||
startIcon={<CleaningServices />}
|
||||
onClick={() => setCleanupDialogOpen(true)}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('logManagement.manualCleanup')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Security />}
|
||||
onClick={handleVerifyIntegrity}
|
||||
disabled={actionLoading}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{actionLoading ? <CircularProgress size={24} /> : t('logManagement.verifyIntegrity')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Archives Table */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2 }}>
|
||||
{t('logManagement.archivesList')}
|
||||
</Typography>
|
||||
|
||||
{archives.length === 0 ? (
|
||||
<Alert severity="info">{t('logManagement.noArchives')}</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>{t('logManagement.filename')}</strong></TableCell>
|
||||
<TableCell><strong>{t('logManagement.size')}</strong></TableCell>
|
||||
<TableCell><strong>{t('logManagement.created')}</strong></TableCell>
|
||||
<TableCell align="right"><strong>{t('common.actions')}</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{archives.map((archive) => (
|
||||
<TableRow key={archive.filename} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontFamily="monospace">
|
||||
{archive.filename}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={`${archive.sizeMB} MB`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(archive.created), 'PPpp')}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('common.download')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleDownloadArchive(archive.filename)}
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteArchive(archive.filename)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cleanup Dialog */}
|
||||
<Dialog open={cleanupDialogOpen} onClose={() => setCleanupDialogOpen(false)}>
|
||||
<DialogTitle>{t('logManagement.manualCleanup')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('logManagement.cleanupWarning')}
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={t('logManagement.retentionDays')}
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(parseInt(e.target.value) || 90)}
|
||||
inputProps={{ min: 7, max: 365 }}
|
||||
helperText={t('logManagement.retentionHelp')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCleanupDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={handleManualCleanup}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? <CircularProgress size={24} /> : t('logManagement.performCleanup')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Integrity Result Dialog */}
|
||||
<Dialog open={verifyDialogOpen} onClose={() => setVerifyDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{integrityResult?.tampered > 0 ? (
|
||||
<ErrorIcon color="error" />
|
||||
) : (
|
||||
<CheckCircle color="success" />
|
||||
)}
|
||||
{t('logManagement.integrityResults')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{integrityResult && (
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.verified')}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="success.main">
|
||||
{integrityResult.verified}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('logManagement.tampered')}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="error.main">
|
||||
{integrityResult.tampered}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{integrityResult.tampered > 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<strong>{t('logManagement.securityAlert')}:</strong> {integrityResult.tampered} tampered logs detected.
|
||||
Immediate investigation required!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{integrityResult.tampered === 0 && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{t('logManagement.allLogsVerified')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setVerifyDialogOpen(false)} variant="contained">
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogManagementDashboard;
|
||||
34
frontend/src/components/Logo.jsx
Normal file
34
frontend/src/components/Logo.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
const Logo = ({ size = 40 }) => {
|
||||
return (
|
||||
<Box
|
||||
component="svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 128 128"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#3b82f6', stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="url(#logoGrad)" />
|
||||
<path d="M 48 35 L 48 93 L 93 64 Z" fill="#ffffff" opacity="0.95" />
|
||||
<path
|
||||
d="M 64 64 C 64 64, 54 56, 48 50 C 42 44, 38 38, 38 36 L 96 64 C 96 64, 94 68, 88 74 C 82 80, 74 88, 66 93"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="3"
|
||||
opacity="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
265
frontend/src/components/MultiScreen.jsx
Normal file
265
frontend/src/components/MultiScreen.jsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
IconButton,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Close,
|
||||
ViewModule,
|
||||
ViewComfy,
|
||||
GridView,
|
||||
SwapHoriz
|
||||
} from '@mui/icons-material';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import Logo from './Logo';
|
||||
|
||||
function MultiScreen({ channels }) {
|
||||
const [screens, setScreens] = useState([null, null]); // Start with 2 screens
|
||||
const [layout, setLayout] = useState('2x1'); // '2x1', '2x2', '3x1', '1x2'
|
||||
const [selectDialogOpen, setSelectDialogOpen] = useState(false);
|
||||
const [selectingScreenIndex, setSelectingScreenIndex] = useState(null);
|
||||
|
||||
const layouts = [
|
||||
{ value: '1x2', label: '1×2', icon: <ViewModule />, screens: 2, cols: 1, rows: 2 },
|
||||
{ value: '2x1', label: '2×1', icon: <ViewComfy />, screens: 2, cols: 2, rows: 1 },
|
||||
{ value: '2x2', label: '2×2', icon: <GridView />, screens: 4, cols: 2, rows: 2 },
|
||||
{ value: '3x1', label: '3×1', icon: <ViewModule />, screens: 3, cols: 3, rows: 1 }
|
||||
];
|
||||
|
||||
const currentLayout = layouts.find(l => l.value === layout) || layouts[1];
|
||||
|
||||
const handleLayoutChange = (newLayout) => {
|
||||
const newLayoutConfig = layouts.find(l => l.value === newLayout);
|
||||
setLayout(newLayout);
|
||||
|
||||
// Adjust screens array to match new layout
|
||||
const newScreens = [...screens];
|
||||
while (newScreens.length < newLayoutConfig.screens) {
|
||||
newScreens.push(null);
|
||||
}
|
||||
while (newScreens.length > newLayoutConfig.screens) {
|
||||
newScreens.pop();
|
||||
}
|
||||
setScreens(newScreens);
|
||||
};
|
||||
|
||||
const handleOpenSelectDialog = (screenIndex) => {
|
||||
setSelectingScreenIndex(screenIndex);
|
||||
setSelectDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectChannel = (channel) => {
|
||||
const newScreens = [...screens];
|
||||
newScreens[selectingScreenIndex] = channel;
|
||||
setScreens(newScreens);
|
||||
setSelectDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveChannel = (screenIndex) => {
|
||||
const newScreens = [...screens];
|
||||
newScreens[screenIndex] = null;
|
||||
setScreens(newScreens);
|
||||
};
|
||||
|
||||
const handleSwapScreens = (index1, index2) => {
|
||||
const newScreens = [...screens];
|
||||
[newScreens[index1], newScreens[index2]] = [newScreens[index2], newScreens[index1]];
|
||||
setScreens(newScreens);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Layout Controls */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
Multi-Screen View
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Watch up to {currentLayout.screens} channels simultaneously
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{layouts.map((layoutOption) => (
|
||||
<Tooltip key={layoutOption.value} title={layoutOption.label}>
|
||||
<IconButton
|
||||
onClick={() => handleLayoutChange(layoutOption.value)}
|
||||
color={layout === layoutOption.value ? 'primary' : 'default'}
|
||||
sx={{
|
||||
border: layout === layoutOption.value ? 2 : 1,
|
||||
borderColor: layout === layoutOption.value ? 'primary.main' : 'divider'
|
||||
}}
|
||||
>
|
||||
{layoutOption.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Multi-Screen Grid */}
|
||||
<Grid container spacing={2}>
|
||||
{screens.map((channel, index) => (
|
||||
<Grid
|
||||
item
|
||||
key={index}
|
||||
xs={12 / currentLayout.cols}
|
||||
md={12 / currentLayout.cols}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 2,
|
||||
borderColor: channel ? 'primary.main' : 'divider'
|
||||
}}
|
||||
>
|
||||
{channel ? (
|
||||
<>
|
||||
{/* Screen Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
gap: 0.5
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={`Screen ${index + 1}`}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.7)', color: 'white' }}
|
||||
/>
|
||||
{index < screens.length - 1 && screens[index + 1] && (
|
||||
<Tooltip title="Swap with next">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleSwapScreens(index, index + 1)}
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.7)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.9)' } }}
|
||||
>
|
||||
<SwapHoriz fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Remove channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRemoveChannel(index)}
|
||||
sx={{ bgcolor: 'rgba(0,0,0,0.7)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.9)' } }}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Video Player (PiP disabled for multi-screen) */}
|
||||
<VideoPlayer channel={channel} enablePip={false} />
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 2,
|
||||
bgcolor: 'action.hover'
|
||||
}}
|
||||
>
|
||||
<Logo size={80} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Screen {index + 1}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => handleOpenSelectDialog(index)}
|
||||
>
|
||||
Select Channel
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Channel Selection Dialog */}
|
||||
<Dialog
|
||||
open={selectDialogOpen}
|
||||
onClose={() => setSelectDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Select Channel for Screen {selectingScreenIndex !== null ? selectingScreenIndex + 1 : ''}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
{channels.map((channel) => {
|
||||
const alreadySelected = screens.some(s => s?.id === channel.id);
|
||||
return (
|
||||
<ListItem key={channel.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => handleSelectChannel(channel)}
|
||||
disabled={alreadySelected}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
{channel.logo ? (
|
||||
<Avatar
|
||||
src={channel.logo.startsWith('http') ? channel.logo : `/logos/${channel.logo}`}
|
||||
alt={channel.name}
|
||||
variant="rounded"
|
||||
/>
|
||||
) : (
|
||||
<Avatar variant="rounded">
|
||||
<Logo size={32} />
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={channel.name}
|
||||
secondary={channel.group_name}
|
||||
/>
|
||||
{alreadySelected && (
|
||||
<Chip label="In Use" size="small" color="primary" />
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiScreen;
|
||||
233
frontend/src/components/PasswordStrengthMeter.jsx
Normal file
233
frontend/src/components/PasswordStrengthMeter.jsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import { CheckCircle, Cancel, Info } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PasswordStrengthMeter = ({ password, username = '', email = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [strength, setStrength] = useState({ score: 0, level: 'veryWeak', feedback: [] });
|
||||
const [requirements, setRequirements] = useState({
|
||||
minLength: false,
|
||||
uppercase: false,
|
||||
lowercase: false,
|
||||
number: false,
|
||||
special: false,
|
||||
noUsername: true,
|
||||
noEmail: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
setStrength({ score: 0, level: 'veryWeak', feedback: [] });
|
||||
setRequirements({
|
||||
minLength: false,
|
||||
uppercase: false,
|
||||
lowercase: false,
|
||||
number: false,
|
||||
special: false,
|
||||
noUsername: true,
|
||||
noEmail: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check requirements
|
||||
const reqs = {
|
||||
minLength: password.length >= 12,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
|
||||
noUsername: username ? !password.toLowerCase().includes(username.toLowerCase()) : true,
|
||||
noEmail: email ? !password.toLowerCase().includes(email.split('@')[0].toLowerCase()) : true
|
||||
};
|
||||
setRequirements(reqs);
|
||||
|
||||
// Calculate score
|
||||
let score = 0;
|
||||
if (reqs.minLength) score += 20;
|
||||
if (reqs.uppercase) score += 15;
|
||||
if (reqs.lowercase) score += 15;
|
||||
if (reqs.number) score += 15;
|
||||
if (reqs.special) score += 15;
|
||||
if (password.length > 15) score += 10;
|
||||
if (password.length > 20) score += 10;
|
||||
if (!reqs.noUsername || !reqs.noEmail) score -= 30;
|
||||
|
||||
// Determine level
|
||||
let level = 'veryWeak';
|
||||
let color = '#d32f2f';
|
||||
if (score >= 91) {
|
||||
level = 'veryStrong';
|
||||
color = '#2e7d32';
|
||||
} else if (score >= 76) {
|
||||
level = 'strong';
|
||||
color = '#66bb6a';
|
||||
} else if (score >= 51) {
|
||||
level = 'good';
|
||||
color = '#fbc02d';
|
||||
} else if (score >= 26) {
|
||||
level = 'weak';
|
||||
color = '#f57c00';
|
||||
}
|
||||
|
||||
setStrength({
|
||||
score: Math.max(0, Math.min(100, score)),
|
||||
level,
|
||||
color,
|
||||
feedback: []
|
||||
});
|
||||
}, [password, username, email]);
|
||||
|
||||
const getStrengthColor = () => {
|
||||
if (strength.score >= 91) return '#2e7d32'; // green
|
||||
if (strength.score >= 76) return '#66bb6a'; // light green
|
||||
if (strength.score >= 51) return '#fbc02d'; // yellow
|
||||
if (strength.score >= 26) return '#f57c00'; // orange
|
||||
return '#d32f2f'; // red
|
||||
};
|
||||
|
||||
const getStrengthLabel = () => {
|
||||
if (strength.score >= 91) return t('security.veryStrong');
|
||||
if (strength.score >= 76) return t('security.strong');
|
||||
if (strength.score >= 51) return t('security.good');
|
||||
if (strength.score >= 26) return t('security.weak');
|
||||
return t('security.veryWeak');
|
||||
};
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
|
||||
{t('security.passwordStrength')}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={strength.score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getStrengthColor(),
|
||||
transition: 'all 0.3s ease'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
sx={{ color: getStrengthColor(), minWidth: 80, textAlign: 'right' }}
|
||||
>
|
||||
{getStrengthLabel()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Collapse in={password.length > 0}>
|
||||
<Box sx={{ mt: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||
<Info fontSize="small" />
|
||||
{t('security.requirements')}
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.minLength ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.minLength')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.uppercase ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.uppercase')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.lowercase ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.lowercase')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.number ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.number')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.special ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.special')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
{username && (
|
||||
<ListItem disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{requirements.noUsername ? (
|
||||
<CheckCircle fontSize="small" sx={{ color: 'success.main' }} />
|
||||
) : (
|
||||
<Cancel fontSize="small" sx={{ color: 'error.main' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.noUsername')}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordStrengthMeter;
|
||||
858
frontend/src/components/RBACDashboard.jsx
Normal file
858
frontend/src/components/RBACDashboard.jsx
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Chip,
|
||||
IconButton,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Tooltip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Edit,
|
||||
Delete,
|
||||
Security,
|
||||
Group,
|
||||
Assessment,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Info,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Block,
|
||||
ArrowBack
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import api from '../utils/api';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
|
||||
function TabPanel(props) {
|
||||
const { children, value, index, ...other } = props;
|
||||
return (
|
||||
<div role="tabpanel" hidden={value !== index} {...other}>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RBACDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const { showNotification } = useSecurityNotification();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Roles state
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [selectedRole, setSelectedRole] = useState(null);
|
||||
const [roleDialog, setRoleDialog] = useState(false);
|
||||
const [roleFormData, setRoleFormData] = useState({
|
||||
role_key: '',
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
});
|
||||
|
||||
// Permissions state
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [permissionCategories, setPermissionCategories] = useState({});
|
||||
const [myPermissions, setMyPermissions] = useState(null);
|
||||
|
||||
// Users state
|
||||
const [users, setUsers] = useState([]);
|
||||
const [userRoleDialog, setUserRoleDialog] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [newUserRole, setNewUserRole] = useState('');
|
||||
|
||||
// Audit log state
|
||||
const [auditLogs, setAuditLogs] = useState([]);
|
||||
const [auditFilters, setAuditFilters] = useState({
|
||||
action: '',
|
||||
userId: '',
|
||||
targetType: ''
|
||||
});
|
||||
|
||||
// Statistics state
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [tabValue]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (tabValue === 0) {
|
||||
await Promise.all([loadRoles(), loadPermissions()]);
|
||||
} else if (tabValue === 1) {
|
||||
await Promise.all([loadUsers(), loadRoles()]);
|
||||
} else if (tabValue === 2) {
|
||||
await loadAuditLog();
|
||||
} else if (tabValue === 3) {
|
||||
await loadStats();
|
||||
} else if (tabValue === 4) {
|
||||
await loadMyPermissions();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load data');
|
||||
showNotification('error', err.response?.data?.error || 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/roles');
|
||||
setRoles(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load roles:', err);
|
||||
setRoles([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPermissions = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/permissions');
|
||||
setPermissions(Array.isArray(response.data.permissions) ? response.data.permissions : []);
|
||||
setPermissionCategories(response.data.categories || {});
|
||||
} catch (err) {
|
||||
console.error('Failed to load permissions:', err);
|
||||
setPermissions([]);
|
||||
setPermissionCategories({});
|
||||
}
|
||||
};
|
||||
|
||||
const loadMyPermissions = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/my-permissions');
|
||||
setMyPermissions(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load my permissions:', err);
|
||||
setMyPermissions({ permissions: [], role: null });
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await api.get('/users');
|
||||
setUsers(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err);
|
||||
setUsers([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuditLog = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (auditFilters.action) params.append('action', auditFilters.action);
|
||||
if (auditFilters.userId) params.append('userId', auditFilters.userId);
|
||||
if (auditFilters.targetType) params.append('targetType', auditFilters.targetType);
|
||||
|
||||
const response = await api.get(`/rbac/audit-log?${params.toString()}`);
|
||||
setAuditLogs(Array.isArray(response.data.logs) ? response.data.logs : []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load audit log:', err);
|
||||
setAuditLogs([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await api.get('/rbac/stats');
|
||||
setStats(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
setStats(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRole = () => {
|
||||
setSelectedRole(null);
|
||||
setRoleFormData({
|
||||
role_key: '',
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
});
|
||||
setRoleDialog(true);
|
||||
};
|
||||
|
||||
const handleEditRole = (role) => {
|
||||
setSelectedRole(role);
|
||||
setRoleFormData({
|
||||
role_key: role.role_key,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
permissions: role.permissions
|
||||
});
|
||||
setRoleDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveRole = async () => {
|
||||
try {
|
||||
if (selectedRole) {
|
||||
// Update existing role
|
||||
await api.patch(`/rbac/roles/${selectedRole.role_key}`, {
|
||||
name: roleFormData.name,
|
||||
description: roleFormData.description,
|
||||
permissions: roleFormData.permissions
|
||||
});
|
||||
showNotification('success', t('rbac.roleUpdated'));
|
||||
} else {
|
||||
// Create new role
|
||||
await api.post('/rbac/roles', roleFormData);
|
||||
showNotification('success', t('rbac.roleCreated'));
|
||||
}
|
||||
setRoleDialog(false);
|
||||
loadRoles();
|
||||
} catch (err) {
|
||||
showNotification('error', err.response?.data?.error || 'Failed to save role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (roleKey) => {
|
||||
if (!window.confirm(t('rbac.confirmDeleteRole'))) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/rbac/roles/${roleKey}`);
|
||||
showNotification('success', t('rbac.roleDeleted'));
|
||||
loadRoles();
|
||||
} catch (err) {
|
||||
showNotification('error', err.response?.data?.error || 'Failed to delete role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePermission = (permissionKey) => {
|
||||
setRoleFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(permissionKey)
|
||||
? prev.permissions.filter(p => p !== permissionKey)
|
||||
: [...prev.permissions, permissionKey]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAssignRole = (user) => {
|
||||
setSelectedUser(user);
|
||||
setNewUserRole(user.role);
|
||||
setUserRoleDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveUserRole = async () => {
|
||||
try {
|
||||
await api.post(`/rbac/users/${selectedUser.id}/role`, {
|
||||
role: newUserRole
|
||||
});
|
||||
showNotification('success', t('rbac.roleAssigned'));
|
||||
setUserRoleDialog(false);
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
showNotification('error', err.response?.data?.error || 'Failed to assign role');
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (roleKey) => {
|
||||
const colors = {
|
||||
admin: 'error',
|
||||
moderator: 'warning',
|
||||
user: 'primary',
|
||||
viewer: 'info'
|
||||
};
|
||||
return colors[roleKey] || 'default';
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category) => {
|
||||
const icons = {
|
||||
'User Management': <Group />,
|
||||
'Session Management': <Security />,
|
||||
'Content Management': <Assessment />,
|
||||
'System & Settings': <Info />,
|
||||
'Security Management': <Security />,
|
||||
'Search & Discovery': <Visibility />,
|
||||
'VPN & Network': <Warning />
|
||||
};
|
||||
return icons[category] || <Info />;
|
||||
};
|
||||
|
||||
if (loading && tabValue === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Security sx={{ mr: 1, fontSize: 32 }} />
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('rbac.dashboard')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/security')}
|
||||
>
|
||||
{t('backToSecurity')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)}>
|
||||
<Tab label={t('rbac.rolesAndPermissions')} />
|
||||
<Tab label={t('rbac.userRoles')} />
|
||||
<Tab label={t('rbac.auditLog')} />
|
||||
<Tab label={t('rbac.statistics')} />
|
||||
<Tab label={t('rbac.myPermissions')} />
|
||||
</Tabs>
|
||||
|
||||
{/* Roles & Permissions Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6">{t('rbac.roles')}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateRole}
|
||||
>
|
||||
{t('rbac.createRole')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('rbac.roleName')}</TableCell>
|
||||
<TableCell>{t('rbac.description')}</TableCell>
|
||||
<TableCell>{t('rbac.permissions')}</TableCell>
|
||||
<TableCell>{t('rbac.type')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.role_key}>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={role.name}
|
||||
color={getRoleColor(role.role_key)}
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({role.role_key})
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{role.description}</TableCell>
|
||||
<TableCell>
|
||||
<Badge badgeContent={role.permissions.length} color="primary">
|
||||
<Chip
|
||||
label={t('rbac.permissionsCount')}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.is_system_role ? (
|
||||
<Chip label={t('rbac.system')} size="small" color="default" />
|
||||
) : (
|
||||
<Chip label={t('rbac.custom')} size="small" color="secondary" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('edit')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditRole(role)}
|
||||
disabled={role.is_system_role}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteRole(role.role_key)}
|
||||
disabled={role.is_system_role}
|
||||
color="error"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* User Roles Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.manageUserRoles')}
|
||||
</Typography>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('username')}</TableCell>
|
||||
<TableCell>{t('email')}</TableCell>
|
||||
<TableCell>{t('role')}</TableCell>
|
||||
<TableCell>{t('status')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={u.role}
|
||||
color={getRoleColor(u.role)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.is_active ? (
|
||||
<Chip
|
||||
label={t('active')}
|
||||
color="success"
|
||||
size="small"
|
||||
icon={<CheckCircle />}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={t('inactive')}
|
||||
color="default"
|
||||
size="small"
|
||||
icon={<Block />}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleAssignRole(u)}
|
||||
disabled={u.id === user?.id}
|
||||
>
|
||||
{t('rbac.changeRole')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Audit Log Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.permissionAuditLog')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('rbac.filterByAction')}</InputLabel>
|
||||
<Select
|
||||
value={auditFilters.action}
|
||||
onChange={(e) => setAuditFilters({ ...auditFilters, action: e.target.value })}
|
||||
>
|
||||
<MenuItem value="">{t('all')}</MenuItem>
|
||||
<MenuItem value="role_created">{t('rbac.roleCreated')}</MenuItem>
|
||||
<MenuItem value="role_updated">{t('rbac.roleUpdated')}</MenuItem>
|
||||
<MenuItem value="role_deleted">{t('rbac.roleDeleted')}</MenuItem>
|
||||
<MenuItem value="role_assigned">{t('rbac.roleAssigned')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={loadAuditLog}
|
||||
>
|
||||
{t('apply')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('timestamp')}</TableCell>
|
||||
<TableCell>{t('user')}</TableCell>
|
||||
<TableCell>{t('action')}</TableCell>
|
||||
<TableCell>{t('rbac.target')}</TableCell>
|
||||
<TableCell>{t('rbac.changes')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{auditLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>{log.username}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={log.action} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.target_type} #{log.target_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={JSON.stringify({ old: log.old_value, new: log.new_value }, null, 2)}>
|
||||
<IconButton size="small">
|
||||
<Info />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TabPanel>
|
||||
|
||||
{/* Statistics Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
{t('rbac.rbacStatistics')}
|
||||
</Typography>
|
||||
|
||||
{stats && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('rbac.roleDistribution')}
|
||||
</Typography>
|
||||
<List>
|
||||
{Array.isArray(stats.role_distribution) && stats.role_distribution.map((role) => (
|
||||
<ListItem key={role.role_key}>
|
||||
<ListItemText
|
||||
primary={role.name}
|
||||
secondary={`${role.user_count} ${t('users')}`}
|
||||
/>
|
||||
<Chip
|
||||
label={role.user_count}
|
||||
color={getRoleColor(role.role_key)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('rbac.recentActions')}
|
||||
</Typography>
|
||||
<List>
|
||||
{Array.isArray(stats.recent_actions) && stats.recent_actions.map((action, idx) => (
|
||||
<ListItem key={idx}>
|
||||
<ListItemText
|
||||
primary={action.action}
|
||||
secondary={`${action.count} ${t('rbac.times')}`}
|
||||
/>
|
||||
<Badge badgeContent={action.count} color="primary" />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
<Typography>
|
||||
{t('rbac.totalPermissions')}: <strong>{stats.total_permissions}</strong> |
|
||||
{t('rbac.totalRoles')}: <strong>{stats.total_roles}</strong>
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* My Permissions Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
{t('rbac.yourPermissions')}
|
||||
</Typography>
|
||||
|
||||
{myPermissions && (
|
||||
<>
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('rbac.yourRole')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Chip
|
||||
label={myPermissions.role_name}
|
||||
color={getRoleColor(myPermissions.role)}
|
||||
size="large"
|
||||
/>
|
||||
<Typography color="text.secondary">
|
||||
{myPermissions.role_description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('rbac.permissionsGranted')}: <strong>{myPermissions.permissions.length}</strong>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.permissionsList')}
|
||||
</Typography>
|
||||
|
||||
{Object.entries(permissionCategories).map(([category, perms]) => {
|
||||
const userPerms = Array.isArray(perms) ? perms.filter(p =>
|
||||
Array.isArray(myPermissions?.permissions) && myPermissions.permissions.includes(p)
|
||||
) : [];
|
||||
if (userPerms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={category} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
{getCategoryIcon(category)}
|
||||
<Typography variant="h6" sx={{ ml: 1 }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${userPerms.length}/${perms.length}`}
|
||||
size="small"
|
||||
sx={{ ml: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
<Grid container spacing={1}>
|
||||
{userPerms.map((perm) => {
|
||||
const permDetail = myPermissions.permission_details.find(p => p.key === perm);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={perm}>
|
||||
<Chip
|
||||
label={permDetail?.key || perm}
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircle />}
|
||||
sx={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
|
||||
{/* Role Create/Edit Dialog */}
|
||||
<Dialog
|
||||
open={roleDialog}
|
||||
onClose={() => setRoleDialog(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{selectedRole ? t('rbac.editRole') : t('rbac.createRole')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('rbac.roleKey')}
|
||||
value={roleFormData.role_key}
|
||||
onChange={(e) => setRoleFormData({ ...roleFormData, role_key: e.target.value })}
|
||||
disabled={!!selectedRole}
|
||||
sx={{ mb: 2 }}
|
||||
helperText={t('rbac.roleKeyHelp')}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('rbac.roleName')}
|
||||
value={roleFormData.name}
|
||||
onChange={(e) => setRoleFormData({ ...roleFormData, name: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
label={t('rbac.description')}
|
||||
value={roleFormData.description}
|
||||
onChange={(e) => setRoleFormData({ ...roleFormData, description: e.target.value })}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('rbac.selectPermissions')} ({roleFormData.permissions.length} {t('rbac.selected')})
|
||||
</Typography>
|
||||
|
||||
{Object.entries(permissionCategories).map(([category, perms]) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{getCategoryIcon(category)}
|
||||
<span style={{ marginLeft: 8 }}>{category}</span>
|
||||
</Typography>
|
||||
<FormGroup>
|
||||
{perms.map((perm) => {
|
||||
const permDetail = permissions.find(p => p.key === perm);
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={perm}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={roleFormData.permissions.includes(perm)}
|
||||
onChange={() => handleTogglePermission(perm)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2">{perm}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{permDetail?.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FormGroup>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRoleDialog(false)}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveRole}
|
||||
disabled={!roleFormData.role_key || !roleFormData.name}
|
||||
>
|
||||
{selectedRole ? t('save') : t('create')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* User Role Assignment Dialog */}
|
||||
<Dialog
|
||||
open={userRoleDialog}
|
||||
onClose={() => setUserRoleDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{t('rbac.assignRole')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedUser && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{t('rbac.assigningRoleTo')}: <strong>{selectedUser.username}</strong>
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('role')}</InputLabel>
|
||||
<Select
|
||||
value={newUserRole}
|
||||
onChange={(e) => setNewUserRole(e.target.value)}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<MenuItem key={role.role_key} value={role.role_key}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={role.name}
|
||||
size="small"
|
||||
color={getRoleColor(role.role_key)}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{role.permissions.length} {t('rbac.permissions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUserRoleDialog(false)}>{t('cancel')}</Button>
|
||||
<Button variant="contained" onClick={handleSaveUserRole}>
|
||||
{t('assign')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RBACDashboard;
|
||||
969
frontend/src/components/SecurityHeadersDashboard.jsx
Normal file
969
frontend/src/components/SecurityHeadersDashboard.jsx
Normal file
|
|
@ -0,0 +1,969 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Button,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
Refresh,
|
||||
Save,
|
||||
PlayArrow,
|
||||
ArrowBack,
|
||||
Add,
|
||||
Delete,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Error as ErrorIcon,
|
||||
Info,
|
||||
ExpandMore,
|
||||
ContentCopy,
|
||||
History,
|
||||
Settings
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../utils/api';
|
||||
|
||||
function TabPanel({ children, value, index }) {
|
||||
return value === index ? <Box sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
const SecurityHeadersDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// Data states
|
||||
const [currentConfig, setCurrentConfig] = useState(null);
|
||||
const [savedConfigs, setSavedConfigs] = useState([]);
|
||||
const [presets, setPresets] = useState({});
|
||||
const [recommendations, setRecommendations] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
|
||||
// Dialog states
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [testDialogOpen, setTestDialogOpen] = useState(false);
|
||||
const [presetDialogOpen, setPresetDialogOpen] = useState(false);
|
||||
|
||||
// Form states
|
||||
const [configName, setConfigName] = useState('');
|
||||
const [selectedPreset, setSelectedPreset] = useState('');
|
||||
const [editableConfig, setEditableConfig] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await api.get('/security-headers/current');
|
||||
setCurrentConfig(response.data.current);
|
||||
setSavedConfigs(response.data.savedConfigs);
|
||||
setPresets(response.data.presets);
|
||||
|
||||
// Initialize editable config with current config
|
||||
if (response.data.current && !editableConfig) {
|
||||
setEditableConfig(convertToEditableFormat(response.data.current));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('security.failedToLoad'));
|
||||
console.error('Error fetching security headers:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-headers/recommendations');
|
||||
setRecommendations(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-headers/history');
|
||||
setHistory(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching history:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConfiguration = async () => {
|
||||
if (!editableConfig) return;
|
||||
|
||||
setTestDialogOpen(true);
|
||||
try {
|
||||
const response = await api.post('/security-headers/test', {
|
||||
config: editableConfig
|
||||
});
|
||||
setTestResults(response.data);
|
||||
} catch (err) {
|
||||
setError(t('security.testFailed'));
|
||||
console.error('Error testing configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfiguration = async () => {
|
||||
if (!configName || !editableConfig) {
|
||||
setError(t('security.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/security-headers/save', {
|
||||
configName,
|
||||
config: editableConfig,
|
||||
setActive: false
|
||||
});
|
||||
|
||||
setSuccess(t('security.configSaved'));
|
||||
setSaveDialogOpen(false);
|
||||
setConfigName('');
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setError(t('security.saveFailed'));
|
||||
console.error('Error saving configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyConfiguration = async (configId) => {
|
||||
if (!window.confirm(t('security.confirmApply'))) return;
|
||||
|
||||
try {
|
||||
const response = await api.post(`/security-headers/apply/${configId}`);
|
||||
|
||||
if (response.data.requiresRestart) {
|
||||
setSuccess(t('security.configAppliedRestart'));
|
||||
} else {
|
||||
setSuccess(t('security.configApplied'));
|
||||
}
|
||||
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setError(t('security.applyFailed'));
|
||||
console.error('Error applying configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfiguration = async (configId) => {
|
||||
if (!window.confirm(t('security.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/security-headers/${configId}`);
|
||||
setSuccess(t('security.configDeleted'));
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
setError(t('security.deleteFailed'));
|
||||
console.error('Error deleting configuration:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = () => {
|
||||
if (!selectedPreset || !presets[selectedPreset]) return;
|
||||
|
||||
setEditableConfig(presets[selectedPreset].config);
|
||||
setPresetDialogOpen(false);
|
||||
setSuccess(t('security.presetApplied', { preset: presets[selectedPreset].name }));
|
||||
};
|
||||
|
||||
const convertToEditableFormat = (config) => {
|
||||
return {
|
||||
csp_default_src: JSON.stringify(config.csp?.directives?.defaultSrc || ["'self'"]),
|
||||
csp_script_src: JSON.stringify(config.csp?.directives?.scriptSrc || ["'self'"]),
|
||||
csp_style_src: JSON.stringify(config.csp?.directives?.styleSrc || ["'self'"]),
|
||||
csp_img_src: JSON.stringify(config.csp?.directives?.imgSrc || ["'self'"]),
|
||||
csp_media_src: JSON.stringify(config.csp?.directives?.mediaSrc || ["'self'"]),
|
||||
csp_connect_src: JSON.stringify(config.csp?.directives?.connectSrc || ["'self'"]),
|
||||
csp_font_src: JSON.stringify(config.csp?.directives?.fontSrc || ["'self'"]),
|
||||
csp_frame_src: JSON.stringify(config.csp?.directives?.frameSrc || ["'self'"]),
|
||||
csp_object_src: JSON.stringify(config.csp?.directives?.objectSrc || ["'none'"]),
|
||||
csp_base_uri: JSON.stringify(config.csp?.directives?.baseUri || ["'self'"]),
|
||||
csp_form_action: JSON.stringify(config.csp?.directives?.formAction || ["'self'"]),
|
||||
csp_frame_ancestors: JSON.stringify(config.csp?.directives?.frameAncestors || ["'self'"]),
|
||||
hsts_enabled: config.hsts?.enabled !== false,
|
||||
hsts_max_age: config.hsts?.maxAge || 31536000,
|
||||
hsts_include_subdomains: config.hsts?.includeSubDomains !== false,
|
||||
hsts_preload: config.hsts?.preload !== false,
|
||||
referrer_policy: config.referrerPolicy || 'strict-origin-when-cross-origin',
|
||||
x_content_type_options: config.xContentTypeOptions !== false,
|
||||
x_frame_options: config.xFrameOptions || 'SAMEORIGIN',
|
||||
x_xss_protection: config.xssProtection !== false
|
||||
};
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'error': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity) => {
|
||||
switch (severity) {
|
||||
case 'error': return <ErrorIcon />;
|
||||
case 'warning': return <Warning />;
|
||||
case 'info': return <Info />;
|
||||
default: return <CheckCircle />;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={() => navigate('/security')} size="small">
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Security sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('security.securityHeaders')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.manageHttpHeaders')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchData}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess('')} sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Security Score Card */}
|
||||
{recommendations && (
|
||||
<Card sx={{ mb: 3, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box sx={{ textAlign: 'center', color: 'white' }}>
|
||||
<Typography variant="h2" fontWeight="bold">
|
||||
{recommendations.score}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('security.securityScore')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${t('security.grade')}: ${recommendations.grade}`}
|
||||
sx={{ mt: 1, bgcolor: 'white', color: 'primary.main', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={9}>
|
||||
<Box sx={{ color: 'white' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.securitySummary')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ErrorIcon />
|
||||
<Typography>
|
||||
{recommendations.summary.critical} {t('security.critical')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning />
|
||||
<Typography>
|
||||
{recommendations.summary.warnings} {t('security.warnings')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Info />
|
||||
<Typography>
|
||||
{recommendations.summary.info} {t('security.info')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label={t('security.currentConfig')} />
|
||||
<Tab label={t('security.editor')} />
|
||||
<Tab label={t('security.savedConfigs')} />
|
||||
<Tab label={t('security.recommendations')} />
|
||||
<Tab label={t('security.history')} onClick={() => tabValue === 4 && fetchHistory()} />
|
||||
</Tabs>
|
||||
|
||||
{/* Current Configuration Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<CardContent>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('security.currentConfigDescription')}
|
||||
</Alert>
|
||||
|
||||
{currentConfig && (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.environment')}: {currentConfig.environment}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
CSP {t('security.mode')}: {currentConfig.csp.mode}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.contentSecurityPolicy')}
|
||||
</Typography>
|
||||
{currentConfig.csp.directives && Object.entries(currentConfig.csp.directives).map(([key, value]) => (
|
||||
<Accordion key={key}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Typography fontWeight="medium">{key}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ bgcolor: 'grey.100', p: 2, borderRadius: 1, fontFamily: 'monospace' }}>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.otherHeaders')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
HSTS
|
||||
</Typography>
|
||||
<Typography>
|
||||
{currentConfig.hsts.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
{currentConfig.hsts.enabled && ` (${currentConfig.hsts.maxAge}s)`}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Referrer-Policy
|
||||
</Typography>
|
||||
<Typography>{currentConfig.referrerPolicy}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
X-Content-Type-Options
|
||||
</Typography>
|
||||
<Typography>
|
||||
{currentConfig.xContentTypeOptions ? 'nosniff' : t('common.disabled')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
X-Frame-Options
|
||||
</Typography>
|
||||
<Typography>{currentConfig.xFrameOptions}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Editor Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<CardContent>
|
||||
<Box sx={{ mb: 3, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
startIcon={<Settings />}
|
||||
onClick={() => setPresetDialogOpen(true)}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('security.loadPreset')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<PlayArrow />}
|
||||
onClick={handleTestConfiguration}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
>
|
||||
{t('security.testConfig')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<Save />}
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
variant="contained"
|
||||
>
|
||||
{t('security.saveConfig')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{t('security.editorWarning')}
|
||||
</Alert>
|
||||
|
||||
{editableConfig && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.cspDirectives')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(editableConfig).filter(([key]) => key.startsWith('csp_')).map(([key, value]) => (
|
||||
<Grid item xs={12} key={key}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={key.replace('csp_', '').replace(/_/g, '-')}
|
||||
value={value}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, [key]: e.target.value })}
|
||||
multiline
|
||||
rows={2}
|
||||
helperText={t('security.jsonArrayFormat')}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.hstsConfiguration')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.hsts_enabled}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_enabled: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('security.enableHSTS')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={t('security.maxAge')}
|
||||
value={editableConfig.hsts_max_age}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_max_age: parseInt(e.target.value) })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.hsts_include_subdomains}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_include_subdomains: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('security.includeSubdomains')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.hsts_preload}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, hsts_preload: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('security.preload')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.otherHeaders')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('security.referrerPolicy')}</InputLabel>
|
||||
<Select
|
||||
value={editableConfig.referrer_policy}
|
||||
label={t('security.referrerPolicy')}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, referrer_policy: e.target.value })}
|
||||
>
|
||||
<MenuItem value="no-referrer">no-referrer</MenuItem>
|
||||
<MenuItem value="no-referrer-when-downgrade">no-referrer-when-downgrade</MenuItem>
|
||||
<MenuItem value="origin">origin</MenuItem>
|
||||
<MenuItem value="origin-when-cross-origin">origin-when-cross-origin</MenuItem>
|
||||
<MenuItem value="same-origin">same-origin</MenuItem>
|
||||
<MenuItem value="strict-origin">strict-origin</MenuItem>
|
||||
<MenuItem value="strict-origin-when-cross-origin">strict-origin-when-cross-origin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('security.xFrameOptions')}</InputLabel>
|
||||
<Select
|
||||
value={editableConfig.x_frame_options}
|
||||
label={t('security.xFrameOptions')}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, x_frame_options: e.target.value })}
|
||||
>
|
||||
<MenuItem value="DENY">DENY</MenuItem>
|
||||
<MenuItem value="SAMEORIGIN">SAMEORIGIN</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.x_content_type_options}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, x_content_type_options: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="X-Content-Type-Options: nosniff"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editableConfig.x_xss_protection}
|
||||
onChange={(e) => setEditableConfig({ ...editableConfig, x_xss_protection: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="X-XSS-Protection"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Saved Configurations Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<CardContent>
|
||||
{savedConfigs.length === 0 ? (
|
||||
<Alert severity="info">{t('security.noSavedConfigs')}</Alert>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{savedConfigs.map((config) => (
|
||||
<Grid item xs={12} key={config.id}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{config.config_name}
|
||||
{config.is_active && (
|
||||
<Chip label={t('security.active')} color="success" size="small" sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.created')}: {new Date(config.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ContentCopy />}
|
||||
onClick={() => {
|
||||
setEditableConfig({
|
||||
csp_default_src: config.csp_default_src,
|
||||
csp_script_src: config.csp_script_src,
|
||||
csp_style_src: config.csp_style_src,
|
||||
csp_img_src: config.csp_img_src,
|
||||
csp_media_src: config.csp_media_src,
|
||||
csp_connect_src: config.csp_connect_src,
|
||||
csp_font_src: config.csp_font_src,
|
||||
csp_frame_src: config.csp_frame_src,
|
||||
csp_object_src: config.csp_object_src,
|
||||
csp_base_uri: config.csp_base_uri,
|
||||
csp_form_action: config.csp_form_action,
|
||||
csp_frame_ancestors: config.csp_frame_ancestors,
|
||||
hsts_enabled: config.hsts_enabled,
|
||||
hsts_max_age: config.hsts_max_age,
|
||||
hsts_include_subdomains: config.hsts_include_subdomains,
|
||||
hsts_preload: config.hsts_preload,
|
||||
referrer_policy: config.referrer_policy,
|
||||
x_content_type_options: config.x_content_type_options,
|
||||
x_frame_options: config.x_frame_options,
|
||||
x_xss_protection: config.x_xss_protection
|
||||
});
|
||||
setTabValue(1);
|
||||
setSuccess(t('security.configLoaded'));
|
||||
}}
|
||||
>
|
||||
{t('security.loadToEditor')}
|
||||
</Button>
|
||||
{!config.is_active && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => handleApplyConfiguration(config.id)}
|
||||
>
|
||||
{t('security.apply')}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteConfiguration(config.id)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Recommendations Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<CardContent>
|
||||
{recommendations && recommendations.recommendations.length > 0 ? (
|
||||
<List>
|
||||
{recommendations.recommendations.map((rec, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Box sx={{ color: `${getSeverityColor(rec.severity)}.main` }}>
|
||||
{getSeverityIcon(rec.severity)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{rec.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${rec.severity.toUpperCase()}`}
|
||||
size="small"
|
||||
color={getSeverityColor(rec.severity)}
|
||||
/>
|
||||
{rec.impact && (
|
||||
<Chip
|
||||
label={`Impact: ${rec.impact}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ pl: 5 }}>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
<strong>Description:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-line' }}>
|
||||
{rec.description}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="body2" color="primary.main" paragraph>
|
||||
<strong>Recommended Action:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-line' }}>
|
||||
{rec.action}
|
||||
</Typography>
|
||||
|
||||
{rec.details && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
<strong>Additional Details:</strong>
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
|
||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
p: 2,
|
||||
borderRadius: 1
|
||||
}}>
|
||||
{Object.entries(rec.details).map(([key, value]) => (
|
||||
<Box key={key} sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" color="primary" display="block" fontWeight="medium">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}:
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.primary">{value}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Alert severity="success">{t('security.noRecommendations')}</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<CardContent>
|
||||
{history.length === 0 ? (
|
||||
<Alert severity="info">{t('security.noHistory')}</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.timestamp')}</TableCell>
|
||||
<TableCell>{t('security.action')}</TableCell>
|
||||
<TableCell>{t('security.configuration')}</TableCell>
|
||||
<TableCell>{t('security.changedBy')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{history.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell>{new Date(entry.timestamp).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={entry.action} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{entry.config_name || '-'}</TableCell>
|
||||
<TableCell>{entry.username || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
</Card>
|
||||
|
||||
{/* Save Dialog */}
|
||||
<Dialog open={saveDialogOpen} onClose={() => setSaveDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('security.saveConfiguration')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('security.configurationName')}
|
||||
value={configName}
|
||||
onChange={(e) => setConfigName(e.target.value)}
|
||||
sx={{ mt: 2 }}
|
||||
helperText={t('security.configNameHelper')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSaveDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSaveConfiguration} variant="contained">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Test Results Dialog */}
|
||||
<Dialog open={testDialogOpen} onClose={() => setTestDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('security.testResults')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{testResults && (
|
||||
<Box>
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h3" fontWeight="bold" color="primary.main">
|
||||
{testResults.score}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('security.grade')}: {testResults.grade}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{testResults.summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{testResults.passed.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" fontWeight="medium" color="success.main" gutterBottom>
|
||||
✓ {t('security.passed')}
|
||||
</Typography>
|
||||
{testResults.passed.map((item, index) => (
|
||||
<Alert key={index} severity="success" sx={{ mb: 1 }}>
|
||||
<strong>{item.test}</strong>: {item.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{testResults.warnings.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" fontWeight="medium" color="warning.main" gutterBottom>
|
||||
⚠ {t('security.warnings')}
|
||||
</Typography>
|
||||
{testResults.warnings.map((item, index) => (
|
||||
<Alert key={index} severity="warning" sx={{ mb: 1 }}>
|
||||
<strong>{item.test}</strong>: {item.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{testResults.errors.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="medium" color="error.main" gutterBottom>
|
||||
✗ {t('security.errors')}
|
||||
</Typography>
|
||||
{testResults.errors.map((item, index) => (
|
||||
<Alert key={index} severity="error" sx={{ mb: 1 }}>
|
||||
<strong>{item.test}</strong>: {item.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTestDialogOpen(false)}>{t('common.close')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Preset Selection Dialog */}
|
||||
<Dialog open={presetDialogOpen} onClose={() => setPresetDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('security.selectPreset')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>{t('security.preset')}</InputLabel>
|
||||
<Select
|
||||
value={selectedPreset}
|
||||
label={t('security.preset')}
|
||||
onChange={(e) => setSelectedPreset(e.target.value)}
|
||||
>
|
||||
{Object.entries(presets).map(([key, preset]) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{preset.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{preset.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPresetDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleApplyPreset} variant="contained" disabled={!selectedPreset}>
|
||||
{t('security.loadPreset')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityHeadersDashboard;
|
||||
216
frontend/src/components/SecurityNotificationProvider.jsx
Normal file
216
frontend/src/components/SecurityNotificationProvider.jsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { Snackbar, Alert, AlertTitle, IconButton, Box, Collapse } from '@mui/material';
|
||||
import { Close, ExpandMore, ExpandLess } from '@mui/icons-material';
|
||||
|
||||
const SecurityNotificationContext = createContext();
|
||||
|
||||
export const useSecurityNotification = () => {
|
||||
const context = useContext(SecurityNotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useSecurityNotification must be used within SecurityNotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SecurityNotificationProvider = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
const addNotification = useCallback(({
|
||||
type = 'info', // 'success', 'info', 'warning', 'error'
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
duration = 6000,
|
||||
persistent = false
|
||||
}) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const notification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
duration,
|
||||
persistent,
|
||||
expanded: false,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setNotifications(prev => [...prev, notification]);
|
||||
|
||||
if (!persistent && duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeNotification = useCallback((id) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const toggleExpanded = useCallback((id) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, expanded: !n.expanded } : n)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Specific notification types for security events
|
||||
const notifySecurityWarning = useCallback((message, details) => {
|
||||
return addNotification({
|
||||
type: 'warning',
|
||||
title: 'Security Warning',
|
||||
message,
|
||||
details,
|
||||
duration: 8000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifySecurityError = useCallback((message, details) => {
|
||||
return addNotification({
|
||||
type: 'error',
|
||||
title: 'Security Alert',
|
||||
message,
|
||||
details,
|
||||
persistent: true
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifyAccountLocked = useCallback((remainingMinutes) => {
|
||||
return addNotification({
|
||||
type: 'error',
|
||||
title: 'Account Locked',
|
||||
message: `Your account has been locked due to multiple failed login attempts.`,
|
||||
details: `Please try again in ${remainingMinutes} minutes.`,
|
||||
persistent: true
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifyPasswordExpiring = useCallback((daysRemaining) => {
|
||||
return addNotification({
|
||||
type: 'warning',
|
||||
title: 'Password Expiring Soon',
|
||||
message: `Your password will expire in ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''}.`,
|
||||
details: 'Please change your password to maintain account security.',
|
||||
duration: 10000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifyInvalidInput = useCallback((fieldName, errors) => {
|
||||
return addNotification({
|
||||
type: 'error',
|
||||
title: 'Invalid Input',
|
||||
message: `Please check the ${fieldName} field.`,
|
||||
details: Array.isArray(errors) ? errors.join(', ') : errors,
|
||||
duration: 5000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const notifySecuritySuccess = useCallback((message) => {
|
||||
return addNotification({
|
||||
type: 'success',
|
||||
title: 'Security Update',
|
||||
message,
|
||||
duration: 4000
|
||||
});
|
||||
}, [addNotification]);
|
||||
|
||||
const value = {
|
||||
addNotification,
|
||||
removeNotification,
|
||||
notifySecurityWarning,
|
||||
notifySecurityError,
|
||||
notifyAccountLocked,
|
||||
notifyPasswordExpiring,
|
||||
notifyInvalidInput,
|
||||
notifySecuritySuccess
|
||||
};
|
||||
|
||||
return (
|
||||
<SecurityNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 80,
|
||||
right: 24,
|
||||
zIndex: 9999,
|
||||
maxWidth: 400,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{notifications.map((notification, index) => (
|
||||
<Snackbar
|
||||
key={notification.id}
|
||||
open={true}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mt: index * 0.5,
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity={notification.type}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
boxShadow: 3,
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%'
|
||||
}
|
||||
}}
|
||||
action={
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="close"
|
||||
color="inherit"
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{notification.title && (
|
||||
<AlertTitle sx={{ fontWeight: 600 }}>
|
||||
{notification.title}
|
||||
</AlertTitle>
|
||||
)}
|
||||
<Box>
|
||||
{notification.message}
|
||||
{notification.details && (
|
||||
<>
|
||||
{notification.details.length > 100 ? (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => toggleExpanded(notification.id)}
|
||||
sx={{ ml: 1, p: 0 }}
|
||||
>
|
||||
{notification.expanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
<Collapse in={notification.expanded}>
|
||||
<Box sx={{ mt: 1, fontSize: '0.875rem', opacity: 0.9 }}>
|
||||
{notification.details}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ mt: 0.5, fontSize: '0.875rem', opacity: 0.9 }}>
|
||||
{notification.details}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</Box>
|
||||
</SecurityNotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityNotificationProvider;
|
||||
301
frontend/src/components/SecuritySettingsPanel.jsx
Normal file
301
frontend/src/components/SecuritySettingsPanel.jsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Divider,
|
||||
Alert,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
Shield,
|
||||
Lock,
|
||||
VpnKey,
|
||||
DevicesOther,
|
||||
Warning,
|
||||
CheckCircle,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
import axios from 'axios';
|
||||
|
||||
const SecuritySettingsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user } = useAuthStore();
|
||||
const { notifySecuritySuccess, notifySecurityError } = useSecurityNotification();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settings, setSettings] = useState({
|
||||
twoFactorEnabled: false,
|
||||
sessionTimeout: 7 * 24 * 60, // minutes
|
||||
passwordExpiryDays: 90,
|
||||
requireStrongPassword: true,
|
||||
accountLockoutEnabled: true,
|
||||
maxFailedAttempts: 5,
|
||||
lockoutDuration: 30 // minutes
|
||||
});
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [showTerminateDialog, setShowTerminateDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecuritySettings();
|
||||
fetchActiveSessions();
|
||||
}, []);
|
||||
|
||||
const fetchSecuritySettings = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/security-status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
twoFactorEnabled: response.data.twoFactorEnabled || false
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching security settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchActiveSessions = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/sessions', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setSessions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateAllSessions = async () => {
|
||||
try {
|
||||
await axios.post('/api/auth/sessions/terminate-all', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(t('security.terminateAllSessions'));
|
||||
setShowTerminateDialog(false);
|
||||
fetchActiveSessions();
|
||||
} catch (error) {
|
||||
notifySecurityError(t('error'), error.response?.data?.error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security color="primary" />
|
||||
{t('security.title')}
|
||||
</Typography>
|
||||
|
||||
{/* Security Status Overview */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Shield color="primary" />
|
||||
{t('security.securityStatus')}
|
||||
</Typography>
|
||||
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{settings.twoFactorEnabled ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Warning color="warning" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('twoFactor.title')}
|
||||
secondary={
|
||||
settings.twoFactorEnabled
|
||||
? t('security.twoFactorEnabled')
|
||||
: t('twoFactor.notEnabled')
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label={settings.twoFactorEnabled ? t('enabled') : t('disabled')}
|
||||
color={settings.twoFactorEnabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{settings.requireStrongPassword ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Info color="info" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.passwordPolicy')}
|
||||
secondary={t('security.minLength')}
|
||||
/>
|
||||
<Chip
|
||||
label={settings.requireStrongPassword ? t('enabled') : t('disabled')}
|
||||
color={settings.requireStrongPassword ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{settings.accountLockoutEnabled ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<Info color="info" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.accountLockout')}
|
||||
secondary={`${settings.maxFailedAttempts} ${t('security.failedAttempts')}`}
|
||||
/>
|
||||
<Chip
|
||||
label={settings.accountLockoutEnabled ? t('enabled') : t('disabled')}
|
||||
color={settings.accountLockoutEnabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Sessions */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DevicesOther color="primary" />
|
||||
{t('security.activeSessions')}
|
||||
</Typography>
|
||||
|
||||
{sessions.length > 0 ? (
|
||||
<>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('security.multipleDevices', { count: sessions.length })}
|
||||
</Alert>
|
||||
|
||||
<List>
|
||||
{sessions.map((session, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText
|
||||
primary={session.device || 'Unknown Device'}
|
||||
secondary={`Last active: ${new Date(session.lastActive).toLocaleString()}`}
|
||||
/>
|
||||
{session.current && (
|
||||
<Chip label="Current" color="primary" size="small" />
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{sessions.length > 1 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setShowTerminateDialog(true)}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.noRecentActivity')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Input Validation Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<VpnKey color="primary" />
|
||||
{t('security.inputValidation')}
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('security.inputSanitized')}
|
||||
</Alert>
|
||||
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('security.xssAttemptBlocked')} />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('security.sqlInjectionBlocked')} />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('security.rateLimitExceeded').split('.')[0]} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminate Sessions Dialog */}
|
||||
<Dialog open={showTerminateDialog} onClose={() => setShowTerminateDialog(false)}>
|
||||
<DialogTitle>{t('security.terminateAllSessions')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t('security.sessionExpired')}
|
||||
</Alert>
|
||||
<Typography>
|
||||
This will log you out from all other devices. Your current session will remain active.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowTerminateDialog(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleTerminateAllSessions} color="error" variant="contained">
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecuritySettingsPanel;
|
||||
261
frontend/src/components/SecurityStatusCard.jsx
Normal file
261
frontend/src/components/SecurityStatusCard.jsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
IconButton,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Error as ErrorIcon,
|
||||
ExpandMore,
|
||||
Shield,
|
||||
Key,
|
||||
Devices,
|
||||
History
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const SecurityStatusCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useAuthStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [securityStatus, setSecurityStatus] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecurityStatus();
|
||||
}, []);
|
||||
|
||||
const fetchSecurityStatus = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await axios.get('/api/auth/security-status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setSecurityStatus(response.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load security status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getPasswordExpiryStatus = () => {
|
||||
if (!securityStatus?.passwordExpiry) return null;
|
||||
const { expired, warning, daysRemaining } = securityStatus.passwordExpiry;
|
||||
|
||||
if (expired) {
|
||||
return { severity: 'error', icon: <ErrorIcon />, message: t('security.passwordExpired') };
|
||||
}
|
||||
if (warning) {
|
||||
return {
|
||||
severity: 'warning',
|
||||
icon: <Warning />,
|
||||
message: t('security.passwordExpiresIn', { days: daysRemaining })
|
||||
};
|
||||
}
|
||||
return { severity: 'success', icon: <CheckCircle />, message: 'Password is valid' };
|
||||
};
|
||||
|
||||
const passwordExpiryStatus = getPasswordExpiryStatus();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security color="primary" />
|
||||
<Typography variant="h6">{t('security.securityStatus')}</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s'
|
||||
}}
|
||||
>
|
||||
<ExpandMore />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Password Expiry Warning */}
|
||||
{passwordExpiryStatus && passwordExpiryStatus.severity !== 'success' && (
|
||||
<Alert
|
||||
severity={passwordExpiryStatus.severity}
|
||||
icon={passwordExpiryStatus.icon}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{passwordExpiryStatus.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Security Overview */}
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Shield color={securityStatus?.twoFactorEnabled ? 'success' : 'disabled'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.twoFactorEnabled')}
|
||||
secondary={securityStatus?.twoFactorEnabled ? t('security.success') : 'Not enabled'}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={securityStatus?.twoFactorEnabled ? 'ON' : 'OFF'}
|
||||
color={securityStatus?.twoFactorEnabled ? 'success' : 'default'}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Key color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.passwordAge', { days: securityStatus?.passwordAge || 0 })}
|
||||
secondary={
|
||||
securityStatus?.passwordExpiry?.daysRemaining
|
||||
? t('security.passwordExpiresIn', { days: securityStatus.passwordExpiry.daysRemaining })
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Devices color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.activeSessions')}
|
||||
secondary={
|
||||
securityStatus?.lastActivity
|
||||
? (() => {
|
||||
try {
|
||||
return format(new Date(securityStatus.lastActivity), 'PPpp');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<Chip size="small" label={securityStatus?.activeSessions || 0} color="primary" />
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Warning color={securityStatus?.failedLoginAttempts > 0 ? 'error' : 'success'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('security.failedAttempts')}
|
||||
secondary={
|
||||
securityStatus?.lastLogin?.timestamp
|
||||
? (() => {
|
||||
try {
|
||||
return format(new Date(securityStatus.lastLogin.timestamp), 'PPpp');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
})()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={securityStatus?.failedLoginAttempts || 0}
|
||||
color={securityStatus?.failedLoginAttempts > 0 ? 'error' : 'success'}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<History fontSize="small" />
|
||||
{t('security.recentActivity')}
|
||||
</Typography>
|
||||
{Array.isArray(securityStatus?.recentEvents) && securityStatus.recentEvents.length > 0 ? (
|
||||
<List dense>
|
||||
{securityStatus.recentEvents.slice(0, 5).map((event, index) => (
|
||||
<ListItem key={index} sx={{ pl: 0 }}>
|
||||
<ListItemIcon>
|
||||
{event.status === 'success' ? (
|
||||
<CheckCircle fontSize="small" color="success" />
|
||||
) : event.status === 'failed' ? (
|
||||
<ErrorIcon fontSize="small" color="error" />
|
||||
) : (
|
||||
<Warning fontSize="small" color="warning" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={event.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
secondary={
|
||||
event.timestamp
|
||||
? (() => {
|
||||
try {
|
||||
return format(new Date(event.timestamp), 'PPpp');
|
||||
} catch {
|
||||
return 'Invalid date';
|
||||
}
|
||||
})()
|
||||
: 'No timestamp'
|
||||
}
|
||||
primaryTypographyProps={{ variant: 'caption' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Chip size="small" label={event.status} variant="outlined" />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', p: 2, textAlign: 'center' }}>
|
||||
{t('security.noRecentActivity')}
|
||||
</Typography>
|
||||
)}
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityStatusCard;
|
||||
640
frontend/src/components/SecurityTestingDashboard.jsx
Normal file
640
frontend/src/components/SecurityTestingDashboard.jsx
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Button,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
PlayArrow,
|
||||
Refresh,
|
||||
ArrowBack,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Warning,
|
||||
Shield,
|
||||
ExpandMore,
|
||||
BugReport,
|
||||
Lock,
|
||||
VpnLock,
|
||||
Storage,
|
||||
Code,
|
||||
NetworkCheck
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../utils/api';
|
||||
|
||||
function TabPanel({ children, value, index }) {
|
||||
return value === index ? <Box sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
const SecurityTestingDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
// Data states
|
||||
const [defenseLayers, setDefenseLayers] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [testHistory, setTestHistory] = useState([]);
|
||||
const [networkStats, setNetworkStats] = useState(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
// Test selection
|
||||
const [selectedTests, setSelectedTests] = useState({
|
||||
auth: true,
|
||||
sql: true,
|
||||
xss: true,
|
||||
csrf: true,
|
||||
rate: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefenseLayers();
|
||||
fetchTestHistory();
|
||||
fetchNetworkStats();
|
||||
}, []);
|
||||
|
||||
const fetchDefenseLayers = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-testing/defense-layers');
|
||||
setDefenseLayers(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching defense layers:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTestHistory = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-testing/test-history?limit=20');
|
||||
setTestHistory(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching test history:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNetworkStats = async () => {
|
||||
try {
|
||||
const response = await api.get('/security-testing/network-stats');
|
||||
setNetworkStats(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching network stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunPenetrationTests = async () => {
|
||||
setTesting(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const testTypes = Object.keys(selectedTests).filter(key => selectedTests[key]);
|
||||
|
||||
const response = await api.post('/security-testing/penetration-test', {
|
||||
testTypes: testTypes.length === 5 ? ['all'] : testTypes
|
||||
});
|
||||
|
||||
setTestResults(response.data);
|
||||
setSuccess(t('security.testsCompleted'));
|
||||
fetchTestHistory();
|
||||
} catch (err) {
|
||||
setError(t('security.testsFailed'));
|
||||
console.error('Error running penetration tests:', err);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getLayerIcon = (layer) => {
|
||||
switch (layer) {
|
||||
case 'Network Level': return <NetworkCheck />;
|
||||
case 'Server Level': return <Shield />;
|
||||
case 'Application Level': return <Code />;
|
||||
case 'Data Level': return <Storage />;
|
||||
default: return <Security />;
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score) => {
|
||||
if (score >= 90) return 'success';
|
||||
if (score >= 75) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'pass': return <CheckCircle color="success" />;
|
||||
case 'fail': return <ErrorIcon color="error" />;
|
||||
case 'warn': return <Warning color="warning" />;
|
||||
default: return <Warning color="info" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'error';
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={() => navigate('/security')} size="small">
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<BugReport sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
{t('security.securityTesting')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.automatedAndManualTesting')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
fetchDefenseLayers();
|
||||
fetchTestHistory();
|
||||
fetchNetworkStats();
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess('')} sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Overall Security Score */}
|
||||
{defenseLayers && (
|
||||
<Card sx={{ mb: 3, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={12} md={3}>
|
||||
<Box sx={{ textAlign: 'center', color: 'white' }}>
|
||||
<Typography variant="h2" fontWeight="bold">
|
||||
{defenseLayers.overall_score}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{t('security.overallScore')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={defenseLayers.overall_score >= 90 ? 'A' : defenseLayers.overall_score >= 80 ? 'B' : defenseLayers.overall_score >= 70 ? 'C' : 'D'}
|
||||
sx={{ mt: 1, bgcolor: 'white', color: 'primary.main', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={9}>
|
||||
<Box sx={{ color: 'white' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.defenseInDepth')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{[defenseLayers.network, defenseLayers.server, defenseLayers.application, defenseLayers.data].map((layer, index) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={index}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{layer.layer}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={layer.score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'rgba(255,255,255,0.2)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: layer.score >= 80 ? 'success.light' : layer.score >= 60 ? 'warning.light' : 'error.light'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption">
|
||||
{layer.score}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Card>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label={t('security.defenseInDepth')} />
|
||||
<Tab label={t('security.penetrationTesting')} />
|
||||
<Tab label={t('security.testHistory')} />
|
||||
<Tab label={t('security.networkSecurity')} />
|
||||
</Tabs>
|
||||
|
||||
{/* Defense-in-Depth Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<CardContent>
|
||||
{defenseLayers && (
|
||||
<Grid container spacing={3}>
|
||||
{[defenseLayers.network, defenseLayers.server, defenseLayers.application, defenseLayers.data].map((layer, layerIndex) => (
|
||||
<Grid item xs={12} key={layerIndex}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{getLayerIcon(layer.layer)}
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight="medium">
|
||||
{layer.layer}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{layer.checks.length} {t('security.checks')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h4" color={getScoreColor(layer.score) + '.main'} fontWeight="bold">
|
||||
{layer.score}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('security.score')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Checks */}
|
||||
<List>
|
||||
{layer.checks.map((check, checkIndex) => (
|
||||
<ListItem key={checkIndex}>
|
||||
<ListItemIcon>
|
||||
{getStatusIcon(check.status)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={check.name}
|
||||
secondary={check.details}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Recommendations */}
|
||||
{layer.recommendations.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" color="warning.main" gutterBottom>
|
||||
{t('security.recommendations')}:
|
||||
</Typography>
|
||||
{layer.recommendations.map((rec, recIndex) => (
|
||||
<Alert key={recIndex} severity="warning" sx={{ mb: 1 }}>
|
||||
{rec}
|
||||
</Alert>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Penetration Testing Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<CardContent>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('security.penetrationTestingInfo')}
|
||||
</Alert>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.selectTests')}
|
||||
</Typography>
|
||||
<FormGroup row>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.auth} onChange={(e) => setSelectedTests({...selectedTests, auth: e.target.checked})} />}
|
||||
label={t('security.authenticationTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.sql} onChange={(e) => setSelectedTests({...selectedTests, sql: e.target.checked})} />}
|
||||
label={t('security.sqlInjectionTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.xss} onChange={(e) => setSelectedTests({...selectedTests, xss: e.target.checked})} />}
|
||||
label={t('security.xssTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.csrf} onChange={(e) => setSelectedTests({...selectedTests, csrf: e.target.checked})} />}
|
||||
label={t('security.csrfTests')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={selectedTests.rate} onChange={(e) => setSelectedTests({...selectedTests, rate: e.target.checked})} />}
|
||||
label={t('security.rateLimitTests')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
startIcon={testing ? <CircularProgress size={20} color="inherit" /> : <PlayArrow />}
|
||||
onClick={handleRunPenetrationTests}
|
||||
disabled={testing || Object.values(selectedTests).every(v => !v)}
|
||||
>
|
||||
{testing ? t('security.runningTests') : t('security.runPenetrationTests')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{testResults && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.testResults')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" color="success.main" fontWeight="bold">
|
||||
{testResults.summary.passed}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.passed')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" color="error.main" fontWeight="bold">
|
||||
{testResults.summary.failed}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.failed')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h3" color="warning.main" fontWeight="bold">
|
||||
{testResults.summary.warnings}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.warnings')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{testResults.tests.map((test, index) => (
|
||||
<Accordion key={index}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
{test.passed ? <CheckCircle color="success" /> : <ErrorIcon color="error" />}
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{test.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={test.severity.toUpperCase()}
|
||||
size="small"
|
||||
color={getSeverityColor(test.severity)}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||
{test.duration_ms}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
<strong>{t('security.findings')}:</strong>
|
||||
</Typography>
|
||||
<List dense>
|
||||
{test.findings.map((finding, fIndex) => (
|
||||
<ListItem key={fIndex}>
|
||||
<ListItemText primary={finding} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{test.recommendations.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mt: 2 }}>
|
||||
<strong>{t('security.recommendations')}:</strong>
|
||||
</Typography>
|
||||
<List dense>
|
||||
{test.recommendations.map((rec, rIndex) => (
|
||||
<ListItem key={rIndex}>
|
||||
<ListItemText primary={rec} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Test History Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<CardContent>
|
||||
{testHistory.length === 0 ? (
|
||||
<Alert severity="info">{t('security.noTestHistory')}</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('security.timestamp')}</TableCell>
|
||||
<TableCell>{t('security.testType')}</TableCell>
|
||||
<TableCell>{t('security.testName')}</TableCell>
|
||||
<TableCell>{t('security.status')}</TableCell>
|
||||
<TableCell>{t('security.severity')}</TableCell>
|
||||
<TableCell>{t('security.executedBy')}</TableCell>
|
||||
<TableCell>{t('security.duration')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{testHistory.map((test) => (
|
||||
<TableRow key={test.id}>
|
||||
<TableCell>{new Date(test.started_at).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={test.test_type} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{test.test_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={test.status}
|
||||
size="small"
|
||||
color={test.status === 'pass' ? 'success' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={test.severity}
|
||||
size="small"
|
||||
color={getSeverityColor(test.severity)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{test.username || '-'}</TableCell>
|
||||
<TableCell>{test.duration_ms}ms</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
|
||||
{/* Network Security Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<CardContent>
|
||||
{networkStats && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" fontWeight="bold" color="primary">
|
||||
{networkStats.active_connections.count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.activeConnections')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" fontWeight="bold" color="error">
|
||||
{networkStats.blocked_requests.count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.blockedRequests')} (24h)
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h4" fontWeight="bold" color="warning">
|
||||
{networkStats.rate_limiting.failed_logins}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.failedLogins')} (24h)
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('security.rateLimitingStats')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.totalRequests')} (24h)
|
||||
</Typography>
|
||||
<Typography variant="h5">
|
||||
{networkStats.rate_limiting.total_requests}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('security.rateLimited')} (24h)
|
||||
</Typography>
|
||||
<Typography variant="h5" color="warning.main">
|
||||
{networkStats.rate_limiting.rate_limited}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</TabPanel>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityTestingDashboard;
|
||||
371
frontend/src/components/SessionManagement.jsx
Normal file
371
frontend/src/components/SessionManagement.jsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DevicesOther,
|
||||
Delete,
|
||||
Refresh,
|
||||
Computer,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSecurityNotification } from './SecurityNotificationProvider';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const SessionManagement = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useAuthStore();
|
||||
const { notifySecuritySuccess, notifySecurityError, notifySecurityWarning } = useSecurityNotification();
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [terminateDialogOpen, setTerminateDialogOpen] = useState(false);
|
||||
const [terminateAllDialogOpen, setTerminateAllDialogOpen] = useState(false);
|
||||
const [sessionToTerminate, setSessionToTerminate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/sessions/my-sessions', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setSessions(response.data);
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to fetch sessions'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (userAgent) => {
|
||||
if (!userAgent) return <Computer />;
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
return <Smartphone />;
|
||||
}
|
||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
return <Tablet />;
|
||||
}
|
||||
return <Computer />;
|
||||
};
|
||||
|
||||
const getDeviceName = (userAgent) => {
|
||||
if (!userAgent) return 'Unknown Device';
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes('chrome')) return 'Chrome Browser';
|
||||
if (ua.includes('firefox')) return 'Firefox Browser';
|
||||
if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari Browser';
|
||||
if (ua.includes('edge')) return 'Edge Browser';
|
||||
if (ua.includes('mobile')) return 'Mobile Device';
|
||||
if (ua.includes('tablet')) return 'Tablet';
|
||||
return 'Desktop Browser';
|
||||
};
|
||||
|
||||
const getLocation = (ipAddress) => {
|
||||
// In a real app, you might do IP geolocation
|
||||
return ipAddress || 'Unknown Location';
|
||||
};
|
||||
|
||||
const handleTerminateSession = async () => {
|
||||
if (!sessionToTerminate) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/sessions/${sessionToTerminate.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(t('security.terminateSession'));
|
||||
setTerminateDialogOpen(false);
|
||||
setSessionToTerminate(null);
|
||||
fetchSessions();
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to terminate session'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateAllOthers = async () => {
|
||||
try {
|
||||
const response = await axios.post('/api/sessions/terminate-all-others', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifySecuritySuccess(
|
||||
`${response.data.count} ${t('security.terminateSession')}(s)`
|
||||
);
|
||||
setTerminateAllDialogOpen(false);
|
||||
fetchSessions();
|
||||
} catch (error) {
|
||||
notifySecurityError(
|
||||
t('error'),
|
||||
error.response?.data?.error || 'Failed to terminate sessions'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openTerminateDialog = (session) => {
|
||||
setSessionToTerminate(session);
|
||||
setTerminateDialogOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const otherSessions = sessions.filter(s => !s.isCurrent);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DevicesOther color="primary" fontSize="large" />
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('security.activeSessions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={fetchSessions}
|
||||
size="small"
|
||||
>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
{otherSessions.length > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setTerminateAllDialogOpen(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sessions.length > 1 && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('security.multipleDevices', { count: sessions.length })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<Alert severity="warning">
|
||||
{t('security.noRecentActivity')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('device')}</TableCell>
|
||||
<TableCell>{t('security.ipAddress')}</TableCell>
|
||||
<TableCell>{t('lastActive')}</TableCell>
|
||||
<TableCell>{t('created')}</TableCell>
|
||||
<TableCell>{t('security.status')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sessions.map((session) => (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
sx={{
|
||||
backgroundColor: session.isCurrent ? 'action.selected' : 'inherit'
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getDeviceIcon(session.user_agent)}
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={session.isCurrent ? 'bold' : 'normal'}>
|
||||
{getDeviceName(session.user_agent)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{session.user_agent?.substring(0, 50)}...
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{getLocation(session.ip_address)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{session.ip_address}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{session.last_activity
|
||||
? format(new Date(session.last_activity), 'MMM d, yyyy HH:mm')
|
||||
: 'N/A'
|
||||
}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{format(new Date(session.created_at), 'MMM d, yyyy HH:mm')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{session.isCurrent ? (
|
||||
<Chip
|
||||
label={t('current')}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={t('active')}
|
||||
color="success"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{!session.isCurrent && (
|
||||
<Tooltip title={t('security.terminateSession')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => openTerminateDialog(session)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Terminate Single Session Dialog */}
|
||||
<Dialog
|
||||
open={terminateDialogOpen}
|
||||
onClose={() => setTerminateDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning color="warning" />
|
||||
{t('security.terminateSession')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography gutterBottom>
|
||||
Are you sure you want to terminate this session?
|
||||
</Typography>
|
||||
{sessionToTerminate && (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>{t('device')}:</strong> {getDeviceName(sessionToTerminate.user_agent)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>{t('security.ipAddress')}:</strong> {sessionToTerminate.ip_address}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>{t('lastActive')}:</strong> {format(new Date(sessionToTerminate.last_activity || sessionToTerminate.created_at), 'MMM d, yyyy HH:mm')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
This device will be logged out immediately.
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTerminateDialogOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleTerminateSession} color="error" variant="contained">
|
||||
{t('security.terminateSession')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Terminate All Other Sessions Dialog */}
|
||||
<Dialog
|
||||
open={terminateAllDialogOpen}
|
||||
onClose={() => setTerminateAllDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning color="error" />
|
||||
{t('security.terminateAllSessions')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography gutterBottom>
|
||||
Are you sure you want to terminate all other sessions?
|
||||
</Typography>
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
This will log you out from all devices except this one. You'll need to log in again on those devices.
|
||||
</Alert>
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
<strong>{otherSessions.length}</strong> session(s) will be terminated.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTerminateAllDialogOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleTerminateAllOthers} color="error" variant="contained">
|
||||
{t('security.terminateAllSessions')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionManagement;
|
||||
165
frontend/src/components/Sidebar.jsx
Normal file
165
frontend/src/components/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Box,
|
||||
Typography,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LiveTv,
|
||||
Movie,
|
||||
Theaters,
|
||||
Favorite,
|
||||
Settings,
|
||||
Radio as RadioIcon,
|
||||
BarChart,
|
||||
Security
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import Logo from './Logo';
|
||||
|
||||
const drawerWidth = 200;
|
||||
|
||||
function Sidebar({ open, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'live_tv', icon: <LiveTv fontSize="small" />, path: '/live-tv' },
|
||||
{ text: 'radio', icon: <RadioIcon fontSize="small" />, path: '/radio' },
|
||||
{ text: 'favorite_tv', icon: <Favorite fontSize="small" />, path: '/favorites/tv' },
|
||||
{ text: 'favorite_radio', icon: <Favorite fontSize="small" />, path: '/favorites/radio' },
|
||||
...(user?.role === 'admin' ? [
|
||||
{ text: 'Analytics', icon: <BarChart fontSize="small" />, path: '/stats' },
|
||||
{ text: 'security.title', icon: <Security fontSize="small" />, path: '/security' }
|
||||
] : []),
|
||||
{ text: 'settings.title', icon: <Settings fontSize="small" />, path: '/settings' }
|
||||
];
|
||||
|
||||
const handleNavigate = (path) => {
|
||||
navigate(path);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Logo size={36} />
|
||||
<Typography variant="h6" fontSize="0.95rem" fontWeight="bold" sx={{ mt: 0.5 }}>
|
||||
{t('app_name')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 1.5, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover'
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ width: 28, height: 28, bgcolor: 'primary.main', fontSize: '0.875rem' }}>
|
||||
{user?.username?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Typography variant="body2" fontSize="0.8rem" fontWeight="600" noWrap>
|
||||
{user?.username}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<List sx={{ px: 1.5 }}>
|
||||
{menuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path === '/live-tv' && location.pathname === '/') ||
|
||||
(item.path === '/favorites/tv' && location.pathname === '/favorites');
|
||||
return (
|
||||
<ListItem key={item.text} disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
selected={isActive}
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
minHeight: 36,
|
||||
py: 0.75,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark'
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32, color: isActive ? 'white' : 'inherit' }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t(item.text)}
|
||||
primaryTypographyProps={{ fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile drawer - temporary */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
borderRight: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
|
||||
{/* Desktop drawer - permanent, always visible */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
borderRight: 'none',
|
||||
position: 'relative'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
537
frontend/src/components/TwoFactorSettings.jsx
Normal file
537
frontend/src/components/TwoFactorSettings.jsx
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Chip,
|
||||
Grid,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security,
|
||||
QrCode2,
|
||||
Download,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Check,
|
||||
Close,
|
||||
Refresh
|
||||
} from '@mui/icons-material';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const TwoFactorSettings = () => {
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [setupDialogOpen, setSetupDialogOpen] = useState(false);
|
||||
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
||||
const [backupCodesDialogOpen, setBackupCodesDialogOpen] = useState(false);
|
||||
|
||||
// Setup flow state
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [setupStep, setSetupStep] = useState(1); // 1: QR, 2: Verify, 3: Backup Codes
|
||||
|
||||
// Disable flow state
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Backup codes regeneration
|
||||
const [regenPassword, setRegenPassword] = useState('');
|
||||
const [regenCode, setRegenCode] = useState('');
|
||||
const [existingBackupCodes, setExistingBackupCodes] = useState([]);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/two-factor/status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setStatus(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch 2FA status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const handleStartSetup = async () => {
|
||||
try {
|
||||
setError('');
|
||||
const response = await axios.post(
|
||||
'/api/two-factor/setup',
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setQrCode(response.data.qrCode);
|
||||
setSecret(response.data.secret);
|
||||
setSetupStep(1);
|
||||
setSetupDialogOpen(true);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to start 2FA setup');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyAndEnable = async () => {
|
||||
try {
|
||||
setError('');
|
||||
const response = await axios.post(
|
||||
'/api/two-factor/enable',
|
||||
{ token: verificationCode },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setBackupCodes(response.data.backupCodes);
|
||||
setSetupStep(3);
|
||||
setSuccess('2FA enabled successfully!');
|
||||
await fetchStatus();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Invalid verification code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
try {
|
||||
setError('');
|
||||
await axios.post(
|
||||
'/api/two-factor/disable',
|
||||
{ password: disablePassword, token: disableCode },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setSuccess('2FA disabled successfully');
|
||||
setDisableDialogOpen(false);
|
||||
setDisablePassword('');
|
||||
setDisableCode('');
|
||||
await fetchStatus();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to disable 2FA');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
try {
|
||||
setError('');
|
||||
const response = await axios.post(
|
||||
'/api/two-factor/backup-codes/regenerate',
|
||||
{ password: regenPassword, token: regenCode },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setExistingBackupCodes(response.data.backupCodes);
|
||||
setSuccess('Backup codes regenerated successfully');
|
||||
setRegenPassword('');
|
||||
setRegenCode('');
|
||||
await fetchStatus();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to regenerate backup codes');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackupCodes = () => {
|
||||
const codes = backupCodes.length > 0 ? backupCodes : existingBackupCodes;
|
||||
const text = `StreamFlow - Two-Factor Authentication Backup Codes\n\n` +
|
||||
`Generated: ${new Date().toLocaleString()}\n\n` +
|
||||
`These are your backup codes. Keep them safe!\n` +
|
||||
`Each code can be used only once.\n\n` +
|
||||
codes.map((code, i) => `${i + 1}. ${code}`).join('\n');
|
||||
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'Streamflow-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleCloseSetup = () => {
|
||||
setSetupDialogOpen(false);
|
||||
setVerificationCode('');
|
||||
setSetupStep(1);
|
||||
setBackupCodes([]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{(error || success) && (
|
||||
<Alert
|
||||
severity={error ? 'error' : 'success'}
|
||||
onClose={() => { setError(''); setSuccess(''); }}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{error || success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Security sx={{ fontSize: 40, mr: 2, color: 'primary.main' }} />
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
<Chip
|
||||
label={status?.enabled ? 'Enabled' : 'Disabled'}
|
||||
color={status?.enabled ? 'success' : 'default'}
|
||||
icon={status?.enabled ? <Check /> : <Close />}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Two-factor authentication adds an extra layer of security to your account.
|
||||
You'll need to enter a 6-digit code from your authenticator app when logging in.
|
||||
</Typography>
|
||||
|
||||
{!status?.enabled ? (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<AlertTitle>Enable 2FA</AlertTitle>
|
||||
Scan the QR code with an authenticator app like Google Authenticator,
|
||||
Microsoft Authenticator, or Authy.
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<QrCode2 />}
|
||||
onClick={handleStartSetup}
|
||||
size="large"
|
||||
>
|
||||
Enable Two-Factor Authentication
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Backup Codes
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{status.backupCodesUnused} / {status.backupCodesTotal}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Unused codes remaining
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDisableDialogOpen(true)}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => setBackupCodesDialogOpen(true)}
|
||||
>
|
||||
Regenerate Backup Codes
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Setup Dialog */}
|
||||
<Dialog
|
||||
open={setupDialogOpen}
|
||||
onClose={handleCloseSetup}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Enable Two-Factor Authentication - Step {setupStep} of 3
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{setupStep === 1 && (
|
||||
<Box>
|
||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
||||
<svg width="80" height="80" viewBox="0 0 192 192" style={{ marginBottom: '10px' }}>
|
||||
<defs>
|
||||
<linearGradient id="qrGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#3b82f6', stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 72 53 L 72 139 L 139 96 Z" fill="url(#qrGrad)" opacity="0.95" />
|
||||
<path
|
||||
d="M 96 96 C 96 96, 80 83, 72 74 C 64 65, 56 55, 56 53 L 144 96 C 144 96, 141 101, 132 110 C 123 119, 111 131, 98 139"
|
||||
fill="none"
|
||||
stroke="url(#qrGrad)"
|
||||
strokeWidth="4"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'primary.main', mb: 1 }}>
|
||||
StreamFlow IPTV
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" paragraph align="center" color="text.secondary">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.):
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
my: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: '2px solid',
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<img src={qrCode} alt="StreamFlow 2FA QR Code" style={{ maxWidth: '280px', width: '100%' }} />
|
||||
</Box>
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<AlertTitle>Can't scan the code?</AlertTitle>
|
||||
Enter this secret key manually in your authenticator app:<br />
|
||||
<Box component="span" sx={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: 600, userSelect: 'all' }}>
|
||||
{secret}
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{setupStep === 2 && (
|
||||
<Box>
|
||||
<Typography variant="body2" paragraph>
|
||||
Enter the 6-digit code from your authenticator app to verify:
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputProps={{ maxLength: 6, style: { textAlign: 'center', fontSize: '24px', letterSpacing: '8px' } }}
|
||||
sx={{ my: 2 }}
|
||||
/>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{setupStep === 3 && (
|
||||
<Box>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
<AlertTitle>2FA Enabled Successfully!</AlertTitle>
|
||||
Save these backup codes in a safe place.
|
||||
</Alert>
|
||||
<Typography variant="body2" paragraph>
|
||||
Each code can be used only once to log in if you don't have access to your authenticator app.
|
||||
</Typography>
|
||||
<List sx={{ bgcolor: 'background.paper', border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||
{backupCodes.map((code, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText
|
||||
primary={code}
|
||||
primaryTypographyProps={{ fontFamily: 'monospace', fontSize: '18px' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Download />}
|
||||
onClick={handleDownloadBackupCodes}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Download Backup Codes
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{setupStep === 3 ? (
|
||||
<Button onClick={handleCloseSetup} variant="contained">
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleCloseSetup}>Cancel</Button>
|
||||
{setupStep === 1 && (
|
||||
<Button onClick={() => setSetupStep(2)} variant="contained">
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{setupStep === 2 && (
|
||||
<Button
|
||||
onClick={handleVerifyAndEnable}
|
||||
variant="contained"
|
||||
disabled={verificationCode.length !== 6}
|
||||
>
|
||||
Verify & Enable
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Disable Dialog */}
|
||||
<Dialog open={disableDialogOpen} onClose={() => setDisableDialogOpen(false)}>
|
||||
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Disabling 2FA will make your account less secure.
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="2FA Code"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputProps={{ maxLength: 6 }}
|
||||
/>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDisableDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleDisable2FA}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={!disablePassword || disableCode.length !== 6}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Regenerate Backup Codes Dialog */}
|
||||
<Dialog open={backupCodesDialogOpen} onClose={() => setBackupCodesDialogOpen(false)}>
|
||||
<DialogTitle>Regenerate Backup Codes</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
This will invalidate all existing backup codes and generate new ones.
|
||||
</Alert>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label="Password"
|
||||
value={regenPassword}
|
||||
onChange={(e) => setRegenPassword(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="2FA Code"
|
||||
value={regenCode}
|
||||
onChange={(e) => setRegenCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputProps={{ maxLength: 6 }}
|
||||
/>
|
||||
{existingBackupCodes.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
New Backup Codes:
|
||||
</Typography>
|
||||
<List sx={{ bgcolor: 'background.paper', border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||
{existingBackupCodes.map((code, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText
|
||||
primary={code}
|
||||
primaryTypographyProps={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Download />}
|
||||
onClick={handleDownloadBackupCodes}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Download Backup Codes
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setBackupCodesDialogOpen(false); setExistingBackupCodes([]); }}>
|
||||
Close
|
||||
</Button>
|
||||
{existingBackupCodes.length === 0 && (
|
||||
<Button
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
disabled={!regenPassword || regenCode.length !== 6}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorSettings;
|
||||
450
frontend/src/components/UserManagement.jsx
Normal file
450
frontend/src/components/UserManagement.jsx
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Switch,
|
||||
FormControlLabel
|
||||
} from '@mui/material';
|
||||
import { Add, Edit, Delete, Lock, Check, Close } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const UserManagement = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, user: currentUser } = useAuthStore();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [openResetDialog, setOpenResetDialog] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [resetUserId, setResetUserId] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
is_active: true
|
||||
});
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} else {
|
||||
setError('Failed to fetch users');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDialog = (user = null) => {
|
||||
if (user) {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
is_active: user.is_active === 1
|
||||
});
|
||||
} else {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
is_active: true
|
||||
});
|
||||
}
|
||||
setOpenDialog(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingUser(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (editingUser) {
|
||||
// Update user
|
||||
const response = await fetch(`/api/users/${editingUser.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
is_active: formData.is_active
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
handleCloseDialog();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to update user');
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
handleCloseDialog();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to create user');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(editingUser ? 'Failed to update user' : 'Failed to create user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (resetPassword.length < 8) {
|
||||
setError(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${resetUserId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ newPassword: resetPassword })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setOpenResetDialog(false);
|
||||
setResetUserId(null);
|
||||
setResetPassword('');
|
||||
alert('Password reset successfully. User must change password on next login.');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to reset password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to reset password');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId, username) => {
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to delete user');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
if (currentUser?.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{t('settings.userManagement')}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => handleOpenDialog()}
|
||||
>
|
||||
{t('addUser')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('username')}</TableCell>
|
||||
<TableCell>{t('email')}</TableCell>
|
||||
<TableCell>{t('role')}</TableCell>
|
||||
<TableCell>{t('status')}</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.role}
|
||||
size="small"
|
||||
color={user.role === 'admin' ? 'primary' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.is_active ? (
|
||||
<Chip label={t('active')} size="small" color="success" icon={<Check />} />
|
||||
) : (
|
||||
<Chip label={t('inactive')} size="small" color="error" icon={<Close />} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => handleOpenDialog(user)}>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setResetUserId(user.id);
|
||||
setOpenResetDialog(true);
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
<Lock fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(user.id, user.username)}
|
||||
disabled={user.id === currentUser.id}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Create/Edit User Dialog */}
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingUser ? t('editUser') : t('createUser')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('username')}
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
size="small"
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="email"
|
||||
label={t('email')}
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
required
|
||||
/>
|
||||
|
||||
{!editingUser && (
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('password')}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
required
|
||||
helperText="Minimum 8 characters"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||
<InputLabel>{t('role')}</InputLabel>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
label={t('role')}
|
||||
>
|
||||
<MenuItem value="user">{t('user')}</MenuItem>
|
||||
<MenuItem value="admin">{t('admin')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t('active')}
|
||||
/>
|
||||
|
||||
{!editingUser && (
|
||||
<Alert severity="info" sx={{ mt: 2, fontSize: '0.75rem' }}>
|
||||
New users must change their password on first login.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={handleCloseDialog}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !formData.email || (!editingUser && (!formData.username || !formData.password))}
|
||||
>
|
||||
{submitting ? <CircularProgress size={20} /> : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset Password Dialog */}
|
||||
<Dialog open={openResetDialog} onClose={() => setOpenResetDialog(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t('resetPassword')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert severity="warning" sx={{ mb: 2, fontSize: '0.75rem' }}>
|
||||
This will set a temporary password and force the user to change it on next login.
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label="New Password"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
required
|
||||
helperText="Minimum 8 characters"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button size="small" onClick={() => {
|
||||
setOpenResetDialog(false);
|
||||
setResetPassword('');
|
||||
setError('');
|
||||
}}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={handleResetPassword}
|
||||
disabled={submitting || resetPassword.length < 8}
|
||||
>
|
||||
{submitting ? <CircularProgress size={20} /> : t('resetPassword')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
491
frontend/src/components/VPNConfigManager.jsx
Normal file
491
frontend/src/components/VPNConfigManager.jsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Chip,
|
||||
Stack,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Upload,
|
||||
Delete,
|
||||
PowerSettingsNew,
|
||||
CheckCircle,
|
||||
Public,
|
||||
Router,
|
||||
Info,
|
||||
CloudUpload
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const VPNConfigManager = () => {
|
||||
const { t } = useTranslation();
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const [configs, setConfigs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [configName, setConfigName] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [configToDelete, setConfigToDelete] = useState(null);
|
||||
const [connecting, setConnecting] = useState(null); // configId being connected/disconnected
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/vpn-configs/configs', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConfigs(data.configs || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load configs:', error);
|
||||
setMessage({ type: 'error', text: t('vpnConfig.loadFailed') || 'Failed to load configurations' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (ext === 'conf' || ext === 'ovpn') {
|
||||
setSelectedFile(file);
|
||||
// Auto-fill name from filename
|
||||
const name = file.name.replace(/\.(conf|ovpn)$/, '');
|
||||
setConfigName(name);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: t('vpnConfig.invalidFileType') || 'Only .conf and .ovpn files are supported' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !configName.trim()) {
|
||||
setMessage({ type: 'error', text: t('vpnConfig.nameRequired') || 'Configuration name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('config', selectedFile);
|
||||
formData.append('name', configName.trim());
|
||||
|
||||
const response = await fetch('/api/vpn-configs/configs/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
data = { error: 'Server error' };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('vpnConfig.uploadSuccess') || 'Configuration uploaded successfully!' });
|
||||
setUploadDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setConfigName('');
|
||||
await loadConfigs();
|
||||
} else {
|
||||
const errorMsg = typeof data.error === 'string' ? data.error : t('vpnConfig.uploadFailed') || 'Failed to upload configuration';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error.message || t('vpnConfig.uploadError') || 'Error uploading configuration';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (configId) => {
|
||||
setMessage({ type: '', text: '' });
|
||||
setConnecting(configId);
|
||||
|
||||
try {
|
||||
console.log('[VPNConfigManager] Connecting to config:', configId);
|
||||
|
||||
const response = await fetch(`/api/vpn-configs/configs/${configId}/connect`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[VPNConfigManager] Response status:', response.status);
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
console.error('[VPNConfigManager] Failed to parse response:', e);
|
||||
data = { error: 'Invalid server response' };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[VPNConfigManager] Connected successfully');
|
||||
setMessage({ type: 'success', text: t('vpnConfig.connected') || 'Connected to VPN successfully!' });
|
||||
await loadConfigs();
|
||||
} else {
|
||||
console.error('[VPNConfigManager] Connection failed:', data);
|
||||
const errorMsg = typeof data.error === 'string' ? data.error : t('vpnConfig.connectFailed') || 'Failed to connect';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VPNConfigManager] Connect error:', error);
|
||||
const errorMsg = error.message || t('vpnConfig.connectError') || 'Error connecting to VPN';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
} finally {
|
||||
setConnecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (configId) => {
|
||||
setMessage({ type: '', text: '' });
|
||||
setConnecting(configId);
|
||||
|
||||
try {
|
||||
console.log('[VPNConfigManager] Disconnecting from config:', configId);
|
||||
|
||||
const response = await fetch(`/api/vpn-configs/configs/${configId}/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
console.error('[VPNConfigManager] Failed to parse response:', e);
|
||||
data = { error: 'Invalid server response' };
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[VPNConfigManager] Disconnected successfully');
|
||||
setMessage({ type: 'success', text: t('vpnConfig.disconnected') || 'Disconnected from VPN successfully!' });
|
||||
await loadConfigs();
|
||||
} else {
|
||||
console.error('[VPNConfigManager] Disconnect failed:', data);
|
||||
const errorMsg = typeof data.error === 'string' ? data.error : t('vpnConfig.disconnectFailed') || 'Failed to disconnect';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VPNConfigManager] Disconnect error:', error);
|
||||
const errorMsg = error.message || t('vpnConfig.disconnectError') || 'Error disconnecting from VPN';
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
} finally {
|
||||
setConnecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/vpn-configs/configs/${configToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('vpnConfig.deleteSuccess') || 'Configuration deleted successfully' });
|
||||
setDeleteDialogOpen(false);
|
||||
setConfigToDelete(null);
|
||||
await loadConfigs();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('vpnConfig.deleteFailed') || 'Failed to delete configuration' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('vpnConfig.deleteError') || 'Error deleting configuration' });
|
||||
}
|
||||
};
|
||||
|
||||
const getCountryFlag = (countryCode) => {
|
||||
const flags = {
|
||||
'US': '🇺🇸', 'NL': '🇳🇱', 'JP': '🇯🇵', 'GB': '🇬🇧',
|
||||
'DE': '🇩🇪', 'FR': '🇫🇷', 'CA': '🇨🇦', 'CH': '🇨🇭',
|
||||
'SE': '🇸🇪', 'RO': '🇷🇴'
|
||||
};
|
||||
return flags[countryCode] || '🌍';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Router /> {t('vpnConfig.title') || 'VPN Configurations'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setUploadDialogOpen(true)}
|
||||
>
|
||||
{t('vpnConfig.uploadConfig') || 'Upload Config'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{message.text && (
|
||||
<Alert severity={message.type} sx={{ mb: 3 }} onClose={() => setMessage({ type: '', text: '' })}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{t('vpnConfig.dockerLimitationWarning')}
|
||||
</Alert>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }} icon={<Info />}>
|
||||
{t('vpnConfig.infoText') || 'Upload VPN configuration files (.conf for WireGuard, .ovpn for OpenVPN) to easily manage multiple VPN connections.'}
|
||||
</Alert>
|
||||
|
||||
{configs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||||
<CloudUpload sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('vpnConfig.noConfigs') || 'No VPN configurations yet'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
||||
{t('vpnConfig.uploadFirst') || 'Upload your first VPN configuration file to get started'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setUploadDialogOpen(true)}
|
||||
>
|
||||
{t('vpnConfig.uploadConfig') || 'Upload Config'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<List>
|
||||
{configs.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body1" fontWeight={config.is_active ? 600 : 400}>
|
||||
{config.name}
|
||||
</Typography>
|
||||
{config.is_active && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<CheckCircle />}
|
||||
label={t('vpnConfig.active') || 'Active'}
|
||||
color="success"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
size="small"
|
||||
label={config.config_type.toUpperCase()}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
secondary={
|
||||
<Stack direction="row" spacing={2} mt={0.5}>
|
||||
{config.country && (
|
||||
<Typography variant="caption" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Public sx={{ fontSize: 14 }} />
|
||||
{getCountryFlag(config.country)} {config.country}
|
||||
</Typography>
|
||||
)}
|
||||
{config.server_name && (
|
||||
<Typography variant="caption" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Router sx={{ fontSize: 14 }} />
|
||||
{config.server_name}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(config.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{config.is_active ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={connecting === config.id ? <CircularProgress size={16} /> : <PowerSettingsNew />}
|
||||
onClick={() => handleDisconnect(config.id)}
|
||||
disabled={connecting === config.id}
|
||||
>
|
||||
{connecting === config.id
|
||||
? (t('vpnConfig.disconnecting') || 'Disconnecting...')
|
||||
: (t('vpnConfig.disconnect') || 'Disconnect')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={connecting === config.id ? <CircularProgress size={16} /> : <PowerSettingsNew />}
|
||||
onClick={() => handleConnect(config.id)}
|
||||
disabled={connecting === config.id}
|
||||
>
|
||||
{connecting === config.id
|
||||
? (t('vpnConfig.connecting') || 'Connecting...')
|
||||
: (t('vpnConfig.connect') || 'Connect')}
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => {
|
||||
setConfigToDelete(config.id);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
disabled={config.is_active || connecting === config.id}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Upload Dialog */}
|
||||
<Dialog
|
||||
open={uploadDialogOpen}
|
||||
onClose={() => !uploading && setUploadDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>{t('vpnConfig.uploadConfig') || 'Upload VPN Configuration'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={3} mt={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('vpnConfig.configName') || 'Configuration Name'}
|
||||
value={configName}
|
||||
onChange={(e) => setConfigName(e.target.value)}
|
||||
placeholder="My VPN Config"
|
||||
helperText={t('vpnConfig.nameHelper') || 'A friendly name to identify this configuration'}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<input
|
||||
accept=".conf,.ovpn"
|
||||
style={{ display: 'none' }}
|
||||
id="vpn-config-file"
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<label htmlFor="vpn-config-file">
|
||||
<Button
|
||||
component="span"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<Upload />}
|
||||
>
|
||||
{selectedFile
|
||||
? selectedFile.name
|
||||
: (t('vpnConfig.selectFile') || 'Select .conf or .ovpn file')}
|
||||
</Button>
|
||||
</label>
|
||||
<Typography variant="caption" display="block" mt={1} color="text.secondary">
|
||||
{t('vpnConfig.fileTypes') || 'Supported: WireGuard (.conf) and OpenVPN (.ovpn) configuration files'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{message.text && uploadDialogOpen && (
|
||||
<Alert severity={message.type}>{message.text}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUploadDialogOpen(false)} disabled={uploading}>
|
||||
{t('cancel') || 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
variant="contained"
|
||||
disabled={!selectedFile || !configName.trim() || uploading}
|
||||
startIcon={uploading ? <CircularProgress size={16} /> : <CloudUpload />}
|
||||
>
|
||||
{t('vpnConfig.upload') || 'Upload'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>{t('vpnConfig.deleteConfirmTitle') || 'Delete Configuration?'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
{t('vpnConfig.deleteConfirmText') || 'Are you sure you want to delete this VPN configuration? This action cannot be undone.'}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>{t('cancel') || 'Cancel'}</Button>
|
||||
<Button onClick={handleDelete} color="error" variant="contained">
|
||||
{t('delete') || 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VPNConfigManager;
|
||||
141
frontend/src/components/ValidatedTextField.jsx
Normal file
141
frontend/src/components/ValidatedTextField.jsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { TextField, FormHelperText, Box, Chip } from '@mui/material';
|
||||
import { CheckCircle, Error as ErrorIcon, Warning } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
validateUsername,
|
||||
validateEmail,
|
||||
validateUrl,
|
||||
validateTextField,
|
||||
validateInteger
|
||||
} from '../utils/inputValidator';
|
||||
|
||||
/**
|
||||
* ValidatedTextField Component
|
||||
* Provides client-side validation with visual feedback
|
||||
*/
|
||||
const ValidatedTextField = ({
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
validationType,
|
||||
minLength,
|
||||
maxLength,
|
||||
min,
|
||||
max,
|
||||
required = false,
|
||||
showValidation = true,
|
||||
relatedValues = {},
|
||||
...textFieldProps
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [isValid, setIsValid] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched || !value) {
|
||||
setErrors([]);
|
||||
setIsValid(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let validationResult;
|
||||
|
||||
switch (validationType) {
|
||||
case 'username':
|
||||
validationResult = validateUsername(value);
|
||||
break;
|
||||
case 'email':
|
||||
validationResult = validateEmail(value);
|
||||
break;
|
||||
case 'url':
|
||||
validationResult = validateUrl(value);
|
||||
break;
|
||||
case 'integer':
|
||||
validationResult = validateInteger(value, min, max);
|
||||
break;
|
||||
case 'text':
|
||||
default:
|
||||
validationResult = validateTextField(value, minLength, maxLength, required);
|
||||
break;
|
||||
}
|
||||
|
||||
setErrors(validationResult.errors);
|
||||
setIsValid(validationResult.valid);
|
||||
|
||||
// If valid and sanitized value is different, update parent
|
||||
if (validationResult.valid && validationResult.sanitized !== value) {
|
||||
onChange({ target: { value: validationResult.sanitized } });
|
||||
}
|
||||
}, [value, touched, validationType, minLength, maxLength, min, max, required]);
|
||||
|
||||
const handleBlur = (e) => {
|
||||
setTouched(true);
|
||||
if (textFieldProps.onBlur) {
|
||||
textFieldProps.onBlur(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange(e);
|
||||
};
|
||||
|
||||
const getValidationColor = () => {
|
||||
if (!showValidation || !touched || !value) return undefined;
|
||||
return isValid ? 'success' : 'error';
|
||||
};
|
||||
|
||||
const getEndAdornment = () => {
|
||||
if (!showValidation || !touched || !value) return textFieldProps.InputProps?.endAdornment;
|
||||
|
||||
const adornment = (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{isValid ? (
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
) : (
|
||||
<ErrorIcon color="error" fontSize="small" />
|
||||
)}
|
||||
{textFieldProps.InputProps?.endAdornment}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return adornment;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
error={touched && !isValid && errors.length > 0}
|
||||
color={getValidationColor()}
|
||||
InputProps={{
|
||||
...textFieldProps.InputProps,
|
||||
endAdornment: getEndAdornment()
|
||||
}}
|
||||
/>
|
||||
{showValidation && touched && errors.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{errors.map((error, index) => (
|
||||
<FormHelperText key={index} error sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<ErrorIcon fontSize="small" />
|
||||
{error}
|
||||
</FormHelperText>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{showValidation && touched && isValid && (
|
||||
<FormHelperText sx={{ color: 'success.main', display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircle fontSize="small" />
|
||||
{t('security.inputSanitized')}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedTextField;
|
||||
551
frontend/src/components/VideoPlayer.jsx
Normal file
551
frontend/src/components/VideoPlayer.jsx
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Box, Paper, IconButton, Typography, Slider, Tooltip } from '@mui/material';
|
||||
import {
|
||||
PlayArrow,
|
||||
Pause,
|
||||
VolumeUp,
|
||||
VolumeOff,
|
||||
Fullscreen,
|
||||
FiberManualRecord,
|
||||
PictureInPicture,
|
||||
PictureInPictureAlt,
|
||||
Cast,
|
||||
CastConnected
|
||||
} from '@mui/icons-material';
|
||||
import ReactPlayer from 'react-player';
|
||||
import Logo from './Logo';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useChromecast } from '../utils/useChromecast';
|
||||
|
||||
function VideoPlayer({ channel, enablePip = true }) {
|
||||
const playerRef = useRef(null);
|
||||
const videoElementRef = useRef(null);
|
||||
const { token } = useAuthStore();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [pip, setPip] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [pipSupported, setPipSupported] = useState(false);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const hideLogoTimer = useRef(null);
|
||||
|
||||
// Chromecast support
|
||||
const {
|
||||
castAvailable,
|
||||
casting,
|
||||
castMedia,
|
||||
stopCasting,
|
||||
openCastDialog,
|
||||
setCastVolume,
|
||||
setCastMuted
|
||||
} = useChromecast();
|
||||
|
||||
// Auto-hide logo after 2 seconds
|
||||
const resetLogoTimer = () => {
|
||||
setShowLogo(true);
|
||||
if (hideLogoTimer.current) {
|
||||
clearTimeout(hideLogoTimer.current);
|
||||
}
|
||||
if (playing) {
|
||||
hideLogoTimer.current = setTimeout(() => {
|
||||
setShowLogo(false);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Show logo on mouse move
|
||||
const handleMouseMove = () => {
|
||||
if (playing) {
|
||||
resetLogoTimer();
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideLogoTimer.current) {
|
||||
clearTimeout(hideLogoTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// Reset timer when playing changes
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
resetLogoTimer();
|
||||
} else {
|
||||
setShowLogo(true);
|
||||
if (hideLogoTimer.current) {
|
||||
clearTimeout(hideLogoTimer.current);
|
||||
}
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
// Check PiP support on mount
|
||||
useEffect(() => {
|
||||
if (document.pictureInPictureEnabled) {
|
||||
setPipSupported(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for PiP events
|
||||
useEffect(() => {
|
||||
const handlePipEnter = () => setPip(true);
|
||||
const handlePipExit = () => setPip(false);
|
||||
|
||||
if (videoElementRef.current) {
|
||||
videoElementRef.current.addEventListener('enterpictureinpicture', handlePipEnter);
|
||||
videoElementRef.current.addEventListener('leavepictureinpicture', handlePipExit);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (videoElementRef.current) {
|
||||
videoElementRef.current.removeEventListener('enterpictureinpicture', handlePipEnter);
|
||||
videoElementRef.current.removeEventListener('leavepictureinpicture', handlePipExit);
|
||||
}
|
||||
};
|
||||
}, [videoElementRef.current]);
|
||||
|
||||
// Always use proxy to bypass geo-blocking and CORS
|
||||
const getStreamUrl = () => {
|
||||
if (!channel?.id) return '';
|
||||
// Use backend proxy for ALL streams to handle CORS and geo-blocking
|
||||
return `/api/stream/proxy/${channel.id}?token=${token}`;
|
||||
};
|
||||
|
||||
// Get full URL for Chromecast (needs absolute URL)
|
||||
const getFullStreamUrl = () => {
|
||||
const streamUrl = getStreamUrl();
|
||||
if (!streamUrl) return '';
|
||||
// Convert relative URL to absolute
|
||||
return `${window.location.origin}${streamUrl}`;
|
||||
};
|
||||
|
||||
// Get original channel URL for Chromecast (bypasses proxy)
|
||||
const getOriginalChannelUrl = async () => {
|
||||
if (!channel?.id) return null;
|
||||
|
||||
try {
|
||||
// Fetch channel data to get original URL
|
||||
const response = await fetch(`/api/channels/${channel.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const channelData = await response.json();
|
||||
console.log('[Cast] Original channel URL:', channelData.url);
|
||||
return channelData.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Cast] Error fetching channel URL:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle Chromecast
|
||||
const handleCast = async () => {
|
||||
if (casting) {
|
||||
// Stop casting
|
||||
stopCasting();
|
||||
// Resume local playback
|
||||
setPlaying(false);
|
||||
} else {
|
||||
// Start casting
|
||||
if (!channel) return;
|
||||
|
||||
// Pause local playback
|
||||
setPlaying(false);
|
||||
|
||||
// Try to get original URL first (Chromecast can access it directly)
|
||||
const originalUrl = await getOriginalChannelUrl();
|
||||
const castUrl = originalUrl || getFullStreamUrl();
|
||||
|
||||
console.log('[Cast] Casting URL:', castUrl);
|
||||
console.log('[Cast] Channel:', channel.name);
|
||||
console.log('[Cast] Is Radio:', channel.is_radio === 1);
|
||||
|
||||
// Cast the media
|
||||
const success = await castMedia({
|
||||
url: castUrl,
|
||||
title: channel.name || 'TV Channel',
|
||||
subtitle: channel.is_radio === 1 ? 'Radio Station' : 'Live TV',
|
||||
contentType: 'application/x-mpegURL',
|
||||
imageUrl: channel.logo || '',
|
||||
isLive: true
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.log('[Cast] Cast failed, opening device selector');
|
||||
// If casting failed, open device selector
|
||||
openCastDialog();
|
||||
} else {
|
||||
console.log('[Cast] Cast successful');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sync volume with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastVolume) {
|
||||
setCastVolume(volume);
|
||||
}
|
||||
}, [volume, casting, setCastVolume]);
|
||||
|
||||
// Sync mute with Chromecast when casting
|
||||
useEffect(() => {
|
||||
if (casting && setCastMuted) {
|
||||
setCastMuted(muted);
|
||||
}
|
||||
}, [muted, casting, setCastMuted]);
|
||||
|
||||
// For radio, use native audio element instead of ReactPlayer
|
||||
if (channel?.is_radio === 1) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'black',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Audio Player for Radio */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Logo size={120} />
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
{channel?.name || 'Radio Station'}
|
||||
</Typography>
|
||||
|
||||
<audio
|
||||
controls
|
||||
autoPlay={playing}
|
||||
src={getStreamUrl()}
|
||||
style={{ width: '80%', maxWidth: '400px' }}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={(e) => {
|
||||
console.error('Radio playback error:', e);
|
||||
setPlaying(false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setPlaying(!playing);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (event, newValue) => {
|
||||
setVolume(newValue / 100);
|
||||
setMuted(newValue === 0);
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
setMuted(!muted);
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
const player = playerRef.current?.wrapper;
|
||||
if (player) {
|
||||
if (player.requestFullscreen) {
|
||||
player.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePip = async () => {
|
||||
if (!pipSupported || !enablePip) return;
|
||||
|
||||
try {
|
||||
const videoElement = playerRef.current?.getInternalPlayer();
|
||||
|
||||
if (!videoElement) {
|
||||
console.warn('Video element not found for PiP');
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.pictureInPictureElement) {
|
||||
// Exit PiP
|
||||
await document.exitPictureInPicture();
|
||||
setPip(false);
|
||||
} else {
|
||||
// Enter PiP
|
||||
await videoElement.requestPictureInPicture();
|
||||
setPip(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PiP error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Store video element reference when player is ready
|
||||
const handleReady = () => {
|
||||
setIsReady(true);
|
||||
const internalPlayer = playerRef.current?.getInternalPlayer();
|
||||
if (internalPlayer) {
|
||||
videoElementRef.current = internalPlayer;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchStart={handleMouseMove}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'black',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Hide player when casting */}
|
||||
{!casting && (
|
||||
<ReactPlayer
|
||||
ref={playerRef}
|
||||
url={getStreamUrl()}
|
||||
playing={playing}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
onReady={handleReady}
|
||||
onError={(error) => {
|
||||
console.error('Video playback error:', error);
|
||||
setPlaying(false);
|
||||
setIsReady(false);
|
||||
}}
|
||||
config={{
|
||||
file: {
|
||||
attributes: {
|
||||
crossOrigin: 'anonymous',
|
||||
disablePictureInPicture: !enablePip
|
||||
},
|
||||
forceHLS: true,
|
||||
hlsOptions: {
|
||||
xhrSetup: function(xhr, url) {
|
||||
xhr.withCredentials = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Casting indicator */}
|
||||
{casting && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.2) 0%, rgba(59, 130, 246, 0.2) 100%)',
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
<CastConnected sx={{ fontSize: 120, color: 'primary.main', opacity: 0.8 }} />
|
||||
<Typography variant="h5" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
Casting to TV
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'white', opacity: 0.7 }}>
|
||||
{channel?.name || 'TV Channel'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Center Logo Play/Pause Button - Auto-hide when playing */}
|
||||
<Box
|
||||
onClick={handlePlayPause}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
opacity: showLogo ? 1 : 0,
|
||||
pointerEvents: showLogo ? 'auto' : 'none',
|
||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translate(-50%, -50%) scale(1.1)'
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translate(-50%, -50%) scale(0.95)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Logo size={playing ? 80 : 120} />
|
||||
{!playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<PlayArrow sx={{ fontSize: 60, color: 'white' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!playing && (
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, textAlign: 'center' }}>
|
||||
{channel?.name || 'Ready to Play'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Semi-transparent overlay when not playing */}
|
||||
{!playing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)',
|
||||
zIndex: 5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(180deg, rgba(0,0,0,0.7) 0%, transparent 100%)',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<FiberManualRecord sx={{ color: 'red', fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
{channel?.name}
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<IconButton size="small" sx={{ color: 'white', bgcolor: 'rgba(255,255,255,0.1)' }}>
|
||||
<FiberManualRecord fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, transparent 100%)',
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton onClick={handleToggleMute} sx={{ color: 'white' }}>
|
||||
{muted ? <VolumeOff /> : <VolumeUp />}
|
||||
</IconButton>
|
||||
|
||||
<Slider
|
||||
value={muted ? 0 : volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
sx={{ width: 100, color: 'white' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{castAvailable && (
|
||||
<Tooltip title={casting ? 'Stop Casting' : 'Cast'}>
|
||||
<IconButton
|
||||
onClick={handleCast}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: casting ? 'rgba(168, 85, 247, 0.3)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{casting ? <CastConnected /> : <Cast />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{pipSupported && enablePip && (
|
||||
<Tooltip title={pip ? 'Exit Picture-in-Picture' : 'Picture-in-Picture'}>
|
||||
<IconButton
|
||||
onClick={handlePip}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: pip ? 'rgba(168, 85, 247, 0.3)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{pip ? <PictureInPictureAlt /> : <PictureInPicture />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Fullscreen">
|
||||
<IconButton onClick={handleFullscreen} sx={{ color: 'white' }}>
|
||||
<Fullscreen />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default VideoPlayer;
|
||||
397
frontend/src/components/WireGuardSettings.jsx
Normal file
397
frontend/src/components/WireGuardSettings.jsx
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Divider,
|
||||
Chip,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import {
|
||||
VpnKey,
|
||||
Public,
|
||||
NetworkCheck,
|
||||
Router,
|
||||
Dns,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const WireGuardSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState({
|
||||
privateKey: '',
|
||||
serverPublicKey: '',
|
||||
serverEndpoint: '',
|
||||
clientAddress: '',
|
||||
dnsServer: ''
|
||||
});
|
||||
const [status, setStatus] = useState({
|
||||
connected: false,
|
||||
publicIP: '',
|
||||
interface: '',
|
||||
loading: false
|
||||
});
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [configParsed, setConfigParsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 10000); // Check status every 10s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.settings) {
|
||||
setSettings({
|
||||
privateKey: '********', // Don't show actual key
|
||||
serverPublicKey: data.settings.server_public_key || '',
|
||||
serverEndpoint: data.settings.server_endpoint || '',
|
||||
clientAddress: data.settings.client_address || '',
|
||||
dnsServer: data.settings.dns_server || ''
|
||||
});
|
||||
setConfigParsed(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load WireGuard settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/status', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStatus({
|
||||
connected: data.connected,
|
||||
publicIP: data.publicIP || '',
|
||||
interface: data.interface || '',
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check status:', error);
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const parseConfig = (configText) => {
|
||||
const lines = configText.split('\n');
|
||||
const parsed = {
|
||||
privateKey: '',
|
||||
serverPublicKey: '',
|
||||
serverEndpoint: '',
|
||||
clientAddress: '',
|
||||
dnsServer: ''
|
||||
};
|
||||
|
||||
let inInterface = false;
|
||||
let inPeer = false;
|
||||
|
||||
lines.forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '[Interface]') {
|
||||
inInterface = true;
|
||||
inPeer = false;
|
||||
} else if (trimmed === '[Peer]') {
|
||||
inInterface = false;
|
||||
inPeer = true;
|
||||
} else if (trimmed.startsWith('PrivateKey') && inInterface) {
|
||||
parsed.privateKey = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('Address') && inInterface) {
|
||||
parsed.clientAddress = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('DNS') && inInterface) {
|
||||
parsed.dnsServer = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('PublicKey') && inPeer) {
|
||||
parsed.serverPublicKey = trimmed.split('=')[1]?.trim() || '';
|
||||
} else if (trimmed.startsWith('Endpoint') && inPeer) {
|
||||
parsed.serverEndpoint = trimmed.split('=')[1]?.trim() || '';
|
||||
}
|
||||
});
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const handleConfigPaste = (e) => {
|
||||
const configText = e.target.value;
|
||||
if (configText.includes('[Interface]') && configText.includes('[Peer]')) {
|
||||
const parsed = parseConfig(configText);
|
||||
setSettings(parsed);
|
||||
setConfigParsed(true);
|
||||
setMessage({ type: 'success', text: t('wireguard.configParsed') || 'Configuration parsed successfully!' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setLoading(true);
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('wireguard.settingsSaved') || 'Settings saved successfully!' });
|
||||
setConfigParsed(true);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('wireguard.saveFailed') || 'Failed to save settings' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('wireguard.saveError') || 'Error saving settings' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
setStatus(prev => ({ ...prev, loading: true }));
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/connect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: t('wireguard.connected') || 'Connected to WireGuard VPN!' });
|
||||
setTimeout(checkStatus, 2000);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('wireguard.connectFailed') || 'Failed to connect' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('wireguard.connectError') || 'Error connecting to VPN' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setStatus(prev => ({ ...prev, loading: true }));
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wireguard/disconnect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'info', text: t('wireguard.disconnected') || 'Disconnected from VPN' });
|
||||
setTimeout(checkStatus, 2000);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || t('wireguard.disconnectFailed') || 'Failed to disconnect' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: t('wireguard.disconnectError') || 'Error disconnecting from VPN' });
|
||||
setStatus(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<VpnKey /> {t('wireguard.title') || 'WireGuard VPN'}
|
||||
</Typography>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card sx={{ mb: 3, bgcolor: status.connected ? 'success.light' : 'grey.100' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{status.loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : status.connected ? (
|
||||
<CheckCircle color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="disabled" />
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{status.connected ? (t('wireguard.status.connected') || 'Connected') : (t('wireguard.status.disconnected') || 'Disconnected')}
|
||||
</Typography>
|
||||
{status.connected && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Public sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
||||
{t('wireguard.publicIP') || 'Public IP'}: {status.publicIP || t('wireguard.checking') || 'Checking...'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Router sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
|
||||
{t('wireguard.interface') || 'Interface'}: {status.interface}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
{status.connected ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleDisconnect}
|
||||
disabled={status.loading}
|
||||
>
|
||||
{t('wireguard.disconnect') || 'Disconnect'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={handleConnect}
|
||||
disabled={status.loading || !configParsed}
|
||||
>
|
||||
{t('wireguard.connect') || 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Messages */}
|
||||
{message.text && (
|
||||
<Alert severity={message.type} sx={{ mb: 3 }} onClose={() => setMessage({ type: '', text: '' })}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Configuration Card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('wireguard.configuration') || 'Configuration'}
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }} icon={<Info />}>
|
||||
{t('wireguard.pasteConfigHint') || 'Paste your complete WireGuard configuration file below, or enter details manually.'}
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
label={t('wireguard.configFile') || 'WireGuard Config File'}
|
||||
placeholder="[Interface] PrivateKey = ... Address = ... DNS = ... [Peer] PublicKey = ... AllowedIPs = ... Endpoint = ..."
|
||||
onChange={handleConfigPaste}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Chip label={t('wireguard.or') || 'OR'} />
|
||||
</Divider>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('wireguard.privateKey') || 'Private Key'}
|
||||
value={settings.privateKey}
|
||||
onChange={(e) => setSettings({ ...settings, privateKey: e.target.value })}
|
||||
placeholder="base64-encoded private key"
|
||||
InputProps={{
|
||||
startAdornment: <VpnKey sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.serverPublicKey') || 'Server Public Key'}
|
||||
value={settings.serverPublicKey}
|
||||
onChange={(e) => setSettings({ ...settings, serverPublicKey: e.target.value })}
|
||||
placeholder="base64-encoded public key"
|
||||
InputProps={{
|
||||
startAdornment: <VpnKey sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.serverEndpoint') || 'Server Endpoint'}
|
||||
value={settings.serverEndpoint}
|
||||
onChange={(e) => setSettings({ ...settings, serverEndpoint: e.target.value })}
|
||||
placeholder="IP:PORT (e.g., 185.163.110.98:51820)"
|
||||
InputProps={{
|
||||
startAdornment: <NetworkCheck sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.clientAddress') || 'Client Address'}
|
||||
value={settings.clientAddress}
|
||||
onChange={(e) => setSettings({ ...settings, clientAddress: e.target.value })}
|
||||
placeholder="10.2.0.2/32"
|
||||
InputProps={{
|
||||
startAdornment: <Router sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wireguard.dnsServer') || 'DNS Server'}
|
||||
value={settings.dnsServer}
|
||||
onChange={(e) => setSettings({ ...settings, dnsServer: e.target.value })}
|
||||
placeholder="10.2.0.1"
|
||||
InputProps={{
|
||||
startAdornment: <Dns sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveSettings}
|
||||
disabled={loading || !settings.privateKey || !settings.serverEndpoint}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : (t('wireguard.saveSettings') || 'Save Settings')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WireGuardSettings;
|
||||
Loading…
Add table
Add a link
Reference in a new issue