From 5a12341d4c5ecb4af5c68a3f738b283fff40256b Mon Sep 17 00:00:00 2001 From: Philip Date: Fri, 30 Jan 2026 16:13:39 -0800 Subject: [PATCH] feat: Implement host authentication and settings management Introduces a new authentication flow for the host, requiring a password to access host-specific features. This commit also adds a dedicated settings section within the host view, allowing administrators to configure essential application parameters such as the API key for AI question generation and the join URL for players. The backend has been updated to include a new `settings` table in the database to persist these configurations. The Gemini service is refactored to accept the API key as a parameter, enhancing flexibility and security. UI components like `HostView` and `App.tsx` are modified to integrate the new authentication and settings management functionalities. Key changes include: - Password-based authentication for host access. - A new settings interface for API key and join URL management. - Database schema update with a `settings` table. - Refactoring `geminiService` to accept API key. - UI adjustments for login and settings screens. --- App.tsx | 61 ++++- api.php | 58 ++++- components/HostView.tsx | 476 ++++++++++++++++++-------------------- context/GameContext.tsx | 149 ++++++------ database.sql | 16 +- services/geminiService.ts | 7 +- 6 files changed, 425 insertions(+), 342 deletions(-) diff --git a/App.tsx b/App.tsx index 25c7fde..85c8d06 100644 --- a/App.tsx +++ b/App.tsx @@ -1,24 +1,68 @@ import React, { useState, useEffect, Suspense } from 'react'; import { GameProvider, useGame } from './context/GameContext'; -import { Monitor, Smartphone, LayoutDashboard, Loader2, RefreshCw } from 'lucide-react'; +import { Monitor, Smartphone, LayoutDashboard, Loader2, RefreshCw, Lock } from 'lucide-react'; -// Lazy load components to reduce initial bundle size const HostView = React.lazy(() => import('./components/HostView').then(module => ({ default: module.HostView }))); const PlayerView = React.lazy(() => import('./components/PlayerView').then(module => ({ default: module.PlayerView }))); const SpectatorView = React.lazy(() => import('./components/SpectatorView').then(module => ({ default: module.SpectatorView }))); -// Inner Component to access Context +const LoginScreen: React.FC = () => { + const { login } = useGame(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + const success = await login(password); + if (!success) { + setError('Invalid Password'); + } + setLoading(false); + }; + + return ( +
+
+
+ +
+

Host Access

