/** * Component: Login Page * Documentation: documentation/frontend/pages/login.md */ 'use client'; import { Suspense, useState, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/Button'; import { AlertModal } from '@/components/ui/AlertModal'; import Image from 'next/image'; interface BookCover { asin: string; title: string; author: string; coverUrl: string; } function LoginContent() { const router = useRouter(); const searchParams = useSearchParams(); const { user, login, setAuthData, isLoading: authLoading } = useAuth(); const [isLoggingIn, setIsLoggingIn] = useState(false); const [error, setError] = useState(null); const [showAdminLogin, setShowAdminLogin] = useState(false); const [adminUsername, setAdminUsername] = useState(''); const [adminPassword, setAdminPassword] = useState(''); const [bookCovers, setBookCovers] = useState([]); const [isMobile, setIsMobile] = useState(false); const [authProviders, setAuthProviders] = useState<{ backendMode: string; providers: string[]; registrationEnabled: boolean; hasLocalUsers: boolean; oidcProviderName: string | null; localLoginDisabled: boolean; allowWeakPassword: boolean; automationEnabled: boolean; } | null>(null); const [showRegisterForm, setShowRegisterForm] = useState(false); const [registerUsername, setRegisterUsername] = useState(''); const [registerPassword, setRegisterPassword] = useState(''); const [registerConfirmPassword, setRegisterConfirmPassword] = useState(''); const [alertModal, setAlertModal] = useState<{ isOpen: boolean; title: string; message: string; variant: 'info' | 'warning' | 'success' | 'danger'; }>({ isOpen: false, title: '', message: '', variant: 'info' }); // Detect mobile viewport useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); // Fetch auth providers useEffect(() => { const fetchProviders = async () => { try { const response = await fetch('/api/auth/providers'); if (response.ok) { const data = await response.json(); setAuthProviders(data); } } catch (err) { console.error('Failed to fetch auth providers:', err); // Default to Plex mode setAuthProviders({ backendMode: 'plex', providers: ['plex'], registrationEnabled: false, hasLocalUsers: false, oidcProviderName: null, localLoginDisabled: false, allowWeakPassword: false, automationEnabled: false, }); } }; fetchProviders(); }, []); // Fetch random popular book covers useEffect(() => { const fetchCovers = async () => { try { const response = await fetch('/api/audiobooks/covers'); if (response.ok) { const data = await response.json(); if (data.success && data.covers.length > 0) { setBookCovers(data.covers); } } } catch (err) { console.error('Failed to fetch book covers:', err); // Silently fail - page will show without covers } }; fetchCovers(); }, []); // Redirect if already logged in useEffect(() => { if (user && !authLoading) { const redirect = searchParams.get('redirect') || '/'; router.push(redirect); } }, [user, authLoading, router, searchParams]); // Handle Plex OAuth callback (mobile redirect with cookies or URL hash) useEffect(() => { const authSuccess = searchParams.get('auth'); console.log('[Mobile Auth] useEffect triggered:', { authSuccess, hasUser: !!user, authLoading }); if (authSuccess === 'success' && !user && !authLoading) { console.log('[Mobile Auth] Processing auth success...'); // First, try to read from URL hash (more reliable for mobile) const hash = window.location.hash; console.log('[Mobile Auth] URL hash:', hash); if (hash && hash.includes('authData=')) { try { const authDataMatch = hash.match(/authData=([^&]+)/); if (authDataMatch) { const authDataStr = decodeURIComponent(authDataMatch[1]); const authData = JSON.parse(authDataStr); console.log('[Mobile Auth] Successfully parsed authData from URL hash:', authData.user); // Store in localStorage localStorage.setItem('accessToken', authData.accessToken); localStorage.setItem('refreshToken', authData.refreshToken); localStorage.setItem('user', JSON.stringify(authData.user)); console.log('[Mobile Auth] Stored tokens in localStorage from hash'); // Update auth context setAuthData(authData.user, authData.accessToken); console.log('[Mobile Auth] Updated AuthContext from hash'); // Clear the hash from URL for security window.history.replaceState(null, '', window.location.pathname + window.location.search); // Redirect to home const redirect = searchParams.get('redirect') || '/'; console.log('[Mobile Auth] Redirecting to:', redirect); router.push(redirect); return; } } catch (err) { console.error('[Mobile Auth] Failed to parse auth data from URL hash:', err); } } // Fallback: Try to read from cookies console.log('[Mobile Auth] No hash data, trying cookies...'); console.log('[Mobile Auth] All cookies:', document.cookie); const getCookie = (name: string) => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift(); return null; }; const accessToken = getCookie('accessToken'); const userDataStr = getCookie('userData'); console.log('[Mobile Auth] Cookie values:', { hasAccessToken: !!accessToken, accessTokenLength: accessToken?.length, hasUserData: !!userDataStr, userDataLength: userDataStr?.length, }); if (accessToken && userDataStr) { try { console.log('[Mobile Auth] Attempting to parse userData from cookies...'); const userData = JSON.parse(decodeURIComponent(userDataStr)); console.log('[Mobile Auth] Successfully parsed userData:', userData); // Store in localStorage for AuthContext localStorage.setItem('accessToken', accessToken); const refreshToken = getCookie('refreshToken'); if (refreshToken) { localStorage.setItem('refreshToken', refreshToken); } localStorage.setItem('user', JSON.stringify(userData)); console.log('[Mobile Auth] Stored tokens in localStorage from cookies'); // Update auth context setAuthData(userData, accessToken); console.log('[Mobile Auth] Updated AuthContext from cookies'); // Redirect to home const redirect = searchParams.get('redirect') || '/'; console.log('[Mobile Auth] Redirecting to:', redirect); router.push(redirect); } catch (err) { console.error('[Mobile Auth] Failed to parse auth data from cookies:', err); console.error('[Mobile Auth] userDataStr was:', userDataStr); setError('Login failed. Please try again.'); } } else { console.warn('[Mobile Auth] Missing required cookies and hash data'); setError('Authentication failed. Please try again.'); } } }, [searchParams, user, authLoading, setAuthData, router]); // Handle error messages from URL query parameters (e.g., OIDC access denied) useEffect(() => { const errorParam = searchParams.get('error'); if (errorParam) { setError(decodeURIComponent(errorParam)); // Clean up URL by removing the error parameter const newUrl = new URL(window.location.href); newUrl.searchParams.delete('error'); window.history.replaceState({}, '', newUrl.toString()); } }, [searchParams]); const handlePlexLogin = async () => { setIsLoggingIn(true); setError(null); try { // Request PIN from Plex const response = await fetch('/api/auth/plex/login', { method: 'POST', }); if (!response.ok) { throw new Error('Failed to initiate login'); } const { pinId, authUrl } = await response.json(); // On mobile, redirect to Plex OAuth instead of using popup // The callback route will set cookies and redirect back to /login?auth=success if (isMobile) { window.location.href = authUrl; return; } // Desktop: Open Plex OAuth in popup const authWindow = window.open( authUrl, 'plex-auth', 'width=600,height=700,scrollbars=yes,resizable=yes' ); if (!authWindow) { setError('Popup was blocked. Please allow popups for this site and try again.'); setIsLoggingIn(false); return; } // Poll for authorization await login(pinId); // Close popup authWindow.close(); // Redirect to intended page or homepage const redirect = searchParams.get('redirect') || '/'; router.push(redirect); } catch (err) { setError(err instanceof Error ? err.message : 'Login failed. Please try again.'); } finally { setIsLoggingIn(false); } }; const handleAdminLogin = async (e: React.FormEvent) => { e.preventDefault(); setIsLoggingIn(true); setError(null); try { // Use local login endpoint for ABS mode with local auth, admin login endpoint for Plex mode const loginEndpoint = authProviders?.providers.includes('local') ? '/api/auth/local/login' : '/api/auth/admin/login'; const response = await fetch(loginEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: adminUsername, password: adminPassword, }), }); const data = await response.json(); // Check if account is pending approval (can be 200 or error status) if (data.pendingApproval) { setAlertModal({ isOpen: true, title: 'Account Pending Approval', message: 'Your account is awaiting administrator approval. You will be able to log in once your registration has been approved.', variant: 'warning', }); return; } if (!response.ok) { throw new Error(data.error || data.message || 'Login failed'); } // Store tokens localStorage.setItem('accessToken', data.accessToken); localStorage.setItem('refreshToken', data.refreshToken); localStorage.setItem('user', JSON.stringify(data.user)); // Update auth context immediately setAuthData(data.user, data.accessToken); // Redirect to intended page or homepage const redirect = searchParams.get('redirect') || '/'; router.push(redirect); } catch (err) { setError(err instanceof Error ? err.message : 'Login failed. Please try again.'); } finally { setIsLoggingIn(false); } }; const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); setIsLoggingIn(true); setError(null); // Validation if (registerPassword !== registerConfirmPassword) { setError('Passwords do not match'); setIsLoggingIn(false); return; } if (!authProviders?.allowWeakPassword && registerPassword.length < 8) { setError('Password must be at least 8 characters'); setIsLoggingIn(false); return; } try { const response = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: registerUsername, password: registerPassword, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Registration failed'); } // Check if pending approval if (data.pendingApproval) { setError(null); setShowRegisterForm(false); setAlertModal({ isOpen: true, title: 'Registration Pending', message: 'Your account has been created successfully! An administrator needs to approve your registration before you can log in. You will be notified once your account is approved.', variant: 'warning', }); return; } // Auto-login after successful registration if (data.success && data.accessToken) { localStorage.setItem('accessToken', data.accessToken); localStorage.setItem('refreshToken', data.refreshToken); localStorage.setItem('user', JSON.stringify(data.user)); // Update auth context setAuthData(data.user, data.accessToken); // Redirect to intended page or homepage const redirect = searchParams.get('redirect') || '/'; router.push(redirect); } } catch (err) { setError(err instanceof Error ? err.message : 'Registration failed. Please try again.'); } finally { setIsLoggingIn(false); } }; if (authLoading) { return (
Loading...
); } // Generate random positions for covers const generateCoverPosition = (index: number, total: number) => { // Create a seeded random for consistent positions per index const random = (seed: number) => { const x = Math.sin(seed) * 10000; return x - Math.floor(x); }; // Different animation types const animations = ['animate-float-slow', 'animate-float-medium', 'animate-float-fast']; const animation = animations[index % 3]; // Random size between 80-160px const size = 80 + random(index * 7) * 80; // Random position (0-100% for both axes) const top = random(index * 13) * 100; const left = random(index * 17) * 100; // Random opacity (0.15-0.35 for subtle layering) const opacity = 0.15 + random(index * 23) * 0.2; // Random delay (0-10s) const delay = random(index * 29) * 10; // Layer depth (z-index) - some in front, some behind const zIndex = Math.floor(random(index * 31) * 20); return { top: `${top}%`, left: `${left}%`, size: Math.floor(size), animation, delay: `${delay.toFixed(1)}s`, opacity: parseFloat(opacity.toFixed(2)), zIndex, }; }; return (
{/* Floating audiobook covers background */}
{bookCovers.length > 0 ? ( <> {/* Floating real book covers - use fewer on mobile (30) vs desktop (100) for better performance */} {bookCovers.slice(0, isMobile ? 30 : 100).map((book, index) => { const pos = generateCoverPosition(index, bookCovers.length); const style: React.CSSProperties = { animationDelay: pos.delay, opacity: pos.opacity, zIndex: pos.zIndex, }; return (
{book.title} { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }} />
); })} ) : ( <> {/* Fallback decorative floating elements if covers don't load */}
)}
{/* Main content - high z-index to appear above all floating covers */}
{/* Login card */}
{/* Logo/Title */}

