feat: Add admin mode functionality with login and status checks
- Implemented admin mode configuration in docker-compose.yml - Added AdminContext and AdminModal components for managing admin state - Integrated admin login functionality in AuthController - Updated App component to handle admin status and modal display - Enhanced ChannelList, ChannelModal, and TvPlaylistModal to support admin features - Added sensitive information handling in ChannelModal and TvPlaylistModal - Modified SocketService methods to include admin checks for channel and playlist operations
This commit is contained in:
32
backend/controllers/AuthController.js
Normal file
32
backend/controllers/AuthController.js
Normal file
@@ -0,0 +1,32 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const ADMIN_ENABLED = process.env.ADMIN_ENABLED === 'true';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||||
|
||||
module.exports = {
|
||||
adminLogin(req, res) {
|
||||
if (!ADMIN_ENABLED || ADMIN_PASSWORD === undefined) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Admin mode is disabled on this server'
|
||||
});
|
||||
}
|
||||
|
||||
const { password } = req.body;
|
||||
|
||||
if (password === ADMIN_PASSWORD) {
|
||||
return res.json({ success: true });
|
||||
} else {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid password'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkAdminStatus(req, res) {
|
||||
res.json({
|
||||
enabled: ADMIN_ENABLED
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const ChannelSocketHandler = require('./socket/ChannelSocketHandler');
|
||||
const proxyController = require('./controllers/ProxyController');
|
||||
const centralChannelController = require('./controllers/CentralChannelController');
|
||||
const channelController = require('./controllers/ChannelController');
|
||||
const authController = require('./controllers/AuthController');
|
||||
const streamController = require('./services/restream/StreamController');
|
||||
const ChannelService = require('./services/ChannelService');
|
||||
const PlaylistSocketHandler = require('./socket/PlaylistSocketHandler');
|
||||
@@ -18,6 +19,24 @@ dotenv.config();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Auth routes
|
||||
const authRouter = express.Router();
|
||||
authRouter.post('/admin-login', authController.adminLogin);
|
||||
authRouter.get('/admin-status', authController.checkAdminStatus);
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// Channel routes
|
||||
const apiRouter = express.Router();
|
||||
apiRouter.get('/', channelController.getChannels);
|
||||
apiRouter.get('/current', channelController.getCurrentChannel);
|
||||
@@ -70,4 +89,4 @@ io.on('connection', socket => {
|
||||
PlaylistSocketHandler(io, socket);
|
||||
|
||||
ChatSocketHandler(io, socket);
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,17 @@
|
||||
const ChannelService = require('../services/ChannelService');
|
||||
require('dotenv').config();
|
||||
|
||||
const ADMIN_ENABLED = process.env.ADMIN_ENABLED === 'true';
|
||||
|
||||
module.exports = (io, socket) => {
|
||||
|
||||
socket.on('add-channel', ({ name, url, avatar, mode, headersJson }) => {
|
||||
// Check if admin mode is required for channel modifications
|
||||
socket.on('add-channel', ({ name, url, avatar, mode, headersJson, isAdmin }) => {
|
||||
try {
|
||||
// If admin mode is enabled but user is not admin, reject the operation
|
||||
if (ADMIN_ENABLED && !isAdmin) {
|
||||
return socket.emit('app-error', { message: 'Admin access required to add channels' });
|
||||
}
|
||||
|
||||
console.log('Adding solo channel:', url);
|
||||
const newChannel = ChannelService.addChannel({ name: name, url: url, avatar: avatar, mode: mode, headersJson: headersJson });
|
||||
io.emit('channel-added', newChannel); // Broadcast to all clients
|
||||
@@ -22,8 +30,17 @@ module.exports = (io, socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('delete-channel', async (id) => {
|
||||
socket.on('delete-channel', async (data) => {
|
||||
try {
|
||||
// Parse input to handle both old format (just id) and new format with admin flag
|
||||
const id = typeof data === 'object' ? data.id : data;
|
||||
const isAdmin = typeof data === 'object' ? data.isAdmin : false;
|
||||
|
||||
// If admin mode is enabled but user is not admin, reject the operation
|
||||
if (ADMIN_ENABLED && !isAdmin) {
|
||||
return socket.emit('app-error', { message: 'Admin access required to delete channels' });
|
||||
}
|
||||
|
||||
const lastChannel = ChannelService.getCurrentChannel();
|
||||
const current = await ChannelService.deleteChannel(id);
|
||||
io.emit('channel-deleted', id); // Broadcast to all clients
|
||||
@@ -34,8 +51,13 @@ module.exports = (io, socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('update-channel', async ({ id, updatedAttributes }) => {
|
||||
socket.on('update-channel', async ({ id, updatedAttributes, isAdmin }) => {
|
||||
try {
|
||||
// If admin mode is enabled but user is not admin, reject the operation
|
||||
if (ADMIN_ENABLED && !isAdmin) {
|
||||
return socket.emit('app-error', { message: 'Admin access required to update channels' });
|
||||
}
|
||||
|
||||
const updatedChannel = await ChannelService.updateChannel(id, updatedAttributes);
|
||||
io.emit('channel-updated', updatedChannel); // Broadcast to all clients
|
||||
} catch (err) {
|
||||
@@ -43,4 +65,4 @@ module.exports = (io, socket) => {
|
||||
socket.emit('app-error', { message: err.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -2,9 +2,17 @@ const PlaylistService = require('../services/PlaylistService');
|
||||
const ChannelService = require('../services/ChannelService');
|
||||
const PlaylistUpdater = require('../services/PlaylistUpdater');
|
||||
const Playlist = require('../models/Playlist');
|
||||
require('dotenv').config();
|
||||
|
||||
async function handleAddPlaylist({ playlist, playlistName, mode, playlistUpdate, headers }, io, socket) {
|
||||
const ADMIN_ENABLED = process.env.ADMIN_ENABLED === 'true';
|
||||
|
||||
async function handleAddPlaylist({ playlist, playlistName, mode, playlistUpdate, headers, isAdmin }, io, socket) {
|
||||
try {
|
||||
// If admin mode is enabled but user is not admin, reject the operation
|
||||
if (ADMIN_ENABLED && !isAdmin) {
|
||||
return socket.emit('app-error', { message: 'Admin access required to add playlists' });
|
||||
}
|
||||
|
||||
const channels = await PlaylistService.addPlaylist(playlist, playlistName, mode, playlistUpdate, headers);
|
||||
|
||||
if (channels) {
|
||||
@@ -23,12 +31,17 @@ async function handleAddPlaylist({ playlist, playlistName, mode, playlistUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdatePlaylist({ playlist, updatedAttributes }, io, socket) {
|
||||
async function handleUpdatePlaylist({ playlist, updatedAttributes, isAdmin }, io, socket) {
|
||||
try {
|
||||
// If admin mode is enabled but user is not admin, reject the operation
|
||||
if (ADMIN_ENABLED && !isAdmin) {
|
||||
return socket.emit('app-error', { message: 'Admin access required to update playlists' });
|
||||
}
|
||||
|
||||
if (playlist !== updatedAttributes.playlist) {
|
||||
// Playlist URL has changed - delete channels and fetch again
|
||||
await handleDeletePlaylist(playlist, io, socket);
|
||||
await handleAddPlaylist(updatedAttributes, io, socket);
|
||||
await handleDeletePlaylist({ playlist, isAdmin }, io, socket);
|
||||
await handleAddPlaylist({ ...updatedAttributes, isAdmin }, io, socket);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,8 +62,13 @@ async function handleUpdatePlaylist({ playlist, updatedAttributes }, io, socket)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePlaylist(playlist, io, socket) {
|
||||
async function handleDeletePlaylist({ playlist, isAdmin }, io, socket) {
|
||||
try {
|
||||
// If admin mode is enabled but user is not admin, reject the operation
|
||||
if (ADMIN_ENABLED && !isAdmin) {
|
||||
return socket.emit('app-error', { message: 'Admin access required to delete playlists' });
|
||||
}
|
||||
|
||||
const channels = await PlaylistService.deletePlaylist(playlist);
|
||||
|
||||
channels.forEach(channel => {
|
||||
@@ -69,5 +87,11 @@ async function handleDeletePlaylist(playlist, io, socket) {
|
||||
module.exports = (io, socket) => {
|
||||
socket.on('add-playlist', data => handleAddPlaylist(data, io, socket));
|
||||
socket.on('update-playlist', data => handleUpdatePlaylist(data, io, socket));
|
||||
socket.on('delete-playlist', playlist => handleDeletePlaylist(playlist, io, socket));
|
||||
socket.on('delete-playlist', data => {
|
||||
// Handle both old format (just playlist string) and new format (object with playlist and isAdmin)
|
||||
const playlist = typeof data === 'object' ? data.playlist : data;
|
||||
const isAdmin = typeof data === 'object' ? data.isAdmin : false;
|
||||
|
||||
handleDeletePlaylist({ playlist, isAdmin }, io, socket);
|
||||
});
|
||||
};
|
||||
@@ -30,6 +30,9 @@ services:
|
||||
STORAGE_PATH: /streams/
|
||||
# If you have problems with the playlist, set the backend url manually here
|
||||
#BACKEND_URL: http://localhost:5000
|
||||
# Admin mode configuration
|
||||
ADMIN_ENABLED: "true"
|
||||
ADMIN_PASSWORD: "your_secure_password"
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Search, Plus, Settings, Users, Radio, Tv2, ChevronDown } from 'lucide-react';
|
||||
import { Search, Plus, Settings, Users, Radio, Tv2, ChevronDown, Shield } from 'lucide-react';
|
||||
import VideoPlayer from './components/VideoPlayer';
|
||||
import ChannelList from './components/ChannelList';
|
||||
import Chat from './components/chat/Chat';
|
||||
@@ -11,13 +11,16 @@ import SettingsModal from './components/SettingsModal';
|
||||
import TvPlaylistModal from './components/TvPlaylistModal';
|
||||
import { ToastProvider } from './components/notifications/ToastContext';
|
||||
import ToastContainer from './components/notifications/ToastContainer';
|
||||
import { AdminProvider, useAdmin } from './components/admin/AdminContext';
|
||||
import AdminModal from './components/admin/AdminModal';
|
||||
|
||||
function App() {
|
||||
function AppContent() {
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isTvPlaylistOpen, setIsTvPlaylistOpen] = useState(false);
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||
const [syncEnabled, setSyncEnabled] = useState(() => {
|
||||
const savedValue = localStorage.getItem('syncEnabled');
|
||||
return savedValue !== null ? JSON.parse(savedValue) : false;
|
||||
@@ -30,6 +33,8 @@ function App() {
|
||||
const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false);
|
||||
const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false);
|
||||
|
||||
const { isAdmin, isAdminEnabled, setIsAdminEnabled } = useAdmin();
|
||||
|
||||
// Get unique playlists from channels
|
||||
const playlists = useMemo(() => {
|
||||
const uniquePlaylists = new Set(channels.map(channel => channel.playlistName).filter(playlistName => playlistName !== null));
|
||||
@@ -64,6 +69,12 @@ function App() {
|
||||
}, [selectedPlaylist, channels]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if admin mode is enabled on the server
|
||||
apiService
|
||||
.request<{enabled: boolean}>('/auth/admin-status', 'GET')
|
||||
.then((data) => setIsAdminEnabled(data.enabled))
|
||||
.catch((error) => console.error('Error checking admin status:', error));
|
||||
|
||||
apiService
|
||||
.request<Channel[]>('/channels/', 'GET')
|
||||
.then((data) => setChannels(data))
|
||||
@@ -132,197 +143,239 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const handleEditChannel = (channel: Channel) => {
|
||||
setEditChannel(channel);
|
||||
setIsModalOpen(true);
|
||||
// Only allow editing if admin mode is not enabled or user is admin
|
||||
if (!isAdminEnabled || isAdmin) {
|
||||
setEditChannel(channel);
|
||||
setIsModalOpen(true);
|
||||
} else {
|
||||
setIsAdminModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||
<div className="container mx-auto py-4">
|
||||
<header className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Radio className="w-8 h-8 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold">StreamHub</h1>
|
||||
</div>
|
||||
<div className="relative max-w-md w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search channels..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Users className="w-6 h-6 text-blue-500" />
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||
<div className="container mx-auto py-4">
|
||||
<header className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Radio className="w-8 h-8 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold">StreamHub</h1>
|
||||
|
||||
{isAdmin && (
|
||||
<span className="ml-2 flex items-center px-2 py-1 text-xs font-medium text-green-400 bg-green-400 bg-opacity-10 rounded-full border border-green-400">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative max-w-md w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search channels..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Users className="w-6 h-6 text-blue-500" />
|
||||
<button
|
||||
onClick={() => setIsTvPlaylistOpen(true)}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Tv2 className="w-6 h-6 text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Settings className="w-6 h-6 text-blue-500" />
|
||||
</button>
|
||||
{isAdminEnabled && (
|
||||
<button
|
||||
onClick={() => setIsTvPlaylistOpen(true)}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className={`p-2 hover:bg-gray-800 rounded-lg transition-colors ${isAdmin ? 'text-green-500' : ''}`}
|
||||
>
|
||||
<Tv2 className="w-6 h-6 text-blue-500" />
|
||||
<Shield className="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Settings className="w-6 h-6 text-blue-500" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-8 space-y-4">
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsPlaylistDropdownOpen(!isPlaylistDropdownOpen);
|
||||
setIsGroupDropdownOpen(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 group"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tv2 className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold group-hover:text-blue-400 transition-colors">
|
||||
{selectedPlaylist}
|
||||
</h2>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-8 space-y-4">
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsPlaylistDropdownOpen(!isPlaylistDropdownOpen);
|
||||
setIsGroupDropdownOpen(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 group"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tv2 className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold group-hover:text-blue-400 transition-colors">
|
||||
{selectedPlaylist}
|
||||
</h2>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isPlaylistDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
|
||||
<div className="max-h-72 overflow-y-auto scroll-container">
|
||||
{playlists.map((playlist) => (
|
||||
<button
|
||||
key={playlist}
|
||||
onClick={() => {
|
||||
setSelectedPlaylist(playlist);
|
||||
setSelectedGroup('Category');
|
||||
setIsPlaylistDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
|
||||
selectedPlaylist === playlist ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
|
||||
}`}
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{playlist}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isPlaylistDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
|
||||
<div className="max-h-72 overflow-y-auto scroll-container">
|
||||
{playlists.map((playlist) => (
|
||||
<button
|
||||
key={playlist}
|
||||
onClick={() => {
|
||||
setSelectedPlaylist(playlist);
|
||||
setSelectedGroup('Category');
|
||||
setIsPlaylistDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
|
||||
selectedPlaylist === playlist ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
|
||||
}`}
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{playlist}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsGroupDropdownOpen(!isGroupDropdownOpen);
|
||||
setIsPlaylistDropdownOpen(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 group py-0.5 px-1.5 rounded-lg transition-all bg-white bg-opacity-10"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-base text-gray-300 group-hover:text-blue-400 transition-colors">
|
||||
{selectedGroup}
|
||||
</h4>
|
||||
</div>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isGroupDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
|
||||
<div className="max-h-72 overflow-y-auto scroll-container">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => {
|
||||
setSelectedGroup(group);
|
||||
setIsGroupDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
|
||||
selectedGroup === group ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
|
||||
}`}
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{group === 'Category' ? 'All Categories' : group}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
{/* Group Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsGroupDropdownOpen(!isGroupDropdownOpen);
|
||||
setIsPlaylistDropdownOpen(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 group py-0.5 px-1.5 rounded-lg transition-all bg-white bg-opacity-10"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-base text-gray-300 group-hover:text-blue-400 transition-colors">
|
||||
{selectedGroup}
|
||||
</h4>
|
||||
</div>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isGroupDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
|
||||
<div className="max-h-72 overflow-y-auto scroll-container">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => {
|
||||
setSelectedGroup(group);
|
||||
setIsGroupDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
|
||||
selectedGroup === group ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
|
||||
}`}
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{group === 'Category' ? 'All Categories' : group}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
// Only allow adding channels if admin mode is not enabled or user is admin
|
||||
if (!isAdminEnabled || isAdmin) {
|
||||
setIsModalOpen(true);
|
||||
setIsGroupDropdownOpen(false);
|
||||
setIsPlaylistDropdownOpen(false);
|
||||
}}
|
||||
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ChannelList
|
||||
channels={filteredChannels}
|
||||
selectedChannel={selectedChannel}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onEditChannel={handleEditChannel}
|
||||
/>
|
||||
} else {
|
||||
setIsAdminModalOpen(true);
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VideoPlayer channel={selectedChannel} syncEnabled={syncEnabled} />
|
||||
<ChannelList
|
||||
channels={filteredChannels}
|
||||
selectedChannel={selectedChannel}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onEditChannel={handleEditChannel}
|
||||
isAdmin={isAdmin}
|
||||
isAdminEnabled={isAdminEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Chat />
|
||||
</div>
|
||||
<VideoPlayer channel={selectedChannel} syncEnabled={syncEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<ChannelModal
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditChannel(null);
|
||||
}}
|
||||
channel={editChannel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
syncEnabled={syncEnabled}
|
||||
onSyncChange={(enabled) => {
|
||||
setSyncEnabled(enabled);
|
||||
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TvPlaylistModal
|
||||
isOpen={isTvPlaylistOpen}
|
||||
onClose={() => setIsTvPlaylistOpen(false)}
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<ChannelModal
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditChannel(null);
|
||||
}}
|
||||
channel={editChannel}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
syncEnabled={syncEnabled}
|
||||
onSyncChange={(enabled) => {
|
||||
setSyncEnabled(enabled);
|
||||
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TvPlaylistModal
|
||||
isOpen={isTvPlaylistOpen}
|
||||
onClose={() => setIsTvPlaylistOpen(false)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
<AdminModal
|
||||
isOpen={isAdminModalOpen}
|
||||
onClose={() => setIsAdminModalOpen(false)}
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AdminProvider>
|
||||
<AppContent />
|
||||
</AdminProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Channel } from '../types';
|
||||
import socketService from '../services/SocketService';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
interface ChannelListProps {
|
||||
channels: Channel[];
|
||||
selectedChannel: Channel | null;
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
onEditChannel: (channel: Channel) => void;
|
||||
isAdmin?: boolean;
|
||||
isAdminEnabled?: boolean;
|
||||
}
|
||||
|
||||
function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel }: ChannelListProps) {
|
||||
function ChannelList({
|
||||
channels,
|
||||
selectedChannel,
|
||||
setSearchQuery,
|
||||
onEditChannel,
|
||||
isAdmin = false,
|
||||
isAdminEnabled = false
|
||||
}: ChannelListProps) {
|
||||
|
||||
const onSelectChannel = (channel: Channel) => {
|
||||
setSearchQuery('');
|
||||
@@ -42,6 +52,8 @@ function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel
|
||||
alt={channel.name}
|
||||
className="w-full h-full object-contain rounded-lg transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate text-center">
|
||||
{channel.name.length > 28 ? `${channel.name.substring(0, 28)}...` : channel.name}
|
||||
@@ -52,4 +64,4 @@ function ChannelList({ channels, selectedChannel, setSearchQuery, onEditChannel
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelList;
|
||||
export default ChannelList;
|
||||
@@ -1,15 +1,17 @@
|
||||
import { X, Copy, Tv2 } from 'lucide-react';
|
||||
import { useContext } from 'react';
|
||||
import { X, Copy, Tv2, Eye, EyeOff } from 'lucide-react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { ToastContext } from './notifications/ToastContext';
|
||||
|
||||
interface TvPlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
function TvPlaylistModal({ isOpen, onClose }: TvPlaylistModalProps) {
|
||||
function TvPlaylistModal({ isOpen, onClose, isAdmin = false }: TvPlaylistModalProps) {
|
||||
const { addToast } = useContext(ToastContext);
|
||||
const playlistUrl = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/api/channels/playlist`;
|
||||
const [showHiddenInfo, setShowHiddenInfo] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -66,6 +68,35 @@ function TvPlaylistModal({ isOpen, onClose }: TvPlaylistModalProps) {
|
||||
<p className="text-sm text-gray-400">
|
||||
Use this playlist in any other IPTV player. If you have problems, check if the base-url in the playlist is correctly pointing to the backend. If not, please set BACKEND_URL in the docker-compose.yml
|
||||
</p>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mt-6 border-t border-gray-700 pt-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-lg font-medium">Admin Information</h3>
|
||||
<button
|
||||
onClick={() => setShowHiddenInfo(!showHiddenInfo)}
|
||||
className="flex items-center space-x-1 text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{showHiddenInfo ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
<span>{showHiddenInfo ? 'Hide' : 'Show'} sensitive info</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-300">
|
||||
{showHiddenInfo ? (
|
||||
<>
|
||||
This playlist contains the actual stream URLs. You can share a link to the
|
||||
application with non-admin users, and they will be able to watch the streams
|
||||
without seeing the actual stream URLs.
|
||||
</>
|
||||
) : (
|
||||
'Click "Show sensitive info" to view additional information about the playlist.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Plus, Trash2, X } from 'lucide-react';
|
||||
import { Plus, Trash2, X, Eye, EyeOff } from 'lucide-react';
|
||||
import socketService from '../../services/SocketService';
|
||||
import { CustomHeader, Channel, ChannelMode } from '../../types';
|
||||
import CustomHeaderInput from './CustomHeaderInput';
|
||||
@@ -9,12 +9,14 @@ import { ModeTooltipContent, Tooltip } from '../Tooltip';
|
||||
interface ChannelModalProps {
|
||||
onClose: () => void;
|
||||
channel?: Channel | null;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
function ChannelModal({ onClose, channel, isAdmin = false }: ChannelModalProps) {
|
||||
const [type, setType] = useState<'channel' | 'playlist'>('playlist');
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [inputMethod, setInputMethod] = useState<'url' | 'text'>('url');
|
||||
const [showSensitiveInfo, setShowSensitiveInfo] = useState(false);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
@@ -100,7 +102,8 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
url.trim(),
|
||||
avatar.trim() || 'https://via.placeholder.com/64',
|
||||
mode,
|
||||
JSON.stringify(headers)
|
||||
JSON.stringify(headers),
|
||||
isAdmin
|
||||
);
|
||||
} else if (type === 'playlist') {
|
||||
if (inputMethod === 'url' && !playlistUrl.trim()) return;
|
||||
@@ -111,7 +114,8 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
playlistName.trim(),
|
||||
mode,
|
||||
playlistUpdate,
|
||||
JSON.stringify(headers)
|
||||
JSON.stringify(headers),
|
||||
isAdmin
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +136,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
avatar: avatar.trim() || 'https://via.placeholder.com/64',
|
||||
mode: mode,
|
||||
headers: headers,
|
||||
});
|
||||
}, isAdmin);
|
||||
} else if (type === 'playlist') {
|
||||
const newPlaylist = inputMethod === 'url' ? playlistUrl.trim() : playlistText.trim();
|
||||
socketService.updatePlaylist(channel!.playlist, {
|
||||
@@ -141,7 +145,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
playlistUpdate: playlistUpdate,
|
||||
mode: mode,
|
||||
headers: headers,
|
||||
});
|
||||
}, isAdmin);
|
||||
}
|
||||
|
||||
addToast({
|
||||
@@ -156,9 +160,9 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
const handleDelete = () => {
|
||||
if (channel) {
|
||||
if (type === 'channel') {
|
||||
socketService.deleteChannel(channel.id);
|
||||
socketService.deleteChannel(channel.id, isAdmin);
|
||||
} else if (type === 'playlist') {
|
||||
socketService.deletePlaylist(channel.playlist);
|
||||
socketService.deletePlaylist(channel.playlist, isAdmin);
|
||||
}
|
||||
}
|
||||
addToast({
|
||||
@@ -169,6 +173,32 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Obfuscate part of the URL to hide sensitive information
|
||||
const getObfuscatedUrl = (fullUrl: string) => {
|
||||
if (!fullUrl || showSensitiveInfo) return fullUrl;
|
||||
|
||||
try {
|
||||
const url = new URL(fullUrl);
|
||||
// Hide username and password in URL if present
|
||||
if (url.username || url.password) {
|
||||
return fullUrl.replace(/\/\/([^:@]+:[^@]+@)/g, '//***:***@');
|
||||
}
|
||||
|
||||
// Hide tokens or API keys in query params
|
||||
if (url.search && (url.search.includes('token') || url.search.includes('key') || url.search.includes('password') || url.search.includes('auth'))) {
|
||||
return `${url.origin}${url.pathname}?***hidden***`;
|
||||
}
|
||||
|
||||
return fullUrl;
|
||||
} catch {
|
||||
// If URL is malformed, just return a partially obfuscated string
|
||||
if (fullUrl.length > 20) {
|
||||
return fullUrl.substring(0, 10) + '...' + fullUrl.substring(fullUrl.length - 10);
|
||||
}
|
||||
return fullUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-800 rounded-lg w-full max-w-md">
|
||||
@@ -221,17 +251,30 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="url" className="block text-sm font-medium mb-1">
|
||||
Stream URL
|
||||
</label>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label htmlFor="url" className="block text-sm font-medium">
|
||||
Stream URL
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSensitiveInfo(!showSensitiveInfo)}
|
||||
className="flex items-center text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{showSensitiveInfo ? <EyeOff className="w-3 h-3 mr-1" /> : <Eye className="w-3 h-3 mr-1" />}
|
||||
{showSensitiveInfo ? 'Hide' : 'Show'} URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
value={url}
|
||||
value={isAdmin ? url : getObfuscatedUrl(url)}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter stream URL"
|
||||
required
|
||||
readOnly={!isAdmin && !showSensitiveInfo}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -330,15 +373,26 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
M3U Text
|
||||
</button>
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSensitiveInfo(!showSensitiveInfo)}
|
||||
className="flex items-center text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{showSensitiveInfo ? <EyeOff className="w-3 h-3 mr-1" /> : <Eye className="w-3 h-3 mr-1" />}
|
||||
{showSensitiveInfo ? 'Hide' : 'Show'} URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
id="playlistUrl"
|
||||
value={playlistUrl}
|
||||
value={isAdmin ? playlistUrl : getObfuscatedUrl(playlistUrl)}
|
||||
onChange={(e) => setPlaylistUrl(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter M3U playlist URL"
|
||||
required={inputMethod === 'url'}
|
||||
readOnly={!isAdmin && !showSensitiveInfo}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -361,15 +415,26 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
M3U Text
|
||||
</button>
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSensitiveInfo(!showSensitiveInfo)}
|
||||
className="flex items-center text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{showSensitiveInfo ? <EyeOff className="w-3 h-3 mr-1" /> : <Eye className="w-3 h-3 mr-1" />}
|
||||
{showSensitiveInfo ? 'Hide' : 'Show'} content
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
id="playlistText"
|
||||
value={playlistText}
|
||||
value={isAdmin || showSensitiveInfo ? playlistText : "#EXTM3U\n# Content hidden for privacy\n# Login as admin to view or edit"}
|
||||
onChange={(e) => setPlaylistText(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[200px] scroll-container overflow-y-auto"
|
||||
placeholder="#EXTM3U..."
|
||||
required={inputMethod === 'text'}
|
||||
style={{ resize: 'none' }}
|
||||
readOnly={!isAdmin && !showSensitiveInfo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -443,30 +508,38 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
<label className="block text-sm font-medium">
|
||||
Custom Headers
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeader}
|
||||
className="flex items-center space-x-1 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Header</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHeader}
|
||||
className="flex items-center space-x-1 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Header</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{headers && headers.map((header, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<CustomHeaderInput
|
||||
header={header}
|
||||
header={{
|
||||
key: header.key,
|
||||
value: isAdmin || showSensitiveInfo ? header.value : "***hidden***"
|
||||
}}
|
||||
onKeyChange={(value) => updateHeader(index, 'key', value)}
|
||||
onValueChange={(value) => updateHeader(index, 'value', value)}
|
||||
readOnly={!isAdmin && !showSensitiveInfo}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHeader(index)}
|
||||
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHeader(index)}
|
||||
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -474,7 +547,7 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
{isEditMode && (
|
||||
{isEditMode && isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
@@ -490,12 +563,14 @@ function ChannelModal({ onClose, channel }: ChannelModalProps) {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{isEditMode ? 'Update' : 'Add'}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{isEditMode ? 'Update' : 'Add'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,10 @@ interface CustomHeaderInputProps {
|
||||
header: CustomHeader;
|
||||
onKeyChange: (value: string) => void;
|
||||
onValueChange: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function CustomHeaderInput({ header, onKeyChange, onValueChange }: CustomHeaderInputProps) {
|
||||
function CustomHeaderInput({ header, onKeyChange, onValueChange, readOnly = false }: CustomHeaderInputProps) {
|
||||
return (
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
@@ -14,14 +15,16 @@ function CustomHeaderInput({ header, onKeyChange, onValueChange }: CustomHeaderI
|
||||
value={header.key}
|
||||
onChange={(e) => onKeyChange(e.target.value)}
|
||||
placeholder="Header name"
|
||||
className="bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={`bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none ${!readOnly ? 'focus:ring-2 focus:ring-blue-500' : ''}`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={header.value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder="Header value"
|
||||
className="bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={`bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none ${!readOnly ? 'focus:ring-2 focus:ring-blue-500' : ''}`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
32
frontend/src/components/admin/AdminContext.tsx
Normal file
32
frontend/src/components/admin/AdminContext.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface AdminContextType {
|
||||
isAdmin: boolean;
|
||||
setIsAdmin: (value: boolean) => void;
|
||||
isAdminEnabled: boolean;
|
||||
setIsAdminEnabled: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextType>({
|
||||
isAdmin: false,
|
||||
setIsAdmin: () => {},
|
||||
isAdminEnabled: false,
|
||||
setIsAdminEnabled: () => {},
|
||||
});
|
||||
|
||||
export const useAdmin = () => useContext(AdminContext);
|
||||
|
||||
interface AdminProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isAdminEnabled, setIsAdminEnabled] = useState(false);
|
||||
|
||||
return (
|
||||
<AdminContext.Provider value={{ isAdmin, setIsAdmin, isAdminEnabled, setIsAdminEnabled }}>
|
||||
{children}
|
||||
</AdminContext.Provider>
|
||||
);
|
||||
};
|
||||
124
frontend/src/components/admin/AdminModal.tsx
Normal file
124
frontend/src/components/admin/AdminModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { X, Shield, ShieldOff } from 'lucide-react';
|
||||
import { ToastContext } from '../notifications/ToastContext';
|
||||
import { useAdmin } from './AdminContext';
|
||||
import apiService from '../../services/ApiService';
|
||||
|
||||
interface AdminModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AdminModal({ isOpen, onClose }: AdminModalProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const { isAdmin, setIsAdmin } = useAdmin();
|
||||
const { addToast } = useContext(ToastContext);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await apiService.request<{success: boolean}>('/auth/admin-login', 'POST', undefined, {
|
||||
password
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setIsAdmin(true);
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Admin mode enabled',
|
||||
duration: 3000,
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Invalid password',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Authentication failed',
|
||||
message: 'Please try again',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsAdmin(false);
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Admin mode disabled',
|
||||
duration: 3000,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-800 rounded-lg w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isAdmin ? (
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<ShieldOff className="w-5 h-5 text-blue-500" />
|
||||
)}
|
||||
<h2 className="text-xl font-semibold">Admin Mode</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{isAdmin ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-green-500">You are currently in admin mode.</p>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full p-2 bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Logout from Admin Mode
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium mb-1">
|
||||
Admin Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="adminPassword"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter admin password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminModal;
|
||||
@@ -70,10 +70,10 @@ class SocketService {
|
||||
}
|
||||
|
||||
// Add channel
|
||||
addChannel(name: string, url: string, avatar: string, mode: ChannelMode, headersJson: string) {
|
||||
addChannel(name: string, url: string, avatar: string, mode: ChannelMode, headersJson: string, isAdmin: boolean = false) {
|
||||
if (!this.socket) throw new Error('Socket is not connected.');
|
||||
|
||||
this.socket.emit('add-channel', { name, url, avatar, mode, headersJson });
|
||||
this.socket.emit('add-channel', { name, url, avatar, mode, headersJson, isAdmin });
|
||||
}
|
||||
|
||||
// Set current channel
|
||||
@@ -84,38 +84,38 @@ class SocketService {
|
||||
}
|
||||
|
||||
// Delete channel
|
||||
deleteChannel(id: number) {
|
||||
deleteChannel(id: number, isAdmin: boolean = false) {
|
||||
if (!this.socket) throw new Error('Socket is not connected.');
|
||||
|
||||
this.socket.emit('delete-channel', id);
|
||||
this.socket.emit('delete-channel', { id, isAdmin });
|
||||
}
|
||||
|
||||
// Update channel
|
||||
updateChannel(id: number, updatedAttributes: any) {
|
||||
updateChannel(id: number, updatedAttributes: any, isAdmin: boolean = false) {
|
||||
if (!this.socket) throw new Error('Socket is not connected.');
|
||||
|
||||
this.socket.emit('update-channel', { id, updatedAttributes });
|
||||
this.socket.emit('update-channel', { id, updatedAttributes, isAdmin });
|
||||
}
|
||||
|
||||
// Add playlist
|
||||
addPlaylist(playlist: string, playlistName: string, mode: ChannelMode, playlistUpdate: boolean, headers: string) {
|
||||
addPlaylist(playlist: string, playlistName: string, mode: ChannelMode, playlistUpdate: boolean, headers: string, isAdmin: boolean = false) {
|
||||
if (!this.socket) throw new Error('Socket is not connected.');
|
||||
|
||||
this.socket.emit('add-playlist', { playlist, playlistName, mode, playlistUpdate, headers });
|
||||
this.socket.emit('add-playlist', { playlist, playlistName, mode, playlistUpdate, headers, isAdmin });
|
||||
}
|
||||
|
||||
// Update playlist
|
||||
updatePlaylist(playlist: string, updatedAttributes: any) {
|
||||
updatePlaylist(playlist: string, updatedAttributes: any, isAdmin: boolean = false) {
|
||||
if (!this.socket) throw new Error('Socket is not connected.');
|
||||
|
||||
this.socket.emit('update-playlist', { playlist, updatedAttributes });
|
||||
this.socket.emit('update-playlist', { playlist, updatedAttributes, isAdmin });
|
||||
}
|
||||
|
||||
// Delete playlist
|
||||
deletePlaylist(playlist: string) {
|
||||
deletePlaylist(playlist: string, isAdmin: boolean = false) {
|
||||
if (!this.socket) throw new Error('Socket is not connected.');
|
||||
|
||||
this.socket.emit('delete-playlist', playlist);
|
||||
this.socket.emit('delete-playlist', { playlist, isAdmin });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user