Compare commits

...

3 Commits

Author SHA1 Message Date
Renovate Bot
78158fc5b7 Update eslint monorepo to v9.39.2 2026-01-12 15:05:16 +00:00
b756204514 add auth persistence 2026-01-12 15:59:04 +01:00
76281892a2 fix bugs 2026-01-12 14:27:01 +01:00
11 changed files with 931 additions and 320 deletions

811
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,17 +6,19 @@ default-run = "backend"
[dependencies] [dependencies]
prost = "0.14.1" prost = "0.14"
prost-types = "0.14.1" prost-types = "0.14"
rocket = { git = "https://github.com/rwf2/Rocket", rev = "504efef179622df82ba1dbd37f2e0d9ed2b7c9e4" } rocket = { git = "https://github.com/rwf2/Rocket", rev = "504efef179622df82ba1dbd37f2e0d9ed2b7c9e4" }
bytes = "1" bytes = "1"
rocket_prost_responder_derive = { path = "rocket_prost_responder_derive" } rocket_prost_responder_derive = { path = "rocket_prost_responder_derive" }
uuid = { version = "1.19.0", features = ["v4"] } uuid = { version = "1.19", features = ["v4"] }
bcrypt = "0.17.1" bcrypt = "0.17.1"
bincode = "2.0.1" bincode = "2.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
reqwest = { version = "0.12.24", features = ["json"] } reqwest = { version = "0.13", features = ["json"] }
aes-gcm = "0.10"
base64 = "0.22"
[build-dependencies] [build-dependencies]
prost-build = "0.14.1" prost-build = "0.14"

View File

