Updating auth

This commit is contained in:
2026-04-04 08:28:43 -04:00
parent 35d07e8df1
commit f17e5061cd
78 changed files with 5266 additions and 1380 deletions

View File

@@ -1,2 +1,15 @@
idea/ # Build
target/ **/target/
**/Cargo.lock
**/node_modules/
**/dist/
**/package-lock.json
logs/
data/
settings.json
.env
# IDE
.idea/
.DS_Store

View File

@@ -15,6 +15,11 @@ API_BASE_URL=http://localhost:3000
API_PORT=3000 API_PORT=3000
API_SESSION_TTL=86400 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 UI_PORT=8080
VALKEY_HOST=localhost VALKEY_HOST=localhost

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
**/target/ **/target/
**/Cargo.lock **/Cargo.lock
**/node_modules/ **/node_modules/
**/dist/
**/package-lock.json **/package-lock.json
logs/ logs/

View File

@@ -11,7 +11,7 @@ resolver = "2"
edition = "2024" edition = "2024"
version = "0.3.0" version = "0.3.0"
rust-version = "1.94" rust-version = "1.86"
authors = ["Ben Sherriff <ben@bensherriff.com>"] authors = ["Ben Sherriff <ben@bensherriff.com>"]
description = "A Discord bot for playing music" description = "A Discord bot for playing music"
repository = "https://github.com/bensherriff/siren" repository = "https://github.com/bensherriff/siren"
@@ -57,9 +57,14 @@ rand_chacha = "0.10"
regex = "1" regex = "1"
lazy_static = "1" lazy_static = "1"
# Auth / Security
argon2 = { version = "0.5", features = ["std"] }
sha2 = "0.10"
cookie = { version = "0.18", features = ["percent-encode"] }
# API # API
axum = { version = "0.8", features = ["json", "ws", "macros"] } 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"] } jsonwebtoken = { version = "10", features = ["rust_crypto"] }
tower-http = { version = "0.6", features = ["fs", "cors"] } tower-http = { version = "0.6", features = ["fs", "cors"] }
dashmap = "6" dashmap = "6"

View File

@@ -8,17 +8,31 @@ vars:
tasks: tasks:
default: default:
desc: List available tasks desc: "List available tasks"
cmds: cmds:
- task --list - task --list
silent: true silent: true
setup: setup:
desc: Copy .env.example to .env if .env does not exist desc: "Copy .env.example to .env if .env does not exist"
cmds: cmds:
- test -f .env || cp .env.example .env - test -f .env || cp .env.example .env
silent: true 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 # Cargo
# ----------------------------------------------------------- # -----------------------------------------------------------
@@ -45,7 +59,7 @@ tasks:
- cargo run - cargo run
silent: true silent: true
format: format:app:
desc: "Format code" desc: "Format code"
cmds: cmds:
- cargo fmt - cargo fmt
@@ -58,7 +72,7 @@ tasks:
- cargo clean - cargo clean
silent: true silent: true
lint: lint:app:
desc: "Run Clippy linter" desc: "Run Clippy linter"
deps: [ setup ] deps: [ setup ]
cmds: cmds:
@@ -116,27 +130,41 @@ tasks:
# ----------------------------------------------------------- # -----------------------------------------------------------
# UI # UI
# ----------------------------------------------------------- # -----------------------------------------------------------
ui:install: install:ui:
desc: "Install UI npm dependencies" desc: "Install UI npm dependencies"
dir: ui dir: ui
cmds: cmds:
- npm install - npm install
silent: true silent: true
ui:run: run:ui:
desc: "Run Vite dev server" desc: "Run Vite dev server"
dir: ui dir: ui
cmds: cmds:
- npm run dev - npm run dev
silent: true silent: true
ui:build: build:ui:
desc: "Build the React UI into ui/dist" desc: "Build the React UI into ui/dist"
dir: ui dir: ui
cmds: cmds:
- npm run build - npm run build
silent: true 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 # Utilities
# ----------------------------------------------------------- # -----------------------------------------------------------

View File

@@ -26,3 +26,6 @@ redis = { workspace = true }
tower-http = { workspace = true } tower-http = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
argon2 = { workspace = true }
sha2 = { workspace = true }
cookie = { workspace = true }

View File

