diff --git a/.dockerignore b/.dockerignore index c0cf0f7..64dd9c6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,15 @@ -idea/ -target/ \ No newline at end of file +# Build +**/target/ +**/Cargo.lock +**/node_modules/ +**/dist/ +**/package-lock.json + +logs/ +data/ +settings.json +.env + +# IDE +.idea/ +.DS_Store diff --git a/.env.example b/.env.example index 1e9315e..beb110f 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ API_BASE_URL=http://localhost:3000 API_PORT=3000 API_SESSION_TTL=86400 +# Set to a specific origin (e.g. https://yourapp.com) when deploying to +# production with a separate frontend origin. Use "*" (the default) in +# development with the Vite proxy, where CORS is not an issue. +CORS_ORIGIN=* + UI_PORT=8080 VALKEY_HOST=localhost diff --git a/.gitignore b/.gitignore index 506550d..64dd9c6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ **/target/ **/Cargo.lock **/node_modules/ +**/dist/ **/package-lock.json logs/ diff --git a/Cargo.toml b/Cargo.toml index 0476988..620d883 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ resolver = "2" edition = "2024" version = "0.3.0" -rust-version = "1.94" +rust-version = "1.86" authors = ["Ben Sherriff "] description = "A Discord bot for playing music" repository = "https://github.com/bensherriff/siren" @@ -57,9 +57,14 @@ rand_chacha = "0.10" regex = "1" lazy_static = "1" +# Auth / Security +argon2 = { version = "0.5", features = ["std"] } +sha2 = "0.10" +cookie = { version = "0.18", features = ["percent-encode"] } + # API axum = { version = "0.8", features = ["json", "ws", "macros"] } -axum-extra = { version = "0.12", features = ["typed-header"] } +axum-extra = { version = "0.12", features = ["typed-header", "cookie"] } jsonwebtoken = { version = "10", features = ["rust_crypto"] } tower-http = { version = "0.6", features = ["fs", "cors"] } dashmap = "6" diff --git a/Taskfile.yml b/Taskfile.yml index 3414f8f..a2c6df2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -8,17 +8,31 @@ vars: tasks: default: - desc: List available tasks + desc: "List available tasks" cmds: - task --list silent: true setup: - desc: Copy .env.example to .env if .env does not exist + desc: "Copy .env.example to .env if .env does not exist" cmds: - test -f .env || cp .env.example .env silent: true + format: + desc: "Format code" + cmds: + - task: format:app + - task: format:ui + silent: true + + lint: + desc: "Run linters" + cmds: + - task: lint:app + - task: lint:ui + silent: true + # ----------------------------------------------------------- # Cargo # ----------------------------------------------------------- @@ -45,7 +59,7 @@ tasks: - cargo run silent: true - format: + format:app: desc: "Format code" cmds: - cargo fmt @@ -58,7 +72,7 @@ tasks: - cargo clean silent: true - lint: + lint:app: desc: "Run Clippy linter" deps: [ setup ] cmds: @@ -116,27 +130,41 @@ tasks: # ----------------------------------------------------------- # UI # ----------------------------------------------------------- - ui:install: + install:ui: desc: "Install UI npm dependencies" dir: ui cmds: - npm install silent: true - ui:run: + run:ui: desc: "Run Vite dev server" dir: ui cmds: - npm run dev silent: true - ui:build: + build:ui: desc: "Build the React UI into ui/dist" dir: ui cmds: - npm run build silent: true + format:ui: + desc: "Format UI code with prettier" + dir: ui + cmds: + - npm run format + silent: true + + lint:ui: + desc: "Lint UI code with eslint" + dir: ui + cmds: + - npm run lint + silent: true + # ----------------------------------------------------------- # Utilities # ----------------------------------------------------------- diff --git a/crates/siren-api/Cargo.toml b/crates/siren-api/Cargo.toml index b3fdf84..5f261bb 100644 --- a/crates/siren-api/Cargo.toml +++ b/crates/siren-api/Cargo.toml @@ -26,3 +26,6 @@ redis = { workspace = true } tower-http = { workspace = true } dashmap = { workspace = true } futures-util = { workspace = true } +argon2 = { workspace = true } +sha2 = { workspace = true } +cookie = { workspace = true } diff --git a/crates/siren-api/src/app.rs b/crates/siren-api/src/app.rs index 0a35b60..342f6d7 100644 --- a/crates/siren-api/src/app.rs +++ b/crates/siren-api/src/app.rs @@ -1,5 +1,5 @@ use crate::{AppState, error::Result}; -use axum::Router; +use axum::{Router, http::HeaderValue}; use std::{env, sync::Arc}; use tokio::net::TcpListener; use tower_http::{ @@ -19,17 +19,36 @@ impl App { pub async fn serve(self) -> Result<()> { log::debug!("Starting API..."); - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + // Build CORS layer. + // + // In production both the UI and API are served from the same origin so + // CORS is a non-issue. In development, Vite proxies all /api/* calls so + // the browser also never makes cross-origin requests directly to this + // server. We keep a permissive default for convenience, but restrict it + // when CORS_ORIGIN is explicitly set. + let cors = match env::var("CORS_ORIGIN") { + Ok(origin) if origin != "*" => { + let header_val = origin + .parse::() + .expect("CORS_ORIGIN is not a valid header value"); + CorsLayer::new() + .allow_origin(header_val) + .allow_methods(Any) + .allow_headers(Any) + .allow_credentials(true) + } + _ => CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + }; - // Serve the built React frontend from frontend/dist (relative to the - // working directory). Falls back gracefully if the directory does not - // exist yet (e.g. during development when using `npm run dev`). + // Serve the built React frontend from ui/dist (relative to the working + // directory). Falls back gracefully if the directory does not exist yet + // (e.g. during development when using `npm run dev`). let frontend_dir = env::current_dir() .unwrap_or_default() - .join("frontend") + .join("ui") .join("dist"); // For SPA routing: any path not matched by a real file (e.g. /map/) diff --git a/crates/siren-api/src/app_state.rs b/crates/siren-api/src/app_state.rs index 0e07942..b3d4fac 100644 --- a/crates/siren-api/src/app_state.rs +++ b/crates/siren-api/src/app_state.rs @@ -5,6 +5,16 @@ use serenity::{ }; use std::{collections::HashMap, sync::Arc}; use tokio::sync::broadcast; +use uuid::Uuid; + +/// Data stored per-entry in the Discord OAuth state cache. +#[derive(Clone, Debug)] +pub struct DiscordOAuthState { + /// Where to send the browser after the OAuth dance completes. + pub redirect_uri: String, + /// Set when a logged-in user is connecting (not logging in) via Discord. + pub connecting_user_id: Option, +} #[derive(Clone)] pub struct AppState { @@ -12,9 +22,9 @@ pub struct AppState { pub client_id: String, pub client_secret: String, pub base_url: String, - /// Maps oauth_state → ui_redirect_uri. - /// Populated on /authorize, consumed on /callback. - pub discord_authorize_cache: Arc>>, + /// Maps oauth_state → DiscordOAuthState. + /// Populated on /authorize or /connect, consumed on /callback. + pub discord_authorize_cache: Arc>>, pub http: Arc, pub cache: Arc, /// Per-map WebSocket broadcast channels for real-time collaboration. diff --git a/crates/siren-api/src/audio/mod.rs b/crates/siren-api/src/audio/mod.rs index 03cb267..8605ae2 100644 --- a/crates/siren-api/src/audio/mod.rs +++ b/crates/siren-api/src/audio/mod.rs @@ -1,14 +1,13 @@ use crate::{ AppState, - auth::{AuthorizationMiddleware, Session}, + auth::SessionAuthorization, error::{Error, Result}, }; use axum::{ - Extension, Json, Router, extract::{Path, State}, - middleware::from_extractor, + http::StatusCode, routing::post, }; use serde::Deserialize; @@ -22,15 +21,13 @@ use siren_bot::{ handler::get_songbird, }; use std::sync::Arc; +use uuid::Uuid; pub fn get_routes() -> Router> { Router::new() .route("/play", post(play_audio)) - .route_layer(from_extractor::()) .route("/pause", post(pause_audio)) - .route_layer(from_extractor::()) .route("/resume", post(resume_audio)) - .route_layer(from_extractor::()) } #[derive(Deserialize)] @@ -38,19 +35,44 @@ struct PlayTrackRequest { url: String, } +/// Resolve the Discord snowflake for a local user from `user_connections`. +/// Returns an error if the user has no linked Discord account. +async fn get_discord_snowflake(local_user_id: Uuid) -> Result { + let pool = siren_core::data::pool(); + let provider_id: Option = sqlx::query_scalar( + "SELECT provider_user_id FROM user_connections \ + WHERE user_id = $1 AND provider = 'discord'", + ) + .bind(local_user_id) + .fetch_optional(pool) + .await?; + + provider_id + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| Error::not_found("Discord account not connected".to_string())) +} + async fn play_audio( - Extension(session): Extension, + SessionAuthorization(session): SessionAuthorization, State(state): State>, Path(guild_id): Path, Json(payload): Json, ) -> Result<()> { log::debug!("Playing audio in guild: {}", guild_id); - // Check if the user exists in the cache - let user_id = session.user_id; - let user_id = match state.cache.user(user_id) { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + + // Resolve Discord snowflake from the local user_id + let discord_snowflake = get_discord_snowflake(session.user_id).await?; + + // Check if the user exists in the Discord cache + let user_id = match state.cache.user(discord_snowflake) { Some(user) => user.id, - None => return Err(Error::not_found("User not found".to_string())), + None => { + return Err(Error::not_found( + "User not found in Discord cache".to_string(), + )); + } }; // Validate if the guild exists in the cache @@ -61,16 +83,17 @@ async fn play_audio( // Play the track let manager = get_songbird(); - let _channel_id = join_voice_channel(&state.cache, &manager, &guild_id, &user_id).await?; + let _channel_id = join_voice_channel(&state.cache, manager, &guild_id, &user_id).await?; enqueue_track(manager, guild_id.to_owned(), &payload.url).await?; Ok(()) } async fn pause_audio( - Extension(_): Extension, + SessionAuthorization(session): SessionAuthorization, State(state): State>, Path(guild_id): Path, ) -> Result<()> { + session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; log::debug!("Pausing audio in guild: {}", guild_id); // Validate if the guild exists in the cache @@ -86,11 +109,12 @@ async fn pause_audio( } async fn resume_audio( - Extension(_): Extension, + SessionAuthorization(session): SessionAuthorization, State(state): State>, Path(guild_id): Path, ) -> Result<()> { - log::debug!("Pausing audio in guild: {}", guild_id); + session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + log::debug!("Resuming audio in guild: {}", guild_id); // Validate if the guild exists in the cache let guild_id = match state.cache.guild(guild_id) { @@ -98,7 +122,7 @@ async fn resume_audio( None => return Err(Error::not_found("Guild not found".to_string())), }; - // Pause the track + // Resume the track let manager = get_songbird(); resume_track(manager, &guild_id).await?; Ok(()) diff --git a/crates/siren-api/src/auth/bearer_token.rs b/crates/siren-api/src/auth/bearer_token.rs index 16947ac..1f48d90 100644 --- a/crates/siren-api/src/auth/bearer_token.rs +++ b/crates/siren-api/src/auth/bearer_token.rs @@ -1,10 +1,16 @@ use serde::{Deserialize, Serialize}; +/// Claims encoded in the JWT stored in the `siren_session` cookie #[derive(Debug, Serialize, Deserialize)] pub struct BearerTokenClaims { - pub sub: u64, + /// Local user UUID (as a string) + pub sub: String, + /// Display username pub name: String, + /// Issued-at epoch seconds pub iat: i64, + /// Expiry epoch seconds pub exp: i64, + /// Redis session key (used to look up the full session) pub jti: String, } diff --git a/crates/siren-api/src/auth/discord.rs b/crates/siren-api/src/auth/discord.rs index 63f4c70..bb59777 100644 --- a/crates/siren-api/src/auth/discord.rs +++ b/crates/siren-api/src/auth/discord.rs @@ -1,16 +1,26 @@ use crate::{ AppState, - auth::{bearer_token::BearerTokenClaims, csprng, session::Session}, + app_state::DiscordOAuthState, + auth::{ + SessionAuthorization, + local::{build_session_cookie, issue_jwt}, + middleware::{compute_fingerprint, extract_ip}, + session::Session, + }, + error::{Error, Result}, }; use axum::{ Router, extract::{Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect}, routing::get, }; +use axum_extra::extract::CookieJar; use serde::{Deserialize, Serialize}; -use std::{env, sync::Arc}; +use siren_core::utils::csprng; +use std::sync::Arc; +use uuid::Uuid; const DISCORD_REDIRECT_PATH: &str = "/api/auth/discord/callback"; @@ -18,14 +28,15 @@ pub fn get_routes() -> Router> { Router::new() .route("/authorize", get(discord_authorize)) .route("/callback", get(discord_callback)) + .route("/connect", get(discord_connect)) } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] struct AuthorizeQuery { redirect_uri: String, } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] struct CallbackQuery { code: String, state: Option, @@ -48,22 +59,66 @@ struct DiscordUser { avatar: Option, } +/// Begin a Discord OAuth login flow (anonymous users) +/// +/// Stores the caller's desired `redirect_uri` in the state cache so the +/// callback can redirect to the right place after login async fn discord_authorize( State(state): State>, Query(query): Query, ) -> impl IntoResponse { let oauth_state = csprng(16); + log::trace!("Discord authorize: {:?}, state={}", query, oauth_state); - state - .discord_authorize_cache - .lock() - .await - .insert(oauth_state.clone(), query.redirect_uri); + state.discord_authorize_cache.lock().await.insert( + oauth_state.clone(), + DiscordOAuthState { + redirect_uri: query.redirect_uri, + connecting_user_id: None, + }, + ); + build_discord_oauth_url(&state, &oauth_state) +} + +/// Begin a Discord OAuth connect flow (already-authenticated users). +/// +/// The caller must have a valid session cookie. Their user ID is stored +/// in the state cache so the callback can link the Discord account to the +/// existing local account. +async fn discord_connect( + State(state): State>, + Query(query): Query, + SessionAuthorization(session): SessionAuthorization, +) -> Result { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + let oauth_state = csprng(16); + log::trace!( + "Discord connect: {:?}, state={} (user_id={})", + query, + oauth_state, + session.user_id + ); + + state.discord_authorize_cache.lock().await.insert( + oauth_state.clone(), + DiscordOAuthState { + redirect_uri: query.redirect_uri, + connecting_user_id: Some(session.user_id), + }, + ); + + Ok(build_discord_oauth_url(&state, &oauth_state)) +} + +fn build_discord_oauth_url( + state: &AppState, + oauth_state: &str, +) -> std::result::Result { let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH); - let encoded_callback = discord_callback_url.replace(':', "%3A").replace('/', "%2F"); + let encoded_callback = urlencoding_encode(&discord_callback_url); - let discord_auth_url = format!( + let url = format!( "https://discord.com/api/oauth2/authorize\ ?client_id={}\ &redirect_uri={}\ @@ -73,8 +128,11 @@ async fn discord_authorize( state.client_id, encoded_callback, oauth_state, ); - match serde_json::to_string(&discord_auth_url) { - Ok(json) => Ok(json), + match serde_json::to_string(&url) { + Ok(json) => { + log::trace!("Discord OAuth URL: {}", json); + Ok(json) + } Err(e) => { log::error!("Failed to serialize Discord OAuth URL: {e}"); Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -82,14 +140,26 @@ async fn discord_authorize( } } +/// Very small percent-encoder for the callback URL (replaces `:` and `/`). +fn urlencoding_encode(s: &str) -> String { + s.replace(':', "%3A").replace('/', "%2F") +} + +/// Handle the Discord OAuth callback. +/// +/// Two modes depending on what was stored in the state cache: +/// - **Login** (`connecting_user_id = None`): look up (or create) the local +/// user for this Discord account, then issue a session cookie and redirect. +/// - **Connect** (`connecting_user_id = Some(id)`): link the Discord account +/// to the existing local user, then redirect (no new session needed). async fn discord_callback( State(state): State>, Query(query): Query, + headers: HeaderMap, + jar: CookieJar, ) -> impl IntoResponse { - match do_oauth_callback(state, query).await { - Ok((token, ui_redirect_uri)) => { - Redirect::temporary(&format!("{}?token={}", ui_redirect_uri, token)).into_response() - } + match do_oauth_callback(state, query, headers, jar).await { + Ok(response) => response, Err((e, ui_redirect_uri)) => { log::error!("OAuth callback error: {:?}", e); let fallback = ui_redirect_uri.unwrap_or_else(|| "/".to_string()); @@ -98,33 +168,37 @@ async fn discord_callback( } } +type CallbackErr = (Error, Option); + async fn do_oauth_callback( state: Arc, query: CallbackQuery, -) -> Result<(String, String), (crate::error::Error, Option)> { - // Validate the state and retrieve the associated UI redirect URI - let ui_redirect_uri = { - let mut oauth_states = state.discord_authorize_cache.lock().await; + headers: HeaderMap, + jar: CookieJar, +) -> std::result::Result { + // Validate state & retrieve stored data + let stored = { + let mut cache = state.discord_authorize_cache.lock().await; match query.state { - Some(ref oauth_state) => match oauth_states.remove(oauth_state) { - Some(uri) => uri, + Some(ref s) => match cache.remove(s) { + Some(v) => v, None => return Err((StatusCode::UNAUTHORIZED.into(), None)), }, None => return Err((StatusCode::UNAUTHORIZED.into(), None)), } }; + log::trace!("Discord callback: query={:?} state={:?}", query, stored); - // Helper closure to tag errors with the redirect URI we already know - let redirect = ui_redirect_uri.clone(); - let err = |s: StatusCode| -> Result<_, (crate::error::Error, Option)> { - Err((s.into(), Some(redirect.clone()))) + let ui_redirect_uri = stored.redirect_uri.clone(); + let err_redirect = |s: StatusCode| -> std::result::Result<_, CallbackErr> { + Err((s.into(), Some(ui_redirect_uri.clone()))) }; - // The discord redirect_uri in the token exchange must match what was sent in /authorize + // The redirect_uri sent to Discord must exactly match /authorize let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH); - // Exchange code for an access token - let token_response = state + // Exchange code for Discord access token + let token_resp = state .client .post("https://discord.com/api/oauth2/token") .form(&[ @@ -136,90 +210,229 @@ async fn do_oauth_callback( ]) .send() .await - .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; + .map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; - if !token_response.status().is_success() { - log::error!( - "Failed to exchange token: {:?}", - token_response.text().await - ); - return err(StatusCode::INTERNAL_SERVER_ERROR); + if !token_resp.status().is_success() { + log::error!("Token exchange failed: {:?}", token_resp.text().await); + return err_redirect(StatusCode::INTERNAL_SERVER_ERROR); } - let token_data: DiscordTokenResponse = token_response + let token_data: DiscordTokenResponse = token_resp .json() .await - .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; + .map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; - // Fetch user information from Discord - let user_response = state + // Fetch Discord user info + let user_resp = state .client .get("https://discord.com/api/users/@me") .bearer_auth(token_data.access_token) .send() .await - .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; + .map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; - if !user_response.status().is_success() { - log::error!( - "Failed to fetch user information: {:?}", - user_response.text().await - ); - return err(StatusCode::INTERNAL_SERVER_ERROR); + if !user_resp.status().is_success() { + log::error!("Discord user fetch failed: {:?}", user_resp.text().await); + return err_redirect(StatusCode::INTERNAL_SERVER_ERROR); } - let user_data: DiscordUser = user_response + let discord_user: DiscordUser = user_resp .json() .await - .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; + .map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; - log::debug!("User authenticated: {:?}", user_data); + log::debug!("Discord OAuth user: {:?}", discord_user); - let user_id: i64 = user_data - .id - .parse::() - .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; - - // Upsert the Discord user into the local users table let pool = siren_core::data::pool(); - sqlx::query( - "INSERT INTO users (id, username, avatar, updated_at) - VALUES ($1, $2, $3, NOW()) - ON CONFLICT (id) DO UPDATE - SET username = EXCLUDED.username, - avatar = EXCLUDED.avatar, - updated_at = NOW()", - ) - .bind(user_id) - .bind(&user_data.username) - .bind(&user_data.avatar) - .execute(pool) - .await - .map_err(|e| { - log::error!("Failed to upsert user: {e}"); - err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() - })?; - // Create and insert the session - let session = Session::new(user_id as u64, user_data.username.clone()); - session - .insert() - .await - .map_err(|e| (e, Some(ui_redirect_uri.clone())))?; + match stored.connecting_user_id { + // Handle connecting an existing local user to a new Discord account + Some(connecting_user_id) => { + // Make sure this Discord account isn't already linked to a DIFFERENT user + let existing_owner: Option = sqlx::query_scalar( + "SELECT user_id FROM user_connections \ + WHERE provider = 'discord' AND provider_user_id = $1", + ) + .bind(&discord_user.id) + .fetch_optional(pool) + .await + .map_err(|e| { + log::error!("DB error checking connection: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; - let issued_at = chrono::Utc::now(); - let claims = BearerTokenClaims { - sub: session.user_id, - name: session.user_name.clone(), - iat: issued_at.timestamp(), - exp: session.expires_at.timestamp(), - jti: session.session_id.clone(), - }; + if let Some(owner_id) = existing_owner { + if owner_id != connecting_user_id { + return err_redirect(StatusCode::CONFLICT); + } + } - let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); - let encoding_key = jsonwebtoken::EncodingKey::from_secret(jwt_secret.as_bytes()); - let token = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &encoding_key) - .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; + // Upsert the connection + sqlx::query( + "INSERT INTO user_connections \ + (user_id, provider, provider_user_id, provider_username, provider_avatar) \ + VALUES ($1, 'discord', $2, $3, $4) \ + ON CONFLICT (user_id, provider) DO UPDATE \ + SET provider_user_id = EXCLUDED.provider_user_id, \ + provider_username = EXCLUDED.provider_username, \ + provider_avatar = EXCLUDED.provider_avatar", + ) + .bind(connecting_user_id) + .bind(&discord_user.id) + .bind(&discord_user.username) + .bind(&discord_user.avatar) + .execute(pool) + .await + .map_err(|e| { + log::error!("DB error upserting connection: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; - Ok((token, ui_redirect_uri)) + // No new session — redirect back to account page with existing cookie + Ok(Redirect::temporary(&ui_redirect_uri).into_response()) + } + + // ------------------------------------------------------------------ // + // LOGIN MODE: look up (or create) the local user for this Discord account + // ------------------------------------------------------------------ // + None => { + // Find existing connection → local user_id + let local_user_id: Option<(Uuid, String)> = sqlx::query_as( + "SELECT u.id, u.username \ + FROM user_connections uc \ + JOIN users u ON u.id = uc.user_id \ + WHERE uc.provider = 'discord' AND uc.provider_user_id = $1", + ) + .bind(&discord_user.id) + .fetch_optional(pool) + .await + .map_err(|e| { + log::error!("DB error looking up discord user: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; + + let (user_id, username) = match local_user_id { + // Already linked — use the existing local user + Some(row) => { + // Keep provider fields up to date + sqlx::query( + "UPDATE user_connections \ + SET provider_username = $1, provider_avatar = $2 \ + WHERE user_id = $3 AND provider = 'discord'", + ) + .bind(&discord_user.username) + .bind(&discord_user.avatar) + .bind(row.0) + .execute(pool) + .await + .map_err(|e| { + log::error!("DB error updating connection: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; + + row + } + + // First login — create a local user + connection + None => { + let base_username = &discord_user.username; + let username = generate_unique_username(pool, base_username) + .await + .map_err(|e| { + log::error!("DB error generating username: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; + + // Create user (no password_hash — OAuth only) + let new_id: Uuid = + sqlx::query_scalar("INSERT INTO users (username) VALUES ($1) RETURNING id") + .bind(&username) + .fetch_one(pool) + .await + .map_err(|e| { + log::error!("DB error creating user: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; + + sqlx::query( + "INSERT INTO user_connections \ + (user_id, provider, provider_user_id, provider_username, provider_avatar) \ + VALUES ($1, 'discord', $2, $3, $4)", + ) + .bind(new_id) + .bind(&discord_user.id) + .bind(&discord_user.username) + .bind(&discord_user.avatar) + .execute(pool) + .await + .map_err(|e| { + log::error!("DB error inserting connection: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; + + (new_id, username) + } + }; + + // Build fingerprint from the callback request's headers + let ip = extract_ip(&headers); + let user_agent = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + let fingerprint = compute_fingerprint(&ip, &user_agent); + + // Issue session + let session = Session::new(user_id, username, fingerprint); + session.insert().await.map_err(|e| { + log::error!("Redis error inserting session: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; + + let token = issue_jwt(&session).map_err(|e| { + log::error!("JWT error: {e}"); + err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() + })?; + + let cookie = build_session_cookie(token); + let new_jar = jar.add(cookie); + + Ok((new_jar, Redirect::temporary(&ui_redirect_uri)).into_response()) + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Return a username derived from `base` that does not yet exist in `users`. +async fn generate_unique_username(pool: &sqlx::PgPool, base: &str) -> crate::error::Result { + // Truncate to 28 chars to leave room for the `_XXXX` suffix + let base = if base.len() > 28 { &base[..28] } else { base }; + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = $1") + .bind(base) + .fetch_one(pool) + .await?; + + if count == 0 { + return Ok(base.to_string()); + } + + for _ in 0..20 { + let candidate = format!("{}_{}", base, csprng(4)); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = $1") + .bind(&candidate) + .fetch_one(pool) + .await?; + if count == 0 { + return Ok(candidate); + } + } + + Err(Error::internal_server_error( + "Could not generate a unique username".into(), + )) } diff --git a/crates/siren-api/src/auth/local.rs b/crates/siren-api/src/auth/local.rs new file mode 100644 index 0000000..59eec3e --- /dev/null +++ b/crates/siren-api/src/auth/local.rs @@ -0,0 +1,474 @@ +use crate::{ + AppState, + auth::{ + SessionAuthorization, + bearer_token::BearerTokenClaims, + middleware::{compute_fingerprint, extract_ip}, + session::Session, + }, + error::{Error, Result}, +}; +use argon2::{ + Argon2, + PasswordHash, + PasswordHasher, + PasswordVerifier, + password_hash::{SaltString, rand_core::OsRng}, +}; +use axum::{ + Json, + Router, + extract::Path, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{delete, get, post, put}, +}; +use axum_extra::extract::CookieJar; +use cookie::{Cookie, SameSite}; +use serde::{Deserialize, Serialize}; +use siren_core::data; +use std::{env, sync::Arc}; +use uuid::Uuid; + +pub fn get_routes() -> Router> { + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) + .route("/logout", post(logout)) + .route("/me", get(me)) + .route("/profile", put(update_profile)) + .route("/change-password", post(change_password)) + .route("/connections/{provider}", delete(disconnect_provider)) +} + +// --------------------------------------------------------------------------- +// Payloads +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct RegisterPayload { + username: String, + password: String, +} + +#[derive(Deserialize)] +struct LoginPayload { + username: String, + password: String, +} + +#[derive(Deserialize)] +struct UpdateProfilePayload { + first_name: Option, + last_name: Option, +} + +#[derive(Deserialize)] +struct ChangePasswordPayload { + /// Required when the user already has a password set. Omit (null) when + /// setting a password for the first time (OAuth-only account). + current_password: Option, + new_password: String, +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct ConnectionInfo { + pub provider: String, + pub provider_username: Option, + pub provider_avatar: Option, +} + +#[derive(Serialize)] +pub struct UserInfo { + pub id: String, + pub username: String, + pub first_name: Option, + pub last_name: Option, + pub email: Option, + /// True when the account has a local password set (i.e. can log in without + /// OAuth and can safely disconnect OAuth providers). + pub has_password: bool, + pub connections: Vec, +} + +// --------------------------------------------------------------------------- +// DB row types +// --------------------------------------------------------------------------- + +#[derive(sqlx::FromRow)] +struct DbUser { + id: Uuid, + username: String, + first_name: Option, + last_name: Option, + email: Option, + password_hash: Option, +} + +#[derive(sqlx::FromRow)] +struct DbConnection { + provider: String, + provider_username: Option, + provider_avatar: Option, +} + +// --------------------------------------------------------------------------- +// Password helpers +// --------------------------------------------------------------------------- + +/// Hash and salt a plaintext password with Argon2. +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| Error::internal_server_error(format!("Password hashing error: {e}"))) +} + +/// Return `true` if `password` matches the stored Argon2id `hash`. +pub fn verify_password(password: &str, hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +// --------------------------------------------------------------------------- +// Cookie / session helpers +// --------------------------------------------------------------------------- + +/// Build the `siren_session` HttpOnly Secure cookie. +pub fn build_session_cookie(token: String) -> Cookie<'static> { + Cookie::build(("siren_session", token)) + .http_only(true) + .secure(true) + .same_site(SameSite::Lax) + .path("/") + .build() +} + +/// Issue a signed JWT for `session`. +pub fn issue_jwt(session: &Session) -> Result { + let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); + let encoding_key = jsonwebtoken::EncodingKey::from_secret(jwt_secret.as_bytes()); + let claims = BearerTokenClaims { + sub: session.user_id.to_string(), + name: session.user_name.clone(), + iat: chrono::Utc::now().timestamp(), + exp: session.expires_at.timestamp(), + jti: session.session_id.clone(), + }; + jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &encoding_key) + .map_err(|e| Error::internal_server_error(format!("JWT error: {e}"))) +} + +/// Create a session + JWT + Set-Cookie for `user_id` / `user_name`. +#[allow(dead_code)] +pub async fn create_session_and_cookie( + user_id: Uuid, + user_name: String, + headers: &HeaderMap, +) -> Result<(CookieJar, ())> { + let ip = extract_ip(headers); + let user_agent = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + let fingerprint = compute_fingerprint(&ip, &user_agent); + + let session = Session::new(user_id, user_name, fingerprint); + session.insert().await?; + + let token = issue_jwt(&session)?; + let cookie = build_session_cookie(token); + let jar = CookieJar::new().add(cookie); + Ok((jar, ())) +} + +// --------------------------------------------------------------------------- +// Helper: load full UserInfo for a given user_id +// --------------------------------------------------------------------------- + +async fn load_user_info(user_id: Uuid) -> Result { + let pool = data::pool(); + + let user: DbUser = sqlx::query_as( + "SELECT id, username, first_name, last_name, email, password_hash FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + let connections: Vec = sqlx::query_as( + "SELECT provider, provider_username, provider_avatar \ + FROM user_connections WHERE user_id = $1", + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + Ok(UserInfo { + id: user.id.to_string(), + username: user.username, + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + has_password: user.password_hash.is_some(), + connections: connections + .into_iter() + .map(|c| ConnectionInfo { + provider: c.provider, + provider_username: c.provider_username, + provider_avatar: c.provider_avatar, + }) + .collect(), + }) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async fn register( + headers: HeaderMap, + jar: CookieJar, + Json(payload): Json, +) -> Result { + let username = payload.username.trim().to_string(); + if username.is_empty() || username.len() > 32 { + return Err(Error::new(422, "Username must be 1–32 characters".into())); + } + if payload.password.len() < 8 { + return Err(Error::new( + 422, + "Password must be at least 8 characters".into(), + )); + } + + let pool = data::pool(); + + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)") + .bind(&username) + .fetch_one(pool) + .await?; + if exists { + return Err(Error::new(409, "Username already taken".into())); + } + + let password_hash = hash_password(&payload.password)?; + let user_id: Uuid = + sqlx::query_scalar("INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id") + .bind(&username) + .bind(&password_hash) + .fetch_one(pool) + .await?; + + let ip = extract_ip(&headers); + let user_agent = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + let fingerprint = compute_fingerprint(&ip, &user_agent); + + let session = Session::new(user_id, username, fingerprint); + session.insert().await?; + + let token = issue_jwt(&session)?; + let cookie = build_session_cookie(token); + + Ok((jar.add(cookie), StatusCode::CREATED)) +} + +async fn login( + headers: HeaderMap, + jar: CookieJar, + Json(payload): Json, +) -> Result { + let pool = data::pool(); + + let row: Option<(Uuid, String, Option)> = + sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = $1") + .bind(&payload.username) + .fetch_optional(pool) + .await?; + + let (user_id, username, password_hash) = + row.ok_or_else(|| Error::new(401, "Invalid username or password".into()))?; + + let hash = + password_hash.ok_or_else(|| Error::new(401, "This account uses external login only".into()))?; + + if !verify_password(&payload.password, &hash) { + return Err(Error::new(401, "Invalid username or password".into())); + } + + let ip = extract_ip(&headers); + let user_agent = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + let fingerprint = compute_fingerprint(&ip, &user_agent); + + let session = Session::new(user_id, username, fingerprint); + session.insert().await?; + + let token = issue_jwt(&session)?; + let cookie = build_session_cookie(token); + + Ok((jar.add(cookie), StatusCode::OK)) +} + +async fn logout( + jar: CookieJar, + SessionAuthorization(session): SessionAuthorization, +) -> impl IntoResponse { + if let Some(s) = session { + let _ = Session::delete(&s.session_id).await; + } + let removal = Cookie::build(("siren_session", "")) + .http_only(true) + .secure(true) + .same_site(SameSite::Lax) + .path("/") + .max_age(cookie::time::Duration::seconds(0)) + .build(); + (jar.add(removal), StatusCode::NO_CONTENT) +} + +async fn me(SessionAuthorization(session): SessionAuthorization) -> Result> { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + Ok(Json(load_user_info(session.user_id).await?)) +} + +async fn update_profile( + SessionAuthorization(session): SessionAuthorization, + Json(payload): Json, +) -> Result> { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + let pool = data::pool(); + + // Validate lengths if provided + if let Some(ref f) = payload.first_name { + if f.len() > 64 { + return Err(Error::new(422, "First name must be ≤ 64 characters".into())); + } + } + if let Some(ref l) = payload.last_name { + if l.len() > 64 { + return Err(Error::new(422, "Last name must be ≤ 64 characters".into())); + } + } + + // COALESCE: only update fields that were sent (Some vs None) + // We allow explicitly setting a field to an empty string to clear it, + // so we map Some("") → SQL NULL. + let first = payload + .first_name + .map(|s| if s.trim().is_empty() { None } else { Some(s) }); + let last = payload + .last_name + .map(|s| if s.trim().is_empty() { None } else { Some(s) }); + + sqlx::query( + "UPDATE users + SET first_name = CASE WHEN $2 THEN $3 ELSE first_name END, + last_name = CASE WHEN $4 THEN $5 ELSE last_name END, + updated_at = NOW() + WHERE id = $1", + ) + .bind(session.user_id) + .bind(first.is_some()) + .bind(first.flatten()) + .bind(last.is_some()) + .bind(last.flatten()) + .execute(pool) + .await?; + + Ok(Json(load_user_info(session.user_id).await?)) +} + +async fn change_password( + SessionAuthorization(session): SessionAuthorization, + Json(payload): Json, +) -> Result { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + + if payload.new_password.len() < 8 { + return Err(Error::new( + 422, + "New password must be at least 8 characters".into(), + )); + } + + let pool = data::pool(); + + let existing_hash: Option = + sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") + .bind(session.user_id) + .fetch_optional(pool) + .await? + .flatten(); + + match existing_hash { + Some(hash) => { + // User already has a password — require current password + let current = payload + .current_password + .ok_or_else(|| Error::new(422, "Current password is required".into()))?; + if !verify_password(¤t, &hash) { + return Err(Error::new(401, "Current password is incorrect".into())); + } + } + None => { + // OAuth-only account — allow setting a password without current_password + } + } + + let new_hash = hash_password(&payload.new_password)?; + sqlx::query("UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2") + .bind(&new_hash) + .bind(session.user_id) + .execute(pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn disconnect_provider( + SessionAuthorization(session): SessionAuthorization, + Path(provider): Path, +) -> Result { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + let pool = data::pool(); + + // Safety check: ensure the user has a password before disconnecting OAuth. + let has_password: bool = + sqlx::query_scalar("SELECT password_hash IS NOT NULL FROM users WHERE id = $1") + .bind(session.user_id) + .fetch_one(pool) + .await?; + + if !has_password { + return Err(Error::new( + 422, + "Set a password before disconnecting your OAuth provider".into(), + )); + } + + sqlx::query("DELETE FROM user_connections WHERE user_id = $1 AND provider = $2") + .bind(session.user_id) + .bind(&provider) + .execute(pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/siren-api/src/auth/middleware.rs b/crates/siren-api/src/auth/middleware.rs index b47ae5b..a52eb75 100644 --- a/crates/siren-api/src/auth/middleware.rs +++ b/crates/siren-api/src/auth/middleware.rs @@ -4,63 +4,23 @@ use crate::{ }; use axum::{ extract::FromRequestParts, - http::{Method, StatusCode, request::Parts}, -}; -use axum_extra::{ - TypedHeader, - headers::{Authorization, authorization::Bearer}, + http::{HeaderMap, StatusCode, request::Parts}, }; +use axum_extra::extract::CookieJar; use chrono::Utc; use jsonwebtoken::{DecodingKey, Validation, decode}; +use sha2::{Digest, Sha256}; -// --------------------------------------------------------------------------- -// AuthorizationMiddleware — rejects unauthenticated requests -// --------------------------------------------------------------------------- +pub const COOKIE_NAME: &str = "siren_session"; -pub struct AuthorizationMiddleware; +/// Wraps an optional authenticated session. +/// +/// Handlers using this extractor work for both authenticated and +/// unauthenticated callers. A valid `siren_session` cookie grants a +/// `Some(session)`. +pub struct SessionAuthorization(pub Option); -impl FromRequestParts for AuthorizationMiddleware -where - S: Send + Sync, -{ - type Rejection = StatusCode; - - async fn from_request_parts( - parts: &mut Parts, - state: &S, - ) -> std::result::Result { - // For options requests browsers will not send the authorization header. - if parts.method == Method::OPTIONS { - return Ok(Self); - } - - // Check for a Bearer token in the `Authorization` header. - if let Ok(TypedHeader(Authorization(bearer))) = - TypedHeader::>::from_request_parts(parts, state).await - { - return match check_bearer_auth(bearer.token()).await { - Ok(session) => { - parts.extensions.insert(session); - Ok(Self) - } - Err(_) => Err(StatusCode::UNAUTHORIZED), - }; - } - - Err(StatusCode::UNAUTHORIZED) - } -} - -// --------------------------------------------------------------------------- -// OptionalAuth — extracts a Session if present, otherwise None -// --------------------------------------------------------------------------- - -/// Wraps an optional authenticated session. -/// Handlers that use this extractor work for both authenticated and -/// unauthenticated callers; callers with a valid Bearer token get a `Some(session)`. -pub struct OptionalAuth(pub Option); - -impl FromRequestParts for OptionalAuth +impl FromRequestParts for SessionAuthorization where S: Send + Sync, { @@ -70,38 +30,103 @@ where parts: &mut Parts, state: &S, ) -> std::result::Result { - if let Ok(TypedHeader(Authorization(bearer))) = - TypedHeader::>::from_request_parts(parts, state).await - { - if let Ok(session) = check_bearer_auth(bearer.token()).await { + let jar = CookieJar::from_request_parts(parts, state).await.unwrap(); + + if let Some(cookie) = jar.get(COOKIE_NAME) { + let ip = extract_ip(&parts.headers); + let user_agent = parts + .headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + + if let Ok(session) = check_cookie_auth(cookie.value(), &ip, &user_agent).await { parts.extensions.insert(session.clone()); return Ok(Self(Some(session))); } } + Ok(Self(None)) } } -// --------------------------------------------------------------------------- -// Shared helper -// --------------------------------------------------------------------------- +/// Extract the client IP from common proxy headers, falling back to "unknown". +pub fn extract_ip(headers: &HeaderMap) -> String { + if let Some(forwarded) = headers.get("x-forwarded-for") { + if let Ok(val) = forwarded.to_str() { + if let Some(ip) = val.split(',').next() { + return ip.trim().to_string(); + } + } + } + if let Some(real_ip) = headers.get("x-real-ip") { + if let Ok(val) = real_ip.to_str() { + return val.trim().to_string(); + } + } + "unknown".to_string() +} -pub async fn check_bearer_auth(bearer_token: &str) -> Result { - let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set in the environment"); +/// Compute a fingerprint from client IP and User-Agent +/// +/// Stored in the Redis session at login time and re-checked on every +/// authenticated request so that a stolen cookie is detected when used +/// from a different device or IP. +pub fn compute_fingerprint(ip: &str, user_agent: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("{ip}:{user_agent}")); + format!("{:x}", hasher.finalize()) +} + +/// Validate a JWT cookie value, look up the Redis session, and verify the +/// fingerprint against the current request's IP / User-Agent. +pub async fn check_cookie_auth(token: &str, ip: &str, user_agent: &str) -> Result { + let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes()); - let token_data = decode::(bearer_token, &decoding_key, &Validation::default()) + let token_data = decode::(token, &decoding_key, &Validation::default()) .map_err(|_| StatusCode::UNAUTHORIZED)?; let claims = token_data.claims; - let now = Utc::now().timestamp(); - if claims.exp < now { + if claims.exp < Utc::now().timestamp() { return Err(StatusCode::UNAUTHORIZED.into()); } - match Session::find(&claims.jti).await { - Ok(Some(session)) => Ok(session), - _ => Err(StatusCode::UNAUTHORIZED)?, + let session = match Session::find(&claims.jti).await? { + Some(s) => s, + None => return Err(StatusCode::UNAUTHORIZED.into()), + }; + + // Reject if the request comes from a different device / network + let expected = compute_fingerprint(ip, user_agent); + if session.fingerprint != expected { + log::warn!( + "Fingerprint mismatch for session {}: stored={} request={}", + claims.jti, + session.fingerprint, + expected + ); + return Err(StatusCode::UNAUTHORIZED.into()); } + + Ok(session) +} + +/// Parse the raw `Cookie:` header string and validate the siren_session +/// value. Used by the WebSocket upgrade handler where we cannot use the +/// normal `FromRequestParts` machinery. +pub async fn check_cookie_from_header_str( + cookie_header: &str, + ip: &str, + user_agent: &str, +) -> Option { + for pair in cookie_header.split(';') { + let pair = pair.trim(); + if let Some(value) = pair.strip_prefix("siren_session=") { + return check_cookie_auth(value, ip, user_agent).await.ok(); + } + } + None } diff --git a/crates/siren-api/src/auth/mod.rs b/crates/siren-api/src/auth/mod.rs index 20e5f42..f0dc209 100644 --- a/crates/siren-api/src/auth/mod.rs +++ b/crates/siren-api/src/auth/mod.rs @@ -1,24 +1,20 @@ use crate::AppState; use axum::Router; -use rand::RngExt; use std::sync::Arc; -mod discord; -mod session; -pub use session::Session; mod bearer_token; +mod discord; +mod local; +mod session; + +pub use local::UserInfo; +pub use session::Session; + pub mod middleware; -pub use middleware::{AuthorizationMiddleware, OptionalAuth}; +pub use middleware::SessionAuthorization; pub fn get_routes() -> Router> { - Router::new().nest("/discord", discord::get_routes()) -} - -pub fn csprng(take: usize) -> String { - // Generate a CSPRNG ID using alphanumeric characters (a-z, A-Z, 0-9) - rand::rng() - .sample_iter(rand::distr::Alphanumeric) - .take(take) - .map(char::from) - .collect() + Router::new() + .merge(local::get_routes()) + .nest("/discord", discord::get_routes()) } diff --git a/crates/siren-api/src/auth/session.rs b/crates/siren-api/src/auth/session.rs index 9769924..da54d39 100644 --- a/crates/siren-api/src/auth/session.rs +++ b/crates/siren-api/src/auth/session.rs @@ -1,38 +1,45 @@ -use crate::{auth::csprng, error::Result}; +use crate::error::Result; use chrono::{DateTime, Utc}; use redis::{AsyncCommands, RedisResult}; use serde::{Deserialize, Serialize}; -use siren_core::data; +use siren_core::{data, utils::csprng}; use std::{env, sync::OnceLock}; +use uuid::Uuid; static SESSION_TTL: OnceLock = OnceLock::new(); -fn get_session_ttl() -> i64 { - // Initialize the SESSION_TTL value lazily +pub fn get_session_ttl() -> i64 { *SESSION_TTL.get_or_init(|| { env::var("API_SESSION_TTL") .ok() .and_then(|val| val.parse::().ok()) - .unwrap_or(3600) // Default to 3600 seconds (1 hour) + .unwrap_or(86400) // 24 hours }) } +/// A server-side session stored in Redis. +/// +/// Contains the user's identity and a `fingerprint` (SHA-256 of +/// `{client_ip}:{user_agent}`) so that stolen cookies can be detected. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Session { pub session_id: String, - pub user_id: u64, + pub user_id: Uuid, pub user_name: String, + /// SHA-256 hex of `{client_ip}:{user_agent}` captured at login time. + pub fingerprint: String, pub expires_at: DateTime, } impl Session { - pub fn new(user_id: u64, user_name: String) -> Session { + pub fn new(user_id: Uuid, user_name: String, fingerprint: String) -> Session { let now = Utc::now(); let session_ttl = get_session_ttl(); Session { session_id: csprng(32), user_id, user_name, + fingerprint, expires_at: now + chrono::Duration::seconds(session_ttl), } } diff --git a/crates/siren-api/src/dice/mod.rs b/crates/siren-api/src/dice/mod.rs index 6791a3d..02d3268 100644 --- a/crates/siren-api/src/dice/mod.rs +++ b/crates/siren-api/src/dice/mod.rs @@ -1,14 +1,13 @@ use crate::{ AppState, - auth::{AuthorizationMiddleware, Session}, + auth::SessionAuthorization, error::{Error, Result}, }; use axum::{ - Extension, Json, Router, extract::{Path, State}, - middleware::from_extractor, + http::StatusCode, routing::post, }; use serde::{Deserialize, Serialize}; @@ -18,9 +17,7 @@ use std::{fmt::Display, str::FromStr, sync::Arc}; use uuid::Uuid; pub fn get_routes() -> Router> { - Router::new() - .route("/{guild_id}/track", post(add_track_dice)) - .route_layer(from_extractor::()) + Router::new().route("/{guild_id}/track", post(add_track_dice)) } const TABLE_NAME: &str = "dice_track"; @@ -156,16 +153,34 @@ impl InsertDiceTrack { } pub async fn add_track_dice( - Extension(session): Extension, + SessionAuthorization(session): SessionAuthorization, State(state): State>, Path(guild_id): Path, Json(payload): Json, ) -> Result> { - // Check if the user exists in the cache - let owner_id = session.user_id; - let owner_id = match state.cache.user(owner_id) { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + + // Resolve Discord snowflake for this local user and verify they exist in cache + let discord_snowflake: u64 = { + let pool = siren_core::data::pool(); + let pid: Option = sqlx::query_scalar( + "SELECT provider_user_id FROM user_connections \ + WHERE user_id = $1 AND provider = 'discord'", + ) + .bind(session.user_id) + .fetch_optional(pool) + .await?; + pid + .and_then(|s| s.parse().ok()) + .ok_or_else(|| Error::not_found("Discord account not connected".to_string()))? + }; + let owner_id = match state.cache.user(discord_snowflake) { Some(user) => user.id, - None => return Err(Error::not_found("User not found".to_string())), + None => { + return Err(Error::not_found( + "User not found in Discord cache".to_string(), + )); + } }; // Validate if the guild exists in the cache @@ -182,10 +197,7 @@ pub async fn add_track_dice( dice: format_roll(dice.0, dice.1, dice.2), user_id: payload.user_id, value: payload.value, - operator: match payload.operator { - None => None, - Some(s) => Some(s.to_string()), - }, + operator: payload.operator.map(|s| s.to_string()), }; // Check for existing dice tracks diff --git a/crates/siren-api/src/grid/mod.rs b/crates/siren-api/src/grid/mod.rs index a092b4b..5c382e4 100644 --- a/crates/siren-api/src/grid/mod.rs +++ b/crates/siren-api/src/grid/mod.rs @@ -2,7 +2,7 @@ pub mod model; use crate::{ AppState, - auth::{OptionalAuth, Session, csprng, middleware::check_bearer_auth}, + auth::{Session, SessionAuthorization, middleware::check_cookie_from_header_str}, error::{Error, Result}, }; use axum::{ @@ -10,81 +10,98 @@ use axum::{ Router, extract::{ Path, - Query, State, WebSocketUpgrade, ws::{Message, WebSocket}, }, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{delete, get, post, put}, }; use futures_util::{SinkExt, StreamExt}; use model::{ + AccessRequestWithUser, ClientMessage, + CreateAccessRequestPayload, CreateMapPayload, GridCell, GridMap, GridToken, - MapPermission, + ListedMap, MapRole, MapState, + PermissionWithUser, + ResolveAccessRequestPayload, ServerMessage, + UpdateMapPayload, UpdatePermissionPayload, }; -use serde::Deserialize; +use siren_core::utils::csprng; use std::sync::Arc; use tokio::sync::broadcast; +use uuid::Uuid; pub fn get_routes() -> Router> { Router::new() .route("/maps", get(list_maps)) .route("/maps", post(create_map)) .route("/maps/{id}", get(get_map)) + .route("/maps/{id}", put(update_map)) .route("/maps/{id}", delete(delete_map)) .route("/maps/{id}/permissions", get(list_permissions)) .route("/maps/{id}/permissions", put(update_permission)) + .route("/maps/{id}/favorite", post(favorite_map)) + .route("/maps/{id}/favorite", delete(unfavorite_map)) + .route("/maps/{id}/access-requests", post(create_access_request)) + .route("/maps/{id}/access-requests", get(list_access_requests)) + .route( + "/maps/{id}/access-requests/{request_id}", + put(resolve_access_request), + ) .route("/maps/{id}/ws", get(ws_handler)) } // --------------------------------------------------------------------------- -// Permission helpers +// Access helpers // --------------------------------------------------------------------------- /// Fetch the role of `user_id` on `map_id`, or `None` if no record exists. -async fn get_user_role(map_id: &str, user_id: i64) -> crate::error::Result> { +async fn get_user_role(map_id: &str, user_id: Uuid) -> Result> { let pool = siren_core::data::pool(); - let perm: Option = sqlx::query_as( - "SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1 AND user_id = $2", - ) - .bind(map_id) - .bind(user_id) - .fetch_optional(pool) - .await?; - Ok(perm.map(|p| p.role)) + let role: Option = + sqlx::query_scalar("SELECT role FROM map_permissions WHERE map_id = $1 AND user_id = $2") + .bind(map_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(role.and_then(|r| match r.as_str() { + "owner" => Some(MapRole::Owner), + "editor" => Some(MapRole::Editor), + "viewer" => Some(MapRole::Viewer), + _ => None, + })) } -/// Returns whether the caller can view the map: -/// - Public maps: always true. -/// - Private maps: true only if the user has any role. +/// Returns whether the caller can view the map. async fn can_view(map: &GridMap, session: &Option) -> bool { - if map.is_public { + if map.public_access == "public_view" || map.public_access == "public_edit" { return true; } let Some(s) = session else { return false }; - let user_id = s.user_id as i64; - get_user_role(&map.id, user_id) + get_user_role(&map.id, s.user_id) .await .ok() .flatten() .is_some() } -/// Returns whether the caller can edit the map (editor or owner role). +/// Returns whether the caller can edit the map (editor or owner role, or public_edit). async fn can_edit(map: &GridMap, session: &Option) -> bool { + if map.public_access == "public_edit" { + return true; + } let Some(s) = session else { return false }; - let user_id = s.user_id as i64; - get_user_role(&map.id, user_id) + get_user_role(&map.id, s.user_id) .await .ok() .flatten() @@ -95,8 +112,7 @@ async fn can_edit(map: &GridMap, session: &Option) -> bool { /// Returns whether the caller is the owner. async fn is_owner(map: &GridMap, session: &Option) -> bool { let Some(s) = session else { return false }; - let user_id = s.user_id as i64; - get_user_role(&map.id, user_id) + get_user_role(&map.id, s.user_id) .await .ok() .flatten() @@ -105,60 +121,68 @@ async fn is_owner(map: &GridMap, session: &Option) -> bool { } // --------------------------------------------------------------------------- -// REST handlers +// Map CRUD // --------------------------------------------------------------------------- -pub async fn list_maps(OptionalAuth(session): OptionalAuth) -> Result>> { +pub async fn list_maps( + SessionAuthorization(session): SessionAuthorization, +) -> Result>> { let pool = siren_core::data::pool(); - let maps: Vec = match &session { + let maps: Vec = match &session { Some(s) => { - let user_id = s.user_id as i64; sqlx::query_as( - "SELECT DISTINCT gm.* + "SELECT + gm.id, gm.name, gm.public_access, gm.owner_id, + u.username AS owner_username, + gm.colors, gm.created_at, gm.updated_at, + mp.role AS user_role, + (mf.user_id IS NOT NULL) AS is_favorited FROM grid_maps gm + JOIN users u ON u.id = gm.owner_id LEFT JOIN map_permissions mp ON mp.map_id = gm.id AND mp.user_id = $1 - WHERE gm.is_public = TRUE OR mp.user_id IS NOT NULL - ORDER BY gm.created_at DESC", + LEFT JOIN map_favorites mf ON mf.map_id = gm.id AND mf.user_id = $1 + WHERE mp.user_id IS NOT NULL OR mf.user_id IS NOT NULL + ORDER BY gm.updated_at DESC", ) - .bind(user_id) + .bind(s.user_id) .fetch_all(pool) .await? } - None => { - sqlx::query_as("SELECT * FROM grid_maps WHERE is_public = TRUE ORDER BY created_at DESC") - .fetch_all(pool) - .await? - } + None => vec![], }; Ok(Json(maps)) } pub async fn create_map( - OptionalAuth(session): OptionalAuth, + SessionAuthorization(session): SessionAuthorization, Json(payload): Json, ) -> Result<(StatusCode, Json)> { let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; - let user_id = session.user_id as i64; + let public_access = payload.public_access.as_str(); + if !matches!(public_access, "private" | "public_view" | "public_edit") { + return Err(Error::new(422, "Invalid public_access value".into())); + } + let map_id = csprng(32); let pool = siren_core::data::pool(); let map: GridMap = sqlx::query_as( - "INSERT INTO grid_maps (id, name, is_public, owner_id) + "INSERT INTO grid_maps (id, name, public_access, owner_id) VALUES ($1, $2, $3, $4) RETURNING *", ) .bind(&map_id) .bind(&payload.name) - .bind(payload.is_public) - .bind(user_id) + .bind(&payload.public_access) + .bind(session.user_id) .fetch_one(pool) .await?; // Auto-assign the creator as owner in map_permissions sqlx::query("INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, 'owner')") .bind(&map_id) - .bind(user_id) + .bind(session.user_id) .execute(pool) .await?; @@ -166,7 +190,7 @@ pub async fn create_map( } pub async fn get_map( - OptionalAuth(session): OptionalAuth, + SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result> { let pool = siren_core::data::pool(); @@ -195,8 +219,51 @@ pub async fn get_map( Ok(Json(MapState { map, cells, tokens })) } +pub async fn update_map( + SessionAuthorization(session): SessionAuthorization, + Path(id): Path, + Json(payload): Json, +) -> Result> { + let pool = siren_core::data::pool(); + + let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") + .bind(&id) + .fetch_optional(pool) + .await?; + + let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; + + if !is_owner(&map, &session).await { + return Err(StatusCode::FORBIDDEN.into()); + } + + if let Some(ref pa) = payload.public_access { + if !matches!(pa.as_str(), "private" | "public_view" | "public_edit") { + return Err(Error::new(422, "Invalid public_access value".into())); + } + } + + let new_name = payload.name.as_deref().unwrap_or(&map.name); + let new_pa = payload + .public_access + .as_deref() + .unwrap_or(&map.public_access); + + let updated: GridMap = sqlx::query_as( + "UPDATE grid_maps SET name = $1, public_access = $2, updated_at = NOW() + WHERE id = $3 RETURNING *", + ) + .bind(new_name) + .bind(new_pa) + .bind(&id) + .fetch_one(pool) + .await?; + + Ok(Json(updated)) +} + pub async fn delete_map( - OptionalAuth(session): OptionalAuth, + SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result { let pool = siren_core::data::pool(); @@ -225,9 +292,9 @@ pub async fn delete_map( // --------------------------------------------------------------------------- pub async fn list_permissions( - OptionalAuth(session): OptionalAuth, + SessionAuthorization(session): SessionAuthorization, Path(id): Path, -) -> Result>> { +) -> Result>> { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") @@ -241,17 +308,22 @@ pub async fn list_permissions( return Err(StatusCode::FORBIDDEN.into()); } - let perms: Vec = - sqlx::query_as("SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1") - .bind(&id) - .fetch_all(pool) - .await?; + let perms: Vec = sqlx::query_as( + "SELECT mp.map_id, mp.user_id, u.username, mp.role + FROM map_permissions mp + JOIN users u ON u.id = mp.user_id + WHERE mp.map_id = $1 + ORDER BY mp.role, u.username", + ) + .bind(&id) + .fetch_all(pool) + .await?; Ok(Json(perms)) } pub async fn update_permission( - OptionalAuth(session): OptionalAuth, + SessionAuthorization(session): SessionAuthorization, Path(id): Path, Json(payload): Json, ) -> Result { @@ -268,10 +340,23 @@ pub async fn update_permission( return Err(StatusCode::FORBIDDEN.into()); } - // Prevent the owner from removing their own owner record - let caller_id = session.as_ref().map(|s| s.user_id as i64).unwrap_or(0); - if payload.user_id == caller_id && payload.role.as_ref().map(|r| r.is_owner()) == Some(false) { - return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY)); + // Resolve username → user_id + let target_id: Option = sqlx::query_scalar("SELECT id FROM users WHERE username = $1") + .bind(&payload.username) + .fetch_optional(pool) + .await?; + + let target_id = target_id.ok_or_else(|| Error::not_found("User not found".into()))?; + + // Prevent the owner from stripping their own owner record + if let Some(ref s) = session { + if target_id == s.user_id { + if let Some(ref role) = payload.role { + if !role.is_owner() { + return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY)); + } + } + } } match payload.role { @@ -282,7 +367,7 @@ pub async fn update_permission( ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role", ) .bind(&id) - .bind(payload.user_id) + .bind(target_id) .bind(role) .execute(pool) .await?; @@ -290,7 +375,7 @@ pub async fn update_permission( None => { sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2") .bind(&id) - .bind(payload.user_id) + .bind(target_id) .execute(pool) .await?; } @@ -299,26 +384,215 @@ pub async fn update_permission( Ok(StatusCode::NO_CONTENT) } +pub async fn favorite_map( + SessionAuthorization(session): SessionAuthorization, + Path(id): Path, +) -> Result { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + let pool = siren_core::data::pool(); + + // Verify the map exists and is viewable + let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") + .bind(&id) + .fetch_optional(pool) + .await?; + let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; + if !can_view(&map, &Some(session.clone())).await { + return Err(StatusCode::FORBIDDEN.into()); + } + + sqlx::query( + "INSERT INTO map_favorites (user_id, map_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING", + ) + .bind(session.user_id) + .bind(&id) + .execute(pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn unfavorite_map( + SessionAuthorization(session): SessionAuthorization, + Path(id): Path, +) -> Result { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + let pool = siren_core::data::pool(); + + sqlx::query("DELETE FROM map_favorites WHERE user_id = $1 AND map_id = $2") + .bind(session.user_id) + .bind(&id) + .execute(pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +// --------------------------------------------------------------------------- +// Access Requests +// --------------------------------------------------------------------------- + +pub async fn create_access_request( + SessionAuthorization(session): SessionAuthorization, + Path(id): Path, + Json(payload): Json, +) -> Result { + let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + + // Only editor and viewer roles can be requested + if matches!(payload.role, MapRole::Owner) { + return Err(Error::new(422, "Cannot request owner role".into())); + } + + let pool = siren_core::data::pool(); + + let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") + .bind(&id) + .fetch_optional(pool) + .await?; + map.ok_or_else(|| Error::not_found("Map not found".into()))?; + + // Check if user already has a direct permission + let existing_role = get_user_role(&id, session.user_id).await?; + if existing_role.is_some() { + return Err(Error::new( + 409, + "You already have access to this map".into(), + )); + } + + // Upsert the request (update role if they change their mind) + sqlx::query( + "INSERT INTO map_access_requests (map_id, user_id, requested_role, status, updated_at) + VALUES ($1, $2, $3, 'pending', NOW()) + ON CONFLICT (map_id, user_id) + DO UPDATE SET requested_role = EXCLUDED.requested_role, + status = 'pending', + updated_at = NOW()", + ) + .bind(&id) + .bind(session.user_id) + .bind(&payload.role) + .execute(pool) + .await?; + + Ok(StatusCode::CREATED) +} + +pub async fn list_access_requests( + SessionAuthorization(session): SessionAuthorization, + Path(id): Path, +) -> Result>> { + let pool = siren_core::data::pool(); + + let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") + .bind(&id) + .fetch_optional(pool) + .await?; + let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; + + if !is_owner(&map, &session).await { + return Err(StatusCode::FORBIDDEN.into()); + } + + let requests: Vec = sqlx::query_as( + "SELECT mar.id, mar.map_id, mar.user_id, u.username, + mar.requested_role, mar.status, mar.created_at, mar.updated_at + FROM map_access_requests mar + JOIN users u ON u.id = mar.user_id + WHERE mar.map_id = $1 AND mar.status = 'pending' + ORDER BY mar.created_at ASC", + ) + .bind(&id) + .fetch_all(pool) + .await?; + + Ok(Json(requests)) +} + +pub async fn resolve_access_request( + SessionAuthorization(session): SessionAuthorization, + Path((map_id, request_id)): Path<(String, Uuid)>, + Json(payload): Json, +) -> Result { + let pool = siren_core::data::pool(); + + let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") + .bind(&map_id) + .fetch_optional(pool) + .await?; + let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; + + if !is_owner(&map, &session).await { + return Err(StatusCode::FORBIDDEN.into()); + } + + if !matches!(payload.action.as_str(), "approve" | "deny") { + return Err(Error::new(422, "action must be 'approve' or 'deny'".into())); + } + + // Fetch the request + let req: Option<(Uuid, String)> = sqlx::query_as( + "SELECT user_id, requested_role FROM map_access_requests WHERE id = $1 AND map_id = $2", + ) + .bind(request_id) + .bind(&map_id) + .fetch_optional(pool) + .await?; + + let (user_id, role) = req.ok_or_else(|| Error::not_found("Access request not found".into()))?; + + if payload.action == "approve" { + // Grant the requested role + sqlx::query( + "INSERT INTO map_permissions (map_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role", + ) + .bind(&map_id) + .bind(user_id) + .bind(&role) + .execute(pool) + .await?; + } + + // Mark request as resolved + let new_status = if payload.action == "approve" { + "approved" + } else { + "denied" + }; + sqlx::query("UPDATE map_access_requests SET status = $1, updated_at = NOW() WHERE id = $2") + .bind(new_status) + .bind(request_id) + .execute(pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + // --------------------------------------------------------------------------- // WebSocket handler // --------------------------------------------------------------------------- -#[derive(Deserialize)] -pub struct WsQuery { - /// Optional Bearer token passed as a query parameter for WS auth. - token: Option, -} - pub async fn ws_handler( ws: WebSocketUpgrade, State(state): State>, Path(map_id): Path, - Query(query): Query, + headers: HeaderMap, ) -> impl IntoResponse { - // Resolve the session from query param (WS can't easily send headers) - let session: Option = match query.token { - Some(ref tok) => check_bearer_auth(tok).await.ok(), - None => None, + let session: Option = { + let ip = crate::auth::middleware::extract_ip(&headers); + let user_agent = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown"); + if let Some(cookie_header) = headers.get("cookie").and_then(|h| h.to_str().ok()) { + check_cookie_from_header_str(cookie_header, &ip, user_agent).await + } else { + None + } }; ws.on_upgrade(move |socket| handle_socket(socket, state, map_id, session)) @@ -330,20 +604,17 @@ async fn handle_socket( map_id: String, session: Option, ) { - // Load the map and verify the caller can view it let map_state = match fetch_map_state(&map_id).await { Ok(ms) => ms, - Err(_) => return, // map doesn't exist + Err(_) => return, }; if !can_view(&map_state.map, &session).await { - // Refuse the connection silently (upgrade already happened; just close) return; } let editor = can_edit(&map_state.map, &session).await; - // Get or create a broadcast channel for this map let tx = state .map_rooms .entry(map_id.clone()) @@ -356,7 +627,6 @@ async fn handle_socket( let (mut ws_tx, mut ws_rx) = socket.split(); - // Send the current full map state to the newly connected client let init_msg = ServerMessage::State { cells: map_state.cells, tokens: map_state.tokens, @@ -366,7 +636,6 @@ async fn handle_socket( let _ = ws_tx.send(Message::Text(json.into())).await; } - // Task 1: forward broadcast messages to this socket let mut send_task = tokio::spawn(async move { while let Ok(json) = rx.recv().await { if ws_tx.send(Message::Text(json.into())).await.is_err() { @@ -375,7 +644,6 @@ async fn handle_socket( } }); - // Task 2: receive messages from this client, persist, and broadcast let tx_clone = tx.clone(); let mut recv_task = tokio::spawn(async move { while let Some(Ok(msg)) = ws_rx.next().await { @@ -430,7 +698,6 @@ async fn handle_client_message( } }; - // All mutating messages require editor or owner role if !can_edit { let err = ServerMessage::Error { message: "You do not have permission to edit this map.".into(), diff --git a/crates/siren-api/src/grid/model.rs b/crates/siren-api/src/grid/model.rs index d1bfe16..4207f11 100644 --- a/crates/siren-api/src/grid/model.rs +++ b/crates/siren-api/src/grid/model.rs @@ -1,5 +1,6 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; +use uuid::Uuid; // --------------------------------------------------------------------------- // Map Role / Permission @@ -26,10 +27,12 @@ impl MapRole { } } -#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)] -pub struct MapPermission { +/// A permission record with the associated user's username included (for display). +#[derive(Serialize, sqlx::FromRow, Clone, Debug)] +pub struct PermissionWithUser { pub map_id: String, - pub user_id: i64, + pub user_id: Uuid, + pub username: String, pub role: MapRole, } @@ -37,32 +40,92 @@ pub struct MapPermission { // Grid Map // --------------------------------------------------------------------------- +/// Core map record as stored/returned by create, get, and update endpoints. #[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)] pub struct GridMap { pub id: String, pub name: String, - pub is_public: bool, - pub owner_id: i64, + /// One of: "private", "public_view", "public_edit" + pub public_access: String, + pub owner_id: Uuid, pub colors: Vec, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, } -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct CreateMapPayload { +/// Extended map record returned by the list endpoint. +/// Includes the owner's username, the caller's role (if any), and a +/// favorited flag. +#[derive(Serialize, sqlx::FromRow, Clone, Debug)] +pub struct ListedMap { + pub id: String, pub name: String, - #[serde(default)] - pub is_public: bool, + pub public_access: String, + pub owner_id: Uuid, + pub owner_username: String, + pub colors: Vec, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + /// The authenticated caller's role on this map, or NULL if they only have it + /// via a favorite (no explicit permission). + pub user_role: Option, + pub is_favorited: bool, } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Deserialize, Clone, Debug)] +pub struct CreateMapPayload { + pub name: String, + /// Defaults to "private" when omitted. + #[serde(default = "default_private")] + pub public_access: String, +} + +fn default_private() -> String { + "private".to_string() +} + +#[derive(Deserialize, Clone, Debug)] +pub struct UpdateMapPayload { + pub name: Option, + pub public_access: Option, +} + +#[derive(Deserialize, Clone, Debug)] pub struct UpdatePermissionPayload { - /// Discord user ID of the target user. - pub user_id: i64, - /// New role to assign. Omit (null) to remove the permission entry. + /// Username of the target user (looked up server-side). + pub username: String, + /// New role to assign. `null` removes the permission entry. pub role: Option, } +// --------------------------------------------------------------------------- +// Map Access Requests +// --------------------------------------------------------------------------- + +/// An access-request row joined with the requesting user's username. +#[derive(Serialize, sqlx::FromRow, Clone, Debug)] +pub struct AccessRequestWithUser { + pub id: Uuid, + pub map_id: String, + pub user_id: Uuid, + pub username: String, + pub requested_role: MapRole, + pub status: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct CreateAccessRequestPayload { + pub role: MapRole, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct ResolveAccessRequestPayload { + /// "approve" or "deny" + pub action: String, +} + // --------------------------------------------------------------------------- // Grid Cell (no id column — composite PK in DB) // --------------------------------------------------------------------------- diff --git a/crates/siren-bot/src/chat/mod.rs b/crates/siren-bot/src/chat/mod.rs index 6951f3e..b74c977 100644 --- a/crates/siren-bot/src/chat/mod.rs +++ b/crates/siren-bot/src/chat/mod.rs @@ -12,7 +12,7 @@ use serenity::all::{ }; pub async fn process_message(ctx: &Context, command: &CommandInteraction, private: bool) { - create_message_response(&ctx, &command, "Processing...".to_string(), private).await; + create_message_response(ctx, command, "Processing...".to_string(), private).await; } pub async fn user_dm(ctx: &Context, user_id: &UserId, content: String) -> Option { diff --git a/crates/siren-bot/src/commands/audio/mod.rs b/crates/siren-bot/src/commands/audio/mod.rs index b111be5..bd9b32a 100644 --- a/crates/siren-bot/src/commands/audio/mod.rs +++ b/crates/siren-bot/src/commands/audio/mod.rs @@ -77,7 +77,7 @@ fn find_voice_channel( match guild .voice_states - .get(&user_id) + .get(user_id) .and_then(|voice_state| voice_state.channel_id) { Some(channel) => Ok(channel), diff --git a/crates/siren-bot/src/commands/audio/mute.rs b/crates/siren-bot/src/commands/audio/mute.rs index 6fda452..a5942cb 100644 --- a/crates/siren-bot/src/commands/audio/mute.rs +++ b/crates/siren-bot/src/commands/audio/mute.rs @@ -9,7 +9,7 @@ use serenity::{ pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response - process_message(&ctx, &command, false).await; + process_message(ctx, command, false).await; // Get the songbird manager let manager = get_songbird(); @@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Some(guild_id) => guild_id, None => { edit_response( - &ctx, - &command, + ctx, + command, "Unable to find the current server ID".to_string(), ) .await; @@ -36,14 +36,14 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Ok(_) => { if is_muted { log::debug!("<{guild_id}> Unmuted"); - edit_response(&ctx, &command, "Unmuted".to_string()).await; + edit_response(ctx, command, "Unmuted".to_string()).await; } else { log::debug!("<{guild_id}> Muted"); - edit_response(&ctx, &command, "Muted".to_string()).await; + edit_response(ctx, command, "Muted".to_string()).await; } } Err(err) => { - edit_response(&ctx, &command, format!("Failed to mute: {}", err)).await; + edit_response(ctx, command, format!("Failed to mute: {}", err)).await; } } } diff --git a/crates/siren-bot/src/commands/audio/pause.rs b/crates/siren-bot/src/commands/audio/pause.rs index 34eba8a..e8eb8cf 100644 --- a/crates/siren-bot/src/commands/audio/pause.rs +++ b/crates/siren-bot/src/commands/audio/pause.rs @@ -12,7 +12,7 @@ use std::sync::Arc; pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response - process_message(&ctx, &command, false).await; + process_message(ctx, command, false).await; // Get the songbird manager let manager = get_songbird(); @@ -22,8 +22,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Some(guild_id) => guild_id, None => { edit_response( - &ctx, - &command, + ctx, + command, "Unable to find the current server ID".to_string(), ) .await; @@ -35,9 +35,9 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { match pause_track(manager, guild_id).await { Ok(_) => { log::debug!("<{guild_id}> Paused the track"); - edit_response(&ctx, &command, "Pausing the track".to_string()).await; + edit_response(ctx, command, "Pausing the track".to_string()).await; } - Err(err) => edit_response(&ctx, &command, format!("Failed to pause: {}", err)).await, + Err(err) => edit_response(ctx, command, format!("Failed to pause: {}", err)).await, } } diff --git a/crates/siren-bot/src/commands/audio/play.rs b/crates/siren-bot/src/commands/audio/play.rs index d455930..f06236c 100644 --- a/crates/siren-bot/src/commands/audio/play.rs +++ b/crates/siren-bot/src/commands/audio/play.rs @@ -32,13 +32,13 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { command.guild_id.unwrap(), command.user.id.get() ); - create_message_response(&ctx, &command, "Track option is missing".to_string(), false).await; + create_message_response(ctx, command, "Track option is missing".to_string(), false).await; return; } }; // Create the initial response - process_message(&ctx, &command, false).await; + process_message(ctx, command, false).await; // Get the songbird manager let manager = get_songbird(); @@ -48,8 +48,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Some(guild_id) => guild_id, None => { edit_response( - &ctx, - &command, + ctx, + command, "Unable to find the current server ID".to_string(), ) .await; @@ -58,7 +58,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { }; // Join the user's voice channel - match join_voice_channel(&ctx.cache, &manager, guild_id, &command.user.id).await { + match join_voice_channel(&ctx.cache, manager, guild_id, &command.user.id).await { Ok(channel_id) => { log::debug!( "<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}" @@ -67,29 +67,29 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { match enqueue_track(manager, guild_id.to_owned(), track_url).await { Ok(items) => { let mut message = format!("Added {} tracks", items.len()); - if items.len() == 0 { + if items.is_empty() { message = "No tracks were played".to_string(); log::warn!("<{guild_id}> No tracks were played"); - if let Err(err) = leave_voice_channel(&manager, guild_id).await { + if let Err(err) = leave_voice_channel(manager, guild_id).await { log::error!("Failed to leave voice channel: {}", err); }; } else if items.len() == 1 { message = format!("Added **{}**", items[0].get_title()); } - edit_response(&ctx, &command, message).await; + edit_response(ctx, command, message).await; } Err(err) => { log::error!("Failed to play track: {}", err); - if let Err(err) = leave_voice_channel(&manager, guild_id).await { + if let Err(err) = leave_voice_channel(manager, guild_id).await { log::error!("Failed to leave voice channel: {}", err); } - edit_response(&ctx, &command, format!("Failed to play track: {}", err)).await; + edit_response(ctx, command, format!("Failed to play track: {}", err)).await; } }; } Err(err) => { log::warn!("<{guild_id}> Failed to join voice channel: {}", err); - edit_response(&ctx, &command, format!("{}", err)).await; + edit_response(ctx, command, format!("{}", err)).await; } } } @@ -103,7 +103,7 @@ pub async fn enqueue_track( if let Some(handler_lock) = manager.get(guild_id) { let mut handler = handler_lock.lock().await; let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap(); - let valid = is_valid_url(&track_url); + let valid = is_valid_url(track_url); // Check if the URL is valid if !valid { @@ -111,7 +111,7 @@ pub async fn enqueue_track( return Err(Error::new(422, format!("Invalid track url: {}", track_url))); } - playlist_items = get_ytdlp_items(&track_url)?; + playlist_items = get_ytdlp_items(track_url)?; // Add each track to the queue for item in &playlist_items { @@ -122,8 +122,7 @@ pub async fn enqueue_track( let input: Input = source.into(); let track_title = item.get_title().to_owned(); - let track_handle: TrackHandle; - track_handle = handler.enqueue_input(input).await; + let track_handle: TrackHandle = handler.enqueue_input(input).await; // Set the volume let _ = track_handle.set_volume(volume); diff --git a/crates/siren-bot/src/commands/audio/resume.rs b/crates/siren-bot/src/commands/audio/resume.rs index 40c662b..2925d14 100644 --- a/crates/siren-bot/src/commands/audio/resume.rs +++ b/crates/siren-bot/src/commands/audio/resume.rs @@ -12,7 +12,7 @@ use std::sync::Arc; pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response - process_message(&ctx, &command, false).await; + process_message(ctx, command, false).await; // Get the songbird manager let manager = get_songbird(); @@ -22,8 +22,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Some(guild_id) => guild_id, None => { edit_response( - &ctx, - &command, + ctx, + command, "Unable to find the current server ID".to_string(), ) .await; @@ -35,9 +35,9 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { match resume_track(manager, guild_id).await { Ok(_) => { log::debug!("<{guild_id}> Resumed the track"); - edit_response(&ctx, &command, "resuming the track".to_string()).await; + edit_response(ctx, command, "resuming the track".to_string()).await; } - Err(err) => edit_response(&ctx, &command, format!("Failed to resume: {}", err)).await, + Err(err) => edit_response(ctx, command, format!("Failed to resume: {}", err)).await, } } diff --git a/crates/siren-bot/src/commands/audio/skip.rs b/crates/siren-bot/src/commands/audio/skip.rs index ca5140f..39b3cbd 100644 --- a/crates/siren-bot/src/commands/audio/skip.rs +++ b/crates/siren-bot/src/commands/audio/skip.rs @@ -9,7 +9,7 @@ use serenity::{ pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response - process_message(&ctx, &command, false).await; + process_message(ctx, command, false).await; // Get the songbird manager let manager = get_songbird(); @@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Some(guild_id) => guild_id, None => { edit_response( - &ctx, - &command, + ctx, + command, "Unable to find the current server ID".to_string(), ) .await; @@ -34,10 +34,10 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { match handler.queue().skip() { Ok(_) => { log::debug!("<{guild_id}> Skipped the track"); - edit_response(&ctx, &command, "Skipping the track".to_string()).await; + edit_response(ctx, command, "Skipping the track".to_string()).await; } Err(err) => { - edit_response(&ctx, &command, format!("Failed to skip: {}", err)).await; + edit_response(ctx, command, format!("Failed to skip: {}", err)).await; } } } diff --git a/crates/siren-bot/src/commands/audio/stop.rs b/crates/siren-bot/src/commands/audio/stop.rs index f3b909e..54c2630 100644 --- a/crates/siren-bot/src/commands/audio/stop.rs +++ b/crates/siren-bot/src/commands/audio/stop.rs @@ -9,7 +9,7 @@ use serenity::{ pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response - process_message(&ctx, &command, false).await; + process_message(ctx, command, false).await; // Get the songbird manager let manager = get_songbird(); @@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Some(g) => g, None => { edit_response( - &ctx, - &command, + ctx, + command, "Unable to find the current server ID".to_string(), ) .await; @@ -33,7 +33,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { let handler = handler_lock.lock().await; handler.queue().stop(); log::debug!("<{guild_id}> Stopped the track"); - edit_response(&ctx, &command, "Stopping the tracks".to_string()).await; + edit_response(ctx, command, "Stopping the tracks".to_string()).await; } } diff --git a/crates/siren-bot/src/commands/audio/volume.rs b/crates/siren-bot/src/commands/audio/volume.rs index 07f3356..44d8e07 100644 --- a/crates/siren-bot/src/commands/audio/volume.rs +++ b/crates/siren-bot/src/commands/audio/volume.rs @@ -20,19 +20,13 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { "{} attempted to change the volume without a volume option", command.user.id.get() ); - create_message_response( - &ctx, - &command, - "Volume option is missing".to_string(), - false, - ) - .await; + create_message_response(ctx, command, "Volume option is missing".to_string(), false).await; return; } }; // Create the initial response - process_message(&ctx, &command, false).await; + process_message(ctx, command, false).await; // Get the songbird manager let manager = get_songbird(); @@ -42,8 +36,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { Some(guild_id) => guild_id, None => { edit_response( - &ctx, - &command, + ctx, + command, "Unable to find the current server ID".to_string(), ) .await; @@ -52,15 +46,14 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { }; // Set the volume - set_volume(&manager, guild_id, volume).await; + set_volume(manager, guild_id, volume).await; log::debug!("<{guild_id}> Setting the volume to {}", volume); - edit_response(&ctx, &command, format!("Setting the volume to {}", volume)).await; + edit_response(ctx, command, format!("Setting the volume to {}", volume)).await; } pub async fn set_volume(manager: &Arc, guild_id: &GuildId, volume: i32) { // Format volume to f32 bound between 0.0 and 1.0 - let volume = std::cmp::min(100, std::cmp::max(0, volume)); - let bound_volume = volume as f32 / 100.0; + let bound_volume = volume.clamp(0, 100) as f32 / 100.0; // Update the guild cache let mut guild_cache = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap(); @@ -70,7 +63,7 @@ pub async fn set_volume(manager: &Arc, guild_id: &GuildId, volume: i32 // Update the volume of the songbird handler if let Some(handler_lock) = manager.get(guild_id.to_owned()) { let handler = handler_lock.lock().await; - for (_, track_handle) in handler.queue().current_queue().iter().enumerate() { + for track_handle in handler.queue().current_queue().iter() { if let Err(err) = track_handle.set_volume(bound_volume) { log::error!("Unable to set volume: {err}"); } diff --git a/crates/siren-bot/src/commands/event/schedule.rs b/crates/siren-bot/src/commands/event/schedule.rs index 3a9e093..6a3979c 100644 --- a/crates/siren-bot/src/commands/event/schedule.rs +++ b/crates/siren-bot/src/commands/event/schedule.rs @@ -17,10 +17,17 @@ use siren_core::data::events::Event; pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response - process_message(&ctx, &command, true).await; + process_message(ctx, command, true).await; // Process the command options - let title = command.data.options.get(0).unwrap().value.as_str().unwrap(); + let title = command + .data + .options + .first() + .unwrap() + .value + .as_str() + .unwrap(); // let datetime_string = command.data.options.get(1).unwrap().value.as_str().unwrap(); let description = command .data diff --git a/crates/siren-bot/src/commands/fun/request_roll.rs b/crates/siren-bot/src/commands/fun/request_roll.rs index 3ea7b33..aee0385 100644 --- a/crates/siren-bot/src/commands/fun/request_roll.rs +++ b/crates/siren-bot/src/commands/fun/request_roll.rs @@ -39,7 +39,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { create_message_response( ctx, - &command, + command, format!("Sending request to {}", user_id.mention()), true, ) @@ -48,7 +48,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { let dice_string = command .data .options - .get(0) + .first() .and_then(|o| o.value.as_str()) .map(|s| s.split_whitespace().collect::()) .unwrap(); @@ -79,7 +79,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { }; } Err(why) => { - edit_response(ctx, &command, why.to_string()).await; + edit_response(ctx, command, why.to_string()).await; } } } diff --git a/crates/siren-bot/src/commands/fun/roll.rs b/crates/siren-bot/src/commands/fun/roll.rs index d782541..ecbcb66 100644 --- a/crates/siren-bot/src/commands/fun/roll.rs +++ b/crates/siren-bot/src/commands/fun/roll.rs @@ -34,19 +34,19 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { .find(|opt| opt.name == "user") .and_then(|o| o.value.as_mentionable()); - create_message_response(ctx, &command, "Rolling...".to_string(), private).await; + create_message_response(ctx, command, "Rolling...".to_string(), private).await; let dice_string = match command .data .options - .get(0) + .first() .and_then(|o| o.value.as_str()) .map(|s| s.split_whitespace().collect::()) { Some(dice_value) => dice_value, None => { log::warn!("Missing or invalid dice option"); - let _ = edit_response(&ctx, &command, "Dice option is missing".to_string()).await; + let _ = edit_response(ctx, command, "Dice option is missing".to_string()).await; return; } }; @@ -63,18 +63,18 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { let roller_id = command.user.id; send_roll_message(ctx, total, user_id, roller_id, &response).await; edit_response( - &ctx, + ctx, command, format!("Sending dice roll results to {}", &user_id.mention()), ) .await; } - None => edit_response(&ctx, &command, format!("🎲 {}\n-# {}", total, response)).await, + None => edit_response(ctx, command, format!("🎲 {}\n-# {}", total, response)).await, }; // Check for dice tracks } Err(why) => { - edit_response(&ctx, &command, format!("Invalid dice string: {}", why)).await; + edit_response(ctx, command, format!("Invalid dice string: {}", why)).await; } } } diff --git a/crates/siren-bot/src/commands/utility/ping.rs b/crates/siren-bot/src/commands/utility/ping.rs index 61068b9..cef98c3 100644 --- a/crates/siren-bot/src/commands/utility/ping.rs +++ b/crates/siren-bot/src/commands/utility/ping.rs @@ -11,7 +11,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { } } - create_message_response(&ctx, &command, "pong".to_string(), true).await; + create_message_response(ctx, command, "pong".to_string(), true).await; } pub fn register() -> CreateCommand { diff --git a/crates/siren-bot/src/handler.rs b/crates/siren-bot/src/handler.rs index 4bedfc3..b34c702 100644 --- a/crates/siren-bot/src/handler.rs +++ b/crates/siren-bot/src/handler.rs @@ -54,7 +54,7 @@ impl EventHandler for BotHandler { } // Handle direct messages - if let None = msg.guild_id { + if msg.guild_id.is_none() { log::trace!("Received DM from {}: {}", msg.author, msg.content); } } diff --git a/crates/siren-bot/src/ytdlp/mod.rs b/crates/siren-bot/src/ytdlp/mod.rs index 7e6a6b3..a872c25 100644 --- a/crates/siren-bot/src/ytdlp/mod.rs +++ b/crates/siren-bot/src/ytdlp/mod.rs @@ -10,6 +10,12 @@ pub struct YtDlp { args: Vec, } +impl Default for YtDlp { + fn default() -> Self { + Self::new() + } +} + impl YtDlp { pub fn new() -> Self { let mut cmd = Command::new(YOUTUBE_DL_COMMAND); diff --git a/crates/siren-core/src/data/condition.rs b/crates/siren-core/src/data/condition.rs index a78f3d7..0a3acb0 100644 --- a/crates/siren-core/src/data/condition.rs +++ b/crates/siren-core/src/data/condition.rs @@ -202,9 +202,9 @@ impl Condition { Condition::Simple(condition, values) => { // Replace each instance of '?' with increasing numbered binds let mut numbered_condition = String::new(); - let mut chars = condition.chars().peekable(); + let chars = condition.chars().peekable(); - while let Some(c) = chars.next() { + for c in chars { if c == '?' { // Increment the counter and replace `?` with a numbered bind *counter += 1; diff --git a/crates/siren-core/src/data/query.rs b/crates/siren-core/src/data/query.rs index 75290a8..b1fb5d6 100644 --- a/crates/siren-core/src/data/query.rs +++ b/crates/siren-core/src/data/query.rs @@ -40,9 +40,7 @@ impl<'a> QueryBuilder<'a> { pub fn order_by(mut self, column: &str, direction: Option) -> Self { match direction { - Some(order) => self - .order_by - .push(format!("{} {}", column, order.to_string())), + Some(order) => self.order_by.push(format!("{} {}", column, order)), None => self.order_by.push(column.to_string()), } self diff --git a/crates/siren-core/src/error.rs b/crates/siren-core/src/error.rs index 31782e9..57924c9 100644 --- a/crates/siren-core/src/error.rs +++ b/crates/siren-core/src/error.rs @@ -65,9 +65,8 @@ impl From for Error { sqlx::Error::PoolClosed => Error::new(503, error.to_string()), sqlx::Error::Database(err) => { if let Some(code) = err.code() { - match code.trim() { - "23505" => return Error::new(409, err.to_string()), - _ => (), + if code.trim() == "23503" { + return Error::new(409, err.to_string()); } } Error::new(500, err.to_string()) diff --git a/crates/siren-core/src/utils/mod.rs b/crates/siren-core/src/utils/mod.rs index fa44ed7..d518118 100644 --- a/crates/siren-core/src/utils/mod.rs +++ b/crates/siren-core/src/utils/mod.rs @@ -1,2 +1,13 @@ pub mod text_utils; + +use rand::RngExt; pub use text_utils::*; + +/// Generate a CSPRNG ID using alphanumeric characters (a-z, A-Z, 0-9) +pub fn csprng(take: usize) -> String { + rand::rng() + .sample_iter(rand::distr::Alphanumeric) + .take(take) + .map(char::from) + .collect() +} diff --git a/crates/siren-core/src/utils/text_utils.rs b/crates/siren-core/src/utils/text_utils.rs index 2075111..e2d756f 100644 --- a/crates/siren-core/src/utils/text_utils.rs +++ b/crates/siren-core/src/utils/text_utils.rs @@ -3,13 +3,13 @@ pub fn a_or_an(word: &str) -> &'static str { let lowercase_word = word.to_lowercase(); // Special cases where the article should be "a" - let special_cases_a = vec!["one"]; + let special_cases_a = ["one"]; if special_cases_a.contains(&lowercase_word.as_str()) { return "a"; } // Special cases where the article should be "an" - let special_cases_an = vec!["hour"]; + let special_cases_an = ["hour"]; if special_cases_an.contains(&lowercase_word.as_str()) { return "an"; } diff --git a/crates/siren/src/main.rs b/crates/siren/src/main.rs index 5db4b03..9bc5e0f 100644 --- a/crates/siren/src/main.rs +++ b/crates/siren/src/main.rs @@ -70,7 +70,7 @@ fn initialize_environment() -> std::io::Result<()> { let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { if file_name.starts_with(".env") && !file_name.ends_with(".example") && path.is_file() { - if let Err(err) = from_filename(&file_name) { + if let Err(err) = from_filename(file_name) { eprintln!("Failed to load {}: {}", file_name, err); } else { println!("Loaded: {}", file_name); diff --git a/migrations/000_initial.sql b/migrations/000_initial.sql index d3c12f9..3b357fb 100644 --- a/migrations/000_initial.sql +++ b/migrations/000_initial.sql @@ -70,24 +70,43 @@ CREATE TABLE IF NOT EXISTS bestiary ( -- Auth / Users -- ============================================================ --- Stores Discord user info, upserted on every successful OAuth login +-- Core local user accounts. password_hash is NULL for OAuth-only users. CREATE TABLE IF NOT EXISTS users ( - id BIGINT PRIMARY KEY NOT NULL, - username TEXT NOT NULL, - avatar TEXT, + id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + username TEXT UNIQUE NOT NULL, + password_hash TEXT, + email TEXT UNIQUE, + first_name TEXT, + last_name TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); +-- External OAuth provider connections (Discord, etc.) +CREATE TABLE IF NOT EXISTS user_connections ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + provider_username TEXT, + provider_avatar TEXT, + PRIMARY KEY (user_id, provider), + UNIQUE (provider, provider_user_id) +); + -- ============================================================ -- Grid maps: unbounded canvas, CSPRNG TEXT ids, auth-aware -- ============================================================ +-- public_access: 'private' | 'public_view' | 'public_edit' +-- private – only users with explicit map_permissions can see/edit +-- public_view – anyone with the link can view; only permissioned users can edit +-- public_edit – anyone with the link can view AND edit CREATE TABLE IF NOT EXISTS grid_maps ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, - is_public BOOLEAN NOT NULL DEFAULT FALSE, - owner_id BIGINT NOT NULL REFERENCES users(id), + public_access TEXT NOT NULL DEFAULT 'private' + CHECK (public_access IN ('private', 'public_view', 'public_edit')), + owner_id UUID NOT NULL REFERENCES users(id), colors TEXT[] NOT NULL DEFAULT ARRAY[ '#6b7280', '#92400e', @@ -106,11 +125,32 @@ CREATE TABLE IF NOT EXISTS grid_maps ( -- Per-map role assignments; owner is auto-inserted on map creation CREATE TABLE IF NOT EXISTS map_permissions ( map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')), PRIMARY KEY (map_id, user_id) ); +-- Maps a user has favorited; makes them appear in the user's map list modal +-- even if they have no explicit map_permissions entry (e.g. public maps) +CREATE TABLE IF NOT EXISTS map_favorites ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, map_id) +); + +-- Pending/resolved requests from users wanting viewer or editor access +CREATE TABLE IF NOT EXISTS map_access_requests ( + id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (map_id, user_id) +); + -- Composite primary key replaces the old UUID id column CREATE TABLE IF NOT EXISTS grid_cells ( map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..edb3023 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/ui/index.html b/ui/index.html index 11802c7..25dfb96 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,6 @@
- + diff --git a/ui/package.json b/ui/package.json index e7bbd10..144d20b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,18 +5,28 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --write src" }, "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-icons": "^5.6.0" }, "devDependencies": { - "@types/react": "^18.3.1", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.1", - "typescript": "^5.5.3", - "vite": "^5.3.4" + "@eslint/js": "^9.39.4", + "@types/node": "^25.5.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^17.4.0", + "prettier": "^3.7.2", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.3" } } diff --git a/ui/prettierrc.json b/ui/prettierrc.json new file mode 100644 index 0000000..7a93457 --- /dev/null +++ b/ui/prettierrc.json @@ -0,0 +1,8 @@ +{ + "trailingComma": "none", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "printWidth": 120 +} diff --git a/ui/src/App.css b/ui/src/App.css index 31a0fcf..fbc4d52 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,4 +1,4 @@ -/* ---- Full-viewport shell ---- */ +/* ── Full-viewport shell ── */ .app { display: flex; flex-direction: column; @@ -6,132 +6,21 @@ overflow: hidden; } -/* ---- Top header ---- */ -.app-header { - flex-shrink: 0; - height: 48px; - display: flex; - align-items: center; - gap: 1rem; - padding: 0 1rem; - background: #1f2937; - border-bottom: 1px solid #374151; - z-index: 10; -} - -.app-brand { - font-size: 1.05rem; - font-weight: 700; - color: #f9fafb; - letter-spacing: 0.08em; - white-space: nowrap; - margin-right: 0.5rem; -} - -.app-brand span { - color: #818cf8; -} - -.app-map-controls { - display: flex; - align-items: center; - gap: 0.5rem; +/* ── App body (everything below the header) ── */ +.app-body { flex: 1; -} - -.map-select { - background: #111827; - border: 1px solid #4b5563; - border-radius: 6px; - color: #e5e7eb; - padding: 0.3rem 0.6rem; - font-size: 0.85rem; - min-width: 160px; - max-width: 280px; - outline: none; - cursor: pointer; -} - -.map-select:focus { - border-color: #6366f1; -} - -.map-select option { - background: #1f2937; -} - -.header-btn { - background: #374151; - border: 1px solid #4b5563; - border-radius: 6px; - color: #e5e7eb; - cursor: pointer; - font-size: 0.82rem; - padding: 0.3rem 0.65rem; - line-height: 1.4; - white-space: nowrap; - transition: background 0.12s; -} - -.header-btn:hover { - background: #4b5563; -} - -.header-btn.danger:hover { - background: #7f1d1d; - border-color: #ef4444; - color: #fca5a5; -} - -.new-map-form { display: flex; - gap: 0.3rem; - align-items: center; + overflow: hidden; } -.new-map-form input { - background: #111827; - border: 1px solid #6366f1; - border-radius: 6px; - color: #e5e7eb; - padding: 0.3rem 0.6rem; - font-size: 0.85rem; - width: 160px; - outline: none; -} - -.new-map-form button { - background: #6366f1; - border: none; - border-radius: 6px; - color: white; - cursor: pointer; - font-size: 0.82rem; - padding: 0.3rem 0.65rem; - transition: background 0.12s; -} - -.new-map-form button:hover { - background: #4f46e5; -} - -.new-map-form .cancel-btn { - background: #374151; - border: 1px solid #4b5563; -} - -.new-map-form .cancel-btn:hover { - background: #4b5563; -} - -/* ---- Grid area (fills remainder) ---- */ +/* ── Grid area (fills the app body) ── */ .app-grid-area { flex: 1; position: relative; overflow: hidden; } -/* ── Floating panel stack – bottom-left corner ── */ +/* ── Floating control panels – bottom-left corner ── */ .floating-panels-container { position: absolute; bottom: 14px; @@ -142,7 +31,7 @@ z-index: 20; } -/* ---- No-map placeholder ---- */ +/* ── Empty state placeholder ── */ .empty-state { height: 100%; display: flex; @@ -151,9 +40,11 @@ justify-content: center; gap: 0.75rem; color: #4b5563; + user-select: none; } .empty-state p { + margin: 0; font-size: 1.1rem; } @@ -162,34 +53,78 @@ color: #374151; } -/* ---- Auth area (right side of header) ---- */ -.app-auth { +/* ── Access denied state ── */ +.access-denied-state { + height: 100%; display: flex; + flex-direction: column; align-items: center; - gap: 0.5rem; - margin-left: auto; - flex-shrink: 0; -} - -.app-username { - font-size: 0.82rem; - color: #9ca3af; - white-space: nowrap; -} - -/* ---- Public checkbox in new-map form ---- */ -.new-map-public { - display: flex; - align-items: center; - gap: 0.3rem; - font-size: 0.82rem; - color: #9ca3af; - cursor: pointer; - white-space: nowrap; + justify-content: center; + gap: 1rem; + color: #4b5563; user-select: none; + padding: 2rem; + text-align: center; } -.new-map-public input[type='checkbox'] { - accent-color: #6366f1; - cursor: pointer; +.access-denied-title { + margin: 0; + font-size: 1.15rem; + font-weight: 600; + color: #9ca3af; +} + +.access-denied-hint { + margin: 0; + font-size: 0.875rem; + color: #6b7280; +} + +.access-request-sent { + color: #34d399; +} + +.access-request-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.access-request-btns { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + justify-content: center; +} + +.btn-request-access { + background: rgba(99, 102, 241, 0.15); + border: 1px solid rgba(99, 102, 241, 0.35); + border-radius: 6px; + color: #818cf8; + cursor: pointer; + font-size: 0.85rem; + padding: 0.45rem 1.1rem; + transition: + background 0.12s, + border-color 0.12s; +} +.btn-request-access:hover { + background: rgba(99, 102, 241, 0.25); + border-color: rgba(99, 102, 241, 0.6); +} + +.link-btn { + background: none; + border: none; + color: #818cf8; + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: underline; + transition: color 0.12s; +} +.link-btn:hover { + color: #a5b4fc; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f56a349..e81e1c8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,297 +1,371 @@ -import { useState, useEffect, useRef } from 'react'; -import type { GridMap, Tool, TokenClaims } from './types'; -import type { GridHandle } from './components/Grid'; -import { api, auth, getToken, setToken, decodeToken } from './api'; -import ControlPanel from './components/ControlPanel.tsx'; -import ColorPanel from './components/ColorPanel'; -import Grid from './components/Grid'; -import LoginButton from './components/LoginButton'; -import './App.css'; +import { useState, useEffect, useRef } from "react"; +import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types"; +import type { GridHandle } from "./components/Grid"; +import { api, auth } from "./api"; +import Header from "./components/Header"; +import ControlPanel from "./components/ControlPanel"; +import ColorPanel from "./components/ColorPanel"; +import Grid from "./components/Grid"; +import LoginModal from "./components/LoginModal"; +import AccountPanel from "./components/AccountPanel"; +import FloatingMapControls from "./components/FloatingMapControls"; +import NewMapModal from "./components/NewMapModal"; +import EditMapModal from "./components/EditMapModal"; +import MapListModal from "./components/MapListModal"; +import "./components/Modal.css"; +import "./App.css"; -/** Default colors shown before a map's own colors load from the server. */ const DEFAULT_COLORS = [ - '#6b7280', // 1 – stone - '#92400e', // 2 – earth - '#15803d', // 3 – grass - '#1d4ed8', // 4 – water - '#7c3aed', // 5 – arcane - '#dc2626', // 6 – lava - '#ca8a04', // 7 – sand - '#0f172a', // 8 – void - '#f9fafb', // 9 – white + "#6b7280", + "#92400e", + "#15803d", + "#1d4ed8", + "#7c3aed", + "#dc2626", + "#ca8a04", + "#0f172a", + "#f9fafb", ]; -/** Read the map ID from the current URL path (/map/:id). */ function getMapIdFromUrl(): string | null { const match = window.location.pathname.match(/^\/map\/([^/]+)/); return match ? decodeURIComponent(match[1]) : null; } -/** Read a query parameter value from the current URL. */ function getQueryParam(key: string): string | null { return new URLSearchParams(window.location.search).get(key); } -/** Strip a query parameter from the current URL without causing a reload. */ function removeQueryParam(key: string) { const url = new URL(window.location.href); url.searchParams.delete(key); - window.history.replaceState(null, '', url.pathname + (url.search !== '?' ? url.search : '')); + window.history.replaceState( + null, + "", + url.pathname + (url.search !== "?" ? url.search : ""), + ); } export default function App() { - // ---- Auth state ---- - const [user, setUser] = useState(() => { - const token = getToken(); - return token ? decodeToken(token) : null; - }); + // ── Auth state ── + const [user, setUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); - // ---- Map state ---- - const [maps, setMaps] = useState([]); - const [selectedId, setSelectedId] = useState(getMapIdFromUrl); + // ── Map state ── + const [maps, setMaps] = useState([]); + const [selectedId, setSelectedId] = useState(getMapIdFromUrl); + /** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */ + const [directMapInfo, setDirectMapInfo] = useState(null); + /** True when the current selectedId returned 403 (no access). */ + const [accessDenied, setAccessDenied] = useState(false); + const [accessRequestSent, setAccessRequestSent] = useState(false); - // Tool + unified active color (shared between draw and token) - const [tool, setTool] = useState('pan'); - const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]); - - // Per-map color palette (updated from WS state on map load / color edits) - const [mapColors, setMapColors] = useState(DEFAULT_COLORS); - - // Ref to Grid so App can push color updates through the WS + // ── Tool + color ── + const [tool, setTool] = useState("pan"); + const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]); + const [mapColors, setMapColors] = useState(DEFAULT_COLORS); const gridRef = useRef(null); - // New-map form - const [showNewMap, setShowNewMap] = useState(false); - const [newMapName, setNewMapName] = useState(''); - const [newMapPublic, setNewMapPublic] = useState(false); - const newMapInputRef = useRef(null); + // ── Modal visibility ── + const [showLoginModal, setShowLoginModal] = useState(false); + const [showAccountPanel, setShowAccountPanel] = useState(false); + const [showNewMap, setShowNewMap] = useState(false); + const [showEditMap, setShowEditMap] = useState(false); + const [showMapList, setShowMapList] = useState(false); - // ---- Handle OAuth callback: ?token= or ?error= ---- + // ── Derived ── + const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null; + const selectedMapInfo: GridMap | ListedMap | null = + selectedMapFromList ?? directMapInfo; + const isOwner = + user !== null && + selectedMapInfo !== null && + selectedMapInfo.owner_id === user.id; + + // ── On mount: load session + handle OAuth errors ── useEffect(() => { - const token = getQueryParam('token'); - const error = getQueryParam('error'); - - if (token) { - setToken(token); - const claims = decodeToken(token); - setUser(claims); - removeQueryParam('token'); - } else if (error) { - console.error('OAuth error:', error); - removeQueryParam('error'); + auth.me().then((u) => { + setUser(u); + setAuthLoading(false); + }); + const error = getQueryParam("error"); + if (error) { + console.error("OAuth error:", error); + removeQueryParam("error"); } }, []); - // ---- Load map list ---- + // ── Load map list after auth resolves ── useEffect(() => { - api.listMaps().then(setMaps).catch(console.error); - }, [user]); // re-fetch when auth state changes + if (!authLoading) { + api.listMaps().then(setMaps).catch(console.error); + } + }, [user, authLoading]); - // Once maps load, validate the URL-sourced selectedId still exists + // ── Direct fetch for URL-accessed maps not in the user's list ── useEffect(() => { - if (maps.length === 0 && selectedId) { - // Maps are still loading — skip + if (!selectedId || authLoading) { + setDirectMapInfo(null); + setAccessDenied(false); return; } - if (selectedId) { - const exists = maps.some(m => m.id === selectedId); - if (!exists) { - // Invalid or inaccessible map ID — reroute to /map - setSelectedId(null); - window.history.replaceState(null, '', '/map'); - } + const inList = maps.some((m) => m.id === selectedId); + if (inList) { + setDirectMapInfo(null); + setAccessDenied(false); + return; } - }, [maps]); // eslint-disable-line react-hooks/exhaustive-deps - // Keep the URL in sync with the selected map + setDirectMapInfo(null); + setAccessDenied(false); + setAccessRequestSent(false); + + api + .getMap(selectedId) + .then((state) => { + setDirectMapInfo(state.map); + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + if (msg.startsWith("403")) { + setAccessDenied(true); + } else { + // 404 or unknown — clear invalid URL + setSelectedId(null); + window.history.replaceState(null, "", "/map"); + } + }); + }, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Keep URL in sync ── useEffect(() => { - const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : '/map'; - window.history.replaceState(null, '', path); + const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map"; + window.history.replaceState(null, "", path); }, [selectedId]); - useEffect(() => { - if (showNewMap) newMapInputRef.current?.focus(); - }, [showNewMap]); - - // Reset palette to defaults when no map is selected + // ── Reset palette + access state when map deselected ── useEffect(() => { if (!selectedId) { setMapColors(DEFAULT_COLORS); setActiveColor(DEFAULT_COLORS[0]); + setAccessRequestSent(false); } }, [selectedId]); - // ---- Derived state ---- - const selectedMap = maps.find(m => m.id === selectedId) ?? null; + // ── Handlers ── - // The current user is considered the owner if their Discord ID matches owner_id - const isOwner = user !== null && selectedMap !== null && selectedMap.owner_id === user.sub; - - // ---- Handlers ---- - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - const name = newMapName.trim(); - if (!name) return; - try { - const m = await api.createMap(name, newMapPublic); - setMaps(prev => [m, ...prev]); - setSelectedId(m.id); - setShowNewMap(false); - setNewMapName(''); - setNewMapPublic(false); - } catch (err) { - console.error('Failed to create map', err); - } + async function handleCreate(name: string, publicAccess: PublicAccess) { + const m = await api.createMap(name, publicAccess); + // Optimistically add to list as an owner entry + const listed: ListedMap = { + ...m, + owner_username: user!.username, + user_role: "owner", + is_favorited: false, + }; + setMaps((prev) => [listed, ...prev]); + setSelectedId(m.id); } async function handleDelete() { if (!selectedId) return; - if (!confirm('Delete this map? This cannot be undone.')) return; + if (!confirm("Delete this map? This cannot be undone.")) return; try { await api.deleteMap(selectedId); - setMaps(prev => prev.filter(m => m.id !== selectedId)); + setMaps((prev) => prev.filter((m) => m.id !== selectedId)); setSelectedId(null); } catch (err) { - console.error('Failed to delete map', err); + console.error("Failed to delete map", err); } } - /** Called by Grid when the WS state/colors_updated message arrives. */ - function handleColorsLoaded(colors: string[]) { - setMapColors(colors); - setActiveColor(prev => colors.includes(prev) ? prev : colors[0]); + function handleMapUpdated(updated: GridMap) { + setMaps((prev) => + prev.map((m) => + m.id === updated.id + ? { + ...m, + name: updated.name, + public_access: updated.public_access, + updated_at: updated.updated_at, + } + : m, + ), + ); + if (directMapInfo?.id === updated.id) { + setDirectMapInfo(updated); + } + } + + function handleColorsLoaded(colors: string[]) { + setMapColors(colors); + setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0])); } - /** Called by ColorPanel when the user double-clicks and edits a swatch. */ function handleColorsChange(colors: string[]) { setMapColors(colors); gridRef.current?.sendColorUpdate(colors); } + async function handleUserRefresh() { + const u = await auth.me(); + setUser(u); + } + + async function handleRequestAccess(role: "viewer" | "editor") { + if (!selectedId) return; + try { + await api.requestAccess(selectedId, role); + setAccessRequestSent(true); + } catch (err) { + console.error("Failed to request access", err); + } + } + + // ── Render ── return (
- {/* ── Header ── */} -
-
- SIREN -
+
setShowLoginModal(true)} + onAccountClick={() => setShowAccountPanel(true)} + /> -
- {/* Map selector */} - {maps.length > 0 && !showNewMap && ( - - )} +
+
+ {/* Top-left floating map controls */} + setShowNewMap(true)} + onViewMaps={() => setShowMapList(true)} + onEditMap={() => setShowEditMap(true)} + onDeleteMap={handleDelete} + /> - {/* New map form — only for authenticated users */} - {user && ( - showNewMap ? ( -
- setNewMapName(e.target.value)} - maxLength={60} - /> - - - -
- ) : ( - - ) - )} - - {/* Delete current map — only for the owner */} - {isOwner && !showNewMap && ( - - )} -
- - {/* ── Auth area ── */} -
- {user ? ( + {selectedId && !accessDenied ? ( <> - {user.name} - + +
+ + +
+ ) : accessDenied ? ( +
+

+ You don't have access to this map +

+ {!user ? ( +

+ {" "} + to request access or view your permissions. +

+ ) : accessRequestSent ? ( +

+ ✓ Access request sent! The map owner will be notified. +

+ ) : ( +
+

+ Request access from the map owner: +

+
+ + +
+
+ )} +
) : ( - +
+

Select or create a map to begin

+

+ {!user + ? "Log in to create maps and access private maps" + : maps.length === 0 + ? 'Click "+ New Map" in the top-left to get started' + : 'Click "Maps" in the top-left to choose a map'} +

+
)}
-
- - {/* ── Grid area ── */} -
- {selectedId ? ( - <> - {/* key forces full remount (new WS + clear state) on map change */} - -
- - -
- - ) : ( -
-

Select or create a map to begin

-

- {!user - ? 'Log in with Discord to create maps and access private maps' - : maps.length === 0 - ? 'Click "+ New Map" in the header to get started' - : 'Choose a map from the header dropdown'} -

-
- )}
+ + {/* ── Global modals (always available regardless of page) ── */} + {showLoginModal && ( + setShowLoginModal(false)} + onLogin={async (u) => { + setUser(u); + api.listMaps().then(setMaps).catch(console.error); + }} + /> + )} + + {showAccountPanel && user && ( + setShowAccountPanel(false)} + onRefresh={handleUserRefresh} + /> + )} + + {showNewMap && ( + setShowNewMap(false)} + onCreate={handleCreate} + /> + )} + + {showEditMap && selectedMapInfo && ( + setShowEditMap(false)} + onUpdated={handleMapUpdated} + /> + )} + + {showMapList && ( + setSelectedId(id)} + onClose={() => setShowMapList(false)} + onMapsChange={setMaps} + /> + )}
); } diff --git a/ui/src/api.ts b/ui/src/api.ts index c868973..227c6a1 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,126 +1,232 @@ -import type { GridMap, MapPermission, MapRole, MapState, TokenClaims } from './types'; +import type { + GridMap, + ListedMap, + MapAccessRequest, + MapPermission, + MapRole, + MapState, + PublicAccess, + UserInfo, +} from "./types"; -const BASE = '/api/grid'; -const AUTH_BASE = '/api/auth/discord'; - -// --------------------------------------------------------------------------- -// Token helpers -// --------------------------------------------------------------------------- - -const TOKEN_KEY = 'siren_token'; - -export function getToken(): string | null { - return localStorage.getItem(TOKEN_KEY); -} - -export function setToken(token: string): void { - localStorage.setItem(TOKEN_KEY, token); -} - -export function removeToken(): void { - localStorage.removeItem(TOKEN_KEY); -} - -/** Decode the JWT payload without verifying the signature (client-side only). */ -export function decodeToken(token: string): TokenClaims | null { - try { - const payload = token.split('.')[1]; - const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); - return JSON.parse(json) as TokenClaims; - } catch { - return null; - } -} - -/** Returns true if the stored token is present and not expired. */ -// export function isAuthenticated(): boolean { -// const token = getToken(); -// if (!token) return false; -// const claims = decodeToken(token); -// if (!claims) return false; -// return claims.exp > Date.now() / 1000; -// } - -// --------------------------------------------------------------------------- -// Core fetch wrapper -// --------------------------------------------------------------------------- +const GRID_BASE = "/api/grid"; +const AUTH_BASE = "/api/auth"; async function request(url: string, init?: RequestInit): Promise { - const token = getToken(); - const headers: Record = { - ...(init?.headers as Record), - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const res = await fetch(url, { ...init, headers }); - - if (res.status === 401) { - // Token expired or invalid — clear local storage - removeToken(); - } + const res = await fetch(url, { + ...init, + credentials: "include", + headers: { + ...(init?.headers as Record), + }, + }); if (!res.ok) { const text = await res.text().catch(() => res.statusText); throw new Error(`${res.status}: ${text}`); } - if (res.status === 204) return undefined as T; - return res.json(); + + // Read the bdoy if it exists + const text = await res.text(); + if (!text) return undefined as T; + + // Parse JSON if it exists + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return JSON.parse(text) as T; + } else if (contentType.includes("text/plain")) { + return text as unknown as T; + } + + throw new Error(`Expected JSON or text but got: ${contentType}`); } -// --------------------------------------------------------------------------- -// Grid map API -// --------------------------------------------------------------------------- - export const api = { - listMaps: (): Promise => - request(`${BASE}/maps`), + /** List maps where the authenticated user has a direct role or has favorited. */ + listMaps: (): Promise => + request(`${GRID_BASE}/maps`), - createMap: (name: string, is_public = false): Promise => - request(`${BASE}/maps`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, is_public }), + /** Create a new map (authenticated). */ + createMap: ( + name: string, + public_access: PublicAccess = "private", + ): Promise => + request(`${GRID_BASE}/maps`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, public_access }), }), + /** Get full map state (cells, tokens, colors). */ getMap: (id: string): Promise => - request(`${BASE}/maps/${id}`), + request(`${GRID_BASE}/maps/${id}`), + /** Update map name and/or public_access (owner only). */ + updateMap: ( + id: string, + payload: { name?: string; public_access?: PublicAccess }, + ): Promise => + request(`${GRID_BASE}/maps/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + + /** Delete a map (owner only). */ deleteMap: (id: string): Promise => - request(`${BASE}/maps/${id}`, { method: 'DELETE' }), + request(`${GRID_BASE}/maps/${id}`, { method: "DELETE" }), // ---- Permissions ---- + /** List all permissions for a map including usernames (owner only). */ listPermissions: (mapId: string): Promise => - request(`${BASE}/maps/${mapId}/permissions`), + request(`${GRID_BASE}/maps/${mapId}/permissions`), - updatePermission: (mapId: string, userId: number, role: MapRole | null): Promise => - request(`${BASE}/maps/${mapId}/permissions`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_id: userId, role }), + /** + * Add or update a user's role by username. + * Pass `role: null` to remove the user's permission entirely. + */ + updatePermission: ( + mapId: string, + username: string, + role: MapRole | null, + ): Promise => + request(`${GRID_BASE}/maps/${mapId}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, role }), + }), + + /** Favorite a map (adds it to the user's map list). */ + favoriteMap: (id: string): Promise => + request(`${GRID_BASE}/maps/${id}/favorite`, { method: "POST" }), + + /** Un-favorite a map. */ + unfavoriteMap: (id: string): Promise => + request(`${GRID_BASE}/maps/${id}/favorite`, { method: "DELETE" }), + + /** Request viewer or editor access to a map. */ + requestAccess: (mapId: string, role: "editor" | "viewer"): Promise => + request(`${GRID_BASE}/maps/${mapId}/access-requests`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }), + + /** List pending access requests for a map (owner only). */ + listAccessRequests: (mapId: string): Promise => + request(`${GRID_BASE}/maps/${mapId}/access-requests`), + + /** Approve or deny a pending access request (owner only). */ + resolveAccessRequest: ( + mapId: string, + requestId: string, + action: "approve" | "deny", + ): Promise => + request(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), }), }; -// --------------------------------------------------------------------------- -// Auth API -// --------------------------------------------------------------------------- - export const auth = { - /** Fetches the Discord OAuth URL and redirects the browser to it. - * Passes the current page's origin + /map as the UI redirect URI so - * the backend knows where to send the browser after login completes. - */ - async login(): Promise { - const redirectUri = encodeURIComponent(window.location.origin + '/map'); - const url = await request(`${AUTH_BASE}/authorize?redirect_uri=${redirectUri}`); - window.location.href = url; + /** Fetch the currently authenticated user's info. Returns null if not logged in. */ + async me(): Promise { + try { + return await request(`${AUTH_BASE}/me`); + } catch { + return null; + } }, - logout(): void { - removeToken(); - window.location.href = '/map'; + /** Register a new local account. */ + async register(username: string, password: string): Promise { + await request(`${AUTH_BASE}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + }, + + /** Login with username and password. */ + async loginLocal(username: string, password: string): Promise { + await request(`${AUTH_BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + }, + + /** Start Discord OAuth login flow (anonymous). */ + async loginDiscord(redirectUri?: string): Promise { + const target = encodeURIComponent(redirectUri ?? window.location.href); + const response = await request( + `${AUTH_BASE}/discord/authorize?redirect_uri=${target}`, + ); + window.location.href = JSON.parse(response); + }, + + /** Start Discord OAuth connect flow (authenticated users only). */ + async connectDiscord(redirectUri?: string): Promise { + const target = encodeURIComponent( + redirectUri ?? window.location.origin + "/account", + ); + const response = await request( + `${AUTH_BASE}/discord/connect?redirect_uri=${target}`, + ); + window.location.href = JSON.parse(response); + }, + + /** Clear the session cookie server-side and reload. */ + async logout(): Promise { + try { + await request(`${AUTH_BASE}/logout`, { method: "POST" }); + } catch { + // Ignore errors + } + window.location.href = "/map"; + }, + + /** + * Update the user's first and/or last name. + * Pass an empty string to clear a field; pass undefined to leave it unchanged. + */ + async updateProfile( + firstName?: string, + lastName?: string, + ): Promise { + return request(`${AUTH_BASE}/profile`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ first_name: firstName, last_name: lastName }), + }); + }, + + /** + * Change or set the account password. + * `currentPassword` is required when the account already has a password, + * and can be omitted (null/undefined) for OAuth-only accounts setting a + * password for the first time. + */ + async changePassword( + currentPassword: string | null, + newPassword: string, + ): Promise { + await request(`${AUTH_BASE}/change-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + current_password: currentPassword ?? undefined, + new_password: newPassword, + }), + }); + }, + + /** Disconnect an OAuth provider (requires a password to be set first). */ + async disconnectProvider(provider: string): Promise { + await request(`${AUTH_BASE}/connections/${provider}`, { + method: "DELETE", + }); }, }; diff --git a/ui/src/components/AccountPanel.css b/ui/src/components/AccountPanel.css new file mode 100644 index 0000000..95c9a55 --- /dev/null +++ b/ui/src/components/AccountPanel.css @@ -0,0 +1,316 @@ +/* ── Backdrop ── */ +.account-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +/* ── Panel card ── */ +.account-panel { + background: #1e2130; + border: 1px solid #2e3348; + border-radius: 10px; + padding: 1.5rem; + width: 440px; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* ── Header ── */ +.account-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.account-header h2 { + margin: 0; + font-size: 1.1rem; + color: #e2e8f0; +} + +.account-close { + background: none; + border: none; + color: #8892a4; + font-size: 1rem; + cursor: pointer; + line-height: 1; + padding: 0.25rem; +} +.account-close:hover { + color: #e2e8f0; +} + +/* ── Section ── */ +.account-section { + border-top: 1px solid #2e3348; + padding-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.account-section h3 { + margin: 0; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8892a4; +} + +.section-header-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.section-header-row h3 { + margin: 0; +} + +/* ── Profile form ── */ +.profile-form { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.profile-name-row { + display: grid; + grid-template-columns: 1fr; + gap: 0.65rem; +} + +.account-field-label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.75rem; + color: #8892a4; + font-weight: 500; +} + +.account-field-label input { + background: #141622; + border: 1px solid #2e3348; + border-radius: 6px; + color: #e2e8f0; + font-size: 0.85rem; + padding: 0.35rem 0.6rem; + outline: none; + transition: border-color 0.12s; +} + +.account-field-label input:focus { + border-color: #6366f1; +} + +/* ── Read-only fields ── */ +.readonly-field { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.account-label { + font-size: 0.8rem; + color: #8892a4; + min-width: 70px; +} + +.account-value { + font-size: 0.9rem; + color: #e2e8f0; +} + +/* ── Profile save / cancel row ── */ +.profile-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.6rem; + margin-top: 0.25rem; +} + +.btn-save { + background: #6366f1; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + padding: 0.35rem 0.9rem; + transition: background 0.12s; +} +.btn-save:hover { + background: #4f46e5; +} +.btn-save:disabled { + opacity: 0.5; + cursor: default; +} + +.btn-text { + background: none; + border: none; + color: #6b7280; + cursor: pointer; + font-size: 0.82rem; + padding: 0.25rem 0.4rem; + transition: color 0.12s; +} +.btn-text:hover { + color: #9ca3af; +} + +/* ── Password form ── */ +.password-form { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +/* ── Messages ── */ +.account-error { + margin: 0; + font-size: 0.8rem; + color: #f87171; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 5px; + padding: 0.35rem 0.6rem; +} + +.account-success { + margin: 0; + font-size: 0.8rem; + color: #34d399; + background: rgba(16, 185, 129, 0.08); + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: 5px; + padding: 0.35rem 0.6rem; +} + +/* ── Connection row ── */ +.account-connection { + display: flex; + align-items: center; + gap: 0.75rem; + background: #141622; + border: 1px solid #2e3348; + border-radius: 8px; + padding: 0.75rem 1rem; +} + +.connection-icon { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.discord-icon { + color: #5865f2; +} + +.connection-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.connection-name { + font-size: 0.85rem; + font-weight: 600; + color: #e2e8f0; +} + +.connection-linked { + font-size: 0.75rem; + color: #4ade80; +} + +.connection-unlinked { + font-size: 0.75rem; + color: #8892a4; +} + +.connection-hint { + margin: 0; + font-size: 0.75rem; + color: #f59e0b; + padding: 0 0.25rem; +} + +.btn-connect-discord { + background: #5865f2; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + padding: 0.35rem 0.75rem; + transition: background 0.15s; + white-space: nowrap; +} +.btn-connect-discord:hover { + background: #4752c4; +} + +.btn-disconnect { + background: transparent; + border: 1px solid #4a5568; + border-radius: 6px; + color: #8892a4; + cursor: pointer; + font-size: 0.8rem; + padding: 0.35rem 0.75rem; + transition: + color 0.15s, + border-color 0.15s; + white-space: nowrap; +} +.btn-disconnect:hover:not(:disabled) { + border-color: #ef4444; + color: #f87171; +} +.btn-disconnect:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Footer ── */ +.account-footer { + border-top: 1px solid #2e3348; + padding-top: 1rem; + display: flex; + justify-content: flex-end; +} + +.btn-logout { + background: transparent; + border: 1px solid #4a5568; + border-radius: 6px; + color: #8892a4; + cursor: pointer; + font-size: 0.85rem; + padding: 0.4rem 1rem; + transition: + color 0.15s, + border-color 0.15s; +} +.btn-logout:hover { + border-color: #ef4444; + color: #ef4444; +} diff --git a/ui/src/components/AccountPanel.tsx b/ui/src/components/AccountPanel.tsx new file mode 100644 index 0000000..4f2b67f --- /dev/null +++ b/ui/src/components/AccountPanel.tsx @@ -0,0 +1,358 @@ +import { useState } from "react"; +import { auth } from "../api"; +import type { UserInfo } from "../types"; +import "./AccountPanel.css"; + +interface Props { + user: UserInfo; + onClose: () => void; + onRefresh: () => void; +} + +export default function AccountPanel({ user, onClose, onRefresh }: Props) { + const discordConnection = user.connections.find( + (c) => c.provider === "discord", + ); + + // ── Profile editing ── + const [firstName, setFirstName] = useState(user.first_name ?? ""); + const [lastName, setLastName] = useState(user.last_name ?? ""); + const [profileDirty, setProfileDirty] = useState(false); + const [profileSaving, setProfileSaving] = useState(false); + const [profileError, setProfileError] = useState(null); + const [profileSuccess, setProfileSuccess] = useState(false); + + // ── Password change ── + const [pwCurrent, setPwCurrent] = useState(""); + const [pwNew, setPwNew] = useState(""); + const [pwConfirm, setPwConfirm] = useState(""); + const [pwSaving, setPwSaving] = useState(false); + const [pwError, setPwError] = useState(null); + const [pwSuccess, setPwSuccess] = useState(false); + const [showPasswordSection, setShowPasswordSection] = useState(false); + + function handleFirstNameChange(v: string) { + setFirstName(v); + setProfileDirty(true); + setProfileSuccess(false); + } + + function handleLastNameChange(v: string) { + setLastName(v); + setProfileDirty(true); + setProfileSuccess(false); + } + + async function handleSaveProfile(e: React.SubmitEvent) { + e.preventDefault(); + setProfileSaving(true); + setProfileError(null); + setProfileSuccess(false); + try { + await auth.updateProfile(firstName, lastName); + setProfileDirty(false); + setProfileSuccess(true); + await onRefresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setProfileError( + msg.replace(/^\d+:\s*/, "").trim() || "Failed to save profile", + ); + } finally { + setProfileSaving(false); + } + } + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setPwError(null); + setPwSuccess(false); + + if (pwNew !== pwConfirm) { + setPwError("Passwords do not match"); + return; + } + if (pwNew.length < 8) { + setPwError("Password must be at least 8 characters"); + return; + } + + setPwSaving(true); + try { + await auth.changePassword(user.has_password ? pwCurrent : null, pwNew); + setPwCurrent(""); + setPwNew(""); + setPwConfirm(""); + setPwSuccess(true); + setShowPasswordSection(false); + await onRefresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setPwError( + msg.replace(/^\d+:\s*/, "").trim() || "Failed to change password", + ); + } finally { + setPwSaving(false); + } + } + + async function handleConnectDiscord() { + try { + await auth.connectDiscord(window.location.origin + "/map"); + } catch (err) { + console.error("Failed to connect Discord:", err); + } + } + + async function handleDisconnectDiscord() { + if (!confirm("Disconnect your Discord account?")) return; + try { + await auth.disconnectProvider("discord"); + await onRefresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + alert( + msg.replace(/^\d+:\s*/, "").trim() || "Failed to disconnect Discord", + ); + } + } + + async function handleLogout() { + await auth.logout(); + } + + return ( +
+
e.stopPropagation()}> +
+

Account

+ +
+ + {/* ── Profile ── */} +
+

Profile

+
+
+ Username + {user.username} +
+
+ + +
+ + {user.email && ( +
+ Email + {user.email} +
+ )} + + {profileError &&

{profileError}

} + {profileSuccess && ( +

Profile saved!

+ )} + + {profileDirty && ( +
+ + +
+ )} +
+
+ + {/* ── Password ── */} +
+
+

{user.has_password ? "Password" : "Set Password"}

+ {!showPasswordSection && ( + + )} +
+ + {pwSuccess && !showPasswordSection && ( +

Password updated successfully!

+ )} + + {showPasswordSection && ( +
+ {user.has_password && ( + + )} + + + + {pwError &&

{pwError}

} + +
+ + +
+
+ )} +
+ + {/* ── Connected services ── */} +
+

Connected Accounts

+ +
+ + + + +
+ Discord + {discordConnection ? ( + + {discordConnection.provider_username ?? "Connected"} + + ) : ( + Not connected + )} +
+ + {discordConnection ? ( + + ) : ( + + )} +
+ + {discordConnection && !user.has_password && ( +

+ Set a password above before disconnecting Discord to avoid being + locked out. +

+ )} +
+ + {/* ── Footer ── */} +
+ +
+
+
+ ); +} diff --git a/ui/src/components/ColorPanel.css b/ui/src/components/ColorPanel.css index a57fd33..b9d3f7a 100644 --- a/ui/src/components/ColorPanel.css +++ b/ui/src/components/ColorPanel.css @@ -31,7 +31,9 @@ border: 2px solid transparent; cursor: pointer; padding: 0; - transition: transform 0.1s, border-color 0.1s; + transition: + transform 0.1s, + border-color 0.1s; overflow: hidden; } diff --git a/ui/src/components/ColorPanel.tsx b/ui/src/components/ColorPanel.tsx index 03403bc..8430977 100644 --- a/ui/src/components/ColorPanel.tsx +++ b/ui/src/components/ColorPanel.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react'; -import './ColorPanel.css'; +import { useEffect, useRef } from "react"; +import "./ColorPanel.css"; interface Props { colors: string[]; @@ -8,7 +8,12 @@ interface Props { onColorsChange: (colors: string[]) => void; } -export default function ColorPanel({ colors, activeColor, onColorChange, onColorsChange }: Props) { +export default function ColorPanel({ + colors, + activeColor, + onColorChange, + onColorsChange, +}: Props) { // One hidden color input ref per slot const inputRefs = useRef<(HTMLInputElement | null)[]>([]); @@ -16,14 +21,18 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; const num = parseInt(e.key, 10); if (num >= 1 && num <= colors.length) { onColorChange(colors[num - 1]); } }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); }, [colors, onColorChange]); function handleDoubleClick(index: number) { @@ -44,7 +53,7 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor {colors.map((c, i) => (
{/* Hidden color picker for this slot */} { inputRefs.current[i] = el; }} + ref={(el) => { + inputRefs.current[i] = el; + }} type="color" value={c} - onChange={e => handleColorEdit(i, e.target.value)} + onChange={(e) => handleColorEdit(i, e.target.value)} className="cp-color-input" tabIndex={-1} /> diff --git a/ui/src/components/ControlPanel.css b/ui/src/components/ControlPanel.css index 85a3b22..c9eae59 100644 --- a/ui/src/components/ControlPanel.css +++ b/ui/src/components/ControlPanel.css @@ -23,7 +23,9 @@ display: flex; align-items: center; justify-content: center; - transition: background 0.12s, border-color 0.12s; + transition: + background 0.12s, + border-color 0.12s; } .fp-tool-btn:hover { diff --git a/ui/src/components/ControlPanel.tsx b/ui/src/components/ControlPanel.tsx index cc3fa34..7481570 100644 --- a/ui/src/components/ControlPanel.tsx +++ b/ui/src/components/ControlPanel.tsx @@ -1,47 +1,85 @@ -import { useEffect } from 'react'; -import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from 'react-icons/md'; -import type { Tool } from '../types'; -import './ControlPanel.css'; +import { useEffect } from "react"; +import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from "react-icons/md"; +import type { Tool } from "../types"; +import "./ControlPanel.css"; interface Props { tool: Tool; onToolChange: (t: Tool) => void; } -const TOOLS: { id: Tool; icon: React.ReactNode; title: string; shortcut: string }[] = [ - { id: 'pan', icon: , title: 'Pan – drag to move the map', shortcut: 'Shift+1' }, - { id: 'zoom', icon: , title: 'Zoom – click to zoom in/out', shortcut: 'Shift+2' }, - { id: 'draw', icon: , title: 'Draw – left-click to paint, right-click to erase, Shift+click to fill', shortcut: 'Shift+3' }, - { id: 'token', icon: , title: 'Token – click to place, drag to move, right-click to delete', shortcut: 'Shift+4' }, +const TOOLS: { + id: Tool; + icon: React.ReactNode; + title: string; + shortcut: string; +}[] = [ + { + id: "pan", + icon: , + title: "Pan – drag to move the map", + shortcut: "Shift+1", + }, + { + id: "zoom", + icon: , + title: "Zoom – click to zoom in/out", + shortcut: "Shift+2", + }, + { + id: "draw", + icon: , + title: + "Draw – left-click to paint, right-click to erase, Shift+click to fill", + shortcut: "Shift+3", + }, + { + id: "token", + icon: , + title: "Token – click to place, drag to move, right-click to delete", + shortcut: "Shift+4", + }, ]; export default function ControlPanel({ tool, onToolChange }: Props) { // Keyboard shortcuts: Shift+1/2/3/4 for tools useEffect(() => { const handler = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; if (!e.shiftKey) return; switch (e.key) { - case '!': // Shift+1 on many layouts - case '1': onToolChange('pan'); break; - case '@': // Shift+2 - case '2': onToolChange('zoom'); break; - case '#': // Shift+3 - case '3': onToolChange('draw'); break; - case '$': // Shift+4 - case '4': onToolChange('token'); break; + case "!": // Shift+1 on many layouts + case "1": + onToolChange("pan"); + break; + case "@": // Shift+2 + case "2": + onToolChange("zoom"); + break; + case "#": // Shift+3 + case "3": + onToolChange("draw"); + break; + case "$": // Shift+4 + case "4": + onToolChange("token"); + break; } }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); }, [onToolChange]); return (
- {TOOLS.map(t => ( + {TOOLS.map((t) => ( +
+ + {/* ── Map settings ── */} +
+ + +
+ Visibility + + + + + + +
+ + {saveError &&

{saveError}

} + +
+ + +
+
+ + {/* ── Permissions ── */} +
+

Permissions

+ + {permsLoading ? ( +

Loading…

+ ) : ( +
+ {permissions.map((p) => ( +
+ {p.username} + + {p.role} + + {p.role !== "owner" && ( + + )} +
+ ))} + {nonOwnerPerms.length === 0 && + permissions.filter((p) => p.role === "owner").length > 0 && ( +

No editors or viewers yet

+ )} +
+ )} + + {/* Add user */} +
+ setAddUsername(e.target.value)} + className="add-perm-input" + /> + + +
+ {addError && ( +

+ {addError} +

+ )} +
+ + {/* ── Access Requests ── */} + {!reqsLoading && requests.length > 0 && ( +
+

Pending Access Requests

+
+ {requests.map((r) => ( +
+ {r.username} + + {r.requested_role} + +
+ + +
+
+ ))} +
+
+ )} +
+ + ); +} diff --git a/ui/src/components/FloatingMapControls.css b/ui/src/components/FloatingMapControls.css new file mode 100644 index 0000000..3b83d04 --- /dev/null +++ b/ui/src/components/FloatingMapControls.css @@ -0,0 +1,54 @@ +.floating-map-controls { + position: absolute; + top: 14px; + left: 14px; + display: flex; + align-items: center; + gap: 0.4rem; + z-index: 20; +} + +.fmc-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: rgba(17, 24, 39, 0.88); + border: 1px solid #374151; + border-radius: 6px; + color: #d1d5db; + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + padding: 0.35rem 0.7rem; + line-height: 1.4; + white-space: nowrap; + backdrop-filter: blur(6px); + transition: + background 0.12s, + border-color 0.12s, + color 0.12s; +} + +.fmc-btn:hover { + background: rgba(55, 65, 81, 0.95); + border-color: #6b7280; + color: #f3f4f6; +} + +.fmc-btn-primary { + background: rgba(99, 102, 241, 0.85); + border-color: #6366f1; + color: #fff; +} + +.fmc-btn-primary:hover { + background: rgba(79, 70, 229, 0.95); + border-color: #4f46e5; + color: #fff; +} + +.fmc-btn-danger:hover { + background: rgba(127, 29, 29, 0.9); + border-color: #ef4444; + color: #fca5a5; +} diff --git a/ui/src/components/FloatingMapControls.tsx b/ui/src/components/FloatingMapControls.tsx new file mode 100644 index 0000000..92958c0 --- /dev/null +++ b/ui/src/components/FloatingMapControls.tsx @@ -0,0 +1,75 @@ +import "./FloatingMapControls.css"; + +interface Props { + isLoggedIn: boolean; + hasSelectedMap: boolean; + isOwner: boolean; + onNewMap: () => void; + onViewMaps: () => void; + onEditMap: () => void; + onDeleteMap: () => void; +} + +export default function FloatingMapControls({ + isLoggedIn, + hasSelectedMap, + isOwner, + onNewMap, + onViewMaps, + onEditMap, + onDeleteMap, +}: Props) { + if (!isLoggedIn) return null; + + return ( +
+ {/* Always visible for logged-in users */} + + + + + {/* Owner-only actions — only when a map is selected */} + {hasSelectedMap && isOwner && ( + <> + + + + )} +
+ ); +} diff --git a/ui/src/components/Grid.tsx b/ui/src/components/Grid.tsx index 9d2ea9c..4659a5a 100644 --- a/ui/src/components/Grid.tsx +++ b/ui/src/components/Grid.tsx @@ -1,11 +1,21 @@ import { - useRef, useEffect, useCallback, useState, - forwardRef, useImperativeHandle, -} from 'react'; -import type { GridCell, GridToken, Tool, ServerMessage, ClientMessage } from '../types'; -import { useWebSocket } from '../hooks/useWebSocket'; -import TokenDialog from './TokenDialog'; -import './Grid.css'; + useRef, + useEffect, + useCallback, + useState, + forwardRef, + useImperativeHandle, +} from "react"; +import type { + GridCell, + GridToken, + Tool, + ServerMessage, + ClientMessage, +} from "../types"; +import { useWebSocket } from "../hooks/useWebSocket"; +import TokenDialog from "./TokenDialog"; +import "./Grid.css"; // --------------------------------------------------------------------------- // Constants @@ -16,9 +26,9 @@ const MIN_ZOOM = 8; const MAX_ZOOM = 160; const ZOOM_STEP = 1.12; -const BG_COLOR = '#111827'; -const GRID_COLOR = 'rgba(255,255,255,0.07)'; -const GRID_COLOR_MAJOR = 'rgba(255,255,255,0.16)'; +const BG_COLOR = "#111827"; +const GRID_COLOR = "rgba(255,255,255,0.07)"; +const GRID_COLOR_MAJOR = "rgba(255,255,255,0.16)"; /** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */ const MAX_FLOOD_CELLS = 2500; @@ -56,14 +66,22 @@ function cellKey(x: number, y: number): string { return `${x},${y}`; } -function canvasToCell(cx: number, cy: number, cam: Camera): { x: number; y: number } { +function canvasToCell( + cx: number, + cy: number, + cam: Camera, +): { x: number; y: number } { return { x: Math.floor(cx / cam.zoom + cam.offsetX), y: Math.floor(cy / cam.zoom + cam.offsetY), }; } -function cellToCanvas(cellX: number, cellY: number, cam: Camera): { x: number; y: number } { +function cellToCanvas( + cellX: number, + cellY: number, + cam: Camera, +): { x: number; y: number } { return { x: (cellX - cam.offsetX) * cam.zoom, y: (cellY - cam.offsetY) * cam.zoom, @@ -84,7 +102,7 @@ function drawToken( const cy = py + zoom / 2; const r = zoom * 0.38; - ctx.shadowColor = 'rgba(0,0,0,0.6)'; + ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 5; ctx.beginPath(); @@ -92,7 +110,7 @@ function drawToken( ctx.fillStyle = color; ctx.fill(); - ctx.strokeStyle = 'rgba(255,255,255,0.55)'; + ctx.strokeStyle = "rgba(255,255,255,0.55)"; ctx.lineWidth = Math.max(1, zoom * 0.04); ctx.stroke(); @@ -104,10 +122,10 @@ function drawToken( words.length >= 2 ? (words[0][0] + words[1][0]).toUpperCase() : label.slice(0, 2).toUpperCase(); - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = "#ffffff"; ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; ctx.fillText(initials, cx, cy); } } @@ -133,8 +151,10 @@ function floodFill( visited.add(cellKey(startX, startY)); const dirs = [ - { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, - { dx: 0, dy: 1 }, { dx: 0, dy: -1 }, + { dx: 1, dy: 0 }, + { dx: -1, dy: 0 }, + { dx: 0, dy: 1 }, + { dx: 0, dy: -1 }, ]; while (queue.length > 0) { @@ -170,9 +190,9 @@ function clampCameraToContent( ) { if (cells.size === 0 && tokens.size === 0) return; - const viewLeft = cam.offsetX; - const viewRight = cam.offsetX + canvasW / cam.zoom; - const viewTop = cam.offsetY; + const viewLeft = cam.offsetX; + const viewRight = cam.offsetX + canvasW / cam.zoom; + const viewTop = cam.offsetY; const viewBottom = cam.offsetY + canvasH / cam.zoom; // Quick visibility check @@ -180,8 +200,10 @@ function clampCameraToContent( cellLoop: for (const cell of cells.values()) { if ( - cell.x + 1 > viewLeft && cell.x < viewRight && - cell.y + 1 > viewTop && cell.y < viewBottom + cell.x + 1 > viewLeft && + cell.x < viewRight && + cell.y + 1 > viewTop && + cell.y < viewBottom ) { anyVisible = true; break cellLoop; @@ -191,8 +213,10 @@ function clampCameraToContent( if (!anyVisible) { for (const tok of tokens.values()) { if ( - tok.x + 1 > viewLeft && tok.x < viewRight && - tok.y + 1 > viewTop && tok.y < viewBottom + tok.x + 1 > viewLeft && + tok.x < viewRight && + tok.y + 1 > viewTop && + tok.y < viewBottom ) { anyVisible = true; break; @@ -203,8 +227,10 @@ function clampCameraToContent( if (anyVisible) return; // Find the bounding box of all content - let minX = Infinity, maxX = -Infinity; - let minY = Infinity, maxY = -Infinity; + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; for (const c of cells.values()) { if (c.x < minX) minX = c.x; @@ -248,43 +274,54 @@ const Grid = forwardRef(function Grid( ref, ) { const containerRef = useRef(null); - const canvasRef = useRef(null); + const canvasRef = useRef(null); - const cameraRef = useRef({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM }); + const cameraRef = useRef({ + offsetX: -2, + offsetY: -2, + zoom: DEFAULT_ZOOM, + }); - const cellsRef = useRef>(new Map()); + const cellsRef = useRef>(new Map()); const tokensRef = useRef>(new Map()); const [tick, setTick] = useState(0); - const redraw = useCallback(() => setTick(n => n + 1), []); + const redraw = useCallback(() => setTick((n) => n + 1), []); // ---- Mouse interaction state (refs to avoid stale closures) ---- - const isPanning = useRef(false); - const panStart = useRef<{ mx: number; my: number; ox: number; oy: number } | null>(null); - const isDrawing = useRef(false); - const isErasing = useRef(false); - const lastPainted = useRef(null); - const isDragging = useRef(false); - const dragTokenId = useRef(null); - const dragCellPos = useRef<{ x: number; y: number } | null>(null); + const isPanning = useRef(false); + const panStart = useRef<{ + mx: number; + my: number; + ox: number; + oy: number; + } | null>(null); + const isDrawing = useRef(false); + const isErasing = useRef(false); + const lastPainted = useRef(null); + const isDragging = useRef(false); + const dragTokenId = useRef(null); + const dragCellPos = useRef<{ x: number; y: number } | null>(null); // ---- WASD state ---- - const keysHeld = useRef>(new Set()); - const rafId = useRef(null); + const keysHeld = useRef>(new Set()); + const rafId = useRef(null); const lastFrameTime = useRef(null); // ---- Stable send ref so handlers never go stale ---- const sendRef = useRef<(msg: ClientMessage) => void>(() => {}); - const [cursor, setCursor] = useState('default'); - const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null); + const [cursor, setCursor] = useState("default"); + const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>( + null, + ); // ------------------------------------------------------------------------- // Imperative handle — lets App.tsx trigger a color WS update // ------------------------------------------------------------------------- useImperativeHandle(ref, () => ({ sendColorUpdate(colors: string[]) { - sendRef.current({ type: 'update_colors', colors }); + sendRef.current({ type: "update_colors", colors }); }, })); @@ -293,11 +330,11 @@ const Grid = forwardRef(function Grid( // ------------------------------------------------------------------------- useEffect(() => { const container = containerRef.current; - const canvas = canvasRef.current; + const canvas = canvasRef.current; if (!container || !canvas) return; const resize = () => { - canvas.width = container.clientWidth; + canvas.width = container.clientWidth; canvas.height = container.clientHeight; redraw(); }; @@ -313,77 +350,92 @@ const Grid = forwardRef(function Grid( // ------------------------------------------------------------------------- // Keep a stable ref to the callback so handleMessage doesn't re-create const onColorsLoadedRef = useRef(onColorsLoaded); - useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [onColorsLoaded]); + useEffect(() => { + onColorsLoadedRef.current = onColorsLoaded; + }, [onColorsLoaded]); - const handleMessage = useCallback((msg: ServerMessage) => { - switch (msg.type) { - case 'state': { - cellsRef.current.clear(); - tokensRef.current.clear(); - msg.cells.forEach(c => cellsRef.current.set(cellKey(c.x, c.y), c)); - msg.tokens.forEach(t => tokensRef.current.set(t.id, t)); - onColorsLoadedRef.current(msg.colors); - redraw(); - break; - } - case 'cell_painted': { - const key = cellKey(msg.x, msg.y); - cellsRef.current.set(key, { - map_id: mapId, - x: msg.x, y: msg.y, color: msg.color, - }); - redraw(); - break; - } - case 'cells_batch_painted': { - msg.cells.forEach(c => { - const key = cellKey(c.x, c.y); + const handleMessage = useCallback( + (msg: ServerMessage) => { + switch (msg.type) { + case "state": { + cellsRef.current.clear(); + tokensRef.current.clear(); + msg.cells.forEach((c) => cellsRef.current.set(cellKey(c.x, c.y), c)); + msg.tokens.forEach((t) => tokensRef.current.set(t.id, t)); + onColorsLoadedRef.current(msg.colors); + redraw(); + break; + } + case "cell_painted": { + const key = cellKey(msg.x, msg.y); cellsRef.current.set(key, { map_id: mapId, - x: c.x, y: c.y, color: c.color, + x: msg.x, + y: msg.y, + color: msg.color, }); - }); - redraw(); - break; - } - case 'cell_erased': { - cellsRef.current.delete(cellKey(msg.x, msg.y)); - redraw(); - break; - } - case 'token_added': { - tokensRef.current.set(msg.id, { - id: msg.id, map_id: mapId, - x: msg.x, y: msg.y, label: msg.label, color: msg.color, - }); - redraw(); - break; - } - case 'token_moved': { - const tok = tokensRef.current.get(msg.id); - if (tok) { - tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y }); redraw(); + break; } - break; + case "cells_batch_painted": { + msg.cells.forEach((c) => { + const key = cellKey(c.x, c.y); + cellsRef.current.set(key, { + map_id: mapId, + x: c.x, + y: c.y, + color: c.color, + }); + }); + redraw(); + break; + } + case "cell_erased": { + cellsRef.current.delete(cellKey(msg.x, msg.y)); + redraw(); + break; + } + case "token_added": { + tokensRef.current.set(msg.id, { + id: msg.id, + map_id: mapId, + x: msg.x, + y: msg.y, + label: msg.label, + color: msg.color, + }); + redraw(); + break; + } + case "token_moved": { + const tok = tokensRef.current.get(msg.id); + if (tok) { + tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y }); + redraw(); + } + break; + } + case "token_deleted": { + tokensRef.current.delete(msg.id); + redraw(); + break; + } + case "colors_updated": { + onColorsLoadedRef.current(msg.colors); + break; + } + case "error": + console.error("[Grid WS]", msg.message); + break; } - case 'token_deleted': { - tokensRef.current.delete(msg.id); - redraw(); - break; - } - case 'colors_updated': { - onColorsLoadedRef.current(msg.colors); - break; - } - case 'error': - console.error('[Grid WS]', msg.message); - break; - } - }, [mapId, redraw]); + }, + [mapId, redraw], + ); const { send } = useWebSocket(mapId, handleMessage); - useEffect(() => { sendRef.current = send; }, [send]); + useEffect(() => { + sendRef.current = send; + }, [send]); // ------------------------------------------------------------------------- // Canvas draw @@ -391,11 +443,11 @@ const Grid = forwardRef(function Grid( useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); if (!ctx) return; - const W = canvas.width; - const H = canvas.height; + const W = canvas.width; + const H = canvas.height; const cam = cameraRef.current; const { offsetX, offsetY, zoom } = cam; @@ -403,13 +455,19 @@ const Grid = forwardRef(function Grid( ctx.fillRect(0, 0, W, H); const startCX = Math.floor(offsetX) - 1; - const endCX = Math.ceil(offsetX + W / zoom) + 1; + const endCX = Math.ceil(offsetX + W / zoom) + 1; const startCY = Math.floor(offsetY) - 1; - const endCY = Math.ceil(offsetY + H / zoom) + 1; + const endCY = Math.ceil(offsetY + H / zoom) + 1; // Painted cells - cellsRef.current.forEach(cell => { - if (cell.x < startCX || cell.x > endCX || cell.y < startCY || cell.y > endCY) return; + cellsRef.current.forEach((cell) => { + if ( + cell.x < startCX || + cell.x > endCX || + cell.y < startCY || + cell.y > endCY + ) + return; const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam); ctx.fillStyle = cell.color; ctx.fillRect(px, py, zoom, zoom); @@ -437,7 +495,7 @@ const Grid = forwardRef(function Grid( } // Tokens (skip the one being dragged) - tokensRef.current.forEach(token => { + tokensRef.current.forEach((token) => { if (isDragging.current && dragTokenId.current === token.id) return; drawToken(ctx, token.x, token.y, token.label, token.color, cam); }); @@ -447,7 +505,14 @@ const Grid = forwardRef(function Grid( const tok = tokensRef.current.get(dragTokenId.current); if (tok) { ctx.globalAlpha = 0.6; - drawToken(ctx, dragCellPos.current.x, dragCellPos.current.y, tok.label, tok.color, cam); + drawToken( + ctx, + dragCellPos.current.x, + dragCellPos.current.y, + tok.label, + tok.color, + cam, + ); ctx.globalAlpha = 1; } } @@ -460,21 +525,24 @@ const Grid = forwardRef(function Grid( const canvas = canvasRef.current; if (canvas) { clampCameraToContent( - cameraRef.current, cellsRef.current, tokensRef.current, - canvas.width, canvas.height, + cameraRef.current, + cellsRef.current, + tokensRef.current, + canvas.width, + canvas.height, ); } redraw(); } function applyZoom(canvasX: number, canvasY: number, factor: number) { - const cam = cameraRef.current; + const cam = cameraRef.current; const worldX = canvasX / cam.zoom + cam.offsetX; const worldY = canvasY / cam.zoom + cam.offsetY; const newZoom = clamp(cam.zoom * factor, MIN_ZOOM, MAX_ZOOM); cam.offsetX = worldX - canvasX / newZoom; cam.offsetY = worldY - canvasY / newZoom; - cam.zoom = newZoom; + cam.zoom = newZoom; applyClampAndRedraw(); } @@ -486,14 +554,14 @@ const Grid = forwardRef(function Grid( if (!canvas) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const cx = e.clientX - rect.left; - const cy = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; applyZoom(cx, cy, factor); }; - canvas.addEventListener('wheel', onWheel, { passive: false }); - return () => canvas.removeEventListener('wheel', onWheel); + canvas.addEventListener("wheel", onWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", onWheel); }, []); // eslint-disable-line react-hooks/exhaustive-deps // ------------------------------------------------------------------------- @@ -508,34 +576,45 @@ const Grid = forwardRef(function Grid( return; } - const dt = lastFrameTime.current !== null - ? (timestamp - lastFrameTime.current) / 1000 - : 0; + const dt = + lastFrameTime.current !== null + ? (timestamp - lastFrameTime.current) / 1000 + : 0; lastFrameTime.current = timestamp; - const cam = cameraRef.current; + const cam = cameraRef.current; const speed = WASD_PAN_SPEED; - if (keys.has('a')) cam.offsetX -= speed * dt; - if (keys.has('d')) cam.offsetX += speed * dt; - if (keys.has('w')) cam.offsetY -= speed * dt; - if (keys.has('s')) cam.offsetY += speed * dt; + if (keys.has("a")) cam.offsetX -= speed * dt; + if (keys.has("d")) cam.offsetX += speed * dt; + if (keys.has("w")) cam.offsetY -= speed * dt; + if (keys.has("s")) cam.offsetY += speed * dt; const canvas = canvasRef.current; if (canvas) { - clampCameraToContent(cam, cellsRef.current, tokensRef.current, canvas.width, canvas.height); + clampCameraToContent( + cam, + cellsRef.current, + tokensRef.current, + canvas.width, + canvas.height, + ); } - setTick(n => n + 1); + setTick((n) => n + 1); rafId.current = requestAnimationFrame(rafTick); } function onKeyDown(e: KeyboardEvent) { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; // Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts) if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; const key = e.key.toLowerCase(); - if (['w', 'a', 's', 'd'].includes(key)) { + if (["w", "a", "s", "d"].includes(key)) { e.preventDefault(); keysHeld.current.add(key); if (rafId.current === null) { @@ -549,11 +628,11 @@ const Grid = forwardRef(function Grid( keysHeld.current.delete(e.key.toLowerCase()); } - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); return () => { - window.removeEventListener('keydown', onKeyDown); - window.removeEventListener('keyup', onKeyUp); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); if (rafId.current !== null) { cancelAnimationFrame(rafId.current); rafId.current = null; @@ -585,22 +664,27 @@ const Grid = forwardRef(function Grid( const cell = canvasToCell(mx, my, cameraRef.current); // ---- Pan tool ---- - if (tool === 'pan' && e.button === 0) { + if (tool === "pan" && e.button === 0) { isPanning.current = true; - panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY }; - setCursor('grabbing'); + panStart.current = { + mx, + my, + ox: cameraRef.current.offsetX, + oy: cameraRef.current.offsetY, + }; + setCursor("grabbing"); return; } // ---- Zoom tool ---- - if (tool === 'zoom') { - if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP); + if (tool === "zoom") { + if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP); else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP)); return; } // ---- Draw tool ---- - if (tool === 'draw') { + if (tool === "draw") { if (e.button === 0) { if (e.shiftKey) { // Shift+click → flood fill uncolored region @@ -609,42 +693,52 @@ const Grid = forwardRef(function Grid( const region = floodFill(cell.x, cell.y, cellsRef.current); if (region === null || region.length === 1) { // Unbounded or trivially single cell → paint one cell - sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor }); + sendRef.current({ + type: "paint_cell", + x: cell.x, + y: cell.y, + color: paintColor, + }); } else { // Bounded enclosed region → batch paint sendRef.current({ - type: 'paint_cells', + type: "paint_cells", cells: region.map(({ x, y }) => ({ x, y, color: paintColor })), }); } } } else { - isDrawing.current = true; + isDrawing.current = true; lastPainted.current = cellKey(cell.x, cell.y); - sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor }); + sendRef.current({ + type: "paint_cell", + x: cell.x, + y: cell.y, + color: paintColor, + }); } } else if (e.button === 2) { - isErasing.current = true; - const key = cellKey(cell.x, cell.y); + isErasing.current = true; + const key = cellKey(cell.x, cell.y); lastPainted.current = key; if (cellsRef.current.has(key)) { - sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y }); + sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y }); } } return; } // ---- Token tool ---- - if (tool === 'token') { + if (tool === "token") { if (e.button === 2) { const tok = tokenAtCell(cell.x, cell.y); - if (tok) sendRef.current({ type: 'delete_token', id: tok.id }); + if (tok) sendRef.current({ type: "delete_token", id: tok.id }); return; } if (e.button === 0) { const tok = tokenAtCell(cell.x, cell.y); if (tok) { - isDragging.current = true; + isDragging.current = true; dragTokenId.current = tok.id; dragCellPos.current = { x: cell.x, y: cell.y }; redraw(); @@ -661,8 +755,8 @@ const Grid = forwardRef(function Grid( // Pan if (isPanning.current && panStart.current) { const cam = cameraRef.current; - const dx = (mx - panStart.current.mx) / cam.zoom; - const dy = (my - panStart.current.my) / cam.zoom; + const dx = (mx - panStart.current.mx) / cam.zoom; + const dy = (my - panStart.current.my) / cam.zoom; cam.offsetX = panStart.current.ox - dx; cam.offsetY = panStart.current.oy - dy; applyClampAndRedraw(); @@ -672,13 +766,18 @@ const Grid = forwardRef(function Grid( // Draw / erase stroke if (isDrawing.current || isErasing.current) { const cell = canvasToCell(mx, my, cameraRef.current); - const key = cellKey(cell.x, cell.y); + const key = cellKey(cell.x, cell.y); if (lastPainted.current !== key) { lastPainted.current = key; if (isDrawing.current) { - sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor }); + sendRef.current({ + type: "paint_cell", + x: cell.x, + y: cell.y, + color: paintColor, + }); } else if (isErasing.current && cellsRef.current.has(key)) { - sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y }); + sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y }); } } return; @@ -687,7 +786,10 @@ const Grid = forwardRef(function Grid( // Token drag if (isDragging.current && dragCellPos.current) { const cell = canvasToCell(mx, my, cameraRef.current); - if (dragCellPos.current.x !== cell.x || dragCellPos.current.y !== cell.y) { + if ( + dragCellPos.current.x !== cell.x || + dragCellPos.current.y !== cell.y + ) { dragCellPos.current = { x: cell.x, y: cell.y }; redraw(); } @@ -697,29 +799,32 @@ const Grid = forwardRef(function Grid( function handleMouseUp(_e: React.MouseEvent) { if (isPanning.current) { isPanning.current = false; - panStart.current = null; - setCursor('grab'); + panStart.current = null; + setCursor("grab"); return; } if (isDrawing.current || isErasing.current) { - isDrawing.current = false; - isErasing.current = false; + isDrawing.current = false; + isErasing.current = false; lastPainted.current = null; return; } if (isDragging.current && dragTokenId.current && dragCellPos.current) { const tok = tokensRef.current.get(dragTokenId.current); - if (tok && (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)) { + if ( + tok && + (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y) + ) { sendRef.current({ - type: 'move_token', + type: "move_token", id: dragTokenId.current, x: dragCellPos.current.x, y: dragCellPos.current.y, }); } - isDragging.current = false; + isDragging.current = false; dragTokenId.current = null; dragCellPos.current = null; redraw(); @@ -727,13 +832,13 @@ const Grid = forwardRef(function Grid( } function handleMouseLeave() { - isPanning.current = false; - panStart.current = null; - isDrawing.current = false; - isErasing.current = false; + isPanning.current = false; + panStart.current = null; + isDrawing.current = false; + isErasing.current = false; lastPainted.current = null; if (isDragging.current) { - isDragging.current = false; + isDragging.current = false; dragTokenId.current = null; dragCellPos.current = null; redraw(); @@ -743,16 +848,30 @@ const Grid = forwardRef(function Grid( // Sync cursor CSS to active tool useEffect(() => { switch (tool) { - case 'pan': setCursor('grab'); break; - case 'zoom': setCursor('zoom-in'); break; - case 'draw': setCursor('crosshair'); break; - case 'token': setCursor('crosshair'); break; + case "pan": + setCursor("grab"); + break; + case "zoom": + setCursor("zoom-in"); + break; + case "draw": + setCursor("crosshair"); + break; + case "token": + setCursor("crosshair"); + break; } }, [tool]); function handleAddToken(label: string, color: string) { if (!dialogPos) return; - sendRef.current({ type: 'add_token', x: dialogPos.x, y: dialogPos.y, label, color }); + sendRef.current({ + type: "add_token", + x: dialogPos.x, + y: dialogPos.y, + label, + color, + }); setDialogPos(null); } @@ -766,7 +885,7 @@ const Grid = forwardRef(function Grid( onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} - onContextMenu={e => e.preventDefault()} + onContextMenu={(e) => e.preventDefault()} /> {dialogPos && ( void; + onAccountClick: () => void; +} + +export default function Header({ + user, + authLoading, + selectedMapName, + onLoginClick, + onAccountClick, +}: Props) { + /** Display name: first name if set, otherwise username */ + const displayName = user ? user.first_name?.trim() || user.username : null; + + return ( +
+
+ SIREN +
+ +
+ {selectedMapName && ( + {selectedMapName} + )} +
+ +
+ {!authLoading && + (user ? ( + + ) : ( + + ))} +
+
+ ); +} diff --git a/ui/src/components/LoginButton.tsx b/ui/src/components/LoginButton.tsx index 075ab95..507eca6 100644 --- a/ui/src/components/LoginButton.tsx +++ b/ui/src/components/LoginButton.tsx @@ -1,21 +1,13 @@ -import { auth } from '../api'; - interface Props { className?: string; + onClick: () => void; } -export default function LoginButton({ className }: Props) { - async function handleLogin() { - try { - await auth.login(); - } catch (err) { - console.error('Failed to initiate login:', err); - } - } - +/** A simple button that opens the login modal when clicked. */ +export default function LoginButton({ className, onClick }: Props) { return ( - ); } diff --git a/ui/src/components/LoginModal.css b/ui/src/components/LoginModal.css new file mode 100644 index 0000000..b8adc2e --- /dev/null +++ b/ui/src/components/LoginModal.css @@ -0,0 +1,159 @@ +/* ── Backdrop ── */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +/* ── Modal card ── */ +.modal { + background: #1e2130; + border: 1px solid #2e3348; + border-radius: 10px; + padding: 2rem; + width: 360px; + max-width: 90vw; + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: none; + border: none; + color: #8892a4; + font-size: 1rem; + cursor: pointer; + line-height: 1; + padding: 0.25rem; +} +.modal-close:hover { + color: #e2e8f0; +} + +/* ── Tabs ── */ +.modal-tabs { + display: flex; + gap: 0.5rem; + border-bottom: 1px solid #2e3348; + margin-bottom: 0.25rem; +} + +.modal-tab { + flex: 1; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: #8892a4; + cursor: pointer; + font-size: 0.9rem; + padding: 0.5rem; + transition: + color 0.15s, + border-color 0.15s; +} +.modal-tab.active { + color: #e2e8f0; + border-bottom-color: #5865f2; +} +.modal-tab:hover:not(.active) { + color: #cbd5e1; +} + +/* ── Form ── */ +.modal-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.modal-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + color: #8892a4; + font-size: 0.8rem; +} + +.modal-form input { + background: #141622; + border: 1px solid #2e3348; + border-radius: 6px; + color: #e2e8f0; + font-size: 0.9rem; + padding: 0.5rem 0.75rem; + outline: none; +} +.modal-form input:focus { + border-color: #5865f2; +} + +.modal-error { + color: #f87171; + font-size: 0.8rem; + margin: 0; +} + +/* ── Buttons ── */ +.btn-primary { + background: #5865f2; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + padding: 0.6rem; + transition: background 0.15s; +} +.btn-primary:hover:not(:disabled) { + background: #4752c4; +} +.btn-primary:disabled { + opacity: 0.5; + cursor: default; +} + +/* ── Divider ── */ +.modal-divider { + display: flex; + align-items: center; + gap: 0.75rem; + color: #4a5568; + font-size: 0.75rem; +} +.modal-divider::before, +.modal-divider::after { + content: ""; + flex: 1; + height: 1px; + background: #2e3348; +} + +/* ── Discord button ── */ +.btn-discord { + align-items: center; + background: #5865f2; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + display: flex; + font-size: 0.9rem; + font-weight: 600; + gap: 0.5rem; + justify-content: center; + padding: 0.6rem; + transition: background 0.15s; +} +.btn-discord:hover { + background: #4752c4; +} diff --git a/ui/src/components/LoginModal.tsx b/ui/src/components/LoginModal.tsx new file mode 100644 index 0000000..b256e9c --- /dev/null +++ b/ui/src/components/LoginModal.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { auth } from "../api"; +import type { UserInfo } from "../types"; +import "./LoginModal.css"; + +interface Props { + onClose: () => void; + onLogin: (user: UserInfo) => void; +} + +type Tab = "login" | "register"; + +export default function LoginModal({ onClose, onLogin }: Props) { + const [tab, setTab] = useState("login"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (tab === "register" && password !== confirm) { + setError("Passwords do not match"); + return; + } + + setLoading(true); + try { + if (tab === "login") { + await auth.loginLocal(username, password); + } else { + await auth.register(username, password); + } + // Cookie is now set server-side; fetch user info to update parent + const user = await auth.me(); + if (user) { + onLogin(user); + onClose(); + } else { + setError("Login succeeded but could not load user info."); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + // Extract the human-readable part (strip leading status code) + setError( + msg + .replace(/^\d+:\s*/, "") + .replace(/\{.*\}/s, "") + .trim() || "Authentication failed", + ); + } finally { + setLoading(false); + } + } + + async function handleDiscordLogin() { + try { + await auth.loginDiscord(window.location.origin + "/map"); + } catch (err) { + console.error("Discord login failed:", err); + } + } + + return ( +
+
e.stopPropagation()}> + + + {/* Tab switcher */} +
+ + +
+ + {/* Username / password form */} +
+ + + {tab === "register" && ( + + )} + + {error &&

{error}

} + + +
+ +
+ or +
+ + {/* Discord OAuth */} + +
+
+ ); +} diff --git a/ui/src/components/MapList.css b/ui/src/components/MapList.css deleted file mode 100644 index 5493a25..0000000 --- a/ui/src/components/MapList.css +++ /dev/null @@ -1,127 +0,0 @@ -.map-list { - width: 220px; - flex-shrink: 0; - background: #1f2937; - border-right: 1px solid #374151; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.map-list-header { - padding: 1rem; - border-bottom: 1px solid #374151; -} - -.map-list-header h2 { - font-size: 0.95rem; - font-weight: 600; - color: #f3f4f6; - letter-spacing: 0.02em; -} - -.map-create-form { - display: flex; - gap: 0.25rem; - padding: 0.75rem; - border-bottom: 1px solid #374151; -} - -.map-create-form input { - flex: 1; - min-width: 0; - background: #111827; - border: 1px solid #4b5563; - border-radius: 4px; - color: #e5e7eb; - padding: 0.35rem 0.5rem; - font-size: 0.85rem; - outline: none; -} - -.map-create-form input:focus { - border-color: #6366f1; -} - -.map-create-form button { - background: #6366f1; - color: white; - border: none; - border-radius: 4px; - padding: 0.35rem 0.6rem; - font-size: 1rem; - cursor: pointer; - line-height: 1; -} - -.map-create-form button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.map-entries { - list-style: none; - flex: 1; - overflow-y: auto; - padding: 0.25rem 0; -} - -.map-empty { - padding: 1rem; - color: #6b7280; - font-size: 0.85rem; - text-align: center; -} - -.map-entry { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - cursor: pointer; - transition: background 0.1s; - border-radius: 4px; - margin: 0.1rem 0.25rem; -} - -.map-entry:hover { - background: #374151; -} - -.map-entry.selected { - background: #4338ca; -} - -.map-name { - flex: 1; - font-size: 0.875rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.map-dims { - font-size: 0.7rem; - color: #9ca3af; - flex-shrink: 0; -} - -.map-entry.selected .map-dims { - color: #c7d2fe; -} - -.map-delete { - background: none; - border: none; - cursor: pointer; - padding: 0.1rem; - opacity: 0; - transition: opacity 0.15s; - font-size: 0.85rem; - line-height: 1; - flex-shrink: 0; -} - -.map-entry:hover .map-delete { - opacity: 1; -} diff --git a/ui/src/components/MapList.tsx b/ui/src/components/MapList.tsx deleted file mode 100644 index 23c37bf..0000000 --- a/ui/src/components/MapList.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useState } from 'react'; -import type { GridMap } from '../types'; -import './MapList.css'; - -interface Props { - maps: GridMap[]; - selectedMapId: string | null; - onSelect: (id: string) => void; - onCreate: (name: string) => void; - onDelete: (id: string) => void; -} - -export default function MapList({ maps, selectedMapId, onSelect, onCreate, onDelete }: Props) { - const [newName, setNewName] = useState(''); - - function handleCreate(e: React.FormEvent) { - e.preventDefault(); - const name = newName.trim(); - if (!name) return; - onCreate(name); - setNewName(''); - } - - function handleDeleteClick(e: React.MouseEvent, id: string) { - e.stopPropagation(); - if (confirm('Delete this map? This cannot be undone.')) { - onDelete(id); - } - } - - return ( - - ); -} diff --git a/ui/src/components/MapListModal.css b/ui/src/components/MapListModal.css new file mode 100644 index 0000000..6f9b9ea --- /dev/null +++ b/ui/src/components/MapListModal.css @@ -0,0 +1,183 @@ +.map-list-modal { + width: 540px; + max-width: 92vw; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.map-list-empty { + font-size: 0.85rem; + color: #6b7280; + text-align: center; + padding: 1.5rem 0; + margin: 0; +} + +.map-list-scroll { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.5rem; + padding-right: 2px; /* room for scrollbar */ +} + +.map-list-row { + display: flex; + align-items: center; + gap: 0.75rem; + background: #141622; + border: 1px solid #2e3348; + border-radius: 8px; + padding: 0.6rem 0.85rem; + cursor: pointer; + transition: + background 0.1s, + border-color 0.1s; + outline: none; +} + +.map-list-row:hover { + background: #1a1d2e; + border-color: #4b5563; +} + +.map-list-row.active { + border-color: #6366f1; + background: rgba(99, 102, 241, 0.08); +} + +.map-list-row:focus-visible { + box-shadow: 0 0 0 2px #6366f1; +} + +.map-list-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} + +.map-list-name { + font-size: 0.9rem; + font-weight: 600; + color: #e2e8f0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.map-list-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; +} + +.map-list-owner { + font-size: 0.75rem; + color: #6b7280; +} + +.map-access-badge { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.1rem 0.4rem; + border-radius: 4px; +} + +.access-private { + background: rgba(75, 85, 99, 0.3); + color: #9ca3af; +} +.access-public_view { + background: rgba(59, 130, 246, 0.15); + color: #60a5fa; +} +.access-public_edit { + background: rgba(16, 185, 129, 0.15); + color: #34d399; +} + +.map-fav-badge { + font-size: 0.68rem; + color: #f59e0b; +} + +/* Role badge reused from EditMapModal */ +.perm-role-badge { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.5rem; + border-radius: 4px; +} +.role-owner { + background: rgba(99, 102, 241, 0.2); + color: #818cf8; +} +.role-editor { + background: rgba(234, 179, 8, 0.15); + color: #fbbf24; +} +.role-viewer { + background: rgba(16, 185, 129, 0.15); + color: #34d399; +} + +/* ── Row action buttons ── */ +.map-list-actions { + display: flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; +} + +.map-action-btn { + background: none; + border: 1px solid transparent; + border-radius: 5px; + color: #6b7280; + cursor: pointer; + font-size: 0.9rem; + line-height: 1; + padding: 0.3rem 0.4rem; + transition: + color 0.12s, + border-color 0.12s, + background 0.12s; + display: flex; + align-items: center; + justify-content: center; +} + +.map-action-btn:hover { + color: #e2e8f0; + background: rgba(255, 255, 255, 0.05); + border-color: #4b5563; +} + +.map-action-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.fav-btn.fav-active { + color: #f59e0b; +} +.fav-btn.fav-active:hover { + color: #fbbf24; +} + +.copy-btn { + color: #6b7280; +} +.copied-text { + color: #34d399; + font-size: 0.85rem; +} diff --git a/ui/src/components/MapListModal.tsx b/ui/src/components/MapListModal.tsx new file mode 100644 index 0000000..f7b7a51 --- /dev/null +++ b/ui/src/components/MapListModal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { api } from "../api"; +import type { ListedMap } from "../types"; +import "./MapListModal.css"; + +interface Props { + maps: ListedMap[]; + selectedMapId: string | null; + onSelect: (id: string) => void; + onClose: () => void; + onMapsChange: (maps: ListedMap[]) => void; +} + +/** Copy text to the clipboard; show a brief "Copied!" toast. */ +function copyToClipboard( + text: string, + setCopied: (id: string | null) => void, + id: string, +) { + navigator.clipboard.writeText(text).then(() => { + setCopied(id); + setTimeout(() => setCopied(null), 1500); + }); +} + +function accessLabel(access: string): string { + switch (access) { + case "public_view": + return "Public (view)"; + case "public_edit": + return "Public (edit)"; + default: + return "Private"; + } +} + +export default function MapListModal({ + maps, + selectedMapId, + onSelect, + onClose, + onMapsChange, +}: Props) { + const [copiedId, setCopiedId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + async function handleFavoriteToggle(e: React.MouseEvent, map: ListedMap) { + e.stopPropagation(); + setTogglingId(map.id); + try { + if (map.is_favorited) { + await api.unfavoriteMap(map.id); + } else { + await api.favoriteMap(map.id); + } + onMapsChange( + maps.map((m) => + m.id === map.id ? { ...m, is_favorited: !m.is_favorited } : m, + ), + ); + } catch (err) { + console.error("Failed to toggle favorite", err); + } finally { + setTogglingId(null); + } + } + + function handleCopyLink(e: React.MouseEvent, map: ListedMap) { + e.stopPropagation(); + const link = `${window.location.origin}/map/${encodeURIComponent(map.id)}`; + copyToClipboard(link, setCopiedId, map.id); + } + + function handleSelect(map: ListedMap) { + onSelect(map.id); + onClose(); + } + + return ( +
+
e.stopPropagation()} + > +
+

My Maps

+ +
+ + {maps.length === 0 ? ( +

+ No maps yet. Click "+ New Map" to create one. +

+ ) : ( +
+ {maps.map((map) => ( +
handleSelect(map)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && handleSelect(map)} + > +
+ {map.name} +
+ + by {map.owner_username} + + + {accessLabel(map.public_access)} + + {map.user_role && ( + + {map.user_role} + + )} + {map.is_favorited && !map.user_role && ( + ★ Favorited + )} +
+
+ +
+ {/* Favorite toggle */} + + + {/* Copy link */} + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/ui/src/components/Modal.css b/ui/src/components/Modal.css new file mode 100644 index 0000000..593bebc --- /dev/null +++ b/ui/src/components/Modal.css @@ -0,0 +1,108 @@ +/* ── Shared modal primitives used across all modal components ── */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; + padding: 1rem; +} + +.modal { + background: #1e2130; + border: 1px solid #2e3348; + border-radius: 10px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 0.25rem; +} + +.modal-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: #e2e8f0; +} + +.modal-close { + background: none; + border: none; + color: #6b7280; + font-size: 1rem; + cursor: pointer; + line-height: 1; + padding: 0.25rem 0.4rem; + border-radius: 4px; + transition: + color 0.12s, + background 0.12s; +} +.modal-close:hover { + color: #e2e8f0; + background: rgba(255, 255, 255, 0.06); +} + +/* ── Shared button styles ── */ + +.btn-primary { + background: #6366f1; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + padding: 0.4rem 1rem; + transition: background 0.12s; + white-space: nowrap; +} +.btn-primary:hover { + background: #4f46e5; +} +.btn-primary:disabled { + opacity: 0.5; + cursor: default; +} + +.btn-secondary { + background: #374151; + border: 1px solid #4b5563; + border-radius: 6px; + color: #d1d5db; + cursor: pointer; + font-size: 0.85rem; + padding: 0.4rem 1rem; + transition: background 0.12s; + white-space: nowrap; +} +.btn-secondary:hover { + background: #4b5563; +} + +/* ── Shared header button ── */ +.header-btn { + background: #374151; + border: 1px solid #4b5563; + border-radius: 6px; + color: #e5e7eb; + cursor: pointer; + font-size: 0.82rem; + padding: 0.3rem 0.65rem; + line-height: 1.4; + white-space: nowrap; + transition: background 0.12s; +} +.header-btn:hover { + background: #4b5563; +} diff --git a/ui/src/components/NewMapModal.css b/ui/src/components/NewMapModal.css new file mode 100644 index 0000000..8583307 --- /dev/null +++ b/ui/src/components/NewMapModal.css @@ -0,0 +1,103 @@ +.new-map-modal { + width: 440px; + max-width: 92vw; +} + +.new-map-form-modal { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding-top: 0.25rem; +} + +.field-label { + display: flex; + flex-direction: column; + gap: 0.4rem; + font-size: 0.82rem; + color: #9ca3af; + font-weight: 500; +} + +.field-label input { + background: #111827; + border: 1px solid #4b5563; + border-radius: 6px; + color: #e5e7eb; + padding: 0.4rem 0.65rem; + font-size: 0.9rem; + outline: none; + transition: border-color 0.12s; +} + +.field-label input:focus { + border-color: #6366f1; +} + +/* ── Visibility fieldset ── */ +.public-access-fieldset { + border: 1px solid #374151; + border-radius: 8px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.public-access-fieldset legend { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #6b7280; + padding: 0 0.3rem; +} + +.radio-option { + display: flex; + align-items: flex-start; + gap: 0.6rem; + cursor: pointer; +} + +.radio-option input[type="radio"] { + accent-color: #6366f1; + margin-top: 3px; + flex-shrink: 0; + cursor: pointer; +} + +.radio-label { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.radio-label strong { + font-size: 0.85rem; + color: #e2e8f0; + font-weight: 500; +} + +.radio-hint { + font-size: 0.75rem; + color: #6b7280; +} + +/* ── Shared form error ── */ +.form-error { + margin: 0; + font-size: 0.82rem; + color: #f87171; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: 6px; + padding: 0.4rem 0.7rem; +} + +/* ── Modal actions row ── */ +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding-top: 0.25rem; +} diff --git a/ui/src/components/NewMapModal.tsx b/ui/src/components/NewMapModal.tsx new file mode 100644 index 0000000..c52dd0e --- /dev/null +++ b/ui/src/components/NewMapModal.tsx @@ -0,0 +1,130 @@ +import { useState, useRef, useEffect } from "react"; +import type { PublicAccess } from "../types"; +import "./NewMapModal.css"; + +interface Props { + onClose: () => void; + onCreate: (name: string, publicAccess: PublicAccess) => Promise; +} + +export default function NewMapModal({ onClose, onCreate }: Props) { + const [name, setName] = useState(""); + const [publicAccess, setPublicAccess] = useState("private"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const nameRef = useRef(null); + + useEffect(() => { + nameRef.current?.focus(); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + setLoading(true); + setError(null); + try { + await onCreate(trimmed, publicAccess); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to create map"); + } finally { + setLoading(false); + } + } + + return ( +
+
e.stopPropagation()}> +
+

New Map

+ +
+ +
+ + +
+ Visibility + + + + + + +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/TokenDialog.css b/ui/src/components/TokenDialog.css index e96deb3..8e9ec02 100644 --- a/ui/src/components/TokenDialog.css +++ b/ui/src/components/TokenDialog.css @@ -38,7 +38,7 @@ color: #9ca3af; } -.dialog label input[type='text'] { +.dialog label input[type="text"] { background: #111827; border: 1px solid #4b5563; border-radius: 5px; @@ -48,11 +48,11 @@ outline: none; } -.dialog label input[type='text']:focus { +.dialog label input[type="text"]:focus { border-color: #6366f1; } -.dialog label input[type='color'] { +.dialog label input[type="color"] { width: 48px; height: 32px; padding: 0; diff --git a/ui/src/components/TokenDialog.tsx b/ui/src/components/TokenDialog.tsx index b51c927..f1ee636 100644 --- a/ui/src/components/TokenDialog.tsx +++ b/ui/src/components/TokenDialog.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useRef } from 'react'; -import './TokenDialog.css'; +import { useState, useEffect, useRef } from "react"; +import "./TokenDialog.css"; interface Props { defaultColor: string; @@ -7,8 +7,12 @@ interface Props { onCancel: () => void; } -export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props) { - const [label, setLabel] = useState(''); +export default function TokenDialog({ + defaultColor, + onConfirm, + onCancel, +}: Props) { + const [label, setLabel] = useState(""); const [color, setColor] = useState(defaultColor); const inputRef = useRef(null); @@ -24,12 +28,16 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props } function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Escape') onCancel(); + if (e.key === "Escape") onCancel(); } return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>

Add Token

@@ -48,14 +56,18 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props setColor(e.target.value)} + onChange={(e) => setColor(e.target.value)} />
-
diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 151f4a0..16b7096 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -1,6 +1,5 @@ -import { useEffect, useRef, useCallback } from 'react'; -import type { ServerMessage, ClientMessage } from '../types'; -import { getToken } from '../api'; +import { useEffect, useRef, useCallback } from "react"; +import type { ServerMessage, ClientMessage } from "../types"; export function useWebSocket( mapId: string, @@ -12,10 +11,10 @@ export function useWebSocket( onMessageRef.current = onMessage; useEffect(() => { - const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const token = getToken(); - const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''; - const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws${tokenParam}`; + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + // The browser automatically sends the siren_session cookie with the + // WebSocket upgrade request — no manual token query param needed. + const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws`; const ws = new WebSocket(url); wsRef.current = ws; @@ -29,12 +28,12 @@ export function useWebSocket( const msg: ServerMessage = JSON.parse(event.data as string); onMessageRef.current(msg); } catch (err) { - console.error('[WS] Failed to parse message:', err); + console.error("[WS] Failed to parse message:", err); } }; ws.onerror = (err) => { - console.error('[WS] Error:', err); + console.error("[WS] Error:", err); }; ws.onclose = () => { diff --git a/ui/src/index.css b/ui/src/index.css index 796d1f1..8e5ebbc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1,12 +1,19 @@ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; } -html, body, #root { +html, +body, +#root { height: 100%; - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; background: #111827; color: #e5e7eb; } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 9bb419d..f25366e 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,9 +1,9 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App.tsx'; -import './index.css'; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( , diff --git a/ui/src/types.ts b/ui/src/types.ts index 7b4180f..85c600d 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -1,27 +1,62 @@ -export interface User { - id: string; // Discord snowflake (stored as string) - username: string; - avatar?: string; +// --------------------------------------------------------------------------- +// User / Auth +// --------------------------------------------------------------------------- + +export interface ConnectionInfo { + provider: string; + provider_username: string | null; + provider_avatar: string | null; } -export type MapRole = 'owner' | 'editor' | 'viewer'; +export interface UserInfo { + id: string; // UUID + username: string; + first_name: string | null; + last_name: string | null; + email: string | null; + /** True when the account has a local password (can log in without OAuth). */ + has_password: boolean; + connections: ConnectionInfo[]; +} + +// --------------------------------------------------------------------------- +// Maps +// --------------------------------------------------------------------------- + +export type MapRole = "owner" | "editor" | "viewer"; + +/** Map visibility / editability level. */ +export type PublicAccess = "private" | "public_view" | "public_edit"; export interface MapPermission { map_id: string; - user_id: number; + user_id: string; // UUID + username: string; role: MapRole; } +/** Core map record (returned by create, get, update). */ export interface GridMap { id: string; name: string; - is_public: boolean; - owner_id: number; + public_access: PublicAccess; + owner_id: string; // UUID colors: string[]; created_at: string; updated_at: string; } +/** + * Extended map record returned by the list endpoint. + * Includes owner username, the caller's role, and a favorite flag. + */ +export interface ListedMap extends GridMap { + owner_username: string; + /** Null when the map is in the list only via a favorite (no explicit permission). */ + user_role: MapRole | null; + is_favorited: boolean; +} + export interface GridCell { map_id: string; x: number; @@ -44,36 +79,52 @@ export interface MapState { tokens: GridToken[]; } -export type Tool = 'pan' | 'zoom' | 'draw' | 'token'; +export interface MapAccessRequest { + id: string; // UUID + map_id: string; + user_id: string; // UUID + username: string; + requested_role: "editor" | "viewer"; + status: "pending" | "approved" | "denied"; + created_at: string; + updated_at: string; +} -// ---- WebSocket message types ------------------------------------------------ +export type Tool = "pan" | "zoom" | "draw" | "token"; + +// --------------------------------------------------------------------------- +// WebSocket message types +// --------------------------------------------------------------------------- export type ClientMessage = - | { type: 'paint_cell'; x: number; y: number; color: string } - | { type: 'paint_cells'; cells: Array<{ x: number; y: number; color: string }> } - | { type: 'erase_cell'; x: number; y: number } - | { type: 'add_token'; x: number; y: number; label: string; color: string } - | { type: 'move_token'; id: string; x: number; y: number } - | { type: 'delete_token'; id: string } - | { type: 'update_colors'; colors: string[] }; + | { type: "paint_cell"; x: number; y: number; color: string } + | { + type: "paint_cells"; + cells: Array<{ x: number; y: number; color: string }>; + } + | { type: "erase_cell"; x: number; y: number } + | { type: "add_token"; x: number; y: number; label: string; color: string } + | { type: "move_token"; id: string; x: number; y: number } + | { type: "delete_token"; id: string } + | { type: "update_colors"; colors: string[] }; export type ServerMessage = - | { type: 'state'; cells: GridCell[]; tokens: GridToken[]; colors: string[] } - | { type: 'cell_painted'; x: number; y: number; color: string } - | { type: 'cells_batch_painted'; cells: Array<{ x: number; y: number; color: string }> } - | { type: 'cell_erased'; x: number; y: number } - | { type: 'token_added'; id: string; x: number; y: number; label: string; color: string } - | { type: 'token_moved'; id: string; x: number; y: number } - | { type: 'token_deleted'; id: string } - | { type: 'colors_updated'; colors: string[] } - | { type: 'error'; message: string }; - -// ---- Auth token payload (JWT claims) ---------------------------------------- - -export interface TokenClaims { - sub: number; // Discord user ID - name: string; - iat: number; - exp: number; - jti: string; -} + | { type: "state"; cells: GridCell[]; tokens: GridToken[]; colors: string[] } + | { type: "cell_painted"; x: number; y: number; color: string } + | { + type: "cells_batch_painted"; + cells: Array<{ x: number; y: number; color: string }>; + } + | { type: "cell_erased"; x: number; y: number } + | { + type: "token_added"; + id: string; + x: number; + y: number; + label: string; + color: string; + } + | { type: "token_moved"; id: string; x: number; y: number } + | { type: "token_deleted"; id: string } + | { type: "colors_updated"; colors: string[] } + | { type: "error"; message: string }; diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 52cf1d5..e1f21b5 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,13 +1,23 @@ import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +import react from '@vitejs/plugin-react' +import path from "path"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@api': path.resolve(__dirname, './src/api'), + '@components': path.resolve(__dirname, './src/components'), + '@hooks': path.resolve(__dirname, './src/hooks'), + '@types': path.resolve(__dirname, './src/types'), + } + }, server: { port: 5173, proxy: { - // Proxy REST calls and WebSocket upgrades to the Axum backend + // Proxy REST calls and WebSocket upgrades to the API '/api': { target: 'http://localhost:3000', changeOrigin: true,