@ -1,3 +1,4 @@
use crate::auth_persistence::AuthStorage;
use crate::items; use crate::items;
use crate::proto_utils::Proto; use crate::proto_utils::Proto;
use rocket::State; use rocket::State;
@ -8,12 +9,16 @@ use uuid::Uuid;
pub struct AuthState { pub struct AuthState {
// Map token -> username // Map token -> username
tokens: Mutex<HashMap<String, String>>, tokens: Mutex<HashMap<String, String>>,
storage: AuthStorage,
} }
impl AuthState { impl AuthState {
pub fn new() -> Self { pub fn new() -> Self {
let storage = AuthStorage::new();
let tokens = storage.load_tokens();
Self { Self {
tokens: Mutex::new(HashMap::new()), tokens: Mutex::new(tokens),
storage,
} }
} }
} }
@ -138,7 +143,8 @@ pub async fn login(
{ {
let token = Uuid::new_v4().to_string(); let token = Uuid::new_v4().to_string();
let mut tokens = state.tokens.lock().await; let mut tokens = state.tokens.lock().await;
tokens.insert(token.clone(), req.username); tokens.insert(token.clone(), req.username.clone());
state.storage.save_tokens(&tokens);
return items::LoginResponse { return items::LoginResponse {
token, token,
@ -163,6 +169,7 @@ pub async fn logout(
let mut tokens = state.tokens.lock().await; let mut tokens = state.tokens.lock().await;
if tokens.remove(&req.token).is_some() { if tokens.remove(&req.token).is_some() {
state.storage.save_tokens(&tokens);
items::LogoutResponse { items::LogoutResponse {
success: true, success: true,
message: "Logged out successfully".to_string(), message: "Logged out successfully".to_string(),

View File

@ -0,0 +1,261 @@
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use bincode::{config, decode_from_slice, encode_to_vec};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Write};
use std::os::unix::fs::PermissionsExt;
#[derive(Debug, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub struct TokenEntry {
pub token: String,
pub username: String,
pub created_at: u64,
}
const AUTH_KEY_FILE: &str = ".auth_key";
const TOKENS_FILE: &str = "tokens.bin";
const NONCE_SIZE: usize = 12;
const KEY_SIZE: usize = 32;
pub struct AuthStorage {
cipher: Aes256Gcm,
}
impl Default for AuthStorage {
fn default() -> Self {
Self::new()
}
}
impl AuthStorage {
pub fn new() -> Self {
let key = Self::load_or_create_key();
let cipher = Aes256Gcm::new_from_slice(&key).expect("Invalid key length");
Self { cipher }
}
fn load_or_create_key() -> Vec<u8> {
if let Ok(existing_key) = Self::load_key_from_file() {
return existing_key;
}
let key = Self::generate_key();
if let Err(e) = Self::save_key_to_file(&key) {
eprintln!("Warning: Failed to save auth key to file: {}", e);
}
key
}
fn generate_key() -> Vec<u8> {
use std::time::{SystemTime, UNIX_EPOCH};
let mut key = [0u8; KEY_SIZE];
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let mut seed = timestamp as u64;
for byte in key.iter_mut() {
seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
*byte = (seed >> (seed % 8)) as u8;
}
key.to_vec()
}
fn load_key_from_file() -> io::Result<Vec<u8>> {
let file = File::open(AUTH_KEY_FILE)?;
let reader = BufReader::new(file);
let encoded = std::io::read_to_string(reader)?;
let key = STANDARD.decode(encoded).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Base64 decode error: {}", e),
)
})?;
if key.len() != KEY_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Invalid key length",
));
}
Ok(key)
}
fn save_key_to_file(key: &[u8]) -> io::Result<()> {
let encoded = STANDARD.encode(key);
let mut file = File::create(AUTH_KEY_FILE)?;
let mut permissions = file.metadata()?.permissions();
permissions.set_mode(0o600);
file.set_permissions(permissions)?;
file.write_all(encoded.as_bytes())?;
file.sync_all()?;
Ok(())
}
pub fn load_tokens(&self) -> HashMap<String, String> {
let file = match File::open(TOKENS_FILE) {
Ok(f) => f,
Err(_) => {
eprintln!("Warning: No existing tokens file, starting with empty token list");
return HashMap::new();
}
};
let reader = BufReader::new(file);
let encrypted_data = match std::io::read_to_string(reader) {
Ok(data) => data,
Err(_) => {
eprintln!("Warning: Failed to read tokens file, starting with empty token list");
return HashMap::new();
}
};
let decoded = match STANDARD.decode(&encrypted_data) {
Ok(data) => data,
Err(_) => {
eprintln!("Warning: Failed to decode tokens file, starting with empty token list");
return HashMap::new();
}
};
if decoded.len() <= NONCE_SIZE {
eprintln!("Warning: Invalid tokens file format, starting with empty token list");
return HashMap::new();
}
let (nonce_bytes, ciphertext) = decoded.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = match self.cipher.decrypt(nonce, ciphertext.as_ref()) {
Ok(p) => p,
Err(_) => {
eprintln!("Warning: Failed to decrypt tokens file, starting with empty token list");
return HashMap::new();
}
};
let config = config::standard();
let (entries, _): (Vec<TokenEntry>, usize) = match decode_from_slice(&plaintext, config) {
Ok(e) => e,
Err(_) => {
eprintln!("Warning: Failed to deserialize tokens, starting with empty token list");
return HashMap::new();
}
};
entries
.into_iter()
.map(|entry| (entry.token, entry.username))
.collect()
}
pub fn save_tokens(&self, tokens: &HashMap<String, String>) {
use std::time::{SystemTime, UNIX_EPOCH};
let entries: Vec<TokenEntry> = tokens
.iter()
.map(|(token, username)| TokenEntry {
token: token.clone(),
username: username.clone(),
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
})
.collect();
let config = config::standard();
let plaintext: Vec<u8> = match encode_to_vec(&entries, config) {
Ok(data) => data,
Err(e) => {
eprintln!("Warning: Failed to serialize tokens: {}", e);
return;
}
};
let nonce_bytes: [u8; NONCE_SIZE] = rand::thread_rng().generate_bytes();
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = match self.cipher.encrypt(nonce, plaintext.as_ref()) {
Ok(encrypted) => encrypted,
Err(e) => {
eprintln!("Warning: Failed to encrypt tokens: {}", e);
return;
}
};
let mut encrypted_data = nonce_bytes.to_vec();
encrypted_data.extend_from_slice(&ciphertext);
let encoded = STANDARD.encode(&encrypted_data);
match File::create(TOKENS_FILE) {
Ok(file) => {
if let Ok(metadata) = file.metadata() {
let mut permissions = metadata.permissions();
permissions.set_mode(0o600);
if let Err(e) = file.set_permissions(permissions) {
eprintln!("Warning: Failed to set permissions on tokens file: {}", e);
}
}
let mut writer = BufWriter::new(file);
if let Err(e) = writer.write_all(encoded.as_bytes()) {
eprintln!("Warning: Failed to write tokens file: {}", e);
}
if let Err(e) = writer.flush() {
eprintln!("Warning: Failed to flush tokens file: {}", e);
}
}
Err(e) => {
eprintln!("Warning: Failed to create tokens file: {}", e);
}
}
}
}
mod rand {
use std::cell::Cell;
thread_local! {
static STATE: Cell<u64> = Cell::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
);
}
pub struct ThreadRng;
pub fn thread_rng() -> ThreadRng {
ThreadRng
}
impl ThreadRng {
pub fn generate_bytes<const N: usize>(&mut self) -> [u8; N] {
let mut result = [0u8; N];
STATE.with(|state| {
let mut s = state.get();
for byte in result.iter_mut() {
s = s.wrapping_mul(1103515245).wrapping_add(12345);
*byte = (s >> (s % 8)) as u8;
}
state.set(s);
});
result
}
}
}

View File

@ -6,6 +6,7 @@ pub mod items {
} }
pub mod auth; pub mod auth;
pub mod auth_persistence;
pub mod proto_utils; pub mod proto_utils;
pub mod store; pub mod store;

