Implement authentication flow with login screen and context management

This commit is contained in:
Salman Qureshi
2025-08-10 03:19:33 +05:30
parent 80a3f28717
commit 04332c3516
9 changed files with 611 additions and 39 deletions

View File

@@ -899,3 +899,9 @@ body {
position: relative;
overflow: visible;
}
/* Loading animation for authentication */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -1,14 +1,51 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import Layout from './components/Layout';
import HomePage from './components/HomePage';
import TorrentPageNetflix from './components/TorrentPageNetflix';
import RecentPage from './components/RecentPage';
import SettingsPage from './components/SettingsPage';
import CacheManagementPage from './components/CacheManagementPage';
import LoginScreen from './components/LoginScreen';
import './App.css';
function App() {
const AuthenticatedApp = () => {
const { isAuthenticated, isLoading, authenticate } = useAuth();
// Show loading spinner while checking authentication
if (isLoading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%)',
color: '#ffffff'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '40px',
height: '40px',
border: '4px solid rgba(255, 255, 255, 0.2)',
borderTop: '4px solid #e50914',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 16px'
}}></div>
<p>Loading Seedbox...</p>
</div>
</div>
);
}
// Show login screen if not authenticated
if (!isAuthenticated) {
return <LoginScreen onAuthSuccess={authenticate} />;
}
// Show main app if authenticated
return (
<Router>
<Routes>
@@ -22,6 +59,14 @@ function App() {
</Routes>
</Router>
);
};
function App() {
return (
<AuthProvider>
<AuthenticatedApp />
</AuthProvider>
);
}
export default App;

View File

