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/
target/
# Build
**/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_SESSION_TTL=86400
# Set to a specific origin (e.g. https://yourapp.com) when deploying to
# production with a separate frontend origin. Use "*" (the default) in
# development with the Vite proxy, where CORS is not an issue.
CORS_ORIGIN=*
UI_PORT=8080
VALKEY_HOST=localhost

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,16 @@ use serenity::{
};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::broadcast;
use uuid::Uuid;
/// Data stored per-entry in the Discord OAuth state cache.
#[derive(Clone, Debug)]
pub struct DiscordOAuthState {
/// Where to send the browser after the OAuth dance completes.
pub redirect_uri: String,
/// Set when a logged-in user is connecting (not logging in) via Discord.
pub connecting_user_id: Option<Uuid>,
}
#[derive(Clone)]
pub struct AppState {
@@ -12,9 +22,9 @@ pub struct AppState {
pub client_id: String,
pub client_secret: String,
pub base_url: String,
/// Maps oauth_state → ui_redirect_uri.
/// Populated on /authorize, consumed on /callback.
pub discord_authorize_cache: Arc<Mutex<HashMap<String, String>>>,
/// Maps oauth_state → DiscordOAuthState.
/// Populated on /authorize or /connect, consumed on /callback.
pub discord_authorize_cache: Arc<Mutex<HashMap<String, DiscordOAuthState>>>,
pub http: Arc<Http>,
pub cache: Arc<Cache>,
/// Per-map WebSocket broadcast channels for real-time collaboration.

View File

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

View File

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

View File

@@ -1,16 +1,26 @@
use crate::{
AppState,
auth::{bearer_token::BearerTokenClaims, csprng, session::Session},
app_state::DiscordOAuthState,
auth::{
SessionAuthorization,
local::{build_session_cookie, issue_jwt},
middleware::{compute_fingerprint, extract_ip},
session::Session,
},
error::{Error, Result},
};
use axum::{
Router,
extract::{Query, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect},
routing::get,
};
use axum_extra::extract::CookieJar;
use serde::{Deserialize, Serialize};
use std::{env, sync::Arc};
use siren_core::utils::csprng;
use std::sync::Arc;
use uuid::Uuid;
const DISCORD_REDIRECT_PATH: &str = "/api/auth/discord/callback";
@@ -18,14 +28,15 @@ pub fn get_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/authorize", get(discord_authorize))
.route("/callback", get(discord_callback))
.route("/connect", get(discord_connect))
}
#[derive(Deserialize)]
#[derive(Deserialize, Debug)]
struct AuthorizeQuery {
redirect_uri: String,
}
#[derive(Deserialize)]
#[derive(Deserialize, Debug)]
struct CallbackQuery {
code: String,
state: Option<String>,
@@ -48,22 +59,66 @@ struct DiscordUser {
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(
State(state): State<Arc<AppState>>,
Query(query): Query<AuthorizeQuery>,
) -> impl IntoResponse {
let oauth_state = csprng(16);
log::trace!("Discord authorize: {:?}, state={}", query, oauth_state);
state
.discord_authorize_cache
.lock()
.await
.insert(oauth_state.clone(), query.redirect_uri);
state.discord_authorize_cache.lock().await.insert(
oauth_state.clone(),
DiscordOAuthState {
redirect_uri: query.redirect_uri,
connecting_user_id: None,
},
);
build_discord_oauth_url(&state, &oauth_state)
}
/// Begin a Discord OAuth connect flow (already-authenticated users).
///
/// The caller must have a valid session cookie. Their user ID is stored
/// in the state cache so the callback can link the Discord account to the
/// existing local account.
async fn discord_connect(
State(state): State<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 encoded_callback = discord_callback_url.replace(':', "%3A").replace('/', "%2F");
let encoded_callback = urlencoding_encode(&discord_callback_url);
let discord_auth_url = format!(
let url = format!(
"https://discord.com/api/oauth2/authorize\
?client_id={}\
&redirect_uri={}\
@@ -73,8 +128,11 @@ async fn discord_authorize(
state.client_id, encoded_callback, oauth_state,
);
match serde_json::to_string(&discord_auth_url) {
Ok(json) => Ok(json),
match serde_json::to_string(&url) {
Ok(json) => {
log::trace!("Discord OAuth URL: {}", json);
Ok(json)
}
Err(e) => {
log::error!("Failed to serialize Discord OAuth URL: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
@@ -82,14 +140,26 @@ async fn discord_authorize(
}
}
/// Very small percent-encoder for the callback URL (replaces `:` and `/`).
fn urlencoding_encode(s: &str) -> String {
s.replace(':', "%3A").replace('/', "%2F")
}
/// Handle the Discord OAuth callback.
///
/// Two modes depending on what was stored in the state cache:
/// - **Login** (`connecting_user_id = None`): look up (or create) the local
/// user for this Discord account, then issue a session cookie and redirect.
/// - **Connect** (`connecting_user_id = Some(id)`): link the Discord account
/// to the existing local user, then redirect (no new session needed).
async fn discord_callback(
State(state): State<Arc<AppState>>,
Query(query): Query<CallbackQuery>,
headers: HeaderMap,
jar: CookieJar,
) -> impl IntoResponse {
match do_oauth_callback(state, query).await {
Ok((token, ui_redirect_uri)) => {
Redirect::temporary(&format!("{}?token={}", ui_redirect_uri, token)).into_response()
}
match do_oauth_callback(state, query, headers, jar).await {
Ok(response) => response,
Err((e, ui_redirect_uri)) => {
log::error!("OAuth callback error: {:?}", e);
let fallback = ui_redirect_uri.unwrap_or_else(|| "/".to_string());
@@ -98,33 +168,37 @@ async fn discord_callback(
}
}
type CallbackErr = (Error, Option<String>);
async fn do_oauth_callback(
state: Arc<AppState>,
query: CallbackQuery,
) -> Result<(String, String), (crate::error::Error, Option<String>)> {
// Validate the state and retrieve the associated UI redirect URI
let ui_redirect_uri = {
let mut oauth_states = state.discord_authorize_cache.lock().await;
headers: HeaderMap,
jar: CookieJar,
) -> std::result::Result<axum::response::Response, CallbackErr> {
// Validate state & retrieve stored data
let stored = {
let mut cache = state.discord_authorize_cache.lock().await;
match query.state {
Some(ref oauth_state) => match oauth_states.remove(oauth_state) {
Some(uri) => uri,
Some(ref s) => match cache.remove(s) {
Some(v) => v,
None => return Err((StatusCode::UNAUTHORIZED.into(), None)),
},
None => return Err((StatusCode::UNAUTHORIZED.into(), None)),
}
};
log::trace!("Discord callback: query={:?} state={:?}", query, stored);
// Helper closure to tag errors with the redirect URI we already know
let redirect = ui_redirect_uri.clone();
let err = |s: StatusCode| -> Result<_, (crate::error::Error, Option<String>)> {
Err((s.into(), Some(redirect.clone())))
let ui_redirect_uri = stored.redirect_uri.clone();
let err_redirect = |s: StatusCode| -> std::result::Result<_, CallbackErr> {
Err((s.into(), Some(ui_redirect_uri.clone())))
};
// The discord redirect_uri in the token exchange must match what was sent in /authorize
// The redirect_uri sent to Discord must exactly match /authorize
let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH);
// Exchange code for an access token
let token_response = state
// Exchange code for Discord access token
let token_resp = state
.client
.post("https://discord.com/api/oauth2/token")
.form(&[
@@ -136,90 +210,229 @@ async fn do_oauth_callback(
])
.send()
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
if !token_response.status().is_success() {
log::error!(
"Failed to exchange token: {:?}",
token_response.text().await
);
return err(StatusCode::INTERNAL_SERVER_ERROR);
if !token_resp.status().is_success() {
log::error!("Token exchange failed: {:?}", token_resp.text().await);
return err_redirect(StatusCode::INTERNAL_SERVER_ERROR);
}
let token_data: DiscordTokenResponse = token_response
let token_data: DiscordTokenResponse = token_resp
.json()
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
// Fetch user information from Discord
let user_response = state
// Fetch Discord user info
let user_resp = state
.client
.get("https://discord.com/api/users/@me")
.bearer_auth(token_data.access_token)
.send()
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
if !user_response.status().is_success() {
log::error!(
"Failed to fetch user information: {:?}",
user_response.text().await
);
return err(StatusCode::INTERNAL_SERVER_ERROR);
if !user_resp.status().is_success() {
log::error!("Discord user fetch failed: {:?}", user_resp.text().await);
return err_redirect(StatusCode::INTERNAL_SERVER_ERROR);
}
let user_data: DiscordUser = user_response
let discord_user: DiscordUser = user_resp
.json()
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
log::debug!("User authenticated: {:?}", user_data);
log::debug!("Discord OAuth user: {:?}", discord_user);
let user_id: i64 = user_data
.id
.parse::<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();
sqlx::query(
"INSERT INTO users (id, username, avatar, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (id) DO UPDATE
SET username = EXCLUDED.username,
avatar = EXCLUDED.avatar,
updated_at = NOW()",
match stored.connecting_user_id {
// Handle connecting an existing local user to a new Discord account
Some(connecting_user_id) => {
// Make sure this Discord account isn't already linked to a DIFFERENT user
let existing_owner: Option<Uuid> = sqlx::query_scalar(
"SELECT user_id FROM user_connections \
WHERE provider = 'discord' AND provider_user_id = $1",
)
.bind(user_id)
.bind(&user_data.username)
.bind(&user_data.avatar)
.bind(&discord_user.id)
.fetch_optional(pool)
.await
.map_err(|e| {
log::error!("DB error checking connection: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
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)
.await
.map_err(|e| {
log::error!("Failed to upsert user: {e}");
err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
log::error!("DB error upserting connection: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
// Create and insert the session
let session = Session::new(user_id as u64, user_data.username.clone());
session
.insert()
.await
.map_err(|e| (e, Some(ui_redirect_uri.clone())))?;
// No new session — redirect back to account page with existing cookie
Ok(Redirect::temporary(&ui_redirect_uri).into_response())
}
let issued_at = chrono::Utc::now();
let claims = BearerTokenClaims {
sub: session.user_id,
name: session.user_name.clone(),
iat: issued_at.timestamp(),
exp: session.expires_at.timestamp(),
jti: session.session_id.clone(),
// ------------------------------------------------------------------ //
// LOGIN MODE: look up (or create) the local user for this Discord account
// ------------------------------------------------------------------ //
None => {
// Find existing connection → local user_id
let local_user_id: Option<(Uuid, String)> = sqlx::query_as(
"SELECT u.id, u.username \
FROM user_connections uc \
JOIN users u ON u.id = uc.user_id \
WHERE uc.provider = 'discord' AND uc.provider_user_id = $1",
)
.bind(&discord_user.id)
.fetch_optional(pool)
.await
.map_err(|e| {
log::error!("DB error looking up discord user: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
let (user_id, username) = match local_user_id {
// Already linked — use the existing local user
Some(row) => {
// Keep provider fields up to date
sqlx::query(
"UPDATE user_connections \
SET provider_username = $1, provider_avatar = $2 \
WHERE user_id = $3 AND provider = 'discord'",
)
.bind(&discord_user.username)
.bind(&discord_user.avatar)
.bind(row.0)
.execute(pool)
.await
.map_err(|e| {
log::error!("DB error updating connection: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
row
}
// First login — create a local user + connection
None => {
let base_username = &discord_user.username;
let username = generate_unique_username(pool, base_username)
.await
.map_err(|e| {
log::error!("DB error generating username: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
// Create user (no password_hash — OAuth only)
let new_id: Uuid =
sqlx::query_scalar("INSERT INTO users (username) VALUES ($1) RETURNING id")
.bind(&username)
.fetch_one(pool)
.await
.map_err(|e| {
log::error!("DB error creating user: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
sqlx::query(
"INSERT INTO user_connections \
(user_id, provider, provider_user_id, provider_username, provider_avatar) \
VALUES ($1, 'discord', $2, $3, $4)",
)
.bind(new_id)
.bind(&discord_user.id)
.bind(&discord_user.username)
.bind(&discord_user.avatar)
.execute(pool)
.await
.map_err(|e| {
log::error!("DB error inserting connection: {e}");
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?;
(new_id, username)
}
};
let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let encoding_key = jsonwebtoken::EncodingKey::from_secret(jwt_secret.as_bytes());
let token = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &encoding_key)
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
// Build fingerprint from the callback request's headers
let ip = extract_ip(&headers);
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("unknown")
.to_string();
let fingerprint = compute_fingerprint(&ip, &user_agent);
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::{
extract::FromRequestParts,
http::{Method, StatusCode, request::Parts},
};
use axum_extra::{
TypedHeader,
headers::{Authorization, authorization::Bearer},
http::{HeaderMap, StatusCode, request::Parts},
};
use axum_extra::extract::CookieJar;
use chrono::Utc;
use jsonwebtoken::{DecodingKey, Validation, decode};
use sha2::{Digest, Sha256};
// ---------------------------------------------------------------------------
// AuthorizationMiddleware — rejects unauthenticated requests
// ---------------------------------------------------------------------------
pub 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
// ---------------------------------------------------------------------------
pub const COOKIE_NAME: &str = "siren_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)`.
pub struct OptionalAuth(pub Option<Session>);
///
/// Handlers using this extractor work for both authenticated and
/// unauthenticated callers. A valid `siren_session` cookie grants a
/// `Some(session)`.
pub struct SessionAuthorization(pub Option<Session>);
impl<S> FromRequestParts<S> for OptionalAuth
impl<S> FromRequestParts<S> for SessionAuthorization
where
S: Send + Sync,
{
@@ -70,38 +30,103 @@ where
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if let Ok(TypedHeader(Authorization(bearer))) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
{
if let Ok(session) = check_bearer_auth(bearer.token()).await {
let jar = CookieJar::from_request_parts(parts, state).await.unwrap();
if let Some(cookie) = jar.get(COOKIE_NAME) {
let ip = extract_ip(&parts.headers);
let user_agent = parts
.headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("unknown")
.to_string();
if let Ok(session) = check_cookie_auth(cookie.value(), &ip, &user_agent).await {
parts.extensions.insert(session.clone());
return Ok(Self(Some(session)));
}
}
Ok(Self(None))
}
}
// ---------------------------------------------------------------------------
// Shared helper
// ---------------------------------------------------------------------------
/// Extract the client IP from common proxy headers, falling back to "unknown".
pub fn extract_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(val) = forwarded.to_str() {
if let Some(ip) = val.split(',').next() {
return ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(val) = real_ip.to_str() {
return val.trim().to_string();
}
}
"unknown".to_string()
}
pub async fn check_bearer_auth(bearer_token: &str) -> Result<Session> {
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set in the environment");
/// Compute a fingerprint from client IP and User-Agent
///
/// Stored in the Redis session at login time and re-checked on every
/// authenticated request so that a stolen cookie is detected when used
/// from a different device or IP.
pub fn compute_fingerprint(ip: &str, user_agent: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(format!("{ip}:{user_agent}"));
format!("{:x}", hasher.finalize())
}
/// Validate a JWT cookie value, look up the Redis session, and verify the
/// fingerprint against the current request's IP / User-Agent.
pub async fn check_cookie_auth(token: &str, ip: &str, user_agent: &str) -> Result<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 token_data = decode::<BearerTokenClaims>(bearer_token, &decoding_key, &Validation::default())
let token_data = decode::<BearerTokenClaims>(token, &decoding_key, &Validation::default())
.map_err(|_| StatusCode::UNAUTHORIZED)?;
let claims = token_data.claims;
let now = Utc::now().timestamp();
if claims.exp < now {
if claims.exp < Utc::now().timestamp() {
return Err(StatusCode::UNAUTHORIZED.into());
}
match Session::find(&claims.jti).await {
Ok(Some(session)) => Ok(session),
_ => Err(StatusCode::UNAUTHORIZED)?,
let session = match Session::find(&claims.jti).await? {
Some(s) => s,
None => return Err(StatusCode::UNAUTHORIZED.into()),
};
// Reject if the request comes from a different device / network
let expected = compute_fingerprint(ip, user_agent);
if session.fingerprint != expected {
log::warn!(
"Fingerprint mismatch for session {}: stored={} request={}",
claims.jti,
session.fingerprint,
expected
);
return Err(StatusCode::UNAUTHORIZED.into());
}
Ok(session)
}
/// Parse the raw `Cookie:` header string and validate the siren_session
/// value. Used by the WebSocket upgrade handler where we cannot use the
/// normal `FromRequestParts` machinery.
pub async fn check_cookie_from_header_str(
cookie_header: &str,
ip: &str,
user_agent: &str,
) -> Option<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 axum::Router;
use rand::RngExt;
use std::sync::Arc;
mod discord;
mod session;
pub use session::Session;
mod bearer_token;
mod discord;
mod local;
mod session;
pub use local::UserInfo;
pub use session::Session;
pub mod middleware;
pub use middleware::{AuthorizationMiddleware, OptionalAuth};
pub use middleware::SessionAuthorization;
pub fn get_routes() -> Router<Arc<AppState>> {
Router::new().nest("/discord", discord::get_routes())
}
pub fn csprng(take: usize) -> String {
// Generate a CSPRNG ID using alphanumeric characters (a-z, A-Z, 0-9)
rand::rng()
.sample_iter(rand::distr::Alphanumeric)
.take(take)
.map(char::from)
.collect()
Router::new()
.merge(local::get_routes())
.nest("/discord", discord::get_routes())
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ use serenity::all::{
};
pub async fn process_message(ctx: &Context, command: &CommandInteraction, private: bool) {
create_message_response(&ctx, &command, "Processing...".to_string(), private).await;
create_message_response(ctx, command, "Processing...".to_string(), private).await;
}
pub async fn user_dm(ctx: &Context, user_id: &UserId, content: String) -> Option<Message> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {

View File

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

View File

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

View File

@@ -202,9 +202,9 @@ impl Condition {
Condition::Simple(condition, values) => {
// Replace each instance of '?' with increasing numbered binds
let mut numbered_condition = String::new();
let mut chars = condition.chars().peekable();
let chars = condition.chars().peekable();
while let Some(c) = chars.next() {
for c in chars {
if c == '?' {
// Increment the counter and replace `?` with a numbered bind
*counter += 1;

View File

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

View File

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

View File

@@ -1,2 +1,13 @@
pub mod text_utils;
use rand::RngExt;
pub use text_utils::*;
/// Generate a CSPRNG ID using alphanumeric characters (a-z, A-Z, 0-9)
pub fn csprng(take: usize) -> String {
rand::rng()
.sample_iter(rand::distr::Alphanumeric)
.take(take)
.map(char::from)
.collect()
}

View File

@@ -3,13 +3,13 @@ pub fn a_or_an(word: &str) -> &'static str {
let lowercase_word = word.to_lowercase();
// Special cases where the article should be "a"
let special_cases_a = vec!["one"];
let special_cases_a = ["one"];
if special_cases_a.contains(&lowercase_word.as_str()) {
return "a";
}
// Special cases where the article should be "an"
let special_cases_an = vec!["hour"];
let special_cases_an = ["hour"];
if special_cases_an.contains(&lowercase_word.as_str()) {
return "an";
}

View File

@@ -70,7 +70,7 @@ fn initialize_environment() -> std::io::Result<()> {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.starts_with(".env") && !file_name.ends_with(".example") && path.is_file() {
if let Err(err) = from_filename(&file_name) {
if let Err(err) = from_filename(file_name) {
eprintln!("Failed to load {}: {}", file_name, err);
} else {
println!("Loaded: {}", file_name);

View File

@@ -70,24 +70,43 @@ CREATE TABLE IF NOT EXISTS bestiary (
-- Auth / Users
-- ============================================================
-- Stores Discord user info, upserted on every successful OAuth login
-- Core local user accounts. password_hash is NULL for OAuth-only users.
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY NOT NULL,
username TEXT NOT NULL,
avatar TEXT,
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
password_hash TEXT,
email TEXT UNIQUE,
first_name TEXT,
last_name TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- External OAuth provider connections (Discord, etc.)
CREATE TABLE IF NOT EXISTS user_connections (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
provider_username TEXT,
provider_avatar TEXT,
PRIMARY KEY (user_id, provider),
UNIQUE (provider, provider_user_id)
);
-- ============================================================
-- Grid maps: unbounded canvas, CSPRNG TEXT ids, auth-aware
-- ============================================================
-- public_access: 'private' | 'public_view' | 'public_edit'
-- private only users with explicit map_permissions can see/edit
-- public_view anyone with the link can view; only permissioned users can edit
-- public_edit anyone with the link can view AND edit
CREATE TABLE IF NOT EXISTS grid_maps (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
owner_id BIGINT NOT NULL REFERENCES users(id),
public_access TEXT NOT NULL DEFAULT 'private'
CHECK (public_access IN ('private', 'public_view', 'public_edit')),
owner_id UUID NOT NULL REFERENCES users(id),
colors TEXT[] NOT NULL DEFAULT ARRAY[
'#6b7280',
'#92400e',
@@ -106,11 +125,32 @@ CREATE TABLE IF NOT EXISTS grid_maps (
-- Per-map role assignments; owner is auto-inserted on map creation
CREATE TABLE IF NOT EXISTS map_permissions (
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
PRIMARY KEY (map_id, user_id)
);
-- Maps a user has favorited; makes them appear in the user's map list modal
-- even if they have no explicit map_permissions entry (e.g. public maps)
CREATE TABLE IF NOT EXISTS map_favorites (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, map_id)
);
-- Pending/resolved requests from users wanting viewer or editor access
CREATE TABLE IF NOT EXISTS map_access_requests (
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (map_id, user_id)
);
-- Composite primary key replaces the old UUID id column
CREATE TABLE IF NOT EXISTS grid_cells (
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,

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>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="src/main.tsx"></script>
</body>
</html>

View File

@@ -5,18 +5,28 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write src"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.4"
"@eslint/js": "^9.39.4",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^17.4.0",
"prettier": "^3.7.2",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.3"
}
}

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 {
display: flex;
flex-direction: column;
@@ -6,132 +6,21 @@
overflow: hidden;
}
/* ---- Top header ---- */
.app-header {
flex-shrink: 0;
height: 48px;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1rem;
background: #1f2937;
border-bottom: 1px solid #374151;
z-index: 10;
}
.app-brand {
font-size: 1.05rem;
font-weight: 700;
color: #f9fafb;
letter-spacing: 0.08em;
white-space: nowrap;
margin-right: 0.5rem;
}
.app-brand span {
color: #818cf8;
}
.app-map-controls {
display: flex;
align-items: center;
gap: 0.5rem;
/* ── App body (everything below the header) ── */
.app-body {
flex: 1;
}
.map-select {
background: #111827;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
min-width: 160px;
max-width: 280px;
outline: none;
cursor: pointer;
}
.map-select:focus {
border-color: #6366f1;
}
.map-select option {
background: #1f2937;
}
.header-btn {
background: #374151;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
cursor: pointer;
font-size: 0.82rem;
padding: 0.3rem 0.65rem;
line-height: 1.4;
white-space: nowrap;
transition: background 0.12s;
}
.header-btn:hover {
background: #4b5563;
}
.header-btn.danger:hover {
background: #7f1d1d;
border-color: #ef4444;
color: #fca5a5;
}
.new-map-form {
display: flex;
gap: 0.3rem;
align-items: center;
overflow: hidden;
}
.new-map-form input {
background: #111827;
border: 1px solid #6366f1;
border-radius: 6px;
color: #e5e7eb;
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
width: 160px;
outline: none;
}
.new-map-form button {
background: #6366f1;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 0.82rem;
padding: 0.3rem 0.65rem;
transition: background 0.12s;
}
.new-map-form button:hover {
background: #4f46e5;
}
.new-map-form .cancel-btn {
background: #374151;
border: 1px solid #4b5563;
}
.new-map-form .cancel-btn:hover {
background: #4b5563;
}
/* ---- Grid area (fills remainder) ---- */
/* ── Grid area (fills the app body) ── */
.app-grid-area {
flex: 1;
position: relative;
overflow: hidden;
}
/* ── Floating panel stack bottom-left corner ── */
/* ── Floating control panels bottom-left corner ── */
.floating-panels-container {
position: absolute;
bottom: 14px;
@@ -142,7 +31,7 @@
z-index: 20;
}
/* ---- No-map placeholder ---- */
/* ── Empty state placeholder ── */
.empty-state {
height: 100%;
display: flex;
@@ -151,9 +40,11 @@
justify-content: center;
gap: 0.75rem;
color: #4b5563;
user-select: none;
}
.empty-state p {
margin: 0;
font-size: 1.1rem;
}
@@ -162,34 +53,78 @@
color: #374151;
}
/* ---- Auth area (right side of header) ---- */
.app-auth {
/* ── Access denied state ── */
.access-denied-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
}
.app-username {
font-size: 0.82rem;
color: #9ca3af;
white-space: nowrap;
}
/* ---- Public checkbox in new-map form ---- */
.new-map-public {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.82rem;
color: #9ca3af;
cursor: pointer;
white-space: nowrap;
justify-content: center;
gap: 1rem;
color: #4b5563;
user-select: none;
padding: 2rem;
text-align: center;
}
.new-map-public input[type='checkbox'] {
accent-color: #6366f1;
cursor: pointer;
.access-denied-title {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
color: #9ca3af;
}
.access-denied-hint {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
}
.access-request-sent {
color: #34d399;
}
.access-request-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.access-request-btns {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
justify-content: center;
}
.btn-request-access {
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: 6px;
color: #818cf8;
cursor: pointer;
font-size: 0.85rem;
padding: 0.45rem 1.1rem;
transition:
background 0.12s,
border-color 0.12s;
}
.btn-request-access:hover {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.6);
}
.link-btn {
background: none;
border: none;
color: #818cf8;
cursor: pointer;
font-size: inherit;
padding: 0;
text-decoration: underline;
transition: color 0.12s;
}
.link-btn:hover {
color: #a5b4fc;
}

View File

@@ -1,262 +1,253 @@
import { useState, useEffect, useRef } from 'react';
import type { GridMap, Tool, TokenClaims } from './types';
import type { GridHandle } from './components/Grid';
import { api, auth, getToken, setToken, decodeToken } from './api';
import ControlPanel from './components/ControlPanel.tsx';
import ColorPanel from './components/ColorPanel';
import Grid from './components/Grid';
import LoginButton from './components/LoginButton';
import './App.css';
import { useState, useEffect, useRef } from "react";
import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types";
import type { GridHandle } from "./components/Grid";
import { api, auth } from "./api";
import Header from "./components/Header";
import ControlPanel from "./components/ControlPanel";
import ColorPanel from "./components/ColorPanel";
import Grid from "./components/Grid";
import LoginModal from "./components/LoginModal";
import AccountPanel from "./components/AccountPanel";
import FloatingMapControls from "./components/FloatingMapControls";
import NewMapModal from "./components/NewMapModal";
import EditMapModal from "./components/EditMapModal";
import MapListModal from "./components/MapListModal";
import "./components/Modal.css";
import "./App.css";
/** Default colors shown before a map's own colors load from the server. */
const DEFAULT_COLORS = [
'#6b7280', // 1 stone
'#92400e', // 2 earth
'#15803d', // 3 grass
'#1d4ed8', // 4 water
'#7c3aed', // 5 arcane
'#dc2626', // 6 lava
'#ca8a04', // 7 sand
'#0f172a', // 8 void
'#f9fafb', // 9 white
"#6b7280",
"#92400e",
"#15803d",
"#1d4ed8",
"#7c3aed",
"#dc2626",
"#ca8a04",
"#0f172a",
"#f9fafb",
];
/** Read the map ID from the current URL path (/map/:id). */
function getMapIdFromUrl(): string | null {
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/** Read a query parameter value from the current URL. */
function getQueryParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key);
}
/** Strip a query parameter from the current URL without causing a reload. */
function removeQueryParam(key: string) {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(null, '', url.pathname + (url.search !== '?' ? url.search : ''));
window.history.replaceState(
null,
"",
url.pathname + (url.search !== "?" ? url.search : ""),
);
}
export default function App() {
// ---- Auth state ----
const [user, setUser] = useState<TokenClaims | null>(() => {
const token = getToken();
return token ? decodeToken(token) : null;
});
// ── Auth state ──
const [user, setUser] = useState<UserInfo | null>(null);
const [authLoading, setAuthLoading] = useState(true);
// ---- Map state ----
const [maps, setMaps] = useState<GridMap[]>([]);
// ── Map state ──
const [maps, setMaps] = useState<ListedMap[]>([]);
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)
const [tool, setTool] = useState<Tool>('pan');
// ── Tool + color ──
const [tool, setTool] = useState<Tool>("pan");
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
// Per-map color palette (updated from WS state on map load / color edits)
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
// Ref to Grid so App can push color updates through the WS
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 [newMapName, setNewMapName] = useState('');
const [newMapPublic, setNewMapPublic] = useState(false);
const newMapInputRef = useRef<HTMLInputElement>(null);
const [showEditMap, setShowEditMap] = useState(false);
const [showMapList, setShowMapList] = useState(false);
// ---- Handle OAuth callback: ?token= or ?error= ----
// ── Derived ──
const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null;
const selectedMapInfo: GridMap | ListedMap | null =
selectedMapFromList ?? directMapInfo;
const isOwner =
user !== null &&
selectedMapInfo !== null &&
selectedMapInfo.owner_id === user.id;
// ── On mount: load session + handle OAuth errors ──
useEffect(() => {
const token = getQueryParam('token');
const error = getQueryParam('error');
if (token) {
setToken(token);
const claims = decodeToken(token);
setUser(claims);
removeQueryParam('token');
} else if (error) {
console.error('OAuth error:', error);
removeQueryParam('error');
auth.me().then((u) => {
setUser(u);
setAuthLoading(false);
});
const error = getQueryParam("error");
if (error) {
console.error("OAuth error:", error);
removeQueryParam("error");
}
}, []);
// ---- Load map list ----
// ── Load map list after auth resolves ──
useEffect(() => {
if (!authLoading) {
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(() => {
if (maps.length === 0 && selectedId) {
// Maps are still loading — skip
if (!selectedId || authLoading) {
setDirectMapInfo(null);
setAccessDenied(false);
return;
}
if (selectedId) {
const exists = maps.some(m => m.id === selectedId);
if (!exists) {
// Invalid or inaccessible map ID — reroute to /map
setSelectedId(null);
window.history.replaceState(null, '', '/map');
const inList = maps.some((m) => m.id === selectedId);
if (inList) {
setDirectMapInfo(null);
setAccessDenied(false);
return;
}
}
}, [maps]); // eslint-disable-line react-hooks/exhaustive-deps
// Keep the URL in sync with the selected map
setDirectMapInfo(null);
setAccessDenied(false);
setAccessRequestSent(false);
api
.getMap(selectedId)
.then((state) => {
setDirectMapInfo(state.map);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg.startsWith("403")) {
setAccessDenied(true);
} else {
// 404 or unknown — clear invalid URL
setSelectedId(null);
window.history.replaceState(null, "", "/map");
}
});
}, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keep URL in sync ──
useEffect(() => {
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : '/map';
window.history.replaceState(null, '', path);
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map";
window.history.replaceState(null, "", path);
}, [selectedId]);
useEffect(() => {
if (showNewMap) newMapInputRef.current?.focus();
}, [showNewMap]);
// Reset palette to defaults when no map is selected
// ── Reset palette + access state when map deselected ──
useEffect(() => {
if (!selectedId) {
setMapColors(DEFAULT_COLORS);
setActiveColor(DEFAULT_COLORS[0]);
setAccessRequestSent(false);
}
}, [selectedId]);
// ---- Derived state ----
const selectedMap = maps.find(m => m.id === selectedId) ?? null;
// ── Handlers ──
// The current user is considered the owner if their Discord ID matches owner_id
const isOwner = user !== null && selectedMap !== null && selectedMap.owner_id === user.sub;
// ---- Handlers ----
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const name = newMapName.trim();
if (!name) return;
try {
const m = await api.createMap(name, newMapPublic);
setMaps(prev => [m, ...prev]);
async function handleCreate(name: string, publicAccess: PublicAccess) {
const m = await api.createMap(name, publicAccess);
// Optimistically add to list as an owner entry
const listed: ListedMap = {
...m,
owner_username: user!.username,
user_role: "owner",
is_favorited: false,
};
setMaps((prev) => [listed, ...prev]);
setSelectedId(m.id);
setShowNewMap(false);
setNewMapName('');
setNewMapPublic(false);
} catch (err) {
console.error('Failed to create map', err);
}
}
async function handleDelete() {
if (!selectedId) return;
if (!confirm('Delete this map? This cannot be undone.')) return;
if (!confirm("Delete this map? This cannot be undone.")) return;
try {
await api.deleteMap(selectedId);
setMaps(prev => prev.filter(m => m.id !== selectedId));
setMaps((prev) => prev.filter((m) => m.id !== selectedId));
setSelectedId(null);
} catch (err) {
console.error('Failed to delete map', err);
console.error("Failed to delete map", err);
}
}
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[]) {
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[]) {
setMapColors(colors);
gridRef.current?.sendColorUpdate(colors);
}
async function handleUserRefresh() {
const u = await auth.me();
setUser(u);
}
async function handleRequestAccess(role: "viewer" | "editor") {
if (!selectedId) return;
try {
await api.requestAccess(selectedId, role);
setAccessRequestSent(true);
} catch (err) {
console.error("Failed to request access", err);
}
}
// ── Render ──
return (
<div className="app">
{/* ── Header ── */}
<header className="app-header">
<div className="app-brand">
<span>SIREN</span>
</div>
<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}
<Header
user={user}
authLoading={authLoading}
selectedMapName={selectedMapInfo?.name ?? null}
onLoginClick={() => setShowLoginModal(true)}
onAccountClick={() => setShowAccountPanel(true)}
/>
<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 */}
{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-body">
<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
key={selectedId}
ref={gridRef}
@@ -267,10 +258,7 @@ export default function App() {
onColorsLoaded={handleColorsLoaded}
/>
<div className="floating-panels-container">
<ControlPanel
tool={tool}
onToolChange={setTool}
/>
<ControlPanel tool={tool} onToolChange={setTool} />
<ColorPanel
colors={mapColors}
activeColor={activeColor}
@@ -279,19 +267,105 @@ export default function App() {
/>
</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">
<p>Select or create a map to begin</p>
<p className="empty-hint">
{!user
? 'Log in with Discord to create maps and access private maps'
? "Log in to create maps and access private maps"
: maps.length === 0
? 'Click "+ New Map" in the header to get started'
: 'Choose a map from the header dropdown'}
? 'Click "+ New Map" in the top-left to get started'
: 'Click "Maps" in the top-left to choose a map'}
</p>
</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 AUTH_BASE = '/api/auth/discord';
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
const TOKEN_KEY = 'siren_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
/** Decode the JWT payload without verifying the signature (client-side only). */
export function decodeToken(token: string): TokenClaims | null {
try {
const payload = token.split('.')[1];
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json) as TokenClaims;
} catch {
return null;
}
}
/** Returns true if the stored token is present and not expired. */
// export function isAuthenticated(): boolean {
// const token = getToken();
// if (!token) return false;
// const claims = decodeToken(token);
// if (!claims) return false;
// return claims.exp > Date.now() / 1000;
// }
// ---------------------------------------------------------------------------
// Core fetch wrapper
// ---------------------------------------------------------------------------
const GRID_BASE = "/api/grid";
const AUTH_BASE = "/api/auth";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
const res = await fetch(url, {
...init,
credentials: "include",
headers: {
...(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) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
if (res.status === 204) return undefined as T;
return res.json();
// Read the bdoy if it exists
const text = await res.text();
if (!text) return undefined as T;
// Parse JSON if it exists
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return JSON.parse(text) as T;
} else if (contentType.includes("text/plain")) {
return text as unknown as T;
}
throw new Error(`Expected JSON or text but got: ${contentType}`);
}
// ---------------------------------------------------------------------------
// Grid map API
// ---------------------------------------------------------------------------
export const api = {
listMaps: (): Promise<GridMap[]> =>
request<GridMap[]>(`${BASE}/maps`),
/** List maps where the authenticated user has a direct role or has favorited. */
listMaps: (): Promise<ListedMap[]> =>
request<ListedMap[]>(`${GRID_BASE}/maps`),
createMap: (name: string, is_public = false): Promise<GridMap> =>
request<GridMap>(`${BASE}/maps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, is_public }),
/** Create a new map (authenticated). */
createMap: (
name: string,
public_access: PublicAccess = "private",
): Promise<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> =>
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> =>
request<void>(`${BASE}/maps/${id}`, { method: 'DELETE' }),
request<void>(`${GRID_BASE}/maps/${id}`, { method: "DELETE" }),
// ---- Permissions ----
/** List all permissions for a map including usernames (owner only). */
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`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, role }),
/**
* Add or update a user's role by username.
* Pass `role: null` to remove the user's permission entirely.
*/
updatePermission: (
mapId: string,
username: string,
role: MapRole | null,
): Promise<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 = {
/** Fetches the Discord OAuth URL and redirects the browser to it.
* Passes the current page's origin + /map as the UI redirect URI so
* the backend knows where to send the browser after login completes.
*/
async login(): Promise<void> {
const redirectUri = encodeURIComponent(window.location.origin + '/map');
const url = await request<string>(`${AUTH_BASE}/authorize?redirect_uri=${redirectUri}`);
window.location.href = url;
/** Fetch the currently authenticated user's info. Returns null if not logged in. */
async me(): Promise<UserInfo | null> {
try {
return await request<UserInfo>(`${AUTH_BASE}/me`);
} catch {
return null;
}
},
logout(): void {
removeToken();
window.location.href = '/map';
/** Register a new local account. */
async register(username: string, password: string): Promise<void> {
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;
cursor: pointer;
padding: 0;
transition: transform 0.1s, border-color 0.1s;
transition:
transform 0.1s,
border-color 0.1s;
overflow: hidden;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,62 @@
export interface User {
id: string; // Discord snowflake (stored as string)
username: string;
avatar?: string;
// ---------------------------------------------------------------------------
// User / Auth
// ---------------------------------------------------------------------------
export interface ConnectionInfo {
provider: string;
provider_username: string | null;
provider_avatar: string | null;
}
export type MapRole = 'owner' | 'editor' | 'viewer';
export interface UserInfo {
id: string; // UUID
username: string;
first_name: string | null;
last_name: string | null;
email: string | null;
/** True when the account has a local password (can log in without OAuth). */
has_password: boolean;
connections: ConnectionInfo[];
}
// ---------------------------------------------------------------------------
// Maps
// ---------------------------------------------------------------------------
export type MapRole = "owner" | "editor" | "viewer";
/** Map visibility / editability level. */
export type PublicAccess = "private" | "public_view" | "public_edit";
export interface MapPermission {
map_id: string;
user_id: number;
user_id: string; // UUID
username: string;
role: MapRole;
}
/** Core map record (returned by create, get, update). */
export interface GridMap {
id: string;
name: string;
is_public: boolean;
owner_id: number;
public_access: PublicAccess;
owner_id: string; // UUID
colors: string[];
created_at: string;
updated_at: string;
}
/**
* Extended map record returned by the list endpoint.
* Includes owner username, the caller's role, and a favorite flag.
*/
export interface ListedMap extends GridMap {
owner_username: string;
/** Null when the map is in the list only via a favorite (no explicit permission). */
user_role: MapRole | null;
is_favorited: boolean;
}
export interface GridCell {
map_id: string;
x: number;
@@ -44,36 +79,52 @@ export interface MapState {
tokens: GridToken[];
}
export type Tool = 'pan' | 'zoom' | 'draw' | 'token';
export interface MapAccessRequest {
id: string; // UUID
map_id: string;
user_id: string; // UUID
username: string;
requested_role: "editor" | "viewer";
status: "pending" | "approved" | "denied";
created_at: string;
updated_at: string;
}
// ---- WebSocket message types ------------------------------------------------
export type Tool = "pan" | "zoom" | "draw" | "token";
// ---------------------------------------------------------------------------
// WebSocket message types
// ---------------------------------------------------------------------------
export type ClientMessage =
| { type: 'paint_cell'; x: number; y: number; color: string }
| { type: 'paint_cells'; cells: Array<{ x: number; y: number; color: string }> }
| { type: 'erase_cell'; x: number; y: number }
| { type: 'add_token'; x: number; y: number; label: string; color: string }
| { type: 'move_token'; id: string; x: number; y: number }
| { type: 'delete_token'; id: string }
| { type: 'update_colors'; colors: string[] };
| { type: "paint_cell"; x: number; y: number; color: string }
| {
type: "paint_cells";
cells: Array<{ x: number; y: number; color: string }>;
}
| { type: "erase_cell"; x: number; y: number }
| { type: "add_token"; x: number; y: number; label: string; color: string }
| { type: "move_token"; id: string; x: number; y: number }
| { type: "delete_token"; id: string }
| { type: "update_colors"; colors: string[] };
export type ServerMessage =
| { type: 'state'; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
| { type: 'cell_painted'; x: number; y: number; color: string }
| { type: 'cells_batch_painted'; cells: Array<{ x: number; y: number; color: string }> }
| { type: 'cell_erased'; x: number; y: number }
| { type: 'token_added'; id: string; x: number; y: number; label: string; color: string }
| { type: 'token_moved'; id: string; x: number; y: number }
| { type: 'token_deleted'; id: string }
| { type: 'colors_updated'; colors: string[] }
| { type: 'error'; message: string };
// ---- Auth token payload (JWT claims) ----------------------------------------
export interface TokenClaims {
sub: number; // Discord user ID
name: string;
iat: number;
exp: number;
jti: string;
}
| { type: "state"; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
| { type: "cell_painted"; x: number; y: number; color: string }
| {
type: "cells_batch_painted";
cells: Array<{ x: number; y: number; color: string }>;
}
| { type: "cell_erased"; x: number; y: number }
| {
type: "token_added";
id: string;
x: number;
y: number;
label: string;
color: string;
}
| { type: "token_moved"; id: string; x: number; y: number }
| { type: "token_deleted"; id: string }
| { type: "colors_updated"; colors: string[] }
| { type: "error"; message: string };

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