View File

@ -3,6 +3,7 @@ use rocket::fs::FileServer;
use rocket::futures::lock::Mutex; use rocket::futures::lock::Mutex;
use backend::auth; use backend::auth;
use backend::auth::AdminState;
use backend::items::{self, Game}; use backend::items::{self, Game};
use backend::proto_utils; use backend::proto_utils;
use backend::store::{self, User, save_state}; use backend::store::{self, User, save_state};
@ -111,6 +112,7 @@ async fn update_game(
game: proto_utils::Proto<items::Game>, game: proto_utils::Proto<items::Game>,
) -> Option<items::Game> { ) -> Option<items::Game> {
let mut games = game_list.lock().await; let mut games = game_list.lock().await;
let mut users = user_list.lock().await;
let mut game = game.into_inner(); let mut game = game.into_inner();
game.title = game.title.trim().to_string(); game.title = game.title.trim().to_string();
@ -125,13 +127,14 @@ async fn update_game(
(g.remote_id == game.remote_id && g.source == game.source) || (g.title == game.title) (g.remote_id == game.remote_id && g.source == game.source) || (g.title == game.title)
}) { }) {
if existing.title != game.title { if existing.title != game.title {
let old_title = existing.title.clone();
// Update title for every opinion // Update title for every opinion
for person in user_list.lock().await.iter_mut() { for person in users.iter_mut() {
let opinion = person let opinion = person
.person .person
.opinion .opinion
.iter_mut() .iter_mut()
.find(|o| o.title == existing.title); .find(|o| o.title == old_title);
if let Some(opinion) = opinion { if let Some(opinion) = opinion {
opinion.title = game.title.clone(); opinion.title = game.title.clone();
} }
@ -149,7 +152,6 @@ async fn update_game(
games.sort_unstable_by(|g1, g2| g1.title.cmp(&g2.title)); games.sort_unstable_by(|g1, g2| g1.title.cmp(&g2.title));
let users = user_list.lock().await;
save_state(&games, &users); save_state(&games, &users);
r_existing r_existing
@ -189,13 +191,16 @@ async fn refresh_state(
_token: auth::AdminToken, _token: auth::AdminToken,
game_list: &rocket::State<Mutex<Vec<Game>>>, game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>, user_list: &rocket::State<Mutex<Vec<User>>>,
admin_state: &rocket::State<AdminState>,
) -> items::RefreshResponse { ) -> items::RefreshResponse {
if let Some((new_games, new_users)) = store::load_state() { if let Some((new_games, new_users)) = store::load_state() {
let mut games = game_list.lock().await; let mut games = game_list.lock().await;
let mut users = user_list.lock().await; let mut users = user_list.lock().await;
let mut admins = admin_state.admins.lock().await;
*games = new_games; *games = new_games;
*users = new_users; *users = new_users;
*admins = store::load_admins();
items::RefreshResponse { items::RefreshResponse {
success: true, success: true,
@ -212,12 +217,12 @@ async fn refresh_state(
#[post("/opinion", data = "<req>")] #[post("/opinion", data = "<req>")]
async fn add_opinion( async fn add_opinion(
token: auth::Token, token: auth::Token,
user_list: &rocket::State<Mutex<Vec<User>>>,
game_list: &rocket::State<Mutex<Vec<Game>>>, game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
req: proto_utils::Proto<items::AddOpinionRequest>, req: proto_utils::Proto<items::AddOpinionRequest>,
) -> Option<items::Person> { ) -> Option<items::Person> {
let mut users = user_list.lock().await;
let games = game_list.lock().await; let games = game_list.lock().await;
let mut users = user_list.lock().await;
let mut result = None; let mut result = None;
// Validate game exists // Validate game exists
@ -261,12 +266,12 @@ async fn add_opinion(
#[patch("/opinion", data = "<req>")] #[patch("/opinion", data = "<req>")]
async fn remove_opinion( async fn remove_opinion(
token: auth::Token, token: auth::Token,
user_list: &rocket::State<Mutex<Vec<User>>>,
game_list: &rocket::State<Mutex<Vec<Game>>>, game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
req: proto_utils::Proto<items::RemoveOpinionRequest>, req: proto_utils::Proto<items::RemoveOpinionRequest>,
) -> Option<items::Person> { ) -> Option<items::Person> {
let mut users = user_list.lock().await;
let games = game_list.lock().await; let games = game_list.lock().await;
let mut users = user_list.lock().await;
let mut result = None; let mut result = None;
if let Some(user) = users if let Some(user) = users
@ -340,9 +345,13 @@ async fn get_game_thumbnail(
.json::<serde_json::Value>() .json::<serde_json::Value>()
.await .await
.ok()? .ok()?
.get("universeId")? .get("universeId")
.as_u64() .and_then(|v| v.as_u64())
.unwrap() };
let universe_id = match universe_id {
Some(id) => id,
None => return None.into(),
}; };
let api_url = format!( let api_url = format!(

View File

@ -26,7 +26,7 @@ importers:
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.1 specifier: ^9.39.1
version: 9.39.1 version: 9.39.2
'@types/node': '@types/node':
specifier: ^24.10.1 specifier: ^24.10.1
version: 24.10.1 version: 24.10.1
@ -41,13 +41,13 @@ importers:
version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.1)) version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.1))
eslint: eslint:
specifier: ^9.39.1 specifier: ^9.39.1
version: 9.39.1 version: 9.39.2
eslint-plugin-react-hooks: eslint-plugin-react-hooks:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1(eslint@9.39.1) version: 7.0.1(eslint@9.39.2)
eslint-plugin-react-refresh: eslint-plugin-react-refresh:
specifier: ^0.4.24 specifier: ^0.4.24
version: 0.4.24(eslint@9.39.1) version: 0.4.24(eslint@9.39.2)
globals: globals:
specifier: ^16.5.0 specifier: ^16.5.0
version: 16.5.0 version: 16.5.0
@ -59,7 +59,7 @@ importers:
version: 5.9.3 version: 5.9.3
typescript-eslint: typescript-eslint:
specifier: ^8.48.1 specifier: ^8.48.1
version: 8.48.1(eslint@9.39.1)(typescript@5.9.3) version: 8.48.1(eslint@9.39.2)(typescript@5.9.3)
vite: vite:
specifier: npm:rolldown-vite@7.2.5 specifier: npm:rolldown-vite@7.2.5
version: rolldown-vite@7.2.5(@types/node@24.10.1) version: rolldown-vite@7.2.5(@types/node@24.10.1)
@ -167,6 +167,12 @@ packages:
peerDependencies: peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.2': '@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@ -187,8 +193,8 @@ packages:
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.1': '@eslint/js@9.39.2':
resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.7': '@eslint/object-schema@2.1.7':
@ -425,7 +431,7 @@ packages:
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: npm:rolldown-vite@7.2.5
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@ -562,8 +568,8 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint@9.39.1: eslint@9.39.2:
resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -576,8 +582,8 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
esquery@1.6.0: esquery@1.7.0:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
esrecurse@4.3.0: esrecurse@4.3.0:
@ -1185,9 +1191,14 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1)': '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2)':
dependencies: dependencies:
eslint: 9.39.1 eslint: 9.39.2
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)':
dependencies:
eslint: 9.39.2
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {} '@eslint-community/regexpp@4.12.2': {}
@ -1222,7 +1233,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@eslint/js@9.39.1': {} '@eslint/js@9.39.2': {}
'@eslint/object-schema@2.1.7': {} '@eslint/object-schema@2.1.7': {}
@ -1362,15 +1373,15 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.48.1(eslint@9.39.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.2)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.48.1 '@typescript-eslint/scope-manager': 8.48.1
'@typescript-eslint/type-utils': 8.48.1(eslint@9.39.1)(typescript@5.9.3) '@typescript-eslint/type-utils': 8.48.1(eslint@9.39.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.48.1(eslint@9.39.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.48.1(eslint@9.39.2)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.48.1 '@typescript-eslint/visitor-keys': 8.48.1
eslint: 9.39.1 eslint: 9.39.2
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
@ -1379,14 +1390,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.48.1(eslint@9.39.1)(typescript@5.9.3)': '@typescript-eslint/parser@8.48.1(eslint@9.39.2)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.48.1 '@typescript-eslint/scope-manager': 8.48.1
'@typescript-eslint/types': 8.48.1 '@typescript-eslint/types': 8.48.1
'@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.48.1 '@typescript-eslint/visitor-keys': 8.48.1
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.1 eslint: 9.39.2
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -1409,13 +1420,13 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/type-utils@8.48.1(eslint@9.39.1)(typescript@5.9.3)': '@typescript-eslint/type-utils@8.48.1(eslint@9.39.2)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.48.1 '@typescript-eslint/types': 8.48.1
'@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.48.1(eslint@9.39.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.48.1(eslint@9.39.2)(typescript@5.9.3)
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.1 eslint: 9.39.2
ts-api-utils: 2.1.0(typescript@5.9.3) ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@ -1438,13 +1449,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.48.1(eslint@9.39.1)(typescript@5.9.3)': '@typescript-eslint/utils@8.48.1(eslint@9.39.2)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2)
'@typescript-eslint/scope-manager': 8.48.1 '@typescript-eslint/scope-manager': 8.48.1
'@typescript-eslint/types': 8.48.1 '@typescript-eslint/types': 8.48.1
'@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
eslint: 9.39.1 eslint: 9.39.2
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -1557,20 +1568,20 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@7.0.1(eslint@9.39.1): eslint-plugin-react-hooks@7.0.1(eslint@9.39.2):
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/parser': 7.28.5 '@babel/parser': 7.28.5
eslint: 9.39.1 eslint: 9.39.2
hermes-parser: 0.25.1 hermes-parser: 0.25.1
zod: 4.1.13 zod: 4.1.13
zod-validation-error: 4.0.2(zod@4.1.13) zod-validation-error: 4.0.2(zod@4.1.13)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-react-refresh@0.4.24(eslint@9.39.1): eslint-plugin-react-refresh@0.4.24(eslint@9.39.2):
dependencies: dependencies:
eslint: 9.39.1 eslint: 9.39.2
eslint-scope@8.4.0: eslint-scope@8.4.0:
dependencies: dependencies:
@ -1581,15 +1592,15 @@ snapshots:
eslint-visitor-keys@4.2.1: {} eslint-visitor-keys@4.2.1: {}
eslint@9.39.1: eslint@9.39.2:
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2)
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1 '@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2 '@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.3 '@eslint/eslintrc': 3.3.3
'@eslint/js': 9.39.1 '@eslint/js': 9.39.2
'@eslint/plugin-kit': 0.4.1 '@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7 '@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/module-importer': 1.0.1
@ -1603,7 +1614,7 @@ snapshots:
eslint-scope: 8.4.0 eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
espree: 10.4.0 espree: 10.4.0
esquery: 1.6.0 esquery: 1.7.0
esutils: 2.0.3 esutils: 2.0.3
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0 file-entry-cache: 8.0.0
@ -1626,7 +1637,7 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.15.0) acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
esquery@1.6.0: esquery@1.7.0:
dependencies: dependencies:
estraverse: 5.3.0 estraverse: 5.3.0
@ -1956,13 +1967,13 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.48.1(eslint@9.39.1)(typescript@5.9.3): typescript-eslint@8.48.1(eslint@9.39.2)(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.48.1(eslint@9.39.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.2)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.48.1(eslint@9.39.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.48.1(eslint@9.39.2)(typescript@5.9.3)
eslint: 9.39.1 eslint: 9.39.2
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Person, PersonList as PersonListProto } from "../items"; import { Person, PersonList as PersonListProto } from "../items";
import { Login } from "./Login"; import { Login } from "./Login";
import { PersonList } from "./PersonList"; import { PersonList } from "./PersonList";
@ -62,10 +62,21 @@ function App() {
} }
}, [theme]); }, [theme]);
const addToast = (message: string, type: ToastType = "info") => { useEffect(() => {
const handleUnauthorized = () => {
setToken("");
setPeople([]);
addToast("Session expired. Please log in again.", "info");
};
window.addEventListener("unauthorized", handleUnauthorized);
return () => window.removeEventListener("unauthorized", handleUnauthorized);
}, [addToast]);
const addToast = useCallback((message: string, type: ToastType = "info") => {
const id = Date.now(); const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]); setToasts((prev) => [...prev, { id, message, type }]);
}; }, []);
const removeToast = (id: number) => { const removeToast = (id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id)); setToasts((prev) => prev.filter((t) => t.id !== id));

View File

@ -67,7 +67,7 @@ export function GameDetails({ onShowToast }: Props) {
}; };
if (loading) return <LoadingState message="Loading game details..." />; if (loading) return <LoadingState message="Loading game details..." />;
if (error) return <ErrorState message={error} onRetry={() => window.location.reload()} />; if (error) return <ErrorState message={error} onRetry={() => navigate(0)} />;
if (!game) return <EmptyState icon="🎮" title="Game not found" description="This game doesn't exist or has been deleted" />; if (!game) return <EmptyState icon="🎮" title="Game not found" description="This game doesn't exist or has been deleted" />;
const getExternalLink = () => { const getExternalLink = () => {

View File

@ -22,7 +22,7 @@ export const apiFetch = async (
if (response.status == 401) { if (response.status == 401) {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("isAdmin"); localStorage.removeItem("isAdmin");
window.location.href = "/"; window.dispatchEvent(new CustomEvent("unauthorized"));
} }
throw new Error(`Request failed with status ${response.status}`); throw new Error(`Request failed with status ${response.status}`);
} }

View File

@ -17,6 +17,12 @@ export function useGameFilter(
const [fetchedTitles, setFetchedTitles] = useState<string[]>([]); const [fetchedTitles, setFetchedTitles] = useState<string[]>([]);
const metaDataRef = useRef<{ [key: string]: GameProto }>({}); const metaDataRef = useRef<{ [key: string]: GameProto }>({});
useEffect(() => {
return () => {
metaDataRef.current = {};
};
}, []);
const { gameToNegative, gameToPositiveOpinion } = useMemo(() => { const { gameToNegative, gameToPositiveOpinion } = useMemo(() => {
const gameToNegative = new Map<string, Set<string>>(); const gameToNegative = new Map<string, Set<string>>();
const gameToPositiveOpinion = new Map<string, Set<string>>(); const gameToPositiveOpinion = new Map<string, Set<string>>();
@ -108,7 +114,7 @@ export function useGameFilter(
const gamesMap = useMemo(() => { const gamesMap = useMemo(() => {
return new Map(Object.entries(metaDataRef.current)); return new Map(Object.entries(metaDataRef.current));
}, [fetchedTitles]); }, []);
return { filteredGames, gameToPositive: gameToPositiveOpinion, games: gamesMap }; return { filteredGames, gameToPositive: gameToPositiveOpinion, games: gamesMap };
} }