ReadMeABook

Your Personal Audiobook Library Manager

{/* Description */}

{(() => { if (!authProviders) return 'Your Personal Audiobook Library Manager'; const { backendMode, automationEnabled } = authProviders; // Audiobookshelf mode if (backendMode === 'audiobookshelf') { if (automationEnabled) { return "Request audiobooks and they'll automatically download and appear in your Audiobookshelf library"; } return "Request audiobooks for your Audiobookshelf library"; } // Plex mode (default) if (automationEnabled) { return "Request audiobooks and they'll automatically download and appear in your Plex library"; } return "Request audiobooks for your Plex library"; })()}

{/* Error message */} {error && (

{error}

)} {!authProviders ? (
Loading...
) : ( <> {/* Plex Login button */} {authProviders.providers.includes('plex') && ( <>

You'll be redirected to Plex to authorize this application

)} {/* OIDC Login button */} {authProviders.providers.includes('oidc') && authProviders.oidcProviderName && ( <>

You'll be redirected to {authProviders.oidcProviderName} to authenticate

)} {/* Divider if we have both OIDC and local auth */} {authProviders.providers.includes('oidc') && authProviders.providers.includes('local') && (
or
)} {/* Local auth login/register form */} {authProviders.providers.includes('local') && ( <> {showRegisterForm ? ( /* Registration Form */

Create Account

setRegisterUsername(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="Choose a username" required minLength={3} autoComplete="username" />

At least 3 characters

setRegisterPassword(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="••••••••" required minLength={authProviders?.allowWeakPassword ? 1 : 8} autoComplete="new-password" /> {!authProviders?.allowWeakPassword && (

At least 8 characters

)}
setRegisterConfirmPassword(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="••••••••" required minLength={authProviders?.allowWeakPassword ? 1 : 8} autoComplete="new-password" />
{authProviders.registrationEnabled && (
)}
) : ( /* Login Form */
setAdminUsername(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="username" required autoComplete="username" />
setAdminPassword(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="••••••••" required autoComplete="current-password" />
{authProviders.registrationEnabled && (
)}
)} )} {/* Admin Login toggle for Plex mode */} {authProviders.providers.includes('plex') && !authProviders.providers.includes('local') && !authProviders.localLoginDisabled && ( <>
or
{showAdminLogin && (
setAdminUsername(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="admin" required autoComplete="username" />
setAdminPassword(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="••••••••" required autoComplete="current-password" />
)} )} )}
{/* Alert Modal */} setAlertModal({ ...alertModal, isOpen: false })} title={alertModal.title} message={alertModal.message} variant={alertModal.variant} /> {/* CSS animations for floating book covers */}
); } export default function LoginPage() { return (
Loading...
}> ); }