Add persistence layer: TOTP auth, game history, restore
- db/ package: async SQLAlchemy engine + Player/Game/Guess models - api/auth.py: passwordless TOTP login (pyotp), session token via socket auth - api/history.py: record guesses/points, DB-backed standings, restore unfinished games on startup, host-only end_game - api/__init__.py: auth-gated handlers, accounts map, rejoin via account - frontend: Auth (QR + code) and History pages, resume/end-game in lobby/table - docker-compose: real PostgreSQL service wired via DATABASE_URL - tests_history.py for the persistence/auth layer; refresh CLAUDE.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
beaf142ee4
commit
30c32b7714
24 files changed
+1446
-87
No files matched your search
@@ -1,23 +1,54 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import type { GameInfo, GameStatusPayload, Hand, MyPlayer } from '../types';
|
||||
import type {
|
||||
Account,
|
||||
GameDetail,
|
||||
GameInfo,
|
||||
GameStatusPayload,
|
||||
Hand,
|
||||
HistoryGame,
|
||||
MyPlayer,
|
||||
Registration,
|
||||
} from '../types';
|
||||
|
||||
export const socket = io({ autoConnect: false });
|
||||
|
||||
/** Attach (or clear) the session token sent in the Socket.IO `auth` handshake
|
||||
* so the server can auto-login this connection (now and on every reconnect). */
|
||||
export function setAuthToken(token: string | null) {
|
||||
socket.auth = token ? { token } : {};
|
||||
}
|
||||
|
||||
// Module-level state for the create→register chain and gid resolution on register_player
|
||||
let _pendingGid: string | null = null;
|
||||
let _createName: string | null = null;
|
||||
|
||||
export const emit = {
|
||||
// auth
|
||||
registerAccount: (username: string) => socket.emit('register_account', username),
|
||||
confirmAccount: (username: string, code: string) =>
|
||||
socket.emit('confirm_account', username, code),
|
||||
login: (username: string, code: string) => socket.emit('login', username, code),
|
||||
// history
|
||||
getPlayerHistory: () => socket.emit('get_player_history'),
|
||||
getGameDetail: (gid: string) => socket.emit('get_game_detail', gid),
|
||||
// lobby / game
|
||||
createGame: (name: string) => {
|
||||
_createName = name;
|
||||
socket.emit('create_game', name);
|
||||
},
|
||||
registerPlayer: (gid: string, name: string) => {
|
||||
registerPlayer: (gid: string) => {
|
||||
_pendingGid = gid;
|
||||
socket.emit('register_player', gid, name);
|
||||
socket.emit('register_player', gid);
|
||||
},
|
||||
// Re-seat into an already-started game via the logged-in account (e.g. after
|
||||
// a server restart). The server replies with register_player + game_status.
|
||||
rejoinGame: (gid: string) => {
|
||||
_pendingGid = gid;
|
||||
socket.emit('rejoin_game', gid);
|
||||
},
|
||||
leaveGame: () => socket.emit('leave_game'),
|
||||
endGame: (gid: string) => socket.emit('end_game', gid),
|
||||
startGame: (gid: string) => socket.emit('start_game', gid),
|
||||
reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token),
|
||||
gameStatus: () => socket.emit('game_status'),
|
||||
@@ -31,10 +62,34 @@ export function setupSocketListeners() {
|
||||
useGameStore.getState().setGames(games);
|
||||
});
|
||||
|
||||
// Registration step 1: server returns the otpauth URI to render as a QR code.
|
||||
socket.on('register_account', (data: Registration) => {
|
||||
useGameStore.getState().setRegistration(data);
|
||||
});
|
||||
|
||||
// Login / confirm / auto-login. A token is present on explicit login (persist it);
|
||||
// auto-login on connect omits it (we keep the stored one).
|
||||
socket.on('login', ({ player, token }: { player: Account; token?: string }) => {
|
||||
if (token) {
|
||||
localStorage.setItem('bridzik_token', token);
|
||||
setAuthToken(token);
|
||||
}
|
||||
useGameStore.getState().setAccount(player);
|
||||
useGameStore.getState().setRegistration(null);
|
||||
});
|
||||
|
||||
socket.on('get_player_history', ({ games }: { games: HistoryGame[] }) => {
|
||||
useGameStore.getState().setHistory(games);
|
||||
});
|
||||
|
||||
socket.on('get_game_detail', (detail: GameDetail) => {
|
||||
useGameStore.getState().setGameDetail(detail);
|
||||
});
|
||||
|
||||
socket.on('create_game', ({ gid }: { gid: string }) => {
|
||||
// Auto-chain: register into the just-created game using the stored name
|
||||
// Auto-chain: register into the just-created game (identity comes from the session).
|
||||
if (_createName !== null) {
|
||||
emit.registerPlayer(gid, _createName);
|
||||
emit.registerPlayer(gid);
|
||||
_createName = null;
|
||||
}
|
||||
});
|
||||
@@ -55,6 +110,12 @@ export function setupSocketListeners() {
|
||||
useGameStore.getState().setGameStatus(payload);
|
||||
});
|
||||
|
||||
// Host ended the game permanently -> drop local game state, back to lobby.
|
||||
socket.on('game_ended', () => {
|
||||
useGameStore.getState().reset();
|
||||
localStorage.removeItem('bridzik_player');
|
||||
});
|
||||
|
||||
socket.on('player_cards', ({ cards }: { cards: Hand }) => {
|
||||
useGameStore.getState().setHand(cards);
|
||||
});
|
||||
@@ -66,4 +127,9 @@ export function setupSocketListeners() {
|
||||
socket.on('error', ({ error }: { error: string }) => {
|
||||
useGameStore.getState().setError(error);
|
||||
});
|
||||
|
||||
// Surface connection failures instead of silently buffering emits.
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
useGameStore.getState().setError(`Spojenie so serverom zlyhalo: ${err.message}`);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user