Security & UX Update: Custom session codes, admin session take-over, secure presentation tokens, and SVG/CSS branding assets

This commit is contained in:
2026-04-16 18:25:03 +02:00
parent 4b6e43ba65
commit fe3c45b8bf
6 changed files with 214 additions and 134 deletions
+5
View File
@@ -11,6 +11,11 @@
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
}
</style>
</head>
<body class="bg-stone-100 text-gray-900 min-h-screen">
+26 -3
View File
@@ -5,7 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quizalarm</title>
<link rel="icon" href="/img/logo.png">
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚨</text></svg>">
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
@@ -13,10 +14,13 @@
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111111 url('/img/bg.png') center/cover fixed;
background: #111111;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
}
/* Answer button base colors */
.answer-a {
background: #dc2626;
}
@@ -49,6 +53,7 @@
background: #15803d;
}
/* Selected state */
.answer-a.selected,
.answer-b.selected,
.answer-c.selected,
@@ -57,6 +62,7 @@
transform: scale(1.03);
}
/* Disabled state */
.answer-a.disabled,
.answer-b.disabled,
.answer-c.disabled,
@@ -65,6 +71,23 @@
cursor: not-allowed;
}
/* Ensure Tailwind doesn't override answer button styles */
button.answer-a,
button.answer-b,
button.answer-c,
button.answer-d {
border: none;
}
button.answer-a.selected,
button.answer-b.selected,
button.answer-c.selected,
button.answer-d.selected {
outline: 4px solid white;
transform: scale(1.03);
}
/* Fire gradient for headings */
.fire-gradient {
background: linear-gradient(135deg, #dc2626, #f59e0b);
-webkit-background-clip: text;
+116 -97
View File
@@ -228,7 +228,7 @@ function StatsPage() {
}
/* ================================================================== */
/* Live Setup Page */
/* Live Setup Page — with custom code + reconnect */
/* ================================================================== */
function LiveSetupPage({ config, token, onSessionCreated }) {
@@ -236,21 +236,30 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
const [shuffle, setShuffle] = useState(true);
const [scoring, setScoring] = useState('binary');
const [timeLimit, setTimeLimit] = useState(30);
const [customCode, setCustomCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Reconnect state
const [reconnectCode, setReconnectCode] = useState('');
const [reconnectError, setReconnectError] = useState('');
const [reconnecting, setReconnecting] = useState(false);
const create = () => {
if (!setId) return setError('Bitte ein Fragenset waehlen');
setLoading(true);
setError('');
const ws = new WebSocket(wsUrl());
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'admin-create', token, setId, options: { shuffle, scoring, timeLimit } }));
ws.send(JSON.stringify({
type: 'admin-create', token, setId,
options: { shuffle, scoring, timeLimit },
customCode: customCode.trim() || null,
}));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'session-created') {
// IMPORTANT: Release WS control before handing off
ws.onmessage = null;
onSessionCreated(msg.code, ws, {
code: msg.code,
@@ -259,6 +268,7 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
options: msg.options,
players: [],
playerCount: 0,
presentToken: msg.presentToken,
});
} else if (msg.type === 'error') {
setError(msg.message);
@@ -269,38 +279,96 @@ function LiveSetupPage({ config, token, onSessionCreated }) {
ws.onerror = () => { setError('WebSocket Verbindungsfehler'); setLoading(false); };
};
const reconnect = () => {
const code = reconnectCode.trim().replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (!code) return setReconnectError('Bitte Code eingeben');
setReconnecting(true);
setReconnectError('');
const ws = new WebSocket(wsUrl());
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'admin-joined') {
ws.onmessage = null;
onSessionCreated(msg.session.code, ws, msg.session);
} else if (msg.type === 'error') {
setReconnectError(msg.message);
setReconnecting(false);
ws.close();
}
};
ws.onerror = () => { setReconnectError('Verbindungsfehler'); setReconnecting(false); };
};
return (
<div className="bg-white rounded-xl shadow p-6 max-w-md mx-auto border-t-4 border-red-600">
<h2 className="text-xl font-bold mb-4">🎮 Live-Session erstellen</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Fragenset</label>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={setId} onChange={(e) => setSetId(e.target.value)}>
<option value=""> Bitte wählen </option>
{(config.sets || []).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-red-600" />
Fragen mischen
</label>
<div>
<label className="block text-sm font-medium mb-1">Punktevergabe</label>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={scoring} onChange={(e) => setScoring(e.target.value)}>
<option value="binary">Binär (1000 / 0)</option>
<option value="time">Zeitbasiert (schneller = mehr Punkte)</option>
</select>
</div>
{scoring === 'time' && (
<div className="space-y-6 max-w-md mx-auto">
{/* Create new session */}
<div className="bg-white rounded-xl shadow p-6 border-t-4 border-red-600">
<h2 className="text-xl font-bold mb-4">🎮 Neue Live-Session</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Sekunden pro Frage</label>
<input type="number" className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
<label className="block text-sm font-medium mb-1">Fragenset</label>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={setId} onChange={(e) => setSetId(e.target.value)}>
<option value=""> Bitte wählen </option>
{(config.sets || []).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{error && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</p>}
<button onClick={create} disabled={loading} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 disabled:opacity-50">
{loading ? 'Erstelle...' : 'Session erstellen'}
</button>
<div>
<label className="block text-sm font-medium mb-1">Session-Code <span className="text-gray-400">(optional)</span></label>
<input
className="w-full p-3 border rounded-lg focus:border-red-500 outline-none uppercase tracking-wider"
placeholder="z.B. FEUERWEHR oder leer für Zufall"
value={customCode}
onChange={(e) => setCustomCode(e.target.value)}
maxLength={20}
/>
<p className="text-xs text-gray-400 mt-1">Nur Buchstaben und Zahlen. Mind. 3 Zeichen. Leer = zufällig generiert.</p>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={shuffle} onChange={() => setShuffle(!shuffle)} className="w-5 h-5 accent-red-600" />
Fragen mischen
</label>
<div>
<label className="block text-sm font-medium mb-1">Punktevergabe</label>
<select className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={scoring} onChange={(e) => setScoring(e.target.value)}>
<option value="binary">Binär (1000 / 0)</option>
<option value="time">Zeitbasiert (schneller = mehr Punkte)</option>
</select>
</div>
{scoring === 'time' && (
<div>
<label className="block text-sm font-medium mb-1">Sekunden pro Frage</label>
<input type="number" className="w-full p-3 border rounded-lg focus:border-red-500 outline-none" value={timeLimit} onChange={(e) => setTimeLimit(parseInt(e.target.value) || 30)} min={5} max={120} />
</div>
)}
{error && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</p>}
<button onClick={create} disabled={loading} className="w-full py-3 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 disabled:opacity-50">
{loading ? 'Erstelle...' : 'Session erstellen'}
</button>
</div>
</div>
{/* Reconnect to existing session */}
<div className="bg-white rounded-xl shadow p-6 border-t-4 border-amber-500">
<h2 className="text-xl font-bold mb-4">🔄 Bestehende Session übernehmen</h2>
<p className="text-sm text-gray-500 mb-3">Falls die Verbindung unterbrochen wurde, kannst du dich hier wieder mit einer laufenden Session verbinden.</p>
<div className="space-y-3">
<input
className="w-full p-3 border rounded-lg focus:border-amber-500 outline-none uppercase tracking-wider text-center text-lg"
placeholder="Session-Code eingeben"
value={reconnectCode}
onChange={(e) => setReconnectCode(e.target.value)}
maxLength={20}
onKeyDown={(e) => e.key === 'Enter' && reconnect()}
/>
{reconnectError && <p className="text-red-500 text-sm bg-red-50 p-2 rounded">{reconnectError}</p>}
<button onClick={reconnect} disabled={reconnecting} className="w-full py-3 bg-amber-500 text-white rounded-lg font-bold hover:bg-amber-600 disabled:opacity-50">
{reconnecting ? 'Verbinde...' : 'Verbinden'}
</button>
</div>
</div>
</div>
);
@@ -321,26 +389,17 @@ function LiveControlPage({ token, code, ws, initialSession }) {
useEffect(() => {
if (!ws) return;
// Take over the WS completely with onmessage (no addEventListener confusion)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
console.log('[admin-control] Received:', msg.type, msg);
switch (msg.type) {
case 'session-created':
setSession({
code: msg.code,
setName: msg.setName,
questionCount: msg.questionCount,
options: msg.options,
players: [],
playerCount: 0,
code: msg.code, setName: msg.setName, questionCount: msg.questionCount,
options: msg.options, players: [], playerCount: 0, presentToken: msg.presentToken,
});
setPhase('waiting');
break;
case 'admin-joined':
console.log('[admin-control] Session synced:', msg.session.players, 'count:', msg.session.playerCount);
setSession(msg.session);
setLeaderboardData(msg.session.leaderboard || []);
if (msg.session.status === 'waiting') setPhase('waiting');
@@ -348,86 +407,46 @@ function LiveControlPage({ token, code, ws, initialSession }) {
else if (msg.session.status === 'showing-result') setPhase('result');
else setPhase('question');
break;
case 'player-joined':
case 'player-left':
console.log('[admin-control] Player update:', msg.players, 'count:', msg.playerCount);
setSession((s) => {
const base = s || { code };
return { ...base, players: msg.players, playerCount: msg.playerCount };
});
break;
case 'game-start':
setPhase('active');
break;
case 'question':
setPhase('question');
setAnswerCount(0);
setResult(null);
break;
case 'answer-count':
setAnswerCount(msg.count);
break;
case 'game-start': setPhase('active'); break;
case 'question': setPhase('question'); setAnswerCount(0); setResult(null); break;
case 'answer-count': setAnswerCount(msg.count); break;
case 'question-result':
setResult(msg);
setLeaderboardData(msg.leaderboard);
setPhase('result');
break;
setResult(msg); setLeaderboardData(msg.leaderboard); setPhase('result'); break;
case 'game-end':
setLeaderboardData(msg.leaderboard);
setPhase('ended');
break;
case 'error':
console.error('[admin-control] Error:', msg.message);
break;
setLeaderboardData(msg.leaderboard); setPhase('ended'); break;
case 'error': console.error('Admin error:', msg.message); break;
}
};
// Sync current session state from server
console.log('[admin-control] Sending admin-join for code:', code);
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
// Safety net: periodically re-sync session state every 5 seconds while waiting
refreshRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'admin-join', token, code }));
}
}, 5000);
return () => {
clearInterval(refreshRef.current);
ws.onmessage = null;
};
return () => { clearInterval(refreshRef.current); ws.onmessage = null; };
}, [ws, code, token]);
// Stop polling once game starts
useEffect(() => {
if (phase !== 'waiting') {
clearInterval(refreshRef.current);
}
if (phase !== 'waiting') clearInterval(refreshRef.current);
}, [phase]);
const start = () => {
console.log('[admin-control] Starting game');
ws.send(JSON.stringify({ type: 'admin-start', token, code }));
};
const showResultAction = () => {
ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
};
const next = () => {
ws.send(JSON.stringify({ type: 'admin-next', token, code }));
};
const end = () => {
ws.send(JSON.stringify({ type: 'admin-end', token, code }));
};
const start = () => ws.send(JSON.stringify({ type: 'admin-start', token, code }));
const showResultAction = () => ws.send(JSON.stringify({ type: 'admin-show-result', token, code }));
const next = () => ws.send(JSON.stringify({ type: 'admin-next', token, code }));
const end = () => ws.send(JSON.stringify({ type: 'admin-end', token, code }));
const presentUrl = `${location.origin}/present?token=${encodeURIComponent(token)}#${code}`;
const presentToken = session?.presentToken || '';
const presentUrl = `${location.origin}/present?pt=${encodeURIComponent(presentToken)}#${code}`;
const playerCount = session?.players?.length || session?.playerCount || 0;
return (
+3 -9
View File
@@ -21,7 +21,8 @@ function PresentApp() {
useEffect(() => {
const code = location.hash.replace('#', '');
const params = new URLSearchParams(location.search);
const token = params.get('token');
// Accept both ?pt= (present token) and ?token= (legacy/admin password)
const token = params.get('pt') || params.get('token');
if (!code || !token) {
setPhase('need-config');
@@ -176,18 +177,12 @@ function PresentApp() {
<h2 className="text-3xl font-bold text-center mb-6">{question?.frage}</h2>
{question?.bild && <img src={question.bild} className="max-h-48 mx-auto rounded-xl mb-6" alt="" />}
{/* Answer options with feedback colors + vote counts */}
{question?.typ?.toLowerCase() !== 'freitext' && (
<div className="grid grid-cols-2 gap-4 mb-8">
{(question?.antworten || []).map((a) => {
const isCorrect = correctKeys.has(a.key);
const count = result?.stats?.[a.key] || 0;
let cls;
if (isCorrect) {
cls = 'bg-green-600 ring-4 ring-green-400';
} else {
cls = 'bg-gray-700 opacity-50';
}
const cls = isCorrect ? 'bg-green-600 ring-4 ring-green-400' : 'bg-gray-700 opacity-50';
return (
<div key={a.key} className={`${cls} rounded-2xl p-6 text-2xl font-bold flex items-center justify-between`}>
<span>{isCorrect ? '✓ ' : ''}{a.text}</span>
@@ -206,7 +201,6 @@ function PresentApp() {
</div>
)}
{/* Leaderboard */}
<div className="flex-1">
<h3 className="text-2xl font-bold text-center mb-4">🏆 Leaderboard</h3>
<div className="max-w-xl mx-auto">
+6 -2
View File
@@ -14,10 +14,13 @@
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111111 url('/img/bg.png') center/cover fixed;
background: #111111;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(220, 38, 38, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
}
/* Answer colors for presentation view */
.answer-a {
background: #dc2626;
}
@@ -34,6 +37,7 @@
background: #16a34a;
}
/* Fire gradient for headings */
.fire-gradient {
background: linear-gradient(135deg, #dc2626, #f59e0b);
-webkit-background-clip: text;