@@ -34,7 +34,7 @@ const Layout = () => {
const diskData = await diskResponse.json().catch(() => ({ percentage: 0, total: 0 }));
// Calculate cache usage percentage relative to total disk space
const cacheSize = stats.cacheSize || 0;
const cacheSize = stats.downloadedBytes || 0;
const totalDisk = diskData.total || 1;
const cacheUsagePercentage = totalDisk > 0 ? (cacheSize / totalDisk) * 100 : 0;

View File

@@ -0,0 +1,268 @@
.login-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.login-background {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.login-background::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 30% 20%, rgba(255, 0, 150, 0.1) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(0, 255, 255, 0.1) 0%, transparent 50%);
animation: floatingGradient 6s ease-in-out infinite alternate;
}
@keyframes floatingGradient {
0% { transform: translateY(0px) rotate(0deg); }
100% { transform: translateY(-10px) rotate(2deg); }
}
.login-container {
background: rgba(20, 20, 35, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05);
position: relative;
z-index: 1;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-icon {
color: #e50914;
margin-bottom: 16px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.login-header h1 {
color: #ffffff;
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.login-header p {
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
margin: 0;
line-height: 1.4;
}
.login-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.password-input-container {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 16px;
color: rgba(255, 255, 255, 0.5);
z-index: 1;
}
.password-input {
width: 100%;
height: 56px;
background: rgba(255, 255, 255, 0.05);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0 50px 0 48px;
font-size: 16px;
color: #ffffff;
transition: all 0.3s ease;
outline: none;
}
.password-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.password-input:focus {
border-color: #e50914;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 4px rgba(229, 9, 20, 0.1);
}
.password-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toggle-password {
position: absolute;
right: 16px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: color 0.3s ease;
}
.toggle-password:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.8);
}
.toggle-password:disabled {
cursor: not-allowed;
}
.error-message {
background: rgba(229, 9, 20, 0.1);
border: 1px solid rgba(229, 9, 20, 0.3);
border-radius: 8px;
padding: 12px 16px;
color: #ff6b6b;
font-size: 14px;
text-align: center;
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.login-button {
height: 56px;
background: linear-gradient(135deg, #e50914 0%, #b8070f 100%);
border: none;
border-radius: 12px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(229, 9, 20, 0.3);
}
.login-button:hover:not(:disabled) {
background: linear-gradient(135deg, #f31e2a 0%, #c7080f 100%);
box-shadow: 0 6px 20px rgba(229, 9, 20, 0.4);
transform: translateY(-1px);
}
.login-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(229, 9, 20, 0.3);
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: 0 4px 16px rgba(229, 9, 20, 0.2);
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.login-footer {
text-align: center;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.login-footer p {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
margin: 0;
}
/* Mobile responsiveness */
@media (max-width: 480px) {
.login-container {
margin: 16px;
padding: 32px 24px;
max-width: none;
}
.login-header h1 {
font-size: 28px;
}
.password-input,
.login-button {
height: 48px;
}
}
/* Dark theme compatibility */
@media (prefers-color-scheme: dark) {
.login-background {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%);
}
}

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { Lock, Eye, EyeOff, Shield } from 'lucide-react';
import { config } from '../config/environment';
import './LoginScreen.css';
const LoginScreen = ({ onAuthSuccess }) => {
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
if (!password.trim()) {
setError('Please enter a password');
return;
}
setLoading(true);
setError('');
try {
const response = await fetch(config.getApiUrl('/api/auth/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const data = await response.json();
if (data.success) {
// Store authentication in localStorage
localStorage.setItem('seedbox_authenticated', 'true');
localStorage.setItem('seedbox_auth_timestamp', Date.now().toString());
console.log('✅ Authentication successful, stored in localStorage');
onAuthSuccess();
} else {
setError(data.error || 'Authentication failed');
setPassword(''); // Clear password on failure
}
} catch (err) {
console.error('Authentication error:', err);
setError('Connection error. Please check if the server is running.');
} finally {
setLoading(false);
}
};
return (
<div className="login-screen">
<div className="login-background">
<div className="login-container">
<div className="login-header">
<Shield size={48} className="login-icon" />
<h1>Seedbox Access</h1>
<p>Enter your password to access the torrent dashboard</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
<div className="password-input-container">
<Lock size={20} className="input-icon" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="password-input"
disabled={loading}
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="toggle-password"
disabled={loading}
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
{error && (
<div className="error-message">
<span>{error}</span>
</div>
)}
<button
type="submit"
className="login-button"
disabled={loading || !password.trim()}
>
{loading ? (
<div className="loading-spinner" />
) : (
<>
<Lock size={18} />
Access Dashboard
</>
)}
</button>
</form>
<div className="login-footer">
<p>🔒 Your session will be remembered on this device</p>
</div>
</div>
</div>
</div>
);
};
export default LoginScreen;

View File

@@ -513,3 +513,22 @@ input:disabled + .slider:hover {
transform: translateX(16px);
}
}
/* Security Section */
.security-section {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
}
.security-info {
margin-bottom: 16px;
}
.security-info p {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
line-height: 1.5;
margin: 0;
}

View File

@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react';
import { Settings, Trash2, Download, Globe, Shield, HardDrive, ExternalLink } from 'lucide-react';
import { Settings, Trash2, Download, Globe, Shield, HardDrive, ExternalLink, LogOut } from 'lucide-react';
import { Link } from 'react-router-dom';
import { config } from '../config/environment';
import { useAuth } from '../context/AuthContext';
import progressService from '../services/progressService';
import './SettingsPage.css';
const SettingsPage = () => {
const { logout } = useAuth();
const [settings, setSettings] = useState({
downloadPath: '/tmp/seedbox-downloads',
maxConnections: 50,
@@ -144,6 +146,12 @@ const SettingsPage = () => {
event.target.value = ''; // Reset file input
};
const handleLogout = () => {
if (window.confirm('Are you sure you want to logout? You will need to enter the password again to access the dashboard.')) {
logout();
}
};
return (
<div className="settings-page">
<div className="page-header">
@@ -327,6 +335,22 @@ const SettingsPage = () => {
</div>
</div>
{/* Security */}
<div className="settings-section">
<h2>🔐 Security</h2>
<div className="security-section">
<div className="security-info">
<p>Your authentication is stored locally on this device for convenience. You can logout to require password entry on next access.</p>
</div>
<div className="action-buttons">
<button onClick={handleLogout} className="action-button danger">
<LogOut size={16} />
Logout
</button>
</div>
</div>
</div>
{/* About */}
<div className="settings-section">
<h2> About</h2>

View File

@@ -0,0 +1,84 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const clearAuth = useCallback(() => {
localStorage.removeItem('seedbox_authenticated');
localStorage.removeItem('seedbox_auth_timestamp');
setIsAuthenticated(false);
console.log('🚪 Authentication cleared');
}, []);
const checkAuthStatus = useCallback(() => {
try {
const authStatus = localStorage.getItem('seedbox_authenticated');
const authTimestamp = localStorage.getItem('seedbox_auth_timestamp');
if (authStatus === 'true' && authTimestamp) {
// Check if authentication is still valid (optional: add expiration logic here)
const timestamp = parseInt(authTimestamp);
const now = Date.now();
// Authentication expires after 30 days (optional)
const EXPIRY_TIME = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
if (now - timestamp < EXPIRY_TIME) {
setIsAuthenticated(true);
console.log('✅ Found valid authentication in localStorage');
} else {
// Clear expired authentication
clearAuth();
console.log('⏰ Authentication expired, cleared localStorage');
}
} else {
console.log('❌ No valid authentication found in localStorage');
}
} catch (error) {
console.error('Error checking auth status:', error);
clearAuth();
} finally {
setIsLoading(false);
}
}, [clearAuth]);
useEffect(() => {
checkAuthStatus();
}, [checkAuthStatus]);
const authenticate = () => {
setIsAuthenticated(true);
console.log('<27> User authenticated successfully');
};
const logout = () => {
clearAuth();
// Optionally redirect to login or refresh page
window.location.reload();
};
const value = {
isAuthenticated,
isLoading,
authenticate,
logout,
clearAuth
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -650,6 +650,35 @@ app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Authentication endpoint
app.post('/api/auth/login', (req, res) => {
const { password } = req.body;
const correctPassword = process.env.ACCESS_PASSWORD || 'seedbox123';
console.log(`🔐 Login attempt with password: ${password ? '[PROVIDED]' : '[MISSING]'}`);
if (!password) {
return res.status(400).json({
success: false,
error: 'Password is required'
});
}
if (password === correctPassword) {
console.log('✅ Authentication successful');
return res.json({
success: true,
message: 'Authentication successful'
});
} else {
console.log('❌ Authentication failed - incorrect password');
return res.status(401).json({
success: false,
error: 'Invalid password'
});
}
});
// UNIVERSAL ADD TORRENT - Always succeeds
app.post('/api/torrents', async (req, res) => {
const { torrentId } = req.body;
@@ -1254,40 +1283,19 @@ app.delete('/api/torrents', (req, res) => {
// Cache stats
app.get('/api/cache/stats', async (req, res) => {
try {
const fs = require('fs').promises;
const path = require('path');
const activeTorrents = client.torrents.length;
const activeTorrents = Object.keys(torrents).length;
// Function to get directory size
const getDirectorySize = async (dirPath) => {
let size = 0;
try {
const items = await fs.readdir(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = await fs.stat(itemPath);
if (stats.isDirectory()) {
size += await getDirectorySize(itemPath);
} else {
size += stats.size;
}
}
} catch (error) {
console.warn('Error calculating directory size:', error.message);
}
return size;
};
// Calculate actual cache size from download directory
const downloadDir = path.join(__dirname, 'downloads');
// Calculate actual cache size from WebTorrent client data
let cacheSize = 0;
try {
cacheSize = await getDirectorySize(downloadDir);
} catch (error) {
console.warn('Could not calculate cache size:', error.message);
}
let downloadedBytes = 0;
client.torrents.forEach(torrent => {
// Add total size of each torrent
cacheSize += torrent.length || 0;
// Add downloaded bytes
downloadedBytes += torrent.downloaded || 0;
});
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -1295,14 +1303,17 @@ app.get('/api/cache/stats', async (req, res) => {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const stats = {
totalSizeFormatted: formatBytes(cacheSize),
totalSizeFormatted: formatBytes(downloadedBytes), // Use actual downloaded data
totalSize: downloadedBytes,
activeTorrents,
cacheSize: cacheSize
cacheSize: downloadedBytes, // Use downloaded bytes for cache calculation
totalTorrentSize: cacheSize, // Total size of all torrents
totalTorrentSizeFormatted: formatBytes(cacheSize)
};
console.log(`📊 Cache stats: ${formatBytes(cacheSize)} (${activeTorrents} torrents)`);
console.log(`📊 Cache stats: ${formatBytes(downloadedBytes)} downloaded (${activeTorrents} torrents, ${formatBytes(cacheSize)} total)`);
res.json(stats);
} catch (error) {
console.error('Error getting cache stats:', error);