+
+ setPassword(e.target.value)} + className="w-full p-3 border border-slate-300 rounded-lg mb-4 text-slate-800 focus:ring-2 focus:ring-indigo-500 outline-none" + placeholder="Enter Admin Password" + autoFocus + /> + {error &&

{error}

} + +
+
+
+ ); +}; + const AppContent: React.FC = () => { - const { setIsHost, isSyncing, isHost } = useGame(); + const { setIsHost, isSyncing, isAuthenticated } = useGame(); const [view, setView] = useState<'HOST' | 'PLAYER' | 'SPECTATOR'>('SPECTATOR'); - // Simple hash routing for demo purposes useEffect(() => { const handleHashChange = () => { const hash = window.location.hash; if (hash === '#host') { setView('HOST'); - setIsHost(true); // Promote this session to Host Authority + setIsHost(true); } else if (hash === '#player') { setView('PLAYER'); @@ -42,7 +86,8 @@ const AppContent: React.FC = () => { const renderView = () => { switch (view) { - case 'HOST': return ; + case 'HOST': + return isAuthenticated ? : ; case 'PLAYER': return ; case 'SPECTATOR': return ; default: return ; @@ -127,4 +172,4 @@ const NavButton: React.FC = ({ active, onClick, icon, label }) = ); -export default App; +export default App; \ No newline at end of file diff --git a/api.php b/api.php index 81e052d..acc701a 100644 --- a/api.php +++ b/api.php @@ -22,7 +22,6 @@ $options = [ try { $pdo = new PDO($dsn, $user, $pass, $options); } catch (\PDOException $e) { - // Fallback for when DB isn't configured yet so app doesn't crash completely echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]); exit; } @@ -43,21 +42,24 @@ try { echo $row['game_data'] ?: '{}'; } elseif ($action === 'getIntents') { - // Transaction to read and delete to ensure processed once $pdo->beginTransaction(); $stmt = $pdo->query("SELECT * FROM player_intents ORDER BY created_at ASC"); $intents = $stmt->fetchAll(); if ($intents) { - $pdo->exec("DELETE FROM player_intents"); // Clear queue after reading + $pdo->exec("DELETE FROM player_intents"); } $pdo->commit(); - - // Parse payloads foreach ($intents as &$intent) { $intent['payload'] = json_decode($intent['payload'], true); } echo json_encode($intents); } + elseif ($action === 'getPublicSettings') { + // Only return the Join URL for public clients (Spectators/Players) + $stmt = $pdo->query("SELECT join_url FROM settings WHERE id = 1"); + $row = $stmt->fetch(); + echo json_encode(['joinUrl' => $row['join_url'] ?? '']); + } } elseif ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($action === 'pushState') { @@ -73,6 +75,52 @@ try { $stmt->execute([$type, $payload]); echo json_encode(['success' => true]); } + elseif ($action === 'login') { + $password = $input['password']; + $stmt = $pdo->query("SELECT * FROM settings WHERE id = 1"); + $row = $stmt->fetch(); + + $loginSuccess = false; + + if ($row) { + // 1. Check secure hash + if (password_verify($password, $row['admin_password'])) { + $loginSuccess = true; + } + // 2. Check plaintext fallback (First Run / Recovery) + elseif ($row['admin_password'] === $password) { + $loginSuccess = true; + // Auto-upgrade to secure hash + $newHash = password_hash($password, PASSWORD_DEFAULT); + $upd = $pdo->prepare("UPDATE settings SET admin_password = ? WHERE id = 1"); + $upd->execute([$newHash]); + } + } + + if ($loginSuccess) { + echo json_encode([ + 'success' => true, + 'apiKey' => $row['api_key'], + 'joinUrl' => $row['join_url'] + ]); + } else { + echo json_encode(['success' => false, 'error' => 'Invalid password']); + } + } + elseif ($action === 'updateSettings') { + $sql = "UPDATE settings SET api_key = ?, join_url = ? WHERE id = 1"; + $params = [$input['apiKey'], $input['joinUrl']]; + + if (!empty($input['newPassword'])) { + $sql = "UPDATE settings SET api_key = ?, join_url = ?, admin_password = ? WHERE id = 1"; + $hash = password_hash($input['newPassword'], PASSWORD_DEFAULT); + $params[] = $hash; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + echo json_encode(['success' => true]); + } } } catch (Exception $e) { http_response_code(500); diff --git a/components/HostView.tsx b/components/HostView.tsx index 50bec51..dc23c27 100644 --- a/components/HostView.tsx +++ b/components/HostView.tsx @@ -3,18 +3,18 @@ import { useGame } from '../context/GameContext'; import { GamePhase } from '../types'; import type { Question } from '../types'; import { Soundboard } from './Soundboard'; -import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw, QrCode } from 'lucide-react'; +import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw, Settings, Save } from 'lucide-react'; import { generateQuestions } from '../services/geminiService'; export const HostView: React.FC = () => { const { - gameState, activeGameName, players, teams, buzzQueue, questions, games, joinUrl, - setJoinUrl, approvePlayer, startGame, startCountdown, openBuzzers, + gameState, activeGameName, players, teams, buzzQueue, questions, games, joinUrl, apiKey, + approvePlayer, startGame, startCountdown, openBuzzers, resolveBuzz, rectifyBuzz, skipQuestion, nextPhase, resetGame, - createGame, updateGame, deleteGame, loadGameToLive + createGame, updateGame, deleteGame, loadGameToLive, updateSettings } = useGame(); - const [activeTab, setActiveTab] = useState<'GAME' | 'PLAYERS' | 'LIBRARY'>('GAME'); + const [activeTab, setActiveTab] = useState<'GAME' | 'PLAYERS' | 'LIBRARY' | 'SETTINGS'>('GAME'); // Library State const [editingGameId, setEditingGameId] = useState(null); @@ -26,10 +26,21 @@ export const HostView: React.FC = () => { const [isAddingQuestion, setIsAddingQuestion] = useState(false); const [manualQ, setManualQ] = useState({ text: '', answer: '', points: 10, category: '', mediaUrl: '' }); + // Settings State + const [settingsForm, setSettingsForm] = useState({ + joinUrl: joinUrl, + apiKey: apiKey, + newPassword: '' + }); + + // Update local form state when context changes + React.useEffect(() => { + setSettingsForm(prev => ({ ...prev, joinUrl: joinUrl, apiKey: apiKey })); + }, [joinUrl, apiKey]); + const currentQ = questions[gameState.currentQuestionIndex]; const isPreGame = gameState.currentQuestionIndex === -1; const isGameOver = gameState.currentQuestionIndex >= questions.length; - // Correctly identify if we have finished all questions and are just lingering on the last leaderboard const isLastQuestionPhase = gameState.currentQuestionIndex === questions.length - 1; // --- Handlers --- @@ -44,7 +55,7 @@ export const HostView: React.FC = () => { const handleAiGenerate = async (gameId: string) => { if (!aiTopic) return; setIsGenerating(true); - const newQuestions = await generateQuestions(aiTopic); + const newQuestions = await generateQuestions(aiTopic, apiKey); // Pass apiKey if (newQuestions.length > 0) { const game = games.find(g => g.id === gameId); if (game) { @@ -53,7 +64,7 @@ export const HostView: React.FC = () => { alert(`Added ${newQuestions.length} questions!`); setAiTopic(''); } else { - alert("Failed to generate. Check API Key or try again."); + alert("Failed to generate. Check API Key in Settings."); } setIsGenerating(false); }; @@ -73,7 +84,6 @@ export const HostView: React.FC = () => { if (!manualQ.text || !manualQ.answer) return; const game = games.find(g => g.id === gameId); if (game) { - // Detect Media Type let mediaType: 'image' | 'video' | undefined = undefined; if (manualQ.mediaUrl) { if (manualQ.mediaUrl.startsWith('data:image') || manualQ.mediaUrl.match(/\.(jpeg|jpg|gif|png)$/i)) { @@ -81,7 +91,6 @@ export const HostView: React.FC = () => { } else if (manualQ.mediaUrl.includes('youtube') || manualQ.mediaUrl.includes('youtu.be')) { mediaType = 'video'; } else { - // Fallback assumption for uploads vs links mediaType = 'image'; } } @@ -109,12 +118,16 @@ export const HostView: React.FC = () => { }; const handleLoadGame = (gameId: string) => { - // Removed confirm() as it can block execution or be annoying loadGameToLive(gameId); setActiveTab('GAME'); }; - // Find the winner of the current round (if any) + const handleSaveSettings = () => { + updateSettings(settingsForm.apiKey, settingsForm.joinUrl, settingsForm.newPassword); + setSettingsForm(prev => ({ ...prev, newPassword: '' })); // Clear password field + alert("Settings Updated!"); + }; + const currentWinnerBuzz = buzzQueue.find(b => b.status === 'CORRECT'); const currentWinner = currentWinnerBuzz ? players.find(p => p.id === currentWinnerBuzz.playerId) : null; @@ -127,7 +140,6 @@ export const HostView: React.FC = () => {
QM

Host Dashboard

- {/* Active Game Display */}
ACTIVE GAME: @@ -155,7 +167,7 @@ export const HostView: React.FC = () => {
- {/* Left Sidebar - Navigation & Soundboard */} + {/* Left Sidebar */}