@@ -1,5 +1,5 @@
use crate::{AppState, error::Result}; use crate::{AppState, error::Result};
use axum::Router; use axum::{Router, http::HeaderValue};
use std::{env, sync::Arc}; use std::{env, sync::Arc};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::{ use tower_http::{
@@ -19,17 +19,36 @@ impl App {
pub async fn serve(self) -> Result<()> { pub async fn serve(self) -> Result<()> {
log::debug!("Starting API..."); log::debug!("Starting API...");
let cors = CorsLayer::new() // 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::<HeaderValue>()
.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_origin(Any)
.allow_methods(Any) .allow_methods(Any)
.allow_headers(Any); .allow_headers(Any),
};
// Serve the built React frontend from frontend/dist (relative to the // Serve the built React frontend from ui/dist (relative to the working
// working directory). Falls back gracefully if the directory does not // directory). Falls back gracefully if the directory does not exist yet
// exist yet (e.g. during development when using `npm run dev`). // (e.g. during development when using `npm run dev`).
let frontend_dir = env::current_dir() let frontend_dir = env::current_dir()
.unwrap_or_default() .unwrap_or_default()
.join("frontend") .join("ui")
.join("dist"); .join("dist");
// For SPA routing: any path not matched by a real file (e.g. /map/<id>) // For SPA routing: any path not matched by a real file (e.g. /map/<id>)

View File

@@ -5,6 +5,16 @@ use serenity::{
}; };
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use tokio::sync::broadcast; 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<Uuid>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -12,9 +22,9 @@ pub struct AppState {
pub client_id: String, pub client_id: String,
pub client_secret: String, pub client_secret: String,
pub base_url: String, pub base_url: String,
/// Maps oauth_state → ui_redirect_uri. /// Maps oauth_state → DiscordOAuthState.
/// Populated on /authorize, consumed on /callback. /// Populated on /authorize or /connect, consumed on /callback.
pub discord_authorize_cache: Arc<Mutex<HashMap<String, String>>>, pub discord_authorize_cache: Arc<Mutex<HashMap<String, DiscordOAuthState>>>,
pub http: Arc<Http>, pub http: Arc<Http>,
pub cache: Arc<Cache>, pub cache: Arc<Cache>,
/// Per-map WebSocket broadcast channels for real-time collaboration. /// Per-map WebSocket broadcast channels for real-time collaboration.

View File

@@ -1,14 +1,13 @@
use crate::{ use crate::{
AppState, AppState,
auth::{AuthorizationMiddleware, Session}, auth::SessionAuthorization,
error::{Error, Result}, error::{Error, Result},
}; };
use axum::{ use axum::{
Extension,
Json, Json,
Router, Router,
extract::{Path, State}, extract::{Path, State},
middleware::from_extractor, http::StatusCode,
routing::post, routing::post,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -22,15 +21,13 @@ use siren_bot::{
handler::get_songbird, handler::get_songbird,
}; };
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid;
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/play", post(play_audio)) .route("/play", post(play_audio))
.route_layer(from_extractor::<AuthorizationMiddleware>())
.route("/pause", post(pause_audio)) .route("/pause", post(pause_audio))
.route_layer(from_extractor::<AuthorizationMiddleware>())
.route("/resume", post(resume_audio)) .route("/resume", post(resume_audio))
.route_layer(from_extractor::<AuthorizationMiddleware>())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -38,19 +35,44 @@ struct PlayTrackRequest {
url: String, 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<u64> {
let pool = siren_core::data::pool();
let provider_id: Option<String> = 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::<u64>().ok())
.ok_or_else(|| Error::not_found("Discord account not connected".to_string()))
}
async fn play_audio( async fn play_audio(
Extension(session): Extension<Session>, SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(guild_id): Path<u64>, Path(guild_id): Path<u64>,
Json(payload): Json<PlayTrackRequest>, Json(payload): Json<PlayTrackRequest>,
) -> Result<()> { ) -> Result<()> {
log::debug!("Playing audio in guild: {}", guild_id); log::debug!("Playing audio in guild: {}", guild_id);
// Check if the user exists in the cache let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
let user_id = session.user_id;
let user_id = match state.cache.user(user_id) { // 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, 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 // Validate if the guild exists in the cache
@@ -61,16 +83,17 @@ async fn play_audio(
// Play the track // Play the track
let manager = get_songbird(); 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?; enqueue_track(manager, guild_id.to_owned(), &payload.url).await?;
Ok(()) Ok(())
} }
async fn pause_audio( async fn pause_audio(
Extension(_): Extension<Session>, SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(guild_id): Path<u64>, Path(guild_id): Path<u64>,
) -> Result<()> { ) -> Result<()> {
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
log::debug!("Pausing audio in guild: {}", guild_id); log::debug!("Pausing audio in guild: {}", guild_id);
// Validate if the guild exists in the cache // Validate if the guild exists in the cache
@@ -86,11 +109,12 @@ async fn pause_audio(
} }
async fn resume_audio( async fn resume_audio(
Extension(_): Extension<Session>, SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(guild_id): Path<u64>, Path(guild_id): Path<u64>,
) -> Result<()> { ) -> 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 // Validate if the guild exists in the cache
let guild_id = match state.cache.guild(guild_id) { 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())), None => return Err(Error::not_found("Guild not found".to_string())),
}; };
// Pause the track // Resume the track
let manager = get_songbird(); let manager = get_songbird();
resume_track(manager, &guild_id).await?; resume_track(manager, &guild_id).await?;
Ok(()) Ok(())

View File

@@ -1,10 +1,16 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Claims encoded in the JWT stored in the `siren_session` cookie
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct BearerTokenClaims { pub struct BearerTokenClaims {
pub sub: u64, /// Local user UUID (as a string)
pub sub: String,
/// Display username
pub name: String, pub name: String,
/// Issued-at epoch seconds
pub iat: i64, pub iat: i64,
/// Expiry epoch seconds
pub exp: i64, pub exp: i64,
/// Redis session key (used to look up the full session)
pub jti: String, pub jti: String,
} }

View File

@@ -1,16 +1,26 @@
use crate::{ use crate::{
AppState, 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::{ use axum::{
Router, Router,
extract::{Query, State}, extract::{Query, State},
http::StatusCode, http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
routing::get, routing::get,
}; };
use axum_extra::extract::CookieJar;
use serde::{Deserialize, Serialize}; 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"; const DISCORD_REDIRECT_PATH: &str = "/api/auth/discord/callback";
@@ -18,14 +28,15 @@ pub fn get_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/authorize", get(discord_authorize)) .route("/authorize", get(discord_authorize))
.route("/callback", get(discord_callback)) .route("/callback", get(discord_callback))
.route("/connect", get(discord_connect))
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
struct AuthorizeQuery { struct AuthorizeQuery {
redirect_uri: String, redirect_uri: String,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
struct CallbackQuery { struct CallbackQuery {
code: String, code: String,
state: Option<String>, state: Option<String>,
@@ -48,22 +59,66 @@ struct DiscordUser {
avatar: Option<String>, avatar: Option<String>,
} }
/// 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( async fn discord_authorize(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(query): Query<AuthorizeQuery>, Query(query): Query<AuthorizeQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let oauth_state = csprng(16); let oauth_state = csprng(16);
log::trace!("Discord authorize: {:?}, state={}", query, oauth_state);
state state.discord_authorize_cache.lock().await.insert(
.discord_authorize_cache oauth_state.clone(),
.lock() DiscordOAuthState {
.await redirect_uri: query.redirect_uri,
.insert(oauth_state.clone(), 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<Arc<AppState>>,
Query(query): Query<AuthorizeQuery>,
SessionAuthorization(session): SessionAuthorization,
) -> Result<impl IntoResponse> {
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<String, StatusCode> {
let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH); 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\ "https://discord.com/api/oauth2/authorize\
?client_id={}\ ?client_id={}\
&redirect_uri={}\ &redirect_uri={}\
@@ -73,8 +128,11 @@ async fn discord_authorize(
state.client_id, encoded_callback, oauth_state, state.client_id, encoded_callback, oauth_state,
); );
match serde_json::to_string(&discord_auth_url) { match serde_json::to_string(&url) {
Ok(json) => Ok(json), Ok(json) => {
log::trace!("Discord OAuth URL: {}", json);
Ok(json)
}
Err(e) => { Err(e) => {
log::error!("Failed to serialize Discord OAuth URL: {e}"); log::error!("Failed to serialize Discord OAuth URL: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR) 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( async fn discord_callback(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(query): Query<CallbackQuery>, Query(query): Query<CallbackQuery>,
headers: HeaderMap,
jar: CookieJar,
) -> impl IntoResponse { ) -> impl IntoResponse {
match do_oauth_callback(state, query).await { match do_oauth_callback(state, query, headers, jar).await {
Ok((token, ui_redirect_uri)) => { Ok(response) => response,
Redirect::temporary(&format!("{}?token={}", ui_redirect_uri, token)).into_response()
}
Err((e, ui_redirect_uri)) => { Err((e, ui_redirect_uri)) => {
log::error!("OAuth callback error: {:?}", e); log::error!("OAuth callback error: {:?}", e);
let fallback = ui_redirect_uri.unwrap_or_else(|| "/".to_string()); let fallback = ui_redirect_uri.unwrap_or_else(|| "/".to_string());
@@ -98,33 +168,37 @@ async fn discord_callback(
} }
} }
type CallbackErr = (Error, Option<String>);
async fn do_oauth_callback( async fn do_oauth_callback(
state: Arc<AppState>, state: Arc<AppState>,
query: CallbackQuery, query: CallbackQuery,
) -> Result<(String, String), (crate::error::Error, Option<String>)> { headers: HeaderMap,
// Validate the state and retrieve the associated UI redirect URI jar: CookieJar,
let ui_redirect_uri = { ) -> std::result::Result<axum::response::Response, CallbackErr> {
let mut oauth_states = state.discord_authorize_cache.lock().await; // Validate state & retrieve stored data
let stored = {
let mut cache = state.discord_authorize_cache.lock().await;
match query.state { match query.state {
Some(ref oauth_state) => match oauth_states.remove(oauth_state) { Some(ref s) => match cache.remove(s) {
Some(uri) => uri, Some(v) => v,
None => return Err((StatusCode::UNAUTHORIZED.into(), None)), None => return Err((StatusCode::UNAUTHORIZED.into(), None)),
}, },
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 ui_redirect_uri = stored.redirect_uri.clone();
let redirect = ui_redirect_uri.clone(); let err_redirect = |s: StatusCode| -> std::result::Result<_, CallbackErr> {
let err = |s: StatusCode| -> Result<_, (crate::error::Error, Option<String>)> { Err((s.into(), Some(ui_redirect_uri.clone())))
Err((s.into(), Some(redirect.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); let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH);
// Exchange code for an access token // Exchange code for Discord access token
let token_response = state let token_resp = state
.client .client
.post("https://discord.com/api/oauth2/token") .post("https://discord.com/api/oauth2/token")
.form(&[ .form(&[
@@ -136,90 +210,229 @@ async fn do_oauth_callback(
]) ])
.send() .send()
.await .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() { if !token_resp.status().is_success() {
log::error!( log::error!("Token exchange failed: {:?}", token_resp.text().await);
"Failed to exchange token: {:?}", return err_redirect(StatusCode::INTERNAL_SERVER_ERROR);
token_response.text().await
);
return err(StatusCode::INTERNAL_SERVER_ERROR);
} }
let token_data: DiscordTokenResponse = token_response let token_data: DiscordTokenResponse = token_resp
.json() .json()
.await .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 // Fetch Discord user info
let user_response = state let user_resp = state
.client .client
.get("https://discord.com/api/users/@me") .get("https://discord.com/api/users/@me")
.bearer_auth(token_data.access_token) .bearer_auth(token_data.access_token)
.send() .send()
.await .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() { if !user_resp.status().is_success() {
log::error!( log::error!("Discord user fetch failed: {:?}", user_resp.text().await);
"Failed to fetch user information: {:?}", return err_redirect(StatusCode::INTERNAL_SERVER_ERROR);
user_response.text().await
);
return err(StatusCode::INTERNAL_SERVER_ERROR);
} }
let user_data: DiscordUser = user_response let discord_user: DiscordUser = user_resp
.json() .json()
.await .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::<i64>()
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
// Upsert the Discord user into the local users table
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
sqlx::query(
"INSERT INTO users (id, username, avatar, updated_at) match stored.connecting_user_id {
VALUES ($1, $2, $3, NOW()) // Handle connecting an existing local user to a new Discord account
ON CONFLICT (id) DO UPDATE Some(connecting_user_id) => {
SET username = EXCLUDED.username, // Make sure this Discord account isn't already linked to a DIFFERENT user
avatar = EXCLUDED.avatar, let existing_owner: Option<Uuid> = sqlx::query_scalar(
updated_at = NOW()", "SELECT user_id FROM user_connections \
WHERE provider = 'discord' AND provider_user_id = $1",
) )
.bind(user_id) .bind(&discord_user.id)
.bind(&user_data.username) .fetch_optional(pool)
.bind(&user_data.avatar) .await
.map_err(|e| {
log::error!("DB error checking connection: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
if let Some(owner_id) = existing_owner {
if owner_id != connecting_user_id {
return err_redirect(StatusCode::CONFLICT);
}
}
// 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) .execute(pool)
.await .await
.map_err(|e| { .map_err(|e| {
log::error!("Failed to upsert user: {e}"); log::error!("DB error upserting connection: {e}");
err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?; })?;
// Create and insert the session // No new session — redirect back to account page with existing cookie
let session = Session::new(user_id as u64, user_data.username.clone()); Ok(Redirect::temporary(&ui_redirect_uri).into_response())
session }
.insert()
.await
.map_err(|e| (e, Some(ui_redirect_uri.clone())))?;
let issued_at = chrono::Utc::now(); // ------------------------------------------------------------------ //
let claims = BearerTokenClaims { // LOGIN MODE: look up (or create) the local user for this Discord account
sub: session.user_id, // ------------------------------------------------------------------ //
name: session.user_name.clone(), None => {
iat: issued_at.timestamp(), // Find existing connection → local user_id
exp: session.expires_at.timestamp(), let local_user_id: Option<(Uuid, String)> = sqlx::query_as(
jti: session.session_id.clone(), "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)
}
}; };
let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); // Build fingerprint from the callback request's headers
let encoding_key = jsonwebtoken::EncodingKey::from_secret(jwt_secret.as_bytes()); let ip = extract_ip(&headers);
let token = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &encoding_key) let user_agent = headers
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?; .get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("unknown")
.to_string();
let fingerprint = compute_fingerprint(&ip, &user_agent);
Ok((token, ui_redirect_uri)) // 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<String> {
// 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(),
))
} }

View File

@@ -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<Arc<AppState>> {
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<String>,
last_name: Option<String>,
}
#[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<String>,
new_password: String,
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct ConnectionInfo {
pub provider: String,
pub provider_username: Option<String>,
pub provider_avatar: Option<String>,
}
#[derive(Serialize)]
pub struct UserInfo {
pub id: String,
pub username: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email: Option<String>,
/// 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<ConnectionInfo>,
}
// ---------------------------------------------------------------------------
// DB row types
// ---------------------------------------------------------------------------
#[derive(sqlx::FromRow)]
struct DbUser {
id: Uuid,
username: String,
first_name: Option<String>,
last_name: Option<String>,
email: Option<String>,
password_hash: Option<String>,
}
#[derive(sqlx::FromRow)]
struct DbConnection {
provider: String,
provider_username: Option<String>,
provider_avatar: Option<String>,
}
// ---------------------------------------------------------------------------
// Password helpers
// ---------------------------------------------------------------------------
/// Hash and salt a plaintext password with Argon2.
pub fn hash_password(password: &str) -> Result<String> {
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<String> {
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<UserInfo> {
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<DbConnection> = 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<RegisterPayload>,
) -> Result<impl IntoResponse> {
let username = payload.username.trim().to_string();
if username.is_empty() || username.len() > 32 {
return Err(Error::new(422, "Username must be 132 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<LoginPayload>,
) -> Result<impl IntoResponse> {
let pool = data::pool();
let row: Option<(Uuid, String, Option<String>)> =
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<Json<UserInfo>> {
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<UpdateProfilePayload>,
) -> Result<Json<UserInfo>> {
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<ChangePasswordPayload>,
) -> Result<StatusCode> {
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<String> =
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(&current, &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<String>,
) -> Result<StatusCode> {
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)
}

View File

@@ -4,63 +4,23 @@ use crate::{
}; };
use axum::{ use axum::{
extract::FromRequestParts, extract::FromRequestParts,
http::{Method, StatusCode, request::Parts}, http::{HeaderMap, StatusCode, request::Parts},
};
use axum_extra::{
TypedHeader,
headers::{Authorization, authorization::Bearer},
}; };
use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use jsonwebtoken::{DecodingKey, Validation, decode}; use jsonwebtoken::{DecodingKey, Validation, decode};
use sha2::{Digest, Sha256};
// --------------------------------------------------------------------------- pub const COOKIE_NAME: &str = "siren_session";
// AuthorizationMiddleware — rejects unauthenticated requests
// ---------------------------------------------------------------------------
pub struct AuthorizationMiddleware;
impl<S> FromRequestParts<S> for AuthorizationMiddleware
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
// 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::<Authorization<Bearer>>::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. /// 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)`. /// Handlers using this extractor work for both authenticated and
pub struct OptionalAuth(pub Option<Session>); /// unauthenticated callers. A valid `siren_session` cookie grants a
/// `Some(session)`.
pub struct SessionAuthorization(pub Option<Session>);
impl<S> FromRequestParts<S> for OptionalAuth impl<S> FromRequestParts<S> for SessionAuthorization
where where
S: Send + Sync, S: Send + Sync,
{ {
@@ -70,38 +30,103 @@ where
parts: &mut Parts, parts: &mut Parts,
state: &S, state: &S,
) -> std::result::Result<Self, Self::Rejection> { ) -> std::result::Result<Self, Self::Rejection> {
if let Ok(TypedHeader(Authorization(bearer))) = let jar = CookieJar::from_request_parts(parts, state).await.unwrap();
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
{ if let Some(cookie) = jar.get(COOKIE_NAME) {
if let Ok(session) = check_bearer_auth(bearer.token()).await { 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()); parts.extensions.insert(session.clone());
return Ok(Self(Some(session))); return Ok(Self(Some(session)));
} }
} }
Ok(Self(None)) Ok(Self(None))
} }
} }
// --------------------------------------------------------------------------- /// Extract the client IP from common proxy headers, falling back to "unknown".
// Shared helper 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<Session> { /// Compute a fingerprint from client IP and User-Agent
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set in the environment"); ///
/// 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<Session> {
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 decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes());
let token_data = decode::<BearerTokenClaims>(bearer_token, &decoding_key, &Validation::default()) let token_data = decode::<BearerTokenClaims>(token, &decoding_key, &Validation::default())
.map_err(|_| StatusCode::UNAUTHORIZED)?; .map_err(|_| StatusCode::UNAUTHORIZED)?;
let claims = token_data.claims; let claims = token_data.claims;
let now = Utc::now().timestamp(); if claims.exp < Utc::now().timestamp() {
if claims.exp < now {
return Err(StatusCode::UNAUTHORIZED.into()); return Err(StatusCode::UNAUTHORIZED.into());
} }
match Session::find(&claims.jti).await { let session = match Session::find(&claims.jti).await? {
Ok(Some(session)) => Ok(session), Some(s) => s,
_ => Err(StatusCode::UNAUTHORIZED)?, 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<Session> {
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
} }

View File

@@ -1,24 +1,20 @@
use crate::AppState; use crate::AppState;
use axum::Router; use axum::Router;
use rand::RngExt;
use std::sync::Arc; use std::sync::Arc;
mod discord;
mod session;
pub use session::Session;
mod bearer_token; mod bearer_token;
mod discord;
mod local;
mod session;
pub use local::UserInfo;
pub use session::Session;
pub mod middleware; pub mod middleware;
pub use middleware::{AuthorizationMiddleware, OptionalAuth}; pub use middleware::SessionAuthorization;
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
Router::new().nest("/discord", discord::get_routes()) Router::new()
} .merge(local::get_routes())
.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()
} }

View File

@@ -1,38 +1,45 @@
use crate::{auth::csprng, error::Result}; use crate::error::Result;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult}; use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use siren_core::data; use siren_core::{data, utils::csprng};
use std::{env, sync::OnceLock}; use std::{env, sync::OnceLock};
use uuid::Uuid;
static SESSION_TTL: OnceLock<i64> = OnceLock::new(); static SESSION_TTL: OnceLock<i64> = OnceLock::new();
fn get_session_ttl() -> i64 { pub fn get_session_ttl() -> i64 {
// Initialize the SESSION_TTL value lazily
*SESSION_TTL.get_or_init(|| { *SESSION_TTL.get_or_init(|| {
env::var("API_SESSION_TTL") env::var("API_SESSION_TTL")
.ok() .ok()
.and_then(|val| val.parse::<i64>().ok()) .and_then(|val| val.parse::<i64>().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)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Session { pub struct Session {
pub session_id: String, pub session_id: String,
pub user_id: u64, pub user_id: Uuid,
pub user_name: String, pub user_name: String,
/// SHA-256 hex of `{client_ip}:{user_agent}` captured at login time.
pub fingerprint: String,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
} }
impl Session { 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 now = Utc::now();
let session_ttl = get_session_ttl(); let session_ttl = get_session_ttl();
Session { Session {
session_id: csprng(32), session_id: csprng(32),
user_id, user_id,
user_name, user_name,
fingerprint,
expires_at: now + chrono::Duration::seconds(session_ttl), expires_at: now + chrono::Duration::seconds(session_ttl),
} }
} }

View File

@@ -1,14 +1,13 @@
use crate::{ use crate::{
AppState, AppState,
auth::{AuthorizationMiddleware, Session}, auth::SessionAuthorization,
error::{Error, Result}, error::{Error, Result},
}; };
use axum::{ use axum::{
Extension,
Json, Json,
Router, Router,
extract::{Path, State}, extract::{Path, State},
middleware::from_extractor, http::StatusCode,
routing::post, routing::post,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -18,9 +17,7 @@ use std::{fmt::Display, str::FromStr, sync::Arc};
use uuid::Uuid; use uuid::Uuid;
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
Router::new() Router::new().route("/{guild_id}/track", post(add_track_dice))
.route("/{guild_id}/track", post(add_track_dice))
.route_layer(from_extractor::<AuthorizationMiddleware>())
} }
const TABLE_NAME: &str = "dice_track"; const TABLE_NAME: &str = "dice_track";
@@ -156,16 +153,34 @@ impl InsertDiceTrack {
} }
pub async fn add_track_dice( pub async fn add_track_dice(
Extension(session): Extension<Session>, SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(guild_id): Path<u64>, Path(guild_id): Path<u64>,
Json(payload): Json<DiceTrackPayload>, Json(payload): Json<DiceTrackPayload>,
) -> Result<Json<QueryDiceTrack>> { ) -> Result<Json<QueryDiceTrack>> {
// Check if the user exists in the cache let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
let owner_id = session.user_id;
let owner_id = match state.cache.user(owner_id) { // 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<String> = 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, 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 // 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), dice: format_roll(dice.0, dice.1, dice.2),
user_id: payload.user_id, user_id: payload.user_id,
value: payload.value, value: payload.value,
operator: match payload.operator { operator: payload.operator.map(|s| s.to_string()),
None => None,
Some(s) => Some(s.to_string()),
},
}; };
// Check for existing dice tracks // Check for existing dice tracks

View File

@@ -2,7 +2,7 @@ pub mod model;
use crate::{ use crate::{
AppState, AppState,
auth::{OptionalAuth, Session, csprng, middleware::check_bearer_auth}, auth::{Session, SessionAuthorization, middleware::check_cookie_from_header_str},
error::{Error, Result}, error::{Error, Result},
}; };
use axum::{ use axum::{
@@ -10,81 +10,98 @@ use axum::{
Router, Router,
extract::{ extract::{
Path, Path,
Query,
State, State,
WebSocketUpgrade, WebSocketUpgrade,
ws::{Message, WebSocket}, ws::{Message, WebSocket},
}, },
http::StatusCode, http::{HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
routing::{delete, get, post, put}, routing::{delete, get, post, put},
}; };
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use model::{ use model::{
AccessRequestWithUser,
ClientMessage, ClientMessage,
CreateAccessRequestPayload,
CreateMapPayload, CreateMapPayload,
GridCell, GridCell,
GridMap, GridMap,
GridToken, GridToken,
MapPermission, ListedMap,
MapRole, MapRole,
MapState, MapState,
PermissionWithUser,
ResolveAccessRequestPayload,
ServerMessage, ServerMessage,
UpdateMapPayload,
UpdatePermissionPayload, UpdatePermissionPayload,
}; };
use serde::Deserialize; use siren_core::utils::csprng;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use uuid::Uuid;
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/maps", get(list_maps)) .route("/maps", get(list_maps))
.route("/maps", post(create_map)) .route("/maps", post(create_map))
.route("/maps/{id}", get(get_map)) .route("/maps/{id}", get(get_map))
.route("/maps/{id}", put(update_map))
.route("/maps/{id}", delete(delete_map)) .route("/maps/{id}", delete(delete_map))
.route("/maps/{id}/permissions", get(list_permissions)) .route("/maps/{id}/permissions", get(list_permissions))
.route("/maps/{id}/permissions", put(update_permission)) .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)) .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. /// 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<Option<MapRole>> { async fn get_user_role(map_id: &str, user_id: Uuid) -> Result<Option<MapRole>> {
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
let perm: Option<MapPermission> = sqlx::query_as( let role: Option<String> =
"SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1 AND user_id = $2", sqlx::query_scalar("SELECT role FROM map_permissions WHERE map_id = $1 AND user_id = $2")
)
.bind(map_id) .bind(map_id)
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
Ok(perm.map(|p| p.role)) 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: /// Returns whether the caller can view the map.
/// - Public maps: always true.
/// - Private maps: true only if the user has any role.
async fn can_view(map: &GridMap, session: &Option<Session>) -> bool { async fn can_view(map: &GridMap, session: &Option<Session>) -> bool {
if map.is_public { if map.public_access == "public_view" || map.public_access == "public_edit" {
return true; return true;
} }
let Some(s) = session else { return false }; let Some(s) = session else { return false };
let user_id = s.user_id as i64; get_user_role(&map.id, s.user_id)
get_user_role(&map.id, user_id)
.await .await
.ok() .ok()
.flatten() .flatten()
.is_some() .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<Session>) -> bool { async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
if map.public_access == "public_edit" {
return true;
}
let Some(s) = session else { return false }; let Some(s) = session else { return false };
let user_id = s.user_id as i64; get_user_role(&map.id, s.user_id)
get_user_role(&map.id, user_id)
.await .await
.ok() .ok()
.flatten() .flatten()
@@ -95,8 +112,7 @@ async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
/// Returns whether the caller is the owner. /// Returns whether the caller is the owner.
async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool { async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
let Some(s) = session else { return false }; let Some(s) = session else { return false };
let user_id = s.user_id as i64; get_user_role(&map.id, s.user_id)
get_user_role(&map.id, user_id)
.await .await
.ok() .ok()
.flatten() .flatten()
@@ -105,60 +121,68 @@ async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// REST handlers // Map CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub async fn list_maps(OptionalAuth(session): OptionalAuth) -> Result<Json<Vec<GridMap>>> { pub async fn list_maps(
SessionAuthorization(session): SessionAuthorization,
) -> Result<Json<Vec<ListedMap>>> {
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
let maps: Vec<GridMap> = match &session { let maps: Vec<ListedMap> = match &session {
Some(s) => { Some(s) => {
let user_id = s.user_id as i64;
sqlx::query_as( 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 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 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 LEFT JOIN map_favorites mf ON mf.map_id = gm.id AND mf.user_id = $1
ORDER BY gm.created_at DESC", 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) .fetch_all(pool)
.await? .await?
} }
None => vec![],
}; };
Ok(Json(maps)) Ok(Json(maps))
} }
pub async fn create_map( pub async fn create_map(
OptionalAuth(session): OptionalAuth, SessionAuthorization(session): SessionAuthorization,
Json(payload): Json<CreateMapPayload>, Json(payload): Json<CreateMapPayload>,
) -> Result<(StatusCode, Json<GridMap>)> { ) -> Result<(StatusCode, Json<GridMap>)> {
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; 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 map_id = csprng(32);
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
let map: GridMap = sqlx::query_as( 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) VALUES ($1, $2, $3, $4)
RETURNING *", RETURNING *",
) )
.bind(&map_id) .bind(&map_id)
.bind(&payload.name) .bind(&payload.name)
.bind(payload.is_public) .bind(&payload.public_access)
.bind(user_id) .bind(session.user_id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
// Auto-assign the creator as owner in map_permissions // Auto-assign the creator as owner in map_permissions
sqlx::query("INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, 'owner')") sqlx::query("INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, 'owner')")
.bind(&map_id) .bind(&map_id)
.bind(user_id) .bind(session.user_id)
.execute(pool) .execute(pool)
.await?; .await?;
@@ -166,7 +190,7 @@ pub async fn create_map(
} }
pub async fn get_map( pub async fn get_map(
OptionalAuth(session): OptionalAuth, SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<MapState>> { ) -> Result<Json<MapState>> {
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
@@ -195,8 +219,51 @@ pub async fn get_map(
Ok(Json(MapState { map, cells, tokens })) Ok(Json(MapState { map, cells, tokens }))
} }
pub async fn update_map(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
Json(payload): Json<UpdateMapPayload>,
) -> Result<Json<GridMap>> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = 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( pub async fn delete_map(
OptionalAuth(session): OptionalAuth, SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
@@ -225,9 +292,9 @@ pub async fn delete_map(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub async fn list_permissions( pub async fn list_permissions(
OptionalAuth(session): OptionalAuth, SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Vec<MapPermission>>> { ) -> Result<Json<Vec<PermissionWithUser>>> {
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
@@ -241,8 +308,13 @@ pub async fn list_permissions(
return Err(StatusCode::FORBIDDEN.into()); return Err(StatusCode::FORBIDDEN.into());
} }
let perms: Vec<MapPermission> = let perms: Vec<PermissionWithUser> = sqlx::query_as(
sqlx::query_as("SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1") "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) .bind(&id)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
@@ -251,7 +323,7 @@ pub async fn list_permissions(
} }
pub async fn update_permission( pub async fn update_permission(
OptionalAuth(session): OptionalAuth, SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>, Path(id): Path<String>,
Json(payload): Json<UpdatePermissionPayload>, Json(payload): Json<UpdatePermissionPayload>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
@@ -268,11 +340,24 @@ pub async fn update_permission(
return Err(StatusCode::FORBIDDEN.into()); return Err(StatusCode::FORBIDDEN.into());
} }
// Prevent the owner from removing their own owner record // Resolve username → user_id
let caller_id = session.as_ref().map(|s| s.user_id as i64).unwrap_or(0); let target_id: Option<Uuid> = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
if payload.user_id == caller_id && payload.role.as_ref().map(|r| r.is_owner()) == Some(false) { .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)); return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY));
} }
}
}
}
match payload.role { match payload.role {
Some(role) => { Some(role) => {
@@ -282,7 +367,7 @@ pub async fn update_permission(
ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role", ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role",
) )
.bind(&id) .bind(&id)
.bind(payload.user_id) .bind(target_id)
.bind(role) .bind(role)
.execute(pool) .execute(pool)
.await?; .await?;
@@ -290,7 +375,7 @@ pub async fn update_permission(
None => { None => {
sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2") sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2")
.bind(&id) .bind(&id)
.bind(payload.user_id) .bind(target_id)
.execute(pool) .execute(pool)
.await?; .await?;
} }
@@ -299,26 +384,215 @@ pub async fn update_permission(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn favorite_map(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
) -> Result<StatusCode> {
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<GridMap> = 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<String>,
) -> Result<StatusCode> {
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<String>,
Json(payload): Json<CreateAccessRequestPayload>,
) -> Result<StatusCode> {
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<GridMap> = 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<String>,
) -> Result<Json<Vec<AccessRequestWithUser>>> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = 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<AccessRequestWithUser> = 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<ResolveAccessRequestPayload>,
) -> Result<StatusCode> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = 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 // WebSocket handler
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct WsQuery {
/// Optional Bearer token passed as a query parameter for WS auth.
token: Option<String>,
}
pub async fn ws_handler( pub async fn ws_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(map_id): Path<String>, Path(map_id): Path<String>,
Query(query): Query<WsQuery>, headers: HeaderMap,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Resolve the session from query param (WS can't easily send headers) let session: Option<Session> = {
let session: Option<Session> = match query.token { let ip = crate::auth::middleware::extract_ip(&headers);
Some(ref tok) => check_bearer_auth(tok).await.ok(), let user_agent = headers
None => None, .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)) ws.on_upgrade(move |socket| handle_socket(socket, state, map_id, session))
@@ -330,20 +604,17 @@ async fn handle_socket(
map_id: String, map_id: String,
session: Option<Session>, session: Option<Session>,
) { ) {
// Load the map and verify the caller can view it
let map_state = match fetch_map_state(&map_id).await { let map_state = match fetch_map_state(&map_id).await {
Ok(ms) => ms, Ok(ms) => ms,
Err(_) => return, // map doesn't exist Err(_) => return,
}; };
if !can_view(&map_state.map, &session).await { if !can_view(&map_state.map, &session).await {
// Refuse the connection silently (upgrade already happened; just close)
return; return;
} }
let editor = can_edit(&map_state.map, &session).await; let editor = can_edit(&map_state.map, &session).await;
// Get or create a broadcast channel for this map
let tx = state let tx = state
.map_rooms .map_rooms
.entry(map_id.clone()) .entry(map_id.clone())
@@ -356,7 +627,6 @@ async fn handle_socket(
let (mut ws_tx, mut ws_rx) = socket.split(); 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 { let init_msg = ServerMessage::State {
cells: map_state.cells, cells: map_state.cells,
tokens: map_state.tokens, tokens: map_state.tokens,
@@ -366,7 +636,6 @@ async fn handle_socket(
let _ = ws_tx.send(Message::Text(json.into())).await; 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 { let mut send_task = tokio::spawn(async move {
while let Ok(json) = rx.recv().await { while let Ok(json) = rx.recv().await {
if ws_tx.send(Message::Text(json.into())).await.is_err() { 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 tx_clone = tx.clone();
let mut recv_task = tokio::spawn(async move { let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await { 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 { if !can_edit {
let err = ServerMessage::Error { let err = ServerMessage::Error {
message: "You do not have permission to edit this map.".into(), message: "You do not have permission to edit this map.".into(),

View File

@@ -1,5 +1,6 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Map Role / Permission // Map Role / Permission
@@ -26,10 +27,12 @@ impl MapRole {
} }
} }
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)] /// A permission record with the associated user's username included (for display).
pub struct MapPermission { #[derive(Serialize, sqlx::FromRow, Clone, Debug)]
pub struct PermissionWithUser {
pub map_id: String, pub map_id: String,
pub user_id: i64, pub user_id: Uuid,
pub username: String,
pub role: MapRole, pub role: MapRole,
} }
@@ -37,32 +40,92 @@ pub struct MapPermission {
// Grid Map // Grid Map
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Core map record as stored/returned by create, get, and update endpoints.
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)] #[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
pub struct GridMap { pub struct GridMap {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub is_public: bool, /// One of: "private", "public_view", "public_edit"
pub owner_id: i64, pub public_access: String,
pub owner_id: Uuid,
pub colors: Vec<String>, pub colors: Vec<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] /// Extended map record returned by the list endpoint.
pub struct CreateMapPayload { /// 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, pub name: String,
#[serde(default)] pub public_access: String,
pub is_public: bool, pub owner_id: Uuid,
pub owner_username: String,
pub colors: Vec<String>,
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<MapRole>,
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<String>,
pub public_access: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct UpdatePermissionPayload { pub struct UpdatePermissionPayload {
/// Discord user ID of the target user. /// Username of the target user (looked up server-side).
pub user_id: i64, pub username: String,
/// New role to assign. Omit (null) to remove the permission entry. /// New role to assign. `null` removes the permission entry.
pub role: Option<MapRole>, pub role: Option<MapRole>,
} }
// ---------------------------------------------------------------------------
// 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) // Grid Cell (no id column — composite PK in DB)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -12,7 +12,7 @@ use serenity::all::{
}; };
pub async fn process_message(ctx: &Context, command: &CommandInteraction, private: bool) { 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<Message> { pub async fn user_dm(ctx: &Context, user_id: &UserId, content: String) -> Option<Message> {

View File

@@ -77,7 +77,7 @@ fn find_voice_channel(
match guild match guild
.voice_states .voice_states
.get(&user_id) .get(user_id)
.and_then(|voice_state| voice_state.channel_id) .and_then(|voice_state| voice_state.channel_id)
{ {
Some(channel) => Ok(channel), Some(channel) => Ok(channel),

View File

@@ -9,7 +9,7 @@ use serenity::{
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
process_message(&ctx, &command, false).await; process_message(ctx, command, false).await;
// Get the songbird manager // Get the songbird manager
let manager = get_songbird(); let manager = get_songbird();
@@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Some(guild_id) => guild_id, Some(guild_id) => guild_id,
None => { None => {
edit_response( edit_response(
&ctx, ctx,
&command, command,
"Unable to find the current server ID".to_string(), "Unable to find the current server ID".to_string(),
) )
.await; .await;
@@ -36,14 +36,14 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Ok(_) => { Ok(_) => {
if is_muted { if is_muted {
log::debug!("<{guild_id}> Unmuted"); log::debug!("<{guild_id}> Unmuted");
edit_response(&ctx, &command, "Unmuted".to_string()).await; edit_response(ctx, command, "Unmuted".to_string()).await;
} else { } else {
log::debug!("<{guild_id}> Muted"); log::debug!("<{guild_id}> Muted");
edit_response(&ctx, &command, "Muted".to_string()).await; edit_response(ctx, command, "Muted".to_string()).await;
} }
} }
Err(err) => { Err(err) => {
edit_response(&ctx, &command, format!("Failed to mute: {}", err)).await; edit_response(ctx, command, format!("Failed to mute: {}", err)).await;
} }
} }
} }

View File

@@ -12,7 +12,7 @@ use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
process_message(&ctx, &command, false).await; process_message(ctx, command, false).await;
// Get the songbird manager // Get the songbird manager
let manager = get_songbird(); let manager = get_songbird();
@@ -22,8 +22,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Some(guild_id) => guild_id, Some(guild_id) => guild_id,
None => { None => {
edit_response( edit_response(
&ctx, ctx,
&command, command,
"Unable to find the current server ID".to_string(), "Unable to find the current server ID".to_string(),
) )
.await; .await;
@@ -35,9 +35,9 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
match pause_track(manager, guild_id).await { match pause_track(manager, guild_id).await {
Ok(_) => { Ok(_) => {
log::debug!("<{guild_id}> Paused the track"); 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,
} }
} }

View File

@@ -32,13 +32,13 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
command.guild_id.unwrap(), command.guild_id.unwrap(),
command.user.id.get() 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; return;
} }
}; };
// Create the initial response // Create the initial response
process_message(&ctx, &command, false).await; process_message(ctx, command, false).await;
// Get the songbird manager // Get the songbird manager
let manager = get_songbird(); let manager = get_songbird();
@@ -48,8 +48,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Some(guild_id) => guild_id, Some(guild_id) => guild_id,
None => { None => {
edit_response( edit_response(
&ctx, ctx,
&command, command,
"Unable to find the current server ID".to_string(), "Unable to find the current server ID".to_string(),
) )
.await; .await;
@@ -58,7 +58,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
}; };
// Join the user's voice channel // 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) => { Ok(channel_id) => {
log::debug!( log::debug!(
"<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}" "<{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 { match enqueue_track(manager, guild_id.to_owned(), track_url).await {
Ok(items) => { Ok(items) => {
let mut message = format!("Added {} tracks", items.len()); let mut message = format!("Added {} tracks", items.len());
if items.len() == 0 { if items.is_empty() {
message = "No tracks were played".to_string(); message = "No tracks were played".to_string();
log::warn!("<{guild_id}> No tracks were played"); 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); log::error!("Failed to leave voice channel: {}", err);
}; };
} else if items.len() == 1 { } else if items.len() == 1 {
message = format!("Added **{}**", items[0].get_title()); message = format!("Added **{}**", items[0].get_title());
} }
edit_response(&ctx, &command, message).await; edit_response(ctx, command, message).await;
} }
Err(err) => { Err(err) => {
log::error!("Failed to play track: {}", 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); 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) => { Err(err) => {
log::warn!("<{guild_id}> Failed to join voice channel: {}", 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) { if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await; let mut handler = handler_lock.lock().await;
let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap(); 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 // Check if the URL is valid
if !valid { if !valid {
@@ -111,7 +111,7 @@ pub async fn enqueue_track(
return Err(Error::new(422, format!("Invalid track url: {}", track_url))); 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 // Add each track to the queue
for item in &playlist_items { for item in &playlist_items {
@@ -122,8 +122,7 @@ pub async fn enqueue_track(
let input: Input = source.into(); let input: Input = source.into();
let track_title = item.get_title().to_owned(); let track_title = item.get_title().to_owned();
let track_handle: TrackHandle; let track_handle: TrackHandle = handler.enqueue_input(input).await;
track_handle = handler.enqueue_input(input).await;
// Set the volume // Set the volume
let _ = track_handle.set_volume(volume); let _ = track_handle.set_volume(volume);

View File

@@ -12,7 +12,7 @@ use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
process_message(&ctx, &command, false).await; process_message(ctx, command, false).await;
// Get the songbird manager // Get the songbird manager
let manager = get_songbird(); let manager = get_songbird();
@@ -22,8 +22,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Some(guild_id) => guild_id, Some(guild_id) => guild_id,
None => { None => {
edit_response( edit_response(
&ctx, ctx,
&command, command,
"Unable to find the current server ID".to_string(), "Unable to find the current server ID".to_string(),
) )
.await; .await;
@@ -35,9 +35,9 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
match resume_track(manager, guild_id).await { match resume_track(manager, guild_id).await {
Ok(_) => { Ok(_) => {
log::debug!("<{guild_id}> Resumed the track"); 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,
} }
} }

View File

@@ -9,7 +9,7 @@ use serenity::{
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
process_message(&ctx, &command, false).await; process_message(ctx, command, false).await;
// Get the songbird manager // Get the songbird manager
let manager = get_songbird(); let manager = get_songbird();
@@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Some(guild_id) => guild_id, Some(guild_id) => guild_id,
None => { None => {
edit_response( edit_response(
&ctx, ctx,
&command, command,
"Unable to find the current server ID".to_string(), "Unable to find the current server ID".to_string(),
) )
.await; .await;
@@ -34,10 +34,10 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
match handler.queue().skip() { match handler.queue().skip() {
Ok(_) => { Ok(_) => {
log::debug!("<{guild_id}> Skipped the track"); 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) => { Err(err) => {
edit_response(&ctx, &command, format!("Failed to skip: {}", err)).await; edit_response(ctx, command, format!("Failed to skip: {}", err)).await;
} }
} }
} }

View File

@@ -9,7 +9,7 @@ use serenity::{
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
process_message(&ctx, &command, false).await; process_message(ctx, command, false).await;
// Get the songbird manager // Get the songbird manager
let manager = get_songbird(); let manager = get_songbird();
@@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Some(g) => g, Some(g) => g,
None => { None => {
edit_response( edit_response(
&ctx, ctx,
&command, command,
"Unable to find the current server ID".to_string(), "Unable to find the current server ID".to_string(),
) )
.await; .await;
@@ -33,7 +33,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
let handler = handler_lock.lock().await; let handler = handler_lock.lock().await;
handler.queue().stop(); handler.queue().stop();
log::debug!("<{guild_id}> Stopped the track"); 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;
} }
} }

View File

@@ -20,19 +20,13 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
"{} attempted to change the volume without a volume option", "{} attempted to change the volume without a volume option",
command.user.id.get() command.user.id.get()
); );
create_message_response( create_message_response(ctx, command, "Volume option is missing".to_string(), false).await;
&ctx,
&command,
"Volume option is missing".to_string(),
false,
)
.await;
return; return;
} }
}; };
// Create the initial response // Create the initial response
process_message(&ctx, &command, false).await; process_message(ctx, command, false).await;
// Get the songbird manager // Get the songbird manager
let manager = get_songbird(); let manager = get_songbird();
@@ -42,8 +36,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
Some(guild_id) => guild_id, Some(guild_id) => guild_id,
None => { None => {
edit_response( edit_response(
&ctx, ctx,
&command, command,
"Unable to find the current server ID".to_string(), "Unable to find the current server ID".to_string(),
) )
.await; .await;
@@ -52,15 +46,14 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
}; };
// Set the volume // 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); 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<Songbird>, guild_id: &GuildId, volume: i32) { pub async fn set_volume(manager: &Arc<Songbird>, guild_id: &GuildId, volume: i32) {
// Format volume to f32 bound between 0.0 and 1.0 // 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.clamp(0, 100) as f32 / 100.0;
let bound_volume = volume as f32 / 100.0;
// Update the guild cache // Update the guild cache
let mut guild_cache = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap(); 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<Songbird>, guild_id: &GuildId, volume: i32
// Update the volume of the songbird handler // Update the volume of the songbird handler
if let Some(handler_lock) = manager.get(guild_id.to_owned()) { if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await; 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) { if let Err(err) = track_handle.set_volume(bound_volume) {
log::error!("Unable to set volume: {err}"); log::error!("Unable to set volume: {err}");
} }

View File

@@ -17,10 +17,17 @@ use siren_core::data::events::Event;
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
process_message(&ctx, &command, true).await; process_message(ctx, command, true).await;
// Process the command options // 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 datetime_string = command.data.options.get(1).unwrap().value.as_str().unwrap();
let description = command let description = command
.data .data

View File

@@ -39,7 +39,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
create_message_response( create_message_response(
ctx, ctx,
&command, command,
format!("Sending request to {}", user_id.mention()), format!("Sending request to {}", user_id.mention()),
true, true,
) )
@@ -48,7 +48,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
let dice_string = command let dice_string = command
.data .data
.options .options
.get(0) .first()
.and_then(|o| o.value.as_str()) .and_then(|o| o.value.as_str())
.map(|s| s.split_whitespace().collect::<String>()) .map(|s| s.split_whitespace().collect::<String>())
.unwrap(); .unwrap();
@@ -79,7 +79,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
}; };
} }
Err(why) => { Err(why) => {
edit_response(ctx, &command, why.to_string()).await; edit_response(ctx, command, why.to_string()).await;
} }
} }
} }

View File

@@ -34,19 +34,19 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
.find(|opt| opt.name == "user") .find(|opt| opt.name == "user")
.and_then(|o| o.value.as_mentionable()); .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 let dice_string = match command
.data .data
.options .options
.get(0) .first()
.and_then(|o| o.value.as_str()) .and_then(|o| o.value.as_str())
.map(|s| s.split_whitespace().collect::<String>()) .map(|s| s.split_whitespace().collect::<String>())
{ {
Some(dice_value) => dice_value, Some(dice_value) => dice_value,
None => { None => {
log::warn!("Missing or invalid dice option"); 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; return;
} }
}; };
@@ -63,18 +63,18 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
let roller_id = command.user.id; let roller_id = command.user.id;
send_roll_message(ctx, total, user_id, roller_id, &response).await; send_roll_message(ctx, total, user_id, roller_id, &response).await;
edit_response( edit_response(
&ctx, ctx,
command, command,
format!("Sending dice roll results to {}", &user_id.mention()), format!("Sending dice roll results to {}", &user_id.mention()),
) )
.await; .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 // Check for dice tracks
} }
Err(why) => { Err(why) => {
edit_response(&ctx, &command, format!("Invalid dice string: {}", why)).await; edit_response(ctx, command, format!("Invalid dice string: {}", why)).await;
} }
} }
} }

View File

@@ -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 { pub fn register() -> CreateCommand {

View File

@@ -54,7 +54,7 @@ impl EventHandler for BotHandler {
} }
// Handle direct messages // Handle direct messages
if let None = msg.guild_id { if msg.guild_id.is_none() {
log::trace!("Received DM from {}: {}", msg.author, msg.content); log::trace!("Received DM from {}: {}", msg.author, msg.content);
} }
} }

View File

@@ -10,6 +10,12 @@ pub struct YtDlp {
args: Vec<String>, args: Vec<String>,
} }
impl Default for YtDlp {
fn default() -> Self {
Self::new()
}
}
impl YtDlp { impl YtDlp {
pub fn new() -> Self { pub fn new() -> Self {
let mut cmd = Command::new(YOUTUBE_DL_COMMAND); let mut cmd = Command::new(YOUTUBE_DL_COMMAND);

View File

@@ -202,9 +202,9 @@ impl Condition {
Condition::Simple(condition, values) => { Condition::Simple(condition, values) => {
// Replace each instance of '?' with increasing numbered binds // Replace each instance of '?' with increasing numbered binds
let mut numbered_condition = String::new(); 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 == '?' { if c == '?' {
// Increment the counter and replace `?` with a numbered bind // Increment the counter and replace `?` with a numbered bind
*counter += 1; *counter += 1;

View File

@@ -40,9 +40,7 @@ impl<'a> QueryBuilder<'a> {
pub fn order_by(mut self, column: &str, direction: Option<OrderDirection>) -> Self { pub fn order_by(mut self, column: &str, direction: Option<OrderDirection>) -> Self {
match direction { match direction {
Some(order) => self Some(order) => self.order_by.push(format!("{} {}", column, order)),
.order_by
.push(format!("{} {}", column, order.to_string())),
None => self.order_by.push(column.to_string()), None => self.order_by.push(column.to_string()),
} }
self self

View File

@@ -65,9 +65,8 @@ impl From<sqlx::Error> for Error {
sqlx::Error::PoolClosed => Error::new(503, error.to_string()), sqlx::Error::PoolClosed => Error::new(503, error.to_string()),
sqlx::Error::Database(err) => { sqlx::Error::Database(err) => {
if let Some(code) = err.code() { if let Some(code) = err.code() {
match code.trim() { if code.trim() == "23503" {
"23505" => return Error::new(409, err.to_string()), return Error::new(409, err.to_string());
_ => (),
} }
} }
Error::new(500, err.to_string()) Error::new(500, err.to_string())

View File

@@ -1,2 +1,13 @@
pub mod text_utils; pub mod text_utils;
use rand::RngExt;
pub use text_utils::*; 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()
}

View File

@@ -3,13 +3,13 @@ pub fn a_or_an(word: &str) -> &'static str {
let lowercase_word = word.to_lowercase(); let lowercase_word = word.to_lowercase();
// Special cases where the article should be "a" // 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()) { if special_cases_a.contains(&lowercase_word.as_str()) {
return "a"; return "a";
} }
// Special cases where the article should be "an" // 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()) { if special_cases_an.contains(&lowercase_word.as_str()) {
return "an"; return "an";
} }

View File

@@ -70,7 +70,7 @@ fn initialize_environment() -> std::io::Result<()> {
let path = entry.path(); let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { 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 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); eprintln!("Failed to load {}: {}", file_name, err);
} else { } else {
println!("Loaded: {}", file_name); println!("Loaded: {}", file_name);

View File

@@ -70,24 +70,43 @@ CREATE TABLE IF NOT EXISTS bestiary (
-- Auth / Users -- 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 ( CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY NOT NULL, id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
username TEXT NOT NULL, username TEXT UNIQUE NOT NULL,
avatar TEXT, password_hash TEXT,
email TEXT UNIQUE,
first_name TEXT,
last_name TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_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 -- 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 ( CREATE TABLE IF NOT EXISTS grid_maps (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT FALSE, public_access TEXT NOT NULL DEFAULT 'private'
owner_id BIGINT NOT NULL REFERENCES users(id), CHECK (public_access IN ('private', 'public_view', 'public_edit')),
owner_id UUID NOT NULL REFERENCES users(id),
colors TEXT[] NOT NULL DEFAULT ARRAY[ colors TEXT[] NOT NULL DEFAULT ARRAY[
'#6b7280', '#6b7280',
'#92400e', '#92400e',
@@ -106,11 +125,32 @@ CREATE TABLE IF NOT EXISTS grid_maps (
-- Per-map role assignments; owner is auto-inserted on map creation -- Per-map role assignments; owner is auto-inserted on map creation
CREATE TABLE IF NOT EXISTS map_permissions ( CREATE TABLE IF NOT EXISTS map_permissions (
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, 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')), role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
PRIMARY KEY (map_id, user_id) 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 -- Composite primary key replaces the old UUID id column
CREATE TABLE IF NOT EXISTS grid_cells ( CREATE TABLE IF NOT EXISTS grid_cells (
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,

28
ui/eslint.config.js Normal file
View File

@@ -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 },
],
},
},
)

View File

@@ -8,6 +8,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -5,18 +5,28 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write src"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^19.2.4",
"react-dom": "^18.3.1", "react-dom": "^19.2.4",
"react-icons": "^5.6.0" "react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.1", "@eslint/js": "^9.39.4",
"@types/react-dom": "^18.3.1", "@types/node": "^25.5.2",
"@vitejs/plugin-react": "^4.3.1", "@types/react": "^19.2.14",
"typescript": "^5.5.3", "@types/react-dom": "^19.2.3",
"vite": "^5.3.4" "@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"
} }
} }

8
ui/prettierrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}

View File

@@ -1,4 +1,4 @@
/* ---- Full-viewport shell ---- */ /* ── Full-viewport shell ── */
.app { .app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -6,132 +6,21 @@
overflow: hidden; overflow: hidden;
} }
/* ---- Top header ---- */ /* ── App body (everything below the header) ── */
.app-header { .app-body {
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;
flex: 1; 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; display: flex;
gap: 0.3rem; overflow: hidden;
align-items: center;
} }
.new-map-form input { /* ── Grid area (fills the app body) ── */
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) ---- */
.app-grid-area { .app-grid-area {
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
/* ── Floating panel stack bottom-left corner ── */ /* ── Floating control panels bottom-left corner ── */
.floating-panels-container { .floating-panels-container {
position: absolute; position: absolute;
bottom: 14px; bottom: 14px;
@@ -142,7 +31,7 @@
z-index: 20; z-index: 20;
} }
/* ---- No-map placeholder ---- */ /* ── Empty state placeholder ── */
.empty-state { .empty-state {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -151,9 +40,11 @@
justify-content: center; justify-content: center;
gap: 0.75rem; gap: 0.75rem;
color: #4b5563; color: #4b5563;
user-select: none;
} }
.empty-state p { .empty-state p {
margin: 0;
font-size: 1.1rem; font-size: 1.1rem;
} }
@@ -162,34 +53,78 @@
color: #374151; color: #374151;
} }
/* ---- Auth area (right side of header) ---- */ /* ── Access denied state ── */
.app-auth { .access-denied-state {
height: 100%;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
margin-left: auto; gap: 1rem;
flex-shrink: 0; color: #4b5563;
}
.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;
user-select: none; user-select: none;
padding: 2rem;
text-align: center;
} }
.new-map-public input[type='checkbox'] { .access-denied-title {
accent-color: #6366f1; margin: 0;
cursor: pointer; 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;
} }

View File

@@ -1,262 +1,253 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import type { GridMap, Tool, TokenClaims } from './types'; import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types";
import type { GridHandle } from './components/Grid'; import type { GridHandle } from "./components/Grid";
import { api, auth, getToken, setToken, decodeToken } from './api'; import { api, auth } from "./api";
import ControlPanel from './components/ControlPanel.tsx'; import Header from "./components/Header";
import ColorPanel from './components/ColorPanel'; import ControlPanel from "./components/ControlPanel";
import Grid from './components/Grid'; import ColorPanel from "./components/ColorPanel";
import LoginButton from './components/LoginButton'; import Grid from "./components/Grid";
import './App.css'; 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 = [ const DEFAULT_COLORS = [
'#6b7280', // 1 stone "#6b7280",
'#92400e', // 2 earth "#92400e",
'#15803d', // 3 grass "#15803d",
'#1d4ed8', // 4 water "#1d4ed8",
'#7c3aed', // 5 arcane "#7c3aed",
'#dc2626', // 6 lava "#dc2626",
'#ca8a04', // 7 sand "#ca8a04",
'#0f172a', // 8 void "#0f172a",
'#f9fafb', // 9 white "#f9fafb",
]; ];
/** Read the map ID from the current URL path (/map/:id). */
function getMapIdFromUrl(): string | null { function getMapIdFromUrl(): string | null {
const match = window.location.pathname.match(/^\/map\/([^/]+)/); const match = window.location.pathname.match(/^\/map\/([^/]+)/);
return match ? decodeURIComponent(match[1]) : null; return match ? decodeURIComponent(match[1]) : null;
} }
/** Read a query parameter value from the current URL. */
function getQueryParam(key: string): string | null { function getQueryParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key); return new URLSearchParams(window.location.search).get(key);
} }
/** Strip a query parameter from the current URL without causing a reload. */
function removeQueryParam(key: string) { function removeQueryParam(key: string) {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete(key); 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() { export default function App() {
// ---- Auth state ---- // ── Auth state ──
const [user, setUser] = useState<TokenClaims | null>(() => { const [user, setUser] = useState<UserInfo | null>(null);
const token = getToken(); const [authLoading, setAuthLoading] = useState(true);
return token ? decodeToken(token) : null;
});
// ---- Map state ---- // ── Map state ──
const [maps, setMaps] = useState<GridMap[]>([]); const [maps, setMaps] = useState<ListedMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl); const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
/** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */
const [directMapInfo, setDirectMapInfo] = useState<GridMap | null>(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) // ── Tool + color ──
const [tool, setTool] = useState<Tool>('pan'); const [tool, setTool] = useState<Tool>("pan");
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]); const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
// Per-map color palette (updated from WS state on map load / color edits)
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS); const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
// Ref to Grid so App can push color updates through the WS
const gridRef = useRef<GridHandle>(null); const gridRef = useRef<GridHandle>(null);
// New-map form // ── Modal visibility ──
const [showLoginModal, setShowLoginModal] = useState(false);
const [showAccountPanel, setShowAccountPanel] = useState(false);
const [showNewMap, setShowNewMap] = useState(false); const [showNewMap, setShowNewMap] = useState(false);
const [newMapName, setNewMapName] = useState(''); const [showEditMap, setShowEditMap] = useState(false);
const [newMapPublic, setNewMapPublic] = useState(false); const [showMapList, setShowMapList] = useState(false);
const newMapInputRef = useRef<HTMLInputElement>(null);
// ---- 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(() => { useEffect(() => {
const token = getQueryParam('token'); auth.me().then((u) => {
const error = getQueryParam('error'); setUser(u);
setAuthLoading(false);
if (token) { });
setToken(token); const error = getQueryParam("error");
const claims = decodeToken(token); if (error) {
setUser(claims); console.error("OAuth error:", error);
removeQueryParam('token'); removeQueryParam("error");
} else if (error) {
console.error('OAuth error:', error);
removeQueryParam('error');
} }
}, []); }, []);
// ---- Load map list ---- // ── Load map list after auth resolves ──
useEffect(() => { useEffect(() => {
if (!authLoading) {
api.listMaps().then(setMaps).catch(console.error); api.listMaps().then(setMaps).catch(console.error);
}, [user]); // re-fetch when auth state changes }
}, [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(() => { useEffect(() => {
if (maps.length === 0 && selectedId) { if (!selectedId || authLoading) {
// Maps are still loading — skip setDirectMapInfo(null);
setAccessDenied(false);
return; return;
} }
if (selectedId) { const inList = maps.some((m) => m.id === selectedId);
const exists = maps.some(m => m.id === selectedId); if (inList) {
if (!exists) { setDirectMapInfo(null);
// Invalid or inaccessible map ID — reroute to /map setAccessDenied(false);
setSelectedId(null); return;
window.history.replaceState(null, '', '/map');
} }
}
}, [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(() => { useEffect(() => {
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : '/map'; const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map";
window.history.replaceState(null, '', path); window.history.replaceState(null, "", path);
}, [selectedId]); }, [selectedId]);
useEffect(() => { // ── Reset palette + access state when map deselected ──
if (showNewMap) newMapInputRef.current?.focus();
}, [showNewMap]);
// Reset palette to defaults when no map is selected
useEffect(() => { useEffect(() => {
if (!selectedId) { if (!selectedId) {
setMapColors(DEFAULT_COLORS); setMapColors(DEFAULT_COLORS);
setActiveColor(DEFAULT_COLORS[0]); setActiveColor(DEFAULT_COLORS[0]);
setAccessRequestSent(false);
} }
}, [selectedId]); }, [selectedId]);
// ---- Derived state ---- // ── Handlers ──
const selectedMap = maps.find(m => m.id === selectedId) ?? null;
// The current user is considered the owner if their Discord ID matches owner_id async function handleCreate(name: string, publicAccess: PublicAccess) {
const isOwner = user !== null && selectedMap !== null && selectedMap.owner_id === user.sub; const m = await api.createMap(name, publicAccess);
// Optimistically add to list as an owner entry
// ---- Handlers ---- const listed: ListedMap = {
async function handleCreate(e: React.FormEvent) { ...m,
e.preventDefault(); owner_username: user!.username,
const name = newMapName.trim(); user_role: "owner",
if (!name) return; is_favorited: false,
try { };
const m = await api.createMap(name, newMapPublic); setMaps((prev) => [listed, ...prev]);
setMaps(prev => [m, ...prev]);
setSelectedId(m.id); setSelectedId(m.id);
setShowNewMap(false);
setNewMapName('');
setNewMapPublic(false);
} catch (err) {
console.error('Failed to create map', err);
}
} }
async function handleDelete() { async function handleDelete() {
if (!selectedId) return; if (!selectedId) return;
if (!confirm('Delete this map? This cannot be undone.')) return; if (!confirm("Delete this map? This cannot be undone.")) return;
try { try {
await api.deleteMap(selectedId); await api.deleteMap(selectedId);
setMaps(prev => prev.filter(m => m.id !== selectedId)); setMaps((prev) => prev.filter((m) => m.id !== selectedId));
setSelectedId(null); setSelectedId(null);
} catch (err) { } catch (err) {
console.error('Failed to delete map', err); console.error("Failed to delete map", err);
}
}
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);
} }
} }
/** Called by Grid when the WS state/colors_updated message arrives. */
function handleColorsLoaded(colors: string[]) { function handleColorsLoaded(colors: string[]) {
setMapColors(colors); setMapColors(colors);
setActiveColor(prev => colors.includes(prev) ? prev : colors[0]); setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0]));
} }
/** Called by ColorPanel when the user double-clicks and edits a swatch. */
function handleColorsChange(colors: string[]) { function handleColorsChange(colors: string[]) {
setMapColors(colors); setMapColors(colors);
gridRef.current?.sendColorUpdate(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 ( return (
<div className="app"> <div className="app">
{/* ── Header ── */} <Header
<header className="app-header"> user={user}
<div className="app-brand"> authLoading={authLoading}
<span>SIREN</span> selectedMapName={selectedMapInfo?.name ?? null}
</div> onLoginClick={() => setShowLoginModal(true)}
onAccountClick={() => setShowAccountPanel(true)}
<div className="app-map-controls">
{/* Map selector */}
{maps.length > 0 && !showNewMap && (
<select
className="map-select"
value={selectedId ?? ''}
onChange={e => setSelectedId(e.target.value || null)}
>
<option value=""> Select a map </option>
{maps.map(m => (
<option key={m.id} value={m.id}>
{m.name}{m.is_public ? ' (Public)' : ' (Private)'}
</option>
))}
</select>
)}
{/* New map form — only for authenticated users */}
{user && (
showNewMap ? (
<form className="new-map-form" onSubmit={handleCreate}>
<input
ref={newMapInputRef}
type="text"
placeholder="Map name…"
value={newMapName}
onChange={e => setNewMapName(e.target.value)}
maxLength={60}
/> />
<label className="new-map-public">
<input
type="checkbox"
checked={newMapPublic}
onChange={e => setNewMapPublic(e.target.checked)}
/>
Public
</label>
<button type="submit" disabled={!newMapName.trim()}>Create</button>
<button
type="button"
className="cancel-btn"
onClick={() => { setShowNewMap(false); setNewMapName(''); setNewMapPublic(false); }}
>
</button>
</form>
) : (
<button className="header-btn" onClick={() => setShowNewMap(true)}>
+ New Map
</button>
)
)}
{/* Delete current map — only for the owner */} <div className="app-body">
{isOwner && !showNewMap && (
<button className="header-btn danger" onClick={handleDelete} title="Delete this map">
Delete
</button>
)}
</div>
{/* ── Auth area ── */}
<div className="app-auth">
{user ? (
<>
<span className="app-username">{user.name}</span>
<button className="header-btn" onClick={() => { auth.logout(); setUser(null); }}>
Log out
</button>
</>
) : (
<LoginButton className="header-btn" />
)}
</div>
</header>
{/* ── Grid area ── */}
<div className="app-grid-area"> <div className="app-grid-area">
{selectedId ? ( {/* Top-left floating map controls */}
<FloatingMapControls
isLoggedIn={!!user}
hasSelectedMap={!!selectedId}
isOwner={isOwner}
onNewMap={() => setShowNewMap(true)}
onViewMaps={() => setShowMapList(true)}
onEditMap={() => setShowEditMap(true)}
onDeleteMap={handleDelete}
/>
{selectedId && !accessDenied ? (
<> <>
{/* key forces full remount (new WS + clear state) on map change */}
<Grid <Grid
key={selectedId} key={selectedId}
ref={gridRef} ref={gridRef}
@@ -267,10 +258,7 @@ export default function App() {
onColorsLoaded={handleColorsLoaded} onColorsLoaded={handleColorsLoaded}
/> />
<div className="floating-panels-container"> <div className="floating-panels-container">
<ControlPanel <ControlPanel tool={tool} onToolChange={setTool} />
tool={tool}
onToolChange={setTool}
/>
<ColorPanel <ColorPanel
colors={mapColors} colors={mapColors}
activeColor={activeColor} activeColor={activeColor}
@@ -279,19 +267,105 @@ export default function App() {
/> />
</div> </div>
</> </>
) : accessDenied ? (
<div className="access-denied-state">
<p className="access-denied-title">
You don't have access to this map
</p>
{!user ? (
<p className="access-denied-hint">
<button
className="link-btn"
onClick={() => setShowLoginModal(true)}
>
Log in
</button>{" "}
to request access or view your permissions.
</p>
) : accessRequestSent ? (
<p className="access-denied-hint access-request-sent">
✓ Access request sent! The map owner will be notified.
</p>
) : (
<div className="access-request-actions">
<p className="access-denied-hint">
Request access from the map owner:
</p>
<div className="access-request-btns">
<button
className="btn-request-access"
onClick={() => handleRequestAccess("viewer")}
>
Request Viewer Access
</button>
<button
className="btn-request-access"
onClick={() => handleRequestAccess("editor")}
>
Request Editor Access
</button>
</div>
</div>
)}
</div>
) : ( ) : (
<div className="empty-state"> <div className="empty-state">
<p>Select or create a map to begin</p> <p>Select or create a map to begin</p>
<p className="empty-hint"> <p className="empty-hint">
{!user {!user
? 'Log in with Discord to create maps and access private maps' ? "Log in to create maps and access private maps"
: maps.length === 0 : maps.length === 0
? 'Click "+ New Map" in the header to get started' ? 'Click "+ New Map" in the top-left to get started'
: 'Choose a map from the header dropdown'} : 'Click "Maps" in the top-left to choose a map'}
</p> </p>
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* ── Global modals (always available regardless of page) ── */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={async (u) => {
setUser(u);
api.listMaps().then(setMaps).catch(console.error);
}}
/>
)}
{showAccountPanel && user && (
<AccountPanel
user={user}
onClose={() => setShowAccountPanel(false)}
onRefresh={handleUserRefresh}
/>
)}
{showNewMap && (
<NewMapModal
onClose={() => setShowNewMap(false)}
onCreate={handleCreate}
/>
)}
{showEditMap && selectedMapInfo && (
<EditMapModal
map={selectedMapInfo}
onClose={() => setShowEditMap(false)}
onUpdated={handleMapUpdated}
/>
)}
{showMapList && (
<MapListModal
maps={maps}
selectedMapId={selectedId}
onSelect={(id) => setSelectedId(id)}
onClose={() => setShowMapList(false)}
onMapsChange={setMaps}
/>
)}
</div>
); );
} }

View File

@@ -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 GRID_BASE = "/api/grid";
const AUTH_BASE = '/api/auth/discord'; const AUTH_BASE = "/api/auth";
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const token = getToken(); const res = await fetch(url, {
const headers: Record<string, string> = { ...init,
credentials: "include",
headers: {
...(init?.headers as Record<string, string>), ...(init?.headers as Record<string, string>),
}; },
});
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();
}
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => res.statusText); const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`); 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 = { export const api = {
listMaps: (): Promise<GridMap[]> => /** List maps where the authenticated user has a direct role or has favorited. */
request<GridMap[]>(`${BASE}/maps`), listMaps: (): Promise<ListedMap[]> =>
request<ListedMap[]>(`${GRID_BASE}/maps`),
createMap: (name: string, is_public = false): Promise<GridMap> => /** Create a new map (authenticated). */
request<GridMap>(`${BASE}/maps`, { createMap: (
method: 'POST', name: string,
headers: { 'Content-Type': 'application/json' }, public_access: PublicAccess = "private",
body: JSON.stringify({ name, is_public }), ): Promise<GridMap> =>
request<GridMap>(`${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<MapState> => getMap: (id: string): Promise<MapState> =>
request<MapState>(`${BASE}/maps/${id}`), request<MapState>(`${GRID_BASE}/maps/${id}`),
/** Update map name and/or public_access (owner only). */
updateMap: (
id: string,
payload: { name?: string; public_access?: PublicAccess },
): Promise<GridMap> =>
request<GridMap>(`${GRID_BASE}/maps/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
/** Delete a map (owner only). */
deleteMap: (id: string): Promise<void> => deleteMap: (id: string): Promise<void> =>
request<void>(`${BASE}/maps/${id}`, { method: 'DELETE' }), request<void>(`${GRID_BASE}/maps/${id}`, { method: "DELETE" }),
// ---- Permissions ---- // ---- Permissions ----
/** List all permissions for a map including usernames (owner only). */
listPermissions: (mapId: string): Promise<MapPermission[]> => listPermissions: (mapId: string): Promise<MapPermission[]> =>
request<MapPermission[]>(`${BASE}/maps/${mapId}/permissions`), request<MapPermission[]>(`${GRID_BASE}/maps/${mapId}/permissions`),
updatePermission: (mapId: string, userId: number, role: MapRole | null): Promise<void> => /**
request<void>(`${BASE}/maps/${mapId}/permissions`, { * Add or update a user's role by username.
method: 'PUT', * Pass `role: null` to remove the user's permission entirely.
headers: { 'Content-Type': 'application/json' }, */
body: JSON.stringify({ user_id: userId, role }), updatePermission: (
mapId: string,
username: string,
role: MapRole | null,
): Promise<void> =>
request<void>(`${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<void> =>
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "POST" }),
/** Un-favorite a map. */
unfavoriteMap: (id: string): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "DELETE" }),
/** Request viewer or editor access to a map. */
requestAccess: (mapId: string, role: "editor" | "viewer"): Promise<void> =>
request<void>(`${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<MapAccessRequest[]> =>
request<MapAccessRequest[]>(`${GRID_BASE}/maps/${mapId}/access-requests`),
/** Approve or deny a pending access request (owner only). */
resolveAccessRequest: (
mapId: string,
requestId: string,
action: "approve" | "deny",
): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}), }),
}; };
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
export const auth = { export const auth = {
/** Fetches the Discord OAuth URL and redirects the browser to it. /** Fetch the currently authenticated user's info. Returns null if not logged in. */
* Passes the current page's origin + /map as the UI redirect URI so async me(): Promise<UserInfo | null> {
* the backend knows where to send the browser after login completes. try {
*/ return await request<UserInfo>(`${AUTH_BASE}/me`);
async login(): Promise<void> { } catch {
const redirectUri = encodeURIComponent(window.location.origin + '/map'); return null;
const url = await request<string>(`${AUTH_BASE}/authorize?redirect_uri=${redirectUri}`); }
window.location.href = url;
}, },
logout(): void { /** Register a new local account. */
removeToken(); async register(username: string, password: string): Promise<void> {
window.location.href = '/map'; await request<void>(`${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<void> {
await request<void>(`${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<void> {
const target = encodeURIComponent(redirectUri ?? window.location.href);
const response = await request<string>(
`${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<void> {
const target = encodeURIComponent(
redirectUri ?? window.location.origin + "/account",
);
const response = await request<string>(
`${AUTH_BASE}/discord/connect?redirect_uri=${target}`,
);
window.location.href = JSON.parse(response);
},
/** Clear the session cookie server-side and reload. */
async logout(): Promise<void> {
try {
await request<void>(`${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<UserInfo> {
return request<UserInfo>(`${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<void> {
await request<void>(`${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<void> {
await request<void>(`${AUTH_BASE}/connections/${provider}`, {
method: "DELETE",
});
}, },
}; };

View File

@@ -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;
}

View File

@@ -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<string | null>(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<string | null>(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<HTMLFormElement>) {
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 (
<div className="account-backdrop" onClick={onClose}>
<div className="account-panel" onClick={(e) => e.stopPropagation()}>
<div className="account-header">
<h2>Account</h2>
<button
className="account-close"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
{/* ── Profile ── */}
<section className="account-section">
<h3>Profile</h3>
<form onSubmit={handleSaveProfile} className="profile-form">
<div className="account-field readonly-field">
<span className="account-label">Username</span>
<span className="account-value">{user.username}</span>
</div>
<div className="profile-name-row">
<label className="account-field-label">
First Name
<input
type="text"
value={firstName}
onChange={(e) => handleFirstNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
<label className="account-field-label">
Last Name
<input
type="text"
value={lastName}
onChange={(e) => handleLastNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
</div>
{user.email && (
<div className="account-field readonly-field">
<span className="account-label">Email</span>
<span className="account-value">{user.email}</span>
</div>
)}
{profileError && <p className="account-error">{profileError}</p>}
{profileSuccess && (
<p className="account-success">Profile saved!</p>
)}
{profileDirty && (
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setFirstName(user.first_name ?? "");
setLastName(user.last_name ?? "");
setProfileDirty(false);
setProfileError(null);
}}
>
Cancel
</button>
<button
type="submit"
className="btn-save"
disabled={profileSaving}
>
{profileSaving ? "Saving…" : "Save"}
</button>
</div>
)}
</form>
</section>
{/* ── Password ── */}
<section className="account-section">
<div className="section-header-row">
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
{!showPasswordSection && (
<button
className="btn-text"
onClick={() => {
setShowPasswordSection(true);
setPwError(null);
setPwSuccess(false);
}}
>
{user.has_password ? "Change" : "Set Password"}
</button>
)}
</div>
{pwSuccess && !showPasswordSection && (
<p className="account-success">Password updated successfully!</p>
)}
{showPasswordSection && (
<form onSubmit={handleChangePassword} className="password-form">
{user.has_password && (
<label className="account-field-label">
Current Password
<input
type="password"
value={pwCurrent}
onChange={(e) => setPwCurrent(e.target.value)}
autoComplete="current-password"
required
/>
</label>
)}
<label className="account-field-label">
New Password
<input
type="password"
value={pwNew}
onChange={(e) => setPwNew(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label className="account-field-label">
Confirm New Password
<input
type="password"
value={pwConfirm}
onChange={(e) => setPwConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
{pwError && <p className="account-error">{pwError}</p>}
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setShowPasswordSection(false);
setPwCurrent("");
setPwNew("");
setPwConfirm("");
setPwError(null);
}}
>
Cancel
</button>
<button type="submit" className="btn-save" disabled={pwSaving}>
{pwSaving
? "Saving…"
: user.has_password
? "Update Password"
: "Set Password"}
</button>
</div>
</form>
)}
</section>
{/* ── Connected services ── */}
<section className="account-section">
<h3>Connected Accounts</h3>
<div className="account-connection">
<svg
className="connection-icon discord-icon"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
<div className="connection-info">
<span className="connection-name">Discord</span>
{discordConnection ? (
<span className="connection-linked">
{discordConnection.provider_username ?? "Connected"}
</span>
) : (
<span className="connection-unlinked">Not connected</span>
)}
</div>
{discordConnection ? (
<button
className="btn-disconnect"
onClick={handleDisconnectDiscord}
disabled={!user.has_password}
title={
!user.has_password
? "Set a password first before disconnecting Discord"
: "Disconnect Discord"
}
>
Disconnect
</button>
) : (
<button
className="btn-connect-discord"
onClick={handleConnectDiscord}
>
Connect
</button>
)}
</div>
{discordConnection && !user.has_password && (
<p className="connection-hint">
Set a password above before disconnecting Discord to avoid being
locked out.
</p>
)}
</section>
{/* ── Footer ── */}
<div className="account-footer">
<button className="btn-logout" onClick={handleLogout}>
Log Out
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,7 +31,9 @@
border: 2px solid transparent; border: 2px solid transparent;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
transition: transform 0.1s, border-color 0.1s; transition:
transform 0.1s,
border-color 0.1s;
overflow: hidden; overflow: hidden;
} }

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import './ColorPanel.css'; import "./ColorPanel.css";
interface Props { interface Props {
colors: string[]; colors: string[];
@@ -8,7 +8,12 @@ interface Props {
onColorsChange: (colors: string[]) => void; 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 // One hidden color input ref per slot
const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@@ -16,14 +21,18 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; 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); const num = parseInt(e.key, 10);
if (num >= 1 && num <= colors.length) { if (num >= 1 && num <= colors.length) {
onColorChange(colors[num - 1]); onColorChange(colors[num - 1]);
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener("keydown", handler);
}, [colors, onColorChange]); }, [colors, onColorChange]);
function handleDoubleClick(index: number) { function handleDoubleClick(index: number) {
@@ -44,7 +53,7 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
{colors.map((c, i) => ( {colors.map((c, i) => (
<div key={i} className="cp-swatch-wrapper"> <div key={i} className="cp-swatch-wrapper">
<button <button
className={`cp-swatch ${activeColor === c ? 'selected' : ''}`} className={`cp-swatch ${activeColor === c ? "selected" : ""}`}
style={{ background: c }} style={{ background: c }}
onClick={() => onColorChange(c)} onClick={() => onColorChange(c)}
onDoubleClick={() => handleDoubleClick(i)} onDoubleClick={() => handleDoubleClick(i)}
@@ -54,10 +63,12 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
</button> </button>
{/* Hidden color picker for this slot */} {/* Hidden color picker for this slot */}
<input <input
ref={el => { inputRefs.current[i] = el; }} ref={(el) => {
inputRefs.current[i] = el;
}}
type="color" type="color"
value={c} value={c}
onChange={e => handleColorEdit(i, e.target.value)} onChange={(e) => handleColorEdit(i, e.target.value)}
className="cp-color-input" className="cp-color-input"
tabIndex={-1} tabIndex={-1}
/> />

View File

@@ -23,7 +23,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background 0.12s, border-color 0.12s; transition:
background 0.12s,
border-color 0.12s;
} }
.fp-tool-btn:hover { .fp-tool-btn:hover {

View File

@@ -1,47 +1,85 @@
import { useEffect } from 'react'; import { useEffect } from "react";
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from 'react-icons/md'; import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from "react-icons/md";
import type { Tool } from '../types'; import type { Tool } from "../types";
import './ControlPanel.css'; import "./ControlPanel.css";
interface Props { interface Props {
tool: Tool; tool: Tool;
onToolChange: (t: Tool) => void; onToolChange: (t: Tool) => void;
} }
const TOOLS: { id: Tool; icon: React.ReactNode; title: string; shortcut: string }[] = [ const TOOLS: {
{ id: 'pan', icon: <MdPanTool />, title: 'Pan drag to move the map', shortcut: 'Shift+1' }, id: Tool;
{ id: 'zoom', icon: <MdZoomIn />, title: 'Zoom click to zoom in/out', shortcut: 'Shift+2' }, icon: React.ReactNode;
{ id: 'draw', icon: <MdBrush />, title: 'Draw left-click to paint, right-click to erase, Shift+click to fill', shortcut: 'Shift+3' }, title: string;
{ id: 'token', icon: <MdPerson />, title: 'Token click to place, drag to move, right-click to delete', shortcut: 'Shift+4' }, shortcut: string;
}[] = [
{
id: "pan",
icon: <MdPanTool />,
title: "Pan drag to move the map",
shortcut: "Shift+1",
},
{
id: "zoom",
icon: <MdZoomIn />,
title: "Zoom click to zoom in/out",
shortcut: "Shift+2",
},
{
id: "draw",
icon: <MdBrush />,
title:
"Draw left-click to paint, right-click to erase, Shift+click to fill",
shortcut: "Shift+3",
},
{
id: "token",
icon: <MdPerson />,
title: "Token click to place, drag to move, right-click to delete",
shortcut: "Shift+4",
},
]; ];
export default function ControlPanel({ tool, onToolChange }: Props) { export default function ControlPanel({ tool, onToolChange }: Props) {
// Keyboard shortcuts: Shift+1/2/3/4 for tools // Keyboard shortcuts: Shift+1/2/3/4 for tools
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { 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; if (!e.shiftKey) return;
switch (e.key) { switch (e.key) {
case '!': // Shift+1 on many layouts case "!": // Shift+1 on many layouts
case '1': onToolChange('pan'); break; case "1":
case '@': // Shift+2 onToolChange("pan");
case '2': onToolChange('zoom'); break; break;
case '#': // Shift+3 case "@": // Shift+2
case '3': onToolChange('draw'); break; case "2":
case '$': // Shift+4 onToolChange("zoom");
case '4': onToolChange('token'); break; break;
case "#": // Shift+3
case "3":
onToolChange("draw");
break;
case "$": // Shift+4
case "4":
onToolChange("token");
break;
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener("keydown", handler);
}, [onToolChange]); }, [onToolChange]);
return ( return (
<div className="floating-panel"> <div className="floating-panel">
{TOOLS.map(t => ( {TOOLS.map((t) => (
<button <button
key={t.id} key={t.id}
className={`fp-tool-btn ${tool === t.id ? 'active' : ''}`} className={`fp-tool-btn ${tool === t.id ? "active" : ""}`}
onClick={() => onToolChange(t.id)} onClick={() => onToolChange(t.id)}
title={`${t.title} (${t.shortcut})`} title={`${t.title} (${t.shortcut})`}
> >

View File

@@ -0,0 +1,198 @@
.edit-map-modal {
width: 500px;
max-width: 92vw;
max-height: 88vh;
overflow-y: auto;
}
.edit-map-form {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-bottom: 0.5rem;
}
/* ── Section dividers ── */
.edit-section {
border-top: 1px solid #2e3348;
padding-top: 1rem;
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.edit-section h3 {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
}
.edit-loading {
font-size: 0.82rem;
color: #6b7280;
margin: 0;
}
/* ── Permission list ── */
.perm-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.perm-row {
display: flex;
align-items: center;
gap: 0.6rem;
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
padding: 0.45rem 0.75rem;
}
.perm-username {
flex: 1;
font-size: 0.85rem;
color: #e2e8f0;
}
.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;
}
.perm-remove {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
font-size: 0.8rem;
padding: 0.1rem 0.3rem;
line-height: 1;
transition: color 0.12s;
}
.perm-remove:hover {
color: #ef4444;
}
.perm-empty {
font-size: 0.8rem;
color: #4b5563;
margin: 0;
}
/* ── Add permission form ── */
.add-perm-form {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
}
.add-perm-input {
flex: 1;
min-width: 120px;
background: #111827;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.35rem 0.6rem;
font-size: 0.82rem;
outline: none;
}
.add-perm-input:focus {
border-color: #6366f1;
}
.add-perm-role {
background: #1f2937;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.35rem 0.6rem;
font-size: 0.82rem;
outline: none;
cursor: pointer;
}
/* ── Access request list ── */
.req-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.req-row {
display: flex;
align-items: center;
gap: 0.6rem;
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
padding: 0.45rem 0.75rem;
}
.req-username {
flex: 1;
font-size: 0.85rem;
color: #e2e8f0;
}
.req-actions {
display: flex;
gap: 0.35rem;
}
.btn-approve {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 5px;
color: #34d399;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
transition: background 0.12s;
}
.btn-approve:hover {
background: rgba(16, 185, 129, 0.25);
}
.btn-deny {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: 5px;
color: #f87171;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
transition: background 0.12s;
}
.btn-deny:hover {
background: rgba(239, 68, 68, 0.2);
}
/* ── Small variant of primary button ── */
.btn-sm {
padding: 0.35rem 0.75rem !important;
font-size: 0.8rem !important;
}

View File

@@ -0,0 +1,326 @@
import { useState, useEffect } from "react";
import { api } from "../api";
import type {
GridMap,
ListedMap,
MapAccessRequest,
MapPermission,
MapRole,
PublicAccess,
} from "../types";
import "./EditMapModal.css";
interface Props {
map: GridMap | ListedMap;
onClose: () => void;
onUpdated: (updated: GridMap) => void;
}
export default function EditMapModal({ map, onClose, onUpdated }: Props) {
const [name, setName] = useState(map.name);
const [publicAccess, setPublicAccess] = useState<PublicAccess>(
map.public_access,
);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Permissions
const [permissions, setPermissions] = useState<MapPermission[]>([]);
const [permsLoading, setPermsLoading] = useState(true);
// Add permission
const [addUsername, setAddUsername] = useState("");
const [addRole, setAddRole] = useState<"editor" | "viewer">("viewer");
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
// Access requests
const [requests, setRequests] = useState<MapAccessRequest[]>([]);
const [reqsLoading, setReqsLoading] = useState(true);
useEffect(() => {
loadPermissions();
loadRequests();
}, [map.id]); // eslint-disable-line react-hooks/exhaustive-deps
async function loadPermissions() {
setPermsLoading(true);
try {
const perms = await api.listPermissions(map.id);
setPermissions(perms);
} catch {
// silent
} finally {
setPermsLoading(false);
}
}
async function loadRequests() {
setReqsLoading(true);
try {
const reqs = await api.listAccessRequests(map.id);
setRequests(reqs);
} catch {
// silent — might just not be owner
} finally {
setReqsLoading(false);
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setSaving(true);
setSaveError(null);
try {
const updated = await api.updateMap(map.id, {
name: trimmed,
public_access: publicAccess,
});
onUpdated(updated);
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setSaveError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to save");
} finally {
setSaving(false);
}
}
async function handleRemovePermission(username: string) {
try {
await api.updatePermission(map.id, username, null);
setPermissions((prev) => prev.filter((p) => p.username !== username));
} catch (err) {
console.error("Failed to remove permission", err);
}
}
async function handleAddPermission(e: React.FormEvent) {
e.preventDefault();
const username = addUsername.trim();
if (!username) return;
setAddLoading(true);
setAddError(null);
try {
await api.updatePermission(map.id, username, addRole as MapRole);
setAddUsername("");
await loadPermissions();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setAddError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to add user");
} finally {
setAddLoading(false);
}
}
async function handleResolveRequest(
requestId: string,
action: "approve" | "deny",
) {
try {
await api.resolveAccessRequest(map.id, requestId, action);
setRequests((prev) => prev.filter((r) => r.id !== requestId));
if (action === "approve") {
await loadPermissions();
}
} catch (err) {
console.error("Failed to resolve request", err);
}
}
const nonOwnerPerms = permissions.filter((p) => p.role !== "owner");
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal edit-map-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>Edit Map</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
</div>
{/* ── Map settings ── */}
<form onSubmit={handleSave} className="edit-map-form">
<label className="field-label">
Map Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
required
/>
</label>
<fieldset className="public-access-fieldset">
<legend>Visibility</legend>
<label className="radio-option">
<input
type="radio"
name="edit_public_access"
value="private"
checked={publicAccess === "private"}
onChange={() => setPublicAccess("private")}
/>
<span className="radio-label">
<strong>Private</strong>
<span className="radio-hint">Only you and invited users</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="edit_public_access"
value="public_view"
checked={publicAccess === "public_view"}
onChange={() => setPublicAccess("public_view")}
/>
<span className="radio-label">
<strong>Public View Only</strong>
<span className="radio-hint">
Anyone with the link can view
</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="edit_public_access"
value="public_edit"
checked={publicAccess === "public_edit"}
onChange={() => setPublicAccess("public_edit")}
/>
<span className="radio-label">
<strong>Public View &amp; Edit</strong>
<span className="radio-hint">
Anyone with the link can view and edit
</span>
</span>
</label>
</fieldset>
{saveError && <p className="form-error">{saveError}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!name.trim() || saving}
>
{saving ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
{/* ── Permissions ── */}
<section className="edit-section">
<h3>Permissions</h3>
{permsLoading ? (
<p className="edit-loading">Loading</p>
) : (
<div className="perm-list">
{permissions.map((p) => (
<div key={p.user_id} className="perm-row">
<span className="perm-username">{p.username}</span>
<span className={`perm-role-badge role-${p.role}`}>
{p.role}
</span>
{p.role !== "owner" && (
<button
className="perm-remove"
onClick={() => handleRemovePermission(p.username)}
title={`Remove ${p.username}`}
>
</button>
)}
</div>
))}
{nonOwnerPerms.length === 0 &&
permissions.filter((p) => p.role === "owner").length > 0 && (
<p className="perm-empty">No editors or viewers yet</p>
)}
</div>
)}
{/* Add user */}
<form className="add-perm-form" onSubmit={handleAddPermission}>
<input
type="text"
placeholder="Username…"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
className="add-perm-input"
/>
<select
value={addRole}
onChange={(e) =>
setAddRole(e.target.value as "editor" | "viewer")
}
className="add-perm-role"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
<button
type="submit"
className="btn-primary btn-sm"
disabled={!addUsername.trim() || addLoading}
>
{addLoading ? "…" : "Add"}
</button>
</form>
{addError && (
<p className="form-error" style={{ marginTop: "0.4rem" }}>
{addError}
</p>
)}
</section>
{/* ── Access Requests ── */}
{!reqsLoading && requests.length > 0 && (
<section className="edit-section">
<h3>Pending Access Requests</h3>
<div className="req-list">
{requests.map((r) => (
<div key={r.id} className="req-row">
<span className="req-username">{r.username}</span>
<span className={`perm-role-badge role-${r.requested_role}`}>
{r.requested_role}
</span>
<div className="req-actions">
<button
className="btn-approve"
onClick={() => handleResolveRequest(r.id, "approve")}
>
Approve
</button>
<button
className="btn-deny"
onClick={() => handleResolveRequest(r.id, "deny")}
>
Deny
</button>
</div>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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 (
<div className="floating-map-controls">
{/* Always visible for logged-in users */}
<button className="fmc-btn" onClick={onViewMaps} title="View my maps">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
Maps
</button>
<button
className="fmc-btn fmc-btn-primary"
onClick={onNewMap}
title="Create a new map"
>
+ New Map
</button>
{/* Owner-only actions — only when a map is selected */}
{hasSelectedMap && isOwner && (
<>
<button
className="fmc-btn"
onClick={onEditMap}
title="Edit map settings"
>
Edit Map
</button>
<button
className="fmc-btn fmc-btn-danger"
onClick={onDeleteMap}
title="Delete this map"
>
Delete
</button>
</>
)}
</div>
);
}

View File

@@ -1,11 +1,21 @@
import { import {
useRef, useEffect, useCallback, useState, useRef,
forwardRef, useImperativeHandle, useEffect,
} from 'react'; useCallback,
import type { GridCell, GridToken, Tool, ServerMessage, ClientMessage } from '../types'; useState,
import { useWebSocket } from '../hooks/useWebSocket'; forwardRef,
import TokenDialog from './TokenDialog'; useImperativeHandle,
import './Grid.css'; } from "react";
import type {
GridCell,
GridToken,
Tool,
ServerMessage,
ClientMessage,
} from "../types";
import { useWebSocket } from "../hooks/useWebSocket";
import TokenDialog from "./TokenDialog";
import "./Grid.css";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -16,9 +26,9 @@ const MIN_ZOOM = 8;
const MAX_ZOOM = 160; const MAX_ZOOM = 160;
const ZOOM_STEP = 1.12; const ZOOM_STEP = 1.12;
const BG_COLOR = '#111827'; const BG_COLOR = "#111827";
const GRID_COLOR = 'rgba(255,255,255,0.07)'; const GRID_COLOR = "rgba(255,255,255,0.07)";
const GRID_COLOR_MAJOR = 'rgba(255,255,255,0.16)'; 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. */ /** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
const MAX_FLOOD_CELLS = 2500; const MAX_FLOOD_CELLS = 2500;
@@ -56,14 +66,22 @@ function cellKey(x: number, y: number): string {
return `${x},${y}`; 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 { return {
x: Math.floor(cx / cam.zoom + cam.offsetX), x: Math.floor(cx / cam.zoom + cam.offsetX),
y: Math.floor(cy / cam.zoom + cam.offsetY), 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 { return {
x: (cellX - cam.offsetX) * cam.zoom, x: (cellX - cam.offsetX) * cam.zoom,
y: (cellY - cam.offsetY) * cam.zoom, y: (cellY - cam.offsetY) * cam.zoom,
@@ -84,7 +102,7 @@ function drawToken(
const cy = py + zoom / 2; const cy = py + zoom / 2;
const r = zoom * 0.38; 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.shadowBlur = 5;
ctx.beginPath(); ctx.beginPath();
@@ -92,7 +110,7 @@ function drawToken(
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fill(); 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.lineWidth = Math.max(1, zoom * 0.04);
ctx.stroke(); ctx.stroke();
@@ -104,10 +122,10 @@ function drawToken(
words.length >= 2 words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase() ? (words[0][0] + words[1][0]).toUpperCase()
: label.slice(0, 2).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.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = "center";
ctx.textBaseline = 'middle'; ctx.textBaseline = "middle";
ctx.fillText(initials, cx, cy); ctx.fillText(initials, cx, cy);
} }
} }
@@ -133,8 +151,10 @@ function floodFill(
visited.add(cellKey(startX, startY)); visited.add(cellKey(startX, startY));
const dirs = [ const dirs = [
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 },
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 }, { dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
]; ];
while (queue.length > 0) { while (queue.length > 0) {
@@ -180,8 +200,10 @@ function clampCameraToContent(
cellLoop: for (const cell of cells.values()) { cellLoop: for (const cell of cells.values()) {
if ( if (
cell.x + 1 > viewLeft && cell.x < viewRight && cell.x + 1 > viewLeft &&
cell.y + 1 > viewTop && cell.y < viewBottom cell.x < viewRight &&
cell.y + 1 > viewTop &&
cell.y < viewBottom
) { ) {
anyVisible = true; anyVisible = true;
break cellLoop; break cellLoop;
@@ -191,8 +213,10 @@ function clampCameraToContent(
if (!anyVisible) { if (!anyVisible) {
for (const tok of tokens.values()) { for (const tok of tokens.values()) {
if ( if (
tok.x + 1 > viewLeft && tok.x < viewRight && tok.x + 1 > viewLeft &&
tok.y + 1 > viewTop && tok.y < viewBottom tok.x < viewRight &&
tok.y + 1 > viewTop &&
tok.y < viewBottom
) { ) {
anyVisible = true; anyVisible = true;
break; break;
@@ -203,8 +227,10 @@ function clampCameraToContent(
if (anyVisible) return; if (anyVisible) return;
// Find the bounding box of all content // Find the bounding box of all content
let minX = Infinity, maxX = -Infinity; let minX = Infinity,
let minY = Infinity, maxY = -Infinity; maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
for (const c of cells.values()) { for (const c of cells.values()) {
if (c.x < minX) minX = c.x; if (c.x < minX) minX = c.x;
@@ -250,17 +276,26 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const cameraRef = useRef<Camera>({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM }); const cameraRef = useRef<Camera>({
offsetX: -2,
offsetY: -2,
zoom: DEFAULT_ZOOM,
});
const cellsRef = useRef<Map<string, GridCell>>(new Map()); const cellsRef = useRef<Map<string, GridCell>>(new Map());
const tokensRef = useRef<Map<string, GridToken>>(new Map()); const tokensRef = useRef<Map<string, GridToken>>(new Map());
const [tick, setTick] = useState(0); 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) ---- // ---- Mouse interaction state (refs to avoid stale closures) ----
const isPanning = useRef(false); const isPanning = useRef(false);
const panStart = useRef<{ mx: number; my: number; ox: number; oy: number } | null>(null); const panStart = useRef<{
mx: number;
my: number;
ox: number;
oy: number;
} | null>(null);
const isDrawing = useRef(false); const isDrawing = useRef(false);
const isErasing = useRef(false); const isErasing = useRef(false);
const lastPainted = useRef<string | null>(null); const lastPainted = useRef<string | null>(null);
@@ -276,15 +311,17 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// ---- Stable send ref so handlers never go stale ---- // ---- Stable send ref so handlers never go stale ----
const sendRef = useRef<(msg: ClientMessage) => void>(() => {}); const sendRef = useRef<(msg: ClientMessage) => void>(() => {});
const [cursor, setCursor] = useState<string>('default'); const [cursor, setCursor] = useState<string>("default");
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null); const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(
null,
);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Imperative handle — lets App.tsx trigger a color WS update // Imperative handle — lets App.tsx trigger a color WS update
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
sendColorUpdate(colors: string[]) { sendColorUpdate(colors: string[]) {
sendRef.current({ type: 'update_colors', colors }); sendRef.current({ type: "update_colors", colors });
}, },
})); }));
@@ -313,53 +350,64 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Keep a stable ref to the callback so handleMessage doesn't re-create // Keep a stable ref to the callback so handleMessage doesn't re-create
const onColorsLoadedRef = useRef(onColorsLoaded); const onColorsLoadedRef = useRef(onColorsLoaded);
useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [onColorsLoaded]); useEffect(() => {
onColorsLoadedRef.current = onColorsLoaded;
}, [onColorsLoaded]);
const handleMessage = useCallback((msg: ServerMessage) => { const handleMessage = useCallback(
(msg: ServerMessage) => {
switch (msg.type) { switch (msg.type) {
case 'state': { case "state": {
cellsRef.current.clear(); cellsRef.current.clear();
tokensRef.current.clear(); tokensRef.current.clear();
msg.cells.forEach(c => cellsRef.current.set(cellKey(c.x, c.y), c)); msg.cells.forEach((c) => cellsRef.current.set(cellKey(c.x, c.y), c));
msg.tokens.forEach(t => tokensRef.current.set(t.id, t)); msg.tokens.forEach((t) => tokensRef.current.set(t.id, t));
onColorsLoadedRef.current(msg.colors); onColorsLoadedRef.current(msg.colors);
redraw(); redraw();
break; break;
} }
case 'cell_painted': { case "cell_painted": {
const key = cellKey(msg.x, msg.y); const key = cellKey(msg.x, msg.y);
cellsRef.current.set(key, { cellsRef.current.set(key, {
map_id: mapId, map_id: mapId,
x: msg.x, y: msg.y, color: msg.color, x: msg.x,
y: msg.y,
color: msg.color,
}); });
redraw(); redraw();
break; break;
} }
case 'cells_batch_painted': { case "cells_batch_painted": {
msg.cells.forEach(c => { msg.cells.forEach((c) => {
const key = cellKey(c.x, c.y); const key = cellKey(c.x, c.y);
cellsRef.current.set(key, { cellsRef.current.set(key, {
map_id: mapId, map_id: mapId,
x: c.x, y: c.y, color: c.color, x: c.x,
y: c.y,
color: c.color,
}); });
}); });
redraw(); redraw();
break; break;
} }
case 'cell_erased': { case "cell_erased": {
cellsRef.current.delete(cellKey(msg.x, msg.y)); cellsRef.current.delete(cellKey(msg.x, msg.y));
redraw(); redraw();
break; break;
} }
case 'token_added': { case "token_added": {
tokensRef.current.set(msg.id, { tokensRef.current.set(msg.id, {
id: msg.id, map_id: mapId, id: msg.id,
x: msg.x, y: msg.y, label: msg.label, color: msg.color, map_id: mapId,
x: msg.x,
y: msg.y,
label: msg.label,
color: msg.color,
}); });
redraw(); redraw();
break; break;
} }
case 'token_moved': { case "token_moved": {
const tok = tokensRef.current.get(msg.id); const tok = tokensRef.current.get(msg.id);
if (tok) { if (tok) {
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y }); tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
@@ -367,23 +415,27 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
} }
break; break;
} }
case 'token_deleted': { case "token_deleted": {
tokensRef.current.delete(msg.id); tokensRef.current.delete(msg.id);
redraw(); redraw();
break; break;
} }
case 'colors_updated': { case "colors_updated": {
onColorsLoadedRef.current(msg.colors); onColorsLoadedRef.current(msg.colors);
break; break;
} }
case 'error': case "error":
console.error('[Grid WS]', msg.message); console.error("[Grid WS]", msg.message);
break; break;
} }
}, [mapId, redraw]); },
[mapId, redraw],
);
const { send } = useWebSocket(mapId, handleMessage); const { send } = useWebSocket(mapId, handleMessage);
useEffect(() => { sendRef.current = send; }, [send]); useEffect(() => {
sendRef.current = send;
}, [send]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Canvas draw // Canvas draw
@@ -391,7 +443,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
const W = canvas.width; const W = canvas.width;
@@ -408,8 +460,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const endCY = Math.ceil(offsetY + H / zoom) + 1; const endCY = Math.ceil(offsetY + H / zoom) + 1;
// Painted cells // Painted cells
cellsRef.current.forEach(cell => { cellsRef.current.forEach((cell) => {
if (cell.x < startCX || cell.x > endCX || cell.y < startCY || cell.y > endCY) return; 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); const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam);
ctx.fillStyle = cell.color; ctx.fillStyle = cell.color;
ctx.fillRect(px, py, zoom, zoom); ctx.fillRect(px, py, zoom, zoom);
@@ -437,7 +495,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
} }
// Tokens (skip the one being dragged) // Tokens (skip the one being dragged)
tokensRef.current.forEach(token => { tokensRef.current.forEach((token) => {
if (isDragging.current && dragTokenId.current === token.id) return; if (isDragging.current && dragTokenId.current === token.id) return;
drawToken(ctx, token.x, token.y, token.label, token.color, cam); drawToken(ctx, token.x, token.y, token.label, token.color, cam);
}); });
@@ -447,7 +505,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const tok = tokensRef.current.get(dragTokenId.current); const tok = tokensRef.current.get(dragTokenId.current);
if (tok) { if (tok) {
ctx.globalAlpha = 0.6; 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; ctx.globalAlpha = 1;
} }
} }
@@ -460,8 +525,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (canvas) { if (canvas) {
clampCameraToContent( clampCameraToContent(
cameraRef.current, cellsRef.current, tokensRef.current, cameraRef.current,
canvas.width, canvas.height, cellsRef.current,
tokensRef.current,
canvas.width,
canvas.height,
); );
} }
redraw(); redraw();
@@ -492,8 +560,8 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
applyZoom(cx, cy, factor); applyZoom(cx, cy, factor);
}; };
canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener("wheel", onWheel, { passive: false });
return () => canvas.removeEventListener('wheel', onWheel); return () => canvas.removeEventListener("wheel", onWheel);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -508,7 +576,8 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return; return;
} }
const dt = lastFrameTime.current !== null const dt =
lastFrameTime.current !== null
? (timestamp - lastFrameTime.current) / 1000 ? (timestamp - lastFrameTime.current) / 1000
: 0; : 0;
lastFrameTime.current = timestamp; lastFrameTime.current = timestamp;
@@ -516,26 +585,36 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const cam = cameraRef.current; const cam = cameraRef.current;
const speed = WASD_PAN_SPEED; const speed = WASD_PAN_SPEED;
if (keys.has('a')) cam.offsetX -= speed * dt; if (keys.has("a")) cam.offsetX -= speed * dt;
if (keys.has('d')) cam.offsetX += speed * dt; if (keys.has("d")) cam.offsetX += speed * dt;
if (keys.has('w')) cam.offsetY -= speed * dt; if (keys.has("w")) cam.offsetY -= speed * dt;
if (keys.has('s')) cam.offsetY += speed * dt; if (keys.has("s")) cam.offsetY += speed * dt;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (canvas) { 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); rafId.current = requestAnimationFrame(rafTick);
} }
function onKeyDown(e: KeyboardEvent) { 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) // 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; if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
if (['w', 'a', 's', 'd'].includes(key)) { if (["w", "a", "s", "d"].includes(key)) {
e.preventDefault(); e.preventDefault();
keysHeld.current.add(key); keysHeld.current.add(key);
if (rafId.current === null) { if (rafId.current === null) {
@@ -549,11 +628,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
keysHeld.current.delete(e.key.toLowerCase()); keysHeld.current.delete(e.key.toLowerCase());
} }
window.addEventListener('keydown', onKeyDown); window.addEventListener("keydown", onKeyDown);
window.addEventListener('keyup', onKeyUp); window.addEventListener("keyup", onKeyUp);
return () => { return () => {
window.removeEventListener('keydown', onKeyDown); window.removeEventListener("keydown", onKeyDown);
window.removeEventListener('keyup', onKeyUp); window.removeEventListener("keyup", onKeyUp);
if (rafId.current !== null) { if (rafId.current !== null) {
cancelAnimationFrame(rafId.current); cancelAnimationFrame(rafId.current);
rafId.current = null; rafId.current = null;
@@ -585,22 +664,27 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const cell = canvasToCell(mx, my, cameraRef.current); const cell = canvasToCell(mx, my, cameraRef.current);
// ---- Pan tool ---- // ---- Pan tool ----
if (tool === 'pan' && e.button === 0) { if (tool === "pan" && e.button === 0) {
isPanning.current = true; isPanning.current = true;
panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY }; panStart.current = {
setCursor('grabbing'); mx,
my,
ox: cameraRef.current.offsetX,
oy: cameraRef.current.offsetY,
};
setCursor("grabbing");
return; return;
} }
// ---- Zoom tool ---- // ---- Zoom tool ----
if (tool === 'zoom') { if (tool === "zoom") {
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP); if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP)); else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP));
return; return;
} }
// ---- Draw tool ---- // ---- Draw tool ----
if (tool === 'draw') { if (tool === "draw") {
if (e.button === 0) { if (e.button === 0) {
if (e.shiftKey) { if (e.shiftKey) {
// Shift+click → flood fill uncolored region // Shift+click → flood fill uncolored region
@@ -609,11 +693,16 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const region = floodFill(cell.x, cell.y, cellsRef.current); const region = floodFill(cell.x, cell.y, cellsRef.current);
if (region === null || region.length === 1) { if (region === null || region.length === 1) {
// Unbounded or trivially single cell → paint one cell // 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 { } else {
// Bounded enclosed region → batch paint // Bounded enclosed region → batch paint
sendRef.current({ sendRef.current({
type: 'paint_cells', type: "paint_cells",
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })), cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
}); });
} }
@@ -621,24 +710,29 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
} else { } else {
isDrawing.current = true; isDrawing.current = true;
lastPainted.current = cellKey(cell.x, cell.y); 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) { } else if (e.button === 2) {
isErasing.current = true; isErasing.current = true;
const key = cellKey(cell.x, cell.y); const key = cellKey(cell.x, cell.y);
lastPainted.current = key; lastPainted.current = key;
if (cellsRef.current.has(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; return;
} }
// ---- Token tool ---- // ---- Token tool ----
if (tool === 'token') { if (tool === "token") {
if (e.button === 2) { if (e.button === 2) {
const tok = tokenAtCell(cell.x, cell.y); 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; return;
} }
if (e.button === 0) { if (e.button === 0) {
@@ -676,9 +770,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
if (lastPainted.current !== key) { if (lastPainted.current !== key) {
lastPainted.current = key; lastPainted.current = key;
if (isDrawing.current) { 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)) { } 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; return;
@@ -687,7 +786,10 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// Token drag // Token drag
if (isDragging.current && dragCellPos.current) { if (isDragging.current && dragCellPos.current) {
const cell = canvasToCell(mx, my, cameraRef.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 }; dragCellPos.current = { x: cell.x, y: cell.y };
redraw(); redraw();
} }
@@ -698,7 +800,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
if (isPanning.current) { if (isPanning.current) {
isPanning.current = false; isPanning.current = false;
panStart.current = null; panStart.current = null;
setCursor('grab'); setCursor("grab");
return; return;
} }
@@ -711,9 +813,12 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
if (isDragging.current && dragTokenId.current && dragCellPos.current) { if (isDragging.current && dragTokenId.current && dragCellPos.current) {
const tok = tokensRef.current.get(dragTokenId.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({ sendRef.current({
type: 'move_token', type: "move_token",
id: dragTokenId.current, id: dragTokenId.current,
x: dragCellPos.current.x, x: dragCellPos.current.x,
y: dragCellPos.current.y, y: dragCellPos.current.y,
@@ -743,16 +848,30 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// Sync cursor CSS to active tool // Sync cursor CSS to active tool
useEffect(() => { useEffect(() => {
switch (tool) { switch (tool) {
case 'pan': setCursor('grab'); break; case "pan":
case 'zoom': setCursor('zoom-in'); break; setCursor("grab");
case 'draw': setCursor('crosshair'); break; break;
case 'token': setCursor('crosshair'); break; case "zoom":
setCursor("zoom-in");
break;
case "draw":
setCursor("crosshair");
break;
case "token":
setCursor("crosshair");
break;
} }
}, [tool]); }, [tool]);
function handleAddToken(label: string, color: string) { function handleAddToken(label: string, color: string) {
if (!dialogPos) return; 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); setDialogPos(null);
} }
@@ -766,7 +885,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onContextMenu={e => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
/> />
{dialogPos && ( {dialogPos && (
<TokenDialog <TokenDialog

View File

@@ -0,0 +1,48 @@
.app-header {
flex-shrink: 0;
height: 48px;
display: grid;
grid-template-columns: auto 1fr auto;
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;
}
.app-brand span {
color: #818cf8;
}
.app-header-center {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.header-map-name {
font-size: 0.9rem;
font-weight: 600;
color: #e5e7eb;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
}
.app-auth {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}

View File

@@ -0,0 +1,51 @@
import type { UserInfo } from "../types";
import LoginButton from "./LoginButton";
import "./Header.css";
interface Props {
user: UserInfo | null;
authLoading: boolean;
selectedMapName: string | null;
onLoginClick: () => 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 (
<header className="app-header">
<div className="app-brand">
<span>SIREN</span>
</div>
<div className="app-header-center">
{selectedMapName && (
<span className="header-map-name">{selectedMapName}</span>
)}
</div>
<div className="app-auth">
{!authLoading &&
(user ? (
<button
className="header-btn"
onClick={onAccountClick}
title="Account settings"
>
{displayName}
</button>
) : (
<LoginButton className="header-btn" onClick={onLoginClick} />
))}
</div>
</header>
);
}

View File

@@ -1,21 +1,13 @@
import { auth } from '../api';
interface Props { interface Props {
className?: string; className?: string;
onClick: () => void;
} }
export default function LoginButton({ className }: Props) { /** A simple button that opens the login modal when clicked. */
async function handleLogin() { export default function LoginButton({ className, onClick }: Props) {
try {
await auth.login();
} catch (err) {
console.error('Failed to initiate login:', err);
}
}
return ( return (
<button className={className} onClick={handleLogin}> <button className={className} onClick={onClick}>
Log in with Discord Log In / Register
</button> </button>
); );
} }

View File

@@ -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;
}

View File

@@ -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<Tab>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
{/* Tab switcher */}
<div className="modal-tabs">
<button
className={`modal-tab ${tab === "login" ? "active" : ""}`}
onClick={() => {
setTab("login");
setError(null);
}}
>
Log In
</button>
<button
className={`modal-tab ${tab === "register" ? "active" : ""}`}
onClick={() => {
setTab("register");
setError(null);
}}
>
Register
</button>
</div>
{/* Username / password form */}
<form className="modal-form" onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
minLength={1}
maxLength={32}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={
tab === "login" ? "current-password" : "new-password"
}
required
minLength={8}
/>
</label>
{tab === "register" && (
<label>
Confirm Password
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
)}
{error && <p className="modal-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading
? "Loading…"
: tab === "login"
? "Log In"
: "Create Account"}
</button>
</form>
<div className="modal-divider">
<span>or</span>
</div>
{/* Discord OAuth */}
<button className="btn-discord" onClick={handleDiscordLogin}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
Log In with Discord
</button>
</div>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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 (
<aside className="map-list">
<div className="map-list-header">
<h2>Maps</h2>
</div>
<form className="map-create-form" onSubmit={handleCreate}>
<input
type="text"
placeholder="New map name…"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
<button type="submit" disabled={!newName.trim()}>+</button>
</form>
<ul className="map-entries">
{maps.length === 0 && (
<li className="map-empty">No maps yet</li>
)}
{maps.map(map => (
<li
key={map.id}
className={`map-entry ${map.id === selectedMapId ? 'selected' : ''}`}
onClick={() => onSelect(map.id)}
>
<span className="map-name">{map.name}</span>
<button
className="map-delete"
onClick={e => handleDeleteClick(e, map.id)}
title="Delete map"
>
Delete
</button>
</li>
))}
</ul>
</aside>
);
}

View File

@@ -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;
}

View File

@@ -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<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(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 (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal map-list-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>My Maps</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
</div>
{maps.length === 0 ? (
<p className="map-list-empty">
No maps yet. Click "+ New Map" to create one.
</p>
) : (
<div className="map-list-scroll">
{maps.map((map) => (
<div
key={map.id}
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
onClick={() => handleSelect(map)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && handleSelect(map)}
>
<div className="map-list-main">
<span className="map-list-name">{map.name}</span>
<div className="map-list-meta">
<span className="map-list-owner">
by {map.owner_username}
</span>
<span
className={`map-access-badge access-${map.public_access}`}
>
{accessLabel(map.public_access)}
</span>
{map.user_role && (
<span className={`perm-role-badge role-${map.user_role}`}>
{map.user_role}
</span>
)}
{map.is_favorited && !map.user_role && (
<span className="map-fav-badge"> Favorited</span>
)}
</div>
</div>
<div className="map-list-actions">
{/* Favorite toggle */}
<button
className={`map-action-btn fav-btn ${map.is_favorited ? "fav-active" : ""}`}
onClick={(e) => handleFavoriteToggle(e, map)}
disabled={togglingId === map.id}
title={
map.is_favorited
? "Remove from favorites"
: "Add to favorites"
}
>
{map.is_favorited ? "★" : "☆"}
</button>
{/* Copy link */}
<button
className="map-action-btn copy-btn"
onClick={(e) => handleCopyLink(e, map)}
title="Copy link"
>
{copiedId === map.id ? (
<span className="copied-text"></span>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

108
ui/src/components/Modal.css Normal file
View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<void>;
}
export default function NewMapModal({ onClose, onCreate }: Props) {
const [name, setName] = useState("");
const [publicAccess, setPublicAccess] = useState<PublicAccess>("private");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>New Map</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
</div>
<form onSubmit={handleSubmit} className="new-map-form-modal">
<label className="field-label">
Map Name
<input
ref={nameRef}
type="text"
placeholder="My awesome map…"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
required
/>
</label>
<fieldset className="public-access-fieldset">
<legend>Visibility</legend>
<label className="radio-option">
<input
type="radio"
name="public_access"
value="private"
checked={publicAccess === "private"}
onChange={() => setPublicAccess("private")}
/>
<span className="radio-label">
<strong>Private</strong>
<span className="radio-hint">Only you and invited users</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="public_access"
value="public_view"
checked={publicAccess === "public_view"}
onChange={() => setPublicAccess("public_view")}
/>
<span className="radio-label">
<strong>Public View Only</strong>
<span className="radio-hint">
Anyone with the link can view
</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="public_access"
value="public_edit"
checked={publicAccess === "public_edit"}
onChange={() => setPublicAccess("public_edit")}
/>
<span className="radio-label">
<strong>Public View &amp; Edit</strong>
<span className="radio-hint">
Anyone with the link can view and edit
</span>
</span>
</label>
</fieldset>
{error && <p className="form-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!name.trim() || loading}
>
{loading ? "Creating…" : "Create Map"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -38,7 +38,7 @@
color: #9ca3af; color: #9ca3af;
} }
.dialog label input[type='text'] { .dialog label input[type="text"] {
background: #111827; background: #111827;
border: 1px solid #4b5563; border: 1px solid #4b5563;
border-radius: 5px; border-radius: 5px;
@@ -48,11 +48,11 @@
outline: none; outline: none;
} }
.dialog label input[type='text']:focus { .dialog label input[type="text"]:focus {
border-color: #6366f1; border-color: #6366f1;
} }
.dialog label input[type='color'] { .dialog label input[type="color"] {
width: 48px; width: 48px;
height: 32px; height: 32px;
padding: 0; padding: 0;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import './TokenDialog.css'; import "./TokenDialog.css";
interface Props { interface Props {
defaultColor: string; defaultColor: string;
@@ -7,8 +7,12 @@ interface Props {
onCancel: () => void; onCancel: () => void;
} }
export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props) { export default function TokenDialog({
const [label, setLabel] = useState(''); defaultColor,
onConfirm,
onCancel,
}: Props) {
const [label, setLabel] = useState("");
const [color, setColor] = useState(defaultColor); const [color, setColor] = useState(defaultColor);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -24,12 +28,16 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
} }
function handleKeyDown(e: React.KeyboardEvent) { function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') onCancel(); if (e.key === "Escape") onCancel();
} }
return ( return (
<div className="dialog-overlay" onClick={onCancel} onKeyDown={handleKeyDown}> <div
<div className="dialog" onClick={e => e.stopPropagation()}> className="dialog-overlay"
onClick={onCancel}
onKeyDown={handleKeyDown}
>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Add Token</h3> <h3>Add Token</h3>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<label> <label>
@@ -39,7 +47,7 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
type="text" type="text"
placeholder="e.g. Strahd von Zarovich" placeholder="e.g. Strahd von Zarovich"
value={label} value={label}
onChange={e => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
maxLength={30} maxLength={30}
/> />
</label> </label>
@@ -48,14 +56,18 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
<input <input
type="color" type="color"
value={color} value={color}
onChange={e => setColor(e.target.value)} onChange={(e) => setColor(e.target.value)}
/> />
</label> </label>
<div className="dialog-actions"> <div className="dialog-actions">
<button type="button" onClick={onCancel} className="btn-secondary"> <button type="button" onClick={onCancel} className="btn-secondary">
Cancel Cancel
</button> </button>
<button type="submit" className="btn-primary" disabled={!label.trim()}> <button
type="submit"
className="btn-primary"
disabled={!label.trim()}
>
Place Token Place Token
</button> </button>
</div> </div>

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from "react";
import type { ServerMessage, ClientMessage } from '../types'; import type { ServerMessage, ClientMessage } from "../types";
import { getToken } from '../api';
export function useWebSocket( export function useWebSocket(
mapId: string, mapId: string,
@@ -12,10 +11,10 @@ export function useWebSocket(
onMessageRef.current = onMessage; onMessageRef.current = onMessage;
useEffect(() => { useEffect(() => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const token = getToken(); // The browser automatically sends the siren_session cookie with the
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''; // WebSocket upgrade request — no manual token query param needed.
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws${tokenParam}`; const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws`;
const ws = new WebSocket(url); const ws = new WebSocket(url);
wsRef.current = ws; wsRef.current = ws;
@@ -29,12 +28,12 @@ export function useWebSocket(
const msg: ServerMessage = JSON.parse(event.data as string); const msg: ServerMessage = JSON.parse(event.data as string);
onMessageRef.current(msg); onMessageRef.current(msg);
} catch (err) { } catch (err) {
console.error('[WS] Failed to parse message:', err); console.error("[WS] Failed to parse message:", err);
} }
}; };
ws.onerror = (err) => { ws.onerror = (err) => {
console.error('[WS] Error:', err); console.error("[WS] Error:", err);
}; };
ws.onclose = () => { ws.onclose = () => {

View File

@@ -1,12 +1,19 @@
*, *::before, *::after { *,
*::before,
*::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
html, body, #root { html,
body,
#root {
height: 100%; height: 100%;
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
background: #111827; background: #111827;
color: #e5e7eb; color: #e5e7eb;
} }

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import App from './App.tsx'; import App from "./App.tsx";
import './index.css'; import "./index.css";
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,

View File

@@ -1,27 +1,62 @@
export interface User { // ---------------------------------------------------------------------------
id: string; // Discord snowflake (stored as string) // User / Auth
username: string; // ---------------------------------------------------------------------------
avatar?: string;
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 { export interface MapPermission {
map_id: string; map_id: string;
user_id: number; user_id: string; // UUID
username: string;
role: MapRole; role: MapRole;
} }
/** Core map record (returned by create, get, update). */
export interface GridMap { export interface GridMap {
id: string; id: string;
name: string; name: string;
is_public: boolean; public_access: PublicAccess;
owner_id: number; owner_id: string; // UUID
colors: string[]; colors: string[];
created_at: string; created_at: string;
updated_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 { export interface GridCell {
map_id: string; map_id: string;
x: number; x: number;
@@ -44,36 +79,52 @@ export interface MapState {
tokens: GridToken[]; 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 = export type ClientMessage =
| { type: 'paint_cell'; x: number; y: number; color: 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: "paint_cells";
| { type: 'add_token'; x: number; y: number; label: string; color: string } cells: Array<{ x: number; y: number; color: string }>;
| { type: 'move_token'; id: string; x: number; y: number } }
| { type: 'delete_token'; id: string } | { type: "erase_cell"; x: number; y: number }
| { type: 'update_colors'; colors: string[] }; | { 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 = export type ServerMessage =
| { type: 'state'; cells: GridCell[]; tokens: GridToken[]; colors: string[] } | { type: "state"; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
| { type: 'cell_painted'; x: number; y: number; color: 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: "cells_batch_painted";
| { type: 'token_added'; id: string; x: number; y: number; label: string; color: string } cells: Array<{ x: number; y: number; color: string }>;
| { type: 'token_moved'; id: string; x: number; y: number } }
| { type: 'token_deleted'; id: string } | { type: "cell_erased"; x: number; y: number }
| { type: 'colors_updated'; colors: string[] } | {
| { type: 'error'; message: string }; type: "token_added";
id: string;
// ---- Auth token payload (JWT claims) ---------------------------------------- x: number;
y: number;
export interface TokenClaims { label: string;
sub: number; // Discord user ID color: string;
name: string; }
iat: number; | { type: "token_moved"; id: string; x: number; y: number }
exp: number; | { type: "token_deleted"; id: string }
jti: string; | { type: "colors_updated"; colors: string[] }
} | { type: "error"; message: string };

1
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,13 +1,23 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react'
import path from "path";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], 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: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
// Proxy REST calls and WebSocket upgrades to the Axum backend // Proxy REST calls and WebSocket upgrades to the API
'/api': { '/api': {
target: 'http://localhost:3000', target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,