auth reload and better tour handling

This commit is contained in:
Maieul BOYER 2026-01-13 16:48:20 +01:00 committed by apetitco
parent deb391807a
commit 30540bfe92
7 changed files with 188 additions and 102 deletions

View file

@ -4,7 +4,6 @@ import cookie from "js-cookie";
import { ensureWindowState, isNullish } from "@app/utils"; import { ensureWindowState, isNullish } from "@app/utils";
import { handleRoute, navigateTo } from "./routing"; import { handleRoute, navigateTo } from "./routing";
cookie.remove("pkce"); cookie.remove("pkce");
const headerProfile = const headerProfile =
document.querySelector<HTMLDivElement>("#header-profile")!; document.querySelector<HTMLDivElement>("#header-profile")!;
@ -29,6 +28,7 @@ declare module "ft_state" {
user: User | null; user: User | null;
_headerProfile: boolean; _headerProfile: boolean;
_reloadOnAuthChange: boolean; _reloadOnAuthChange: boolean;
lastAuthChange: number;
} }
} }
@ -41,12 +41,16 @@ export function isLogged(): boolean {
} }
export function setUser(newUser: User | null) { export function setUser(newUser: User | null) {
let sendEvent = (window.__state.user?.id !== newUser?.id); let sendEvent = window.__state.user?.id !== newUser?.id;
window.__state.user = newUser; window.__state.user = newUser;
updateHeaderProfile(); updateHeaderProfile();
if (sendEvent) { if (sendEvent) {
const event = new CustomEvent("ft:userChange", { detail: isNullish(newUser) ? null : { id: newUser.id, name: newUser.name } }); const event = new CustomEvent("ft:userChange", {
detail: isNullish(newUser)
? null
: { id: newUser.id, name: newUser.name },
});
document.dispatchEvent(event); document.dispatchEvent(event);
} }
} }
@ -89,7 +93,9 @@ window.__state._headerProfile ??= false;
if (!window.__state._headerProfile) { if (!window.__state._headerProfile) {
headerProfile.addEventListener("click", () => { headerProfile.addEventListener("click", () => {
if (window.__state.user === null) { if (window.__state.user === null) {
navigateTo(`/login?returnTo=${encodeURIComponent(window.location.pathname)}`); navigateTo(
`/login?returnTo=${encodeURIComponent(window.location.pathname)}`,
);
} else { } else {
navigateTo("/profile"); navigateTo("/profile");
} }
@ -99,7 +105,12 @@ if (!window.__state._headerProfile) {
window.__state._reloadOnAuthChange ??= false; window.__state._reloadOnAuthChange ??= false;
if (!window.__state._reloadOnAuthChange) { if (!window.__state._reloadOnAuthChange) {
document.addEventListener("ft:userChange", () => handleRoute()); document.addEventListener("ft:userChange", () => {
// if the last forced auth change is less than 1000 sec old -> we do nothing
if (Date.now() - (window.__state.lastAuthChange ?? Date.now()) < 1000)
return;
handleRoute();
});
window.__state._reloadOnAuthChange = true; window.__state._reloadOnAuthChange = true;
} }
updateHeaderProfile(); updateHeaderProfile();

View file

@ -11,11 +11,13 @@ import client from "@app/api";
import cuteCat from "./cuteCat.png"; import cuteCat from "./cuteCat.png";
import loggedInHtml from "./alreadyLoggedin.html?raw"; import loggedInHtml from "./alreadyLoggedin.html?raw";
import totpHtml from "./totp.html?raw"; import totpHtml from "./totp.html?raw";
import { isNullish } from "@app/utils"; import { ensureWindowState, isNullish } from "@app/utils";
import { showError, showInfo, showSuccess } from "@app/toast"; import { showError, showInfo, showSuccess } from "@app/toast";
import { updateUser } from "@app/auth"; import { updateUser } from "@app/auth";
const TOTP_LENGTH = 6; const TOTP_LENGTH = 6;
ensureWindowState();
window.__state.lastAuthChange = Date.now();
async function handleOtp( async function handleOtp(
app: HTMLElement, app: HTMLElement,
@ -86,6 +88,7 @@ async function handleOtp(
}); });
if (res.kind === "success") { if (res.kind === "success") {
window.__state.lastAuthChange = Date.now();
Cookie.set("token", res.payload.token, { Cookie.set("token", res.payload.token, {
path: "/", path: "/",
sameSite: "lax", sameSite: "lax",
@ -178,6 +181,7 @@ async function handleLogin(
}); });
switch (res.kind) { switch (res.kind) {
case "success": { case "success": {
window.__state.lastAuthChange = Date.now();
Cookie.set("token", res.payload.token, { Cookie.set("token", res.payload.token, {
path: "/", path: "/",
sameSite: "lax", sameSite: "lax",
@ -219,6 +223,7 @@ async function handleLogin(
}); });
switch (res.kind) { switch (res.kind) {
case "success": { case "success": {
window.__state.lastAuthChange = Date.now();
Cookie.set("token", res.payload.token, { Cookie.set("token", res.payload.token, {
path: "/", path: "/",
sameSite: "lax", sameSite: "lax",

View file

@ -8,7 +8,13 @@ import {
import authHtml from "./pong.html?raw"; import authHtml from "./pong.html?raw";
import tourScoresHtml from "./tourTable.html?raw"; import tourScoresHtml from "./tourTable.html?raw";
import io from "socket.io-client"; import io from "socket.io-client";
import { JoinRes, type CSocket, type GameMove, type GameUpdate, type TourInfo } from "./socket"; import {
JoinRes,
type CSocket,
type GameMove,
type GameUpdate,
type TourInfo,
} from "./socket";
import { showError, showInfo, showSuccess } from "@app/toast"; import { showError, showInfo, showSuccess } from "@app/toast";
import { getUser as getSelfUser, type User } from "@app/auth"; import { getUser as getSelfUser, type User } from "@app/auth";
import { isNullish } from "@app/utils"; import { isNullish } from "@app/utils";
@ -86,6 +92,7 @@ function pongClient(
// [ ] shape sock // [ ] shape sock
// - [ ] joinGame (guid) -> ["ok"|"no, dont ever talk to my kid or me ever again you creep"]; // - [ ] joinGame (guid) -> ["ok"|"no, dont ever talk to my kid or me ever again you creep"];
// - [ ] launch newgame evt? // - [ ] launch newgame evt?
let inTournament = false;
return { return {
html: authHtml, html: authHtml,
@ -245,21 +252,19 @@ function pongClient(
// join game // join game
// --- // ---
if (game_req_join != null) { if (game_req_join != null) {
socket.emit('joinGame', game_req_join, socket.emit("joinGame", game_req_join, (res: JoinRes) => {
(res : JoinRes) => { switch (res) {
switch (res) { case JoinRes.yes:
case JoinRes.yes : showInfo("JoinRes = yes");
showInfo('JoinRes = yes'); quitChat();
quitChat(); break;
break; case JoinRes.no:
case JoinRes.no : showInfo("JoinRes = no");
showInfo('JoinRes = no'); break;
break; default:
default: showError("JoinRes switch fail:" + res);
showError('JoinRes switch fail:' + res);
}
} }
) });
game_req_join = null; game_req_join = null;
} }
// --- // ---
@ -300,20 +305,33 @@ function pongClient(
playerL.innerText = ""; playerL.innerText = "";
currentGame = null; currentGame = null;
} }
let render_tour_score_once = false;
const renderTournamentScores = (info: TourInfo) => { const renderTournamentScores = (info: TourInfo) => {
let players = info.players.sort((l, r) => r.score - l.score); let players = info.players.sort((l, r) => r.score - l.score);
const medals = ["🥇", "🥈", "🥉"]; const medals = ["🥇", "🥈", "🥉"];
tour_scores.innerHTML = tourScoresHtml; if (!render_tour_score_once)
{
tour_scores.innerHTML = tourScoresHtml;
render_tour_score_once = true;
}
let table = tour_scores.querySelector("#tour-score-body"); let table = tour_scores.querySelector("#tour-score-body");
if (table) let table_shadow = document.createElement("tbody");
table.innerHTML = players.map((player, idx) => if (table) {
`<tr class="${player.id === user.id ? "bg-amber-400 hover:bg-amber-500" : "hover:bg-gray-50"}" key="${player.id}"> table_shadow.innerHTML = players
<td class="px-4 py-2 text-sm text-gray-800 text-center border-b font-semibold min-w-100px">${idx < medals.length ? `<span class="font-lg">${medals[idx]}</span>` : ''}${player.name}</td> .map(
<td class="px-4 py-2 text-sm text-gray-800 text-center border-b font-bold min-w-100px">${player.score}</td> (player, idx) =>
</tr>`) `<tr class="${player.id === user.id ? "bg-amber-400 hover:bg-amber-500" : "hover:bg-gray-50"}" data-id="${player.id}">
<td class="px-4 py-2 text-sm text-gray-800 text-center border-b font-semibold min-w-100px"><span class="font-lg medal">${idx < medals.length ? medals[idx] : ""}</span>${player.name}</td>
<td class="px-4 py-2 text-sm text-gray-800 text-center border-b font-bold min-w-100px">${player.score}</td>
</tr>`,
)
.join(""); .join("");
if (table_shadow.innerHTML !== table.innerHTML) {
table.innerHTML = table_shadow.innerHTML;
}
}
}; };
// TODO: REMOVE THIS // TODO: REMOVE THIS
@ -371,6 +389,10 @@ function pongClient(
// btn setup // btn setup
queueBtn.addEventListener("click", () => { queueBtn.addEventListener("click", () => {
if (inTournament) {
showError("You can't queue up currently !");
return;
}
if (queueBtn.innerText !== QueueState.Iddle) { if (queueBtn.innerText !== QueueState.Iddle) {
if (queueBtn.innerText === QueueState.InQueu) { if (queueBtn.innerText === QueueState.InQueu) {
socket.emit("dequeue"); socket.emit("dequeue");
@ -384,11 +406,10 @@ function pongClient(
LocalGameBtn.addEventListener("click", () => { LocalGameBtn.addEventListener("click", () => {
if ( if (
queueBtn.innerText !== QueueState.Iddle || queueBtn.innerText !== QueueState.Iddle ||
currentGame !== null currentGame !== null ||
inTournament
) { ) {
showError( showError("cant launch a game currently");
"cant launch a local game while in queue/in game",
);
return; return;
} }
socket.emit("localGame"); socket.emit("localGame");
@ -467,6 +488,7 @@ function pongClient(
updateCurrentGame(state); updateCurrentGame(state);
render(state); render(state);
tour_scores.classList.add("hidden");
queueBtn.innerText = QueueState.InGame; queueBtn.innerText = QueueState.InGame;
queueBtn.style.color = "red"; queueBtn.style.color = "red";
batLeft.style.backgroundColor = DEFAULT_COLOR; batLeft.style.backgroundColor = DEFAULT_COLOR;
@ -487,14 +509,17 @@ function pongClient(
queueBtn.style.color = "white"; queueBtn.style.color = "white";
if (!isNullish(currentGame)) { if (!isNullish(currentGame)) {
let end_txt: string = ''; let end_txt: string = "";
if ((user.id === currentGame.game.left.id && winner === 'left') || if (
(user.id === currentGame.game.right.id && winner === 'right')) (user.id === currentGame.game.left.id &&
end_txt = 'you won! #yippe'; winner === "left") ||
else (user.id === currentGame.game.right.id &&
end_txt = 'you lost #sadge'; winner === "right")
)
end_txt = "you won! #yippe";
else end_txt = "you lost #sadge";
if (currentGame.spectating) if (currentGame.spectating)
end_txt = `${winner === 'left' ? currentGame.playerL.name : currentGame.playerR.name} won #gg`; end_txt = `${winner === "left" ? currentGame.playerL.name : currentGame.playerR.name} won #gg`;
end_scr.innerText = end_txt; end_scr.innerText = end_txt;
end_scr.classList.remove("hidden"); end_scr.classList.remove("hidden");
setTimeout(() => { setTimeout(() => {
@ -528,13 +553,16 @@ function pongClient(
let imOwner = s.ownerId === user.id; let imOwner = s.ownerId === user.id;
switch (s.state) { switch (s.state) {
case "ended": case "ended":
inTournament = false;
tournamentBtn.innerText = TourBtnState.AbleToCreate; tournamentBtn.innerText = TourBtnState.AbleToCreate;
break; break;
case "playing": case "playing":
inTournament = true;
tournamentBtn.innerText = TourBtnState.Started; tournamentBtn.innerText = TourBtnState.Started;
tour_infos.innerText = `${TourInfoState.Running} ${s.players.length}👤 ${s.remainingMatches ?? "?"}▮•▮`; tour_infos.innerText = `${TourInfoState.Running} ${s.players.length}👤 ${s.remainingMatches ?? "?"}▮•▮`;
break; break;
case "prestart": case "prestart":
inTournament = true;
tour_infos.innerText = `${imOwner ? TourInfoState.Owner : weIn ? TourInfoState.Registered : TourInfoState.NotRegisted} ${s.players.length}👤 ?▮•▮`; tour_infos.innerText = `${imOwner ? TourInfoState.Owner : weIn ? TourInfoState.Registered : TourInfoState.NotRegisted} ${s.players.length}👤 ?▮•▮`;
if (imOwner) { if (imOwner) {
tournamentBtn.innerText = TourBtnState.AbleToStart; tournamentBtn.innerText = TourBtnState.AbleToStart;

View file

@ -5,10 +5,10 @@
<table class="min-w-full border-collapse table-fixed"> <table class="min-w-full border-collapse table-fixed">
<thead class="sticky top-0 z-10 bg-gray-100"> <thead class="sticky top-0 z-10 bg-gray-100">
<tr> <tr>
<th class="px-4 py-2 text-right text-sm font-semibold text-gray-700 border-b"> <th class="px-4 py-2 text-center text-sm font-semibold text-gray-700 border-b">
Name Name
</th> </th>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 border-b"> <th class="px-4 py-2 text-center text-sm font-semibold text-gray-700 border-b">
Score Score
</th> </th>
</tr> </tr>

View file

@ -1,26 +1,38 @@
import { addRoute, setTitle, navigateTo, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing"; import {
addRoute,
setTitle,
navigateTo,
type RouteHandlerParams,
type RouteHandlerReturn,
} from "@app/routing";
import { showError, showInfo, showSuccess } from "@app/toast"; import { showError, showInfo, showSuccess } from "@app/toast";
import page from './signin.html?raw'; import page from "./signin.html?raw";
import client from '@app/api' import client from "@app/api";
import { updateUser } from "@app/auth"; import { updateUser } from "@app/auth";
import Cookie from 'js-cookie'; import Cookie from "js-cookie";
import loggedInHtml from './alreadyLoggedin.html?raw'; import loggedInHtml from "./alreadyLoggedin.html?raw";
import { isNullish } from "@app/utils"; import { ensureWindowState, isNullish } from "@app/utils";
import cuteCat from './cuteCat.png'; import cuteCat from "./cuteCat.png";
const MSG_KEY_TO_STRING = { const MSG_KEY_TO_STRING = {
'signin.failed.username.existing': 'Username already exists', "signin.failed.username.existing": "Username already exists",
'signin.failed.username.toolong': 'Username is too short', "signin.failed.username.toolong": "Username is too short",
'signin.failed.username.tooshort': 'Username is too long', "signin.failed.username.tooshort": "Username is too long",
'signin.failed.username.invalid': 'Username is invalid', "signin.failed.username.invalid": "Username is invalid",
'signin.failed.password.toolong': 'Password is too long', "signin.failed.password.toolong": "Password is too long",
'signin.failed.password.tooshort': 'Password is too short', "signin.failed.password.tooshort": "Password is too short",
'signin.failed.password.invalid': 'Password is invalid', "signin.failed.password.invalid": "Password is invalid",
'signin.failed.generic': 'Unknown Error', "signin.failed.generic": "Unknown Error",
}; };
async function handleSignin(_url: string, _args: RouteHandlerParams): Promise<RouteHandlerReturn> { ensureWindowState();
setTitle('Signin') window.__state.lastAuthChange = Date.now();
async function handleSignin(
_url: string,
_args: RouteHandlerParams,
): Promise<RouteHandlerReturn> {
setTitle("Signin");
let user = await updateUser(); let user = await updateUser();
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const returnTo = urlParams.get("returnTo"); const returnTo = urlParams.get("returnTo");
@ -57,43 +69,67 @@ async function handleSignin(_url: string, _args: RouteHandlerParams): Promise<Ro
} }
return { return {
html: page, postInsert: async (app) => { html: page,
const fSignin = document.querySelector<HTMLFormElement>('form#signin-form'); postInsert: async (app) => {
const fSignin =
document.querySelector<HTMLFormElement>("form#signin-form");
if (fSignin === null) if (fSignin === null)
return showError('Error while rendering the page: no form found'); return showError(
fSignin.addEventListener('submit', async function(e: SubmitEvent) { "Error while rendering the page: no form found",
);
fSignin.addEventListener("submit", async function(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
let form = e.target as (HTMLFormElement | null); let form = e.target as HTMLFormElement | null;
if (form === null) if (form === null) return showError("Failed to send form...");
return showError('Failed to send form...'); let formData = Object.fromEntries(new FormData(form).entries());
let formData = Object.fromEntries((new FormData(form)).entries()); if (
if (!('login' in formData) || typeof formData['login'] !== 'string' || (formData['login'] as string).length === 0) !("login" in formData) ||
return showError('Please enter a Login'); typeof formData["login"] !== "string" ||
if (!('password' in formData) || typeof formData['password'] !== 'string' || (formData['password'] as string).length === 0) (formData["login"] as string).length === 0
return showError('Please enter a Password'); )
return showError("Please enter a Login");
if (
!("password" in formData) ||
typeof formData["password"] !== "string" ||
(formData["password"] as string).length === 0
)
return showError("Please enter a Password");
try { try {
const res = await client.signin({ loginRequest: { name: formData.login, password: formData.password } }); const res = await client.signin({
loginRequest: {
name: formData.login,
password: formData.password,
},
});
switch (res.kind) { switch (res.kind) {
case 'success': { case "success": {
Cookie.set('token', res.payload.token, { path: '/', sameSite: 'lax' }); window.__state.lastAuthChange = Date.now();
Cookie.set("token", res.payload.token, {
path: "/",
sameSite: "lax",
});
let user = await updateUser(); let user = await updateUser();
if (user === null) if (user === null)
return showError('Failed to get user: no user ?'); return showError(
navigateTo(returnTo !== null ? returnTo : '/') "Failed to get user: no user ?",
);
navigateTo(returnTo !== null ? returnTo : "/");
break; break;
} }
case 'failed': { case "failed": {
showError(`Failed to signin: ${MSG_KEY_TO_STRING[res.msg]}`); showError(
`Failed to signin: ${MSG_KEY_TO_STRING[res.msg]}`,
);
} }
} }
} catch (e) { } catch (e) {
console.error("Signin error:", e); console.error("Signin error:", e);
showError('Failed to signin: Unknown error'); showError("Failed to signin: Unknown error");
} }
}); });
} },
} };
}; }
addRoute("/signin", handleSignin, { bypass_auth: true });
addRoute('/signin', handleSignin, { bypass_auth: true })

View file

@ -82,12 +82,12 @@ const route: FastifyPluginAsync = async (fastify): Promise<void> => {
left: { left: {
score: v.left.score, score: v.left.score,
id: v.left.id, id: v.left.id,
name: `${v.nameL}-left`, name: `${v.nameL}`,
}, },
right: { right: {
score: v.right.score, score: v.right.score,
id: v.right.id, id: v.right.id,
name: `${v.nameR}-right`, name: `${v.nameR}`,
}, },
local: v.local, local: v.local,
date: v.time.toString(), date: v.time.toString(),

View file

@ -77,23 +77,21 @@ class StateI {
const u1 = this.users.get(id1); const u1 = this.users.get(id1);
const u2 = this.users.get(id2); const u2 = this.users.get(id2);
if (isNullish(u1) || isNullish(u2)) return null;
this.fastify.log.info({ this.fastify.log.info({
msg: 'init new game', msg: 'init new game',
user1: u1.id, user1: id1,
user2: u2.id, user2: id2,
}); });
if (g === null) g = new Pong(u1.id, u2.id); if (g === null) g = new Pong(id1, id2);
const iState: GameUpdate = StateI.getGameUpdateData(gameId, g); const iState: GameUpdate = StateI.getGameUpdateData(gameId, g);
u1.socket.emit('newGame', iState); u1?.socket.emit('newGame', iState);
u2.socket.emit('newGame', iState); u2?.socket.emit('newGame', iState);
g.rdy_timer = Date.now(); g.rdy_timer = Date.now();
this.games.set(gameId, g); this.games.set(gameId, g);
u1.currentGame = gameId; if (u1) { u1.currentGame = gameId; }
u2.currentGame = gameId; if (u2) { u2.currentGame = gameId; }
g.gameUpdate = setInterval(() => { g.gameUpdate = setInterval(() => {
g.tick(); g.tick();
@ -102,13 +100,21 @@ class StateI {
g.ready_checks[0] === true && g.ready_checks[0] === true &&
g.ready_checks[1] === true g.ready_checks[1] === true
) { ) {
u1.socket.emit('rdyEnd'); if (u1) {
u2.socket.emit('rdyEnd'); u1.socket.emit('rdyEnd');
}
if (u2) {
u2.socket.emit('rdyEnd');
}
g.sendSig = true; g.sendSig = true;
} }
if (g.ready_checks[0] === true && g.ready_checks[1] === true) { if (g.ready_checks[0] === true && g.ready_checks[1] === true) {
this.gameUpdate(gameId, u1.socket); if (u1) {
this.gameUpdate(gameId, u2.socket); this.gameUpdate(gameId, u1.socket);
}
if (u2) {
this.gameUpdate(gameId, u2.socket);
}
} }
if (g.checkWinner() !== null) { if (g.checkWinner() !== null) {
this.cleanupGame(gameId, g); this.cleanupGame(gameId, g);
@ -376,14 +382,14 @@ class StateI {
this.cleanupUser(sock); this.cleanupUser(sock);
} }
private tryJoinGame(g_id : string, sock : SSocket) : JoinRes { private tryJoinGame(g_id: string, sock: SSocket): JoinRes {
const game_id : PongGameId = g_id as PongGameId; const game_id: PongGameId = g_id as PongGameId;
if (this.games.has(game_id) === false) { if (this.games.has(game_id) === false) {
this.fastify.log.warn('gameId:' + g_id + ' is unknown!'); this.fastify.log.warn('gameId:' + g_id + ' is unknown!');
return (JoinRes.no); return (JoinRes.no);
} }
const game : Pong = this.games.get(game_id)!; const game: Pong = this.games.get(game_id)!;
if (game.local === true || (game.userLeft !== sock.authUser.id && game.userRight !== sock.authUser.id)) { if (game.local === true || (game.userLeft !== sock.authUser.id && game.userRight !== sock.authUser.id)) {
this.fastify.log.warn('user trying to connect to a game he\'s not part of: gameId:' + g_id + ' userId:' + sock.authUser.id); this.fastify.log.warn('user trying to connect to a game he\'s not part of: gameId:' + g_id + ' userId:' + sock.authUser.id);
return (JoinRes.no); return (JoinRes.no);
@ -426,7 +432,7 @@ class StateI {
socket.on('gameMove', (e) => this.gameMove(socket, e)); socket.on('gameMove', (e) => this.gameMove(socket, e));
socket.on('localGame', () => this.newLocalGame(socket)); socket.on('localGame', () => this.newLocalGame(socket));
socket.on('joinGame', (g_id, ack) => {return (ack(this.tryJoinGame(g_id, socket)));}); socket.on('joinGame', (g_id, ack) => { return (ack(this.tryJoinGame(g_id, socket))); });
// todo: allow passing nickname // todo: allow passing nickname
socket.on('tourRegister', () => socket.on('tourRegister', () =>
this.registerForTournament(socket, null), this.registerForTournament(socket, null),
@ -587,4 +593,4 @@ export let State: StateI = undefined as unknown as StateI;
export function newState(f: FastifyInstance) { export function newState(f: FastifyInstance) {
State = new StateI(f); State = new StateI(f);
} }