Updating auth
This commit is contained in:
@@ -1,2 +1,15 @@
|
|||||||
idea/
|
# Build
|
||||||
target/
|
**/target/
|
||||||
|
**/Cargo.lock
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/package-lock.json
|
||||||
|
|
||||||
|
logs/
|
||||||
|
data/
|
||||||
|
settings.json
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ API_BASE_URL=http://localhost:3000
|
|||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
API_SESSION_TTL=86400
|
API_SESSION_TTL=86400
|
||||||
|
|
||||||
|
# Set to a specific origin (e.g. https://yourapp.com) when deploying to
|
||||||
|
# production with a separate frontend origin. Use "*" (the default) in
|
||||||
|
# development with the Vite proxy, where CORS is not an issue.
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
UI_PORT=8080
|
UI_PORT=8080
|
||||||
|
|
||||||
VALKEY_HOST=localhost
|
VALKEY_HOST=localhost
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
**/target/
|
**/target/
|
||||||
**/Cargo.lock
|
**/Cargo.lock
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
**/package-lock.json
|
**/package-lock.json
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ resolver = "2"
|
|||||||
|
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
rust-version = "1.94"
|
rust-version = "1.86"
|
||||||
authors = ["Ben Sherriff <ben@bensherriff.com>"]
|
authors = ["Ben Sherriff <ben@bensherriff.com>"]
|
||||||
description = "A Discord bot for playing music"
|
description = "A Discord bot for playing music"
|
||||||
repository = "https://github.com/bensherriff/siren"
|
repository = "https://github.com/bensherriff/siren"
|
||||||
@@ -57,9 +57,14 @@ rand_chacha = "0.10"
|
|||||||
regex = "1"
|
regex = "1"
|
||||||
lazy_static = "1"
|
lazy_static = "1"
|
||||||
|
|
||||||
|
# Auth / Security
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
cookie = { version = "0.18", features = ["percent-encode"] }
|
||||||
|
|
||||||
# API
|
# API
|
||||||
axum = { version = "0.8", features = ["json", "ws", "macros"] }
|
axum = { version = "0.8", features = ["json", "ws", "macros"] }
|
||||||
axum-extra = { version = "0.12", features = ["typed-header"] }
|
axum-extra = { version = "0.12", features = ["typed-header", "cookie"] }
|
||||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
|
|||||||
42
Taskfile.yml
42
Taskfile.yml
@@ -8,17 +8,31 @@ vars:
|
|||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
default:
|
default:
|
||||||
desc: List available tasks
|
desc: "List available tasks"
|
||||||
cmds:
|
cmds:
|
||||||
- task --list
|
- task --list
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
setup:
|
setup:
|
||||||
desc: Copy .env.example to .env if .env does not exist
|
desc: "Copy .env.example to .env if .env does not exist"
|
||||||
cmds:
|
cmds:
|
||||||
- test -f .env || cp .env.example .env
|
- test -f .env || cp .env.example .env
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
|
format:
|
||||||
|
desc: "Format code"
|
||||||
|
cmds:
|
||||||
|
- task: format:app
|
||||||
|
- task: format:ui
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
lint:
|
||||||
|
desc: "Run linters"
|
||||||
|
cmds:
|
||||||
|
- task: lint:app
|
||||||
|
- task: lint:ui
|
||||||
|
silent: true
|
||||||
|
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
# Cargo
|
# Cargo
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
@@ -45,7 +59,7 @@ tasks:
|
|||||||
- cargo run
|
- cargo run
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
format:
|
format:app:
|
||||||
desc: "Format code"
|
desc: "Format code"
|
||||||
cmds:
|
cmds:
|
||||||
- cargo fmt
|
- cargo fmt
|
||||||
@@ -58,7 +72,7 @@ tasks:
|
|||||||
- cargo clean
|
- cargo clean
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
lint:
|
lint:app:
|
||||||
desc: "Run Clippy linter"
|
desc: "Run Clippy linter"
|
||||||
deps: [ setup ]
|
deps: [ setup ]
|
||||||
cmds:
|
cmds:
|
||||||
@@ -116,27 +130,41 @@ tasks:
|
|||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
# UI
|
# UI
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
ui:install:
|
install:ui:
|
||||||
desc: "Install UI npm dependencies"
|
desc: "Install UI npm dependencies"
|
||||||
dir: ui
|
dir: ui
|
||||||
cmds:
|
cmds:
|
||||||
- npm install
|
- npm install
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
ui:run:
|
run:ui:
|
||||||
desc: "Run Vite dev server"
|
desc: "Run Vite dev server"
|
||||||
dir: ui
|
dir: ui
|
||||||
cmds:
|
cmds:
|
||||||
- npm run dev
|
- npm run dev
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
ui:build:
|
build:ui:
|
||||||
desc: "Build the React UI into ui/dist"
|
desc: "Build the React UI into ui/dist"
|
||||||
dir: ui
|
dir: ui
|
||||||
cmds:
|
cmds:
|
||||||
- npm run build
|
- npm run build
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
|
format:ui:
|
||||||
|
desc: "Format UI code with prettier"
|
||||||
|
dir: ui
|
||||||
|
cmds:
|
||||||
|
- npm run format
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
lint:ui:
|
||||||
|
desc: "Lint UI code with eslint"
|
||||||
|
dir: ui
|
||||||
|
cmds:
|
||||||
|
- npm run lint
|
||||||
|
silent: true
|
||||||
|
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
# Utilities
|
# Utilities
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ redis = { workspace = true }
|
|||||||
tower-http = { workspace = true }
|
tower-http = { workspace = true }
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
|
argon2 = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
cookie = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{AppState, error::Result};
|
use crate::{AppState, error::Result};
|
||||||
use axum::Router;
|
use axum::{Router, http::HeaderValue};
|
||||||
use std::{env, sync::Arc};
|
use std::{env, sync::Arc};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
@@ -19,17 +19,36 @@ impl App {
|
|||||||
pub async fn serve(self) -> Result<()> {
|
pub async fn serve(self) -> Result<()> {
|
||||||
log::debug!("Starting API...");
|
log::debug!("Starting API...");
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
// Build CORS layer.
|
||||||
|
//
|
||||||
|
// In production both the UI and API are served from the same origin so
|
||||||
|
// CORS is a non-issue. In development, Vite proxies all /api/* calls so
|
||||||
|
// the browser also never makes cross-origin requests directly to this
|
||||||
|
// server. We keep a permissive default for convenience, but restrict it
|
||||||
|
// when CORS_ORIGIN is explicitly set.
|
||||||
|
let cors = match env::var("CORS_ORIGIN") {
|
||||||
|
Ok(origin) if origin != "*" => {
|
||||||
|
let header_val = origin
|
||||||
|
.parse::<HeaderValue>()
|
||||||
|
.expect("CORS_ORIGIN is not a valid header value");
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(header_val)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
|
.allow_credentials(true)
|
||||||
|
}
|
||||||
|
_ => CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(Any);
|
.allow_headers(Any),
|
||||||
|
};
|
||||||
|
|
||||||
// Serve the built React frontend from frontend/dist (relative to the
|
// Serve the built React frontend from ui/dist (relative to the working
|
||||||
// working directory). Falls back gracefully if the directory does not
|
// directory). Falls back gracefully if the directory does not exist yet
|
||||||
// exist yet (e.g. during development when using `npm run dev`).
|
// (e.g. during development when using `npm run dev`).
|
||||||
let frontend_dir = env::current_dir()
|
let frontend_dir = env::current_dir()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.join("frontend")
|
.join("ui")
|
||||||
.join("dist");
|
.join("dist");
|
||||||
|
|
||||||
// For SPA routing: any path not matched by a real file (e.g. /map/<id>)
|
// For SPA routing: any path not matched by a real file (e.g. /map/<id>)
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ use serenity::{
|
|||||||
};
|
};
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Data stored per-entry in the Discord OAuth state cache.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DiscordOAuthState {
|
||||||
|
/// Where to send the browser after the OAuth dance completes.
|
||||||
|
pub redirect_uri: String,
|
||||||
|
/// Set when a logged-in user is connecting (not logging in) via Discord.
|
||||||
|
pub connecting_user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -12,9 +22,9 @@ pub struct AppState {
|
|||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
pub client_secret: String,
|
pub client_secret: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// Maps oauth_state → ui_redirect_uri.
|
/// Maps oauth_state → DiscordOAuthState.
|
||||||
/// Populated on /authorize, consumed on /callback.
|
/// Populated on /authorize or /connect, consumed on /callback.
|
||||||
pub discord_authorize_cache: Arc<Mutex<HashMap<String, String>>>,
|
pub discord_authorize_cache: Arc<Mutex<HashMap<String, DiscordOAuthState>>>,
|
||||||
pub http: Arc<Http>,
|
pub http: Arc<Http>,
|
||||||
pub cache: Arc<Cache>,
|
pub cache: Arc<Cache>,
|
||||||
/// Per-map WebSocket broadcast channels for real-time collaboration.
|
/// Per-map WebSocket broadcast channels for real-time collaboration.
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::{AuthorizationMiddleware, Session},
|
auth::SessionAuthorization,
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension,
|
|
||||||
Json,
|
Json,
|
||||||
Router,
|
Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
middleware::from_extractor,
|
http::StatusCode,
|
||||||
routing::post,
|
routing::post,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -22,15 +21,13 @@ use siren_bot::{
|
|||||||
handler::get_songbird,
|
handler::get_songbird,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/play", post(play_audio))
|
.route("/play", post(play_audio))
|
||||||
.route_layer(from_extractor::<AuthorizationMiddleware>())
|
|
||||||
.route("/pause", post(pause_audio))
|
.route("/pause", post(pause_audio))
|
||||||
.route_layer(from_extractor::<AuthorizationMiddleware>())
|
|
||||||
.route("/resume", post(resume_audio))
|
.route("/resume", post(resume_audio))
|
||||||
.route_layer(from_extractor::<AuthorizationMiddleware>())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -38,19 +35,44 @@ struct PlayTrackRequest {
|
|||||||
url: String,
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the Discord snowflake for a local user from `user_connections`.
|
||||||
|
/// Returns an error if the user has no linked Discord account.
|
||||||
|
async fn get_discord_snowflake(local_user_id: Uuid) -> Result<u64> {
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
let provider_id: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT provider_user_id FROM user_connections \
|
||||||
|
WHERE user_id = $1 AND provider = 'discord'",
|
||||||
|
)
|
||||||
|
.bind(local_user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
provider_id
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.ok_or_else(|| Error::not_found("Discord account not connected".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn play_audio(
|
async fn play_audio(
|
||||||
Extension(session): Extension<Session>,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(guild_id): Path<u64>,
|
Path(guild_id): Path<u64>,
|
||||||
Json(payload): Json<PlayTrackRequest>,
|
Json(payload): Json<PlayTrackRequest>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
log::debug!("Playing audio in guild: {}", guild_id);
|
log::debug!("Playing audio in guild: {}", guild_id);
|
||||||
|
|
||||||
// Check if the user exists in the cache
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
let user_id = session.user_id;
|
|
||||||
let user_id = match state.cache.user(user_id) {
|
// Resolve Discord snowflake from the local user_id
|
||||||
|
let discord_snowflake = get_discord_snowflake(session.user_id).await?;
|
||||||
|
|
||||||
|
// Check if the user exists in the Discord cache
|
||||||
|
let user_id = match state.cache.user(discord_snowflake) {
|
||||||
Some(user) => user.id,
|
Some(user) => user.id,
|
||||||
None => return Err(Error::not_found("User not found".to_string())),
|
None => {
|
||||||
|
return Err(Error::not_found(
|
||||||
|
"User not found in Discord cache".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate if the guild exists in the cache
|
// Validate if the guild exists in the cache
|
||||||
@@ -61,16 +83,17 @@ async fn play_audio(
|
|||||||
|
|
||||||
// Play the track
|
// Play the track
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
let _channel_id = join_voice_channel(&state.cache, &manager, &guild_id, &user_id).await?;
|
let _channel_id = join_voice_channel(&state.cache, manager, &guild_id, &user_id).await?;
|
||||||
enqueue_track(manager, guild_id.to_owned(), &payload.url).await?;
|
enqueue_track(manager, guild_id.to_owned(), &payload.url).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pause_audio(
|
async fn pause_audio(
|
||||||
Extension(_): Extension<Session>,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(guild_id): Path<u64>,
|
Path(guild_id): Path<u64>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
log::debug!("Pausing audio in guild: {}", guild_id);
|
log::debug!("Pausing audio in guild: {}", guild_id);
|
||||||
|
|
||||||
// Validate if the guild exists in the cache
|
// Validate if the guild exists in the cache
|
||||||
@@ -86,11 +109,12 @@ async fn pause_audio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn resume_audio(
|
async fn resume_audio(
|
||||||
Extension(_): Extension<Session>,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(guild_id): Path<u64>,
|
Path(guild_id): Path<u64>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
log::debug!("Pausing audio in guild: {}", guild_id);
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
log::debug!("Resuming audio in guild: {}", guild_id);
|
||||||
|
|
||||||
// Validate if the guild exists in the cache
|
// Validate if the guild exists in the cache
|
||||||
let guild_id = match state.cache.guild(guild_id) {
|
let guild_id = match state.cache.guild(guild_id) {
|
||||||
@@ -98,7 +122,7 @@ async fn resume_audio(
|
|||||||
None => return Err(Error::not_found("Guild not found".to_string())),
|
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pause the track
|
// Resume the track
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
resume_track(manager, &guild_id).await?;
|
resume_track(manager, &guild_id).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Claims encoded in the JWT stored in the `siren_session` cookie
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct BearerTokenClaims {
|
pub struct BearerTokenClaims {
|
||||||
pub sub: u64,
|
/// Local user UUID (as a string)
|
||||||
|
pub sub: String,
|
||||||
|
/// Display username
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Issued-at epoch seconds
|
||||||
pub iat: i64,
|
pub iat: i64,
|
||||||
|
/// Expiry epoch seconds
|
||||||
pub exp: i64,
|
pub exp: i64,
|
||||||
|
/// Redis session key (used to look up the full session)
|
||||||
pub jti: String,
|
pub jti: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::{bearer_token::BearerTokenClaims, csprng, session::Session},
|
app_state::DiscordOAuthState,
|
||||||
|
auth::{
|
||||||
|
SessionAuthorization,
|
||||||
|
local::{build_session_cookie, issue_jwt},
|
||||||
|
middleware::{compute_fingerprint, extract_ip},
|
||||||
|
session::Session,
|
||||||
|
},
|
||||||
|
error::{Error, Result},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::StatusCode,
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{env, sync::Arc};
|
use siren_core::utils::csprng;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
const DISCORD_REDIRECT_PATH: &str = "/api/auth/discord/callback";
|
const DISCORD_REDIRECT_PATH: &str = "/api/auth/discord/callback";
|
||||||
|
|
||||||
@@ -18,14 +28,15 @@ pub fn get_routes() -> Router<Arc<AppState>> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/authorize", get(discord_authorize))
|
.route("/authorize", get(discord_authorize))
|
||||||
.route("/callback", get(discord_callback))
|
.route("/callback", get(discord_callback))
|
||||||
|
.route("/connect", get(discord_connect))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct AuthorizeQuery {
|
struct AuthorizeQuery {
|
||||||
redirect_uri: String,
|
redirect_uri: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct CallbackQuery {
|
struct CallbackQuery {
|
||||||
code: String,
|
code: String,
|
||||||
state: Option<String>,
|
state: Option<String>,
|
||||||
@@ -48,22 +59,66 @@ struct DiscordUser {
|
|||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Begin a Discord OAuth login flow (anonymous users)
|
||||||
|
///
|
||||||
|
/// Stores the caller's desired `redirect_uri` in the state cache so the
|
||||||
|
/// callback can redirect to the right place after login
|
||||||
async fn discord_authorize(
|
async fn discord_authorize(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<AuthorizeQuery>,
|
Query(query): Query<AuthorizeQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let oauth_state = csprng(16);
|
let oauth_state = csprng(16);
|
||||||
|
log::trace!("Discord authorize: {:?}, state={}", query, oauth_state);
|
||||||
|
|
||||||
state
|
state.discord_authorize_cache.lock().await.insert(
|
||||||
.discord_authorize_cache
|
oauth_state.clone(),
|
||||||
.lock()
|
DiscordOAuthState {
|
||||||
.await
|
redirect_uri: query.redirect_uri,
|
||||||
.insert(oauth_state.clone(), query.redirect_uri);
|
connecting_user_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
build_discord_oauth_url(&state, &oauth_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin a Discord OAuth connect flow (already-authenticated users).
|
||||||
|
///
|
||||||
|
/// The caller must have a valid session cookie. Their user ID is stored
|
||||||
|
/// in the state cache so the callback can link the Discord account to the
|
||||||
|
/// existing local account.
|
||||||
|
async fn discord_connect(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(query): Query<AuthorizeQuery>,
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
let oauth_state = csprng(16);
|
||||||
|
log::trace!(
|
||||||
|
"Discord connect: {:?}, state={} (user_id={})",
|
||||||
|
query,
|
||||||
|
oauth_state,
|
||||||
|
session.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
state.discord_authorize_cache.lock().await.insert(
|
||||||
|
oauth_state.clone(),
|
||||||
|
DiscordOAuthState {
|
||||||
|
redirect_uri: query.redirect_uri,
|
||||||
|
connecting_user_id: Some(session.user_id),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(build_discord_oauth_url(&state, &oauth_state))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_discord_oauth_url(
|
||||||
|
state: &AppState,
|
||||||
|
oauth_state: &str,
|
||||||
|
) -> std::result::Result<String, StatusCode> {
|
||||||
let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH);
|
let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH);
|
||||||
let encoded_callback = discord_callback_url.replace(':', "%3A").replace('/', "%2F");
|
let encoded_callback = urlencoding_encode(&discord_callback_url);
|
||||||
|
|
||||||
let discord_auth_url = format!(
|
let url = format!(
|
||||||
"https://discord.com/api/oauth2/authorize\
|
"https://discord.com/api/oauth2/authorize\
|
||||||
?client_id={}\
|
?client_id={}\
|
||||||
&redirect_uri={}\
|
&redirect_uri={}\
|
||||||
@@ -73,8 +128,11 @@ async fn discord_authorize(
|
|||||||
state.client_id, encoded_callback, oauth_state,
|
state.client_id, encoded_callback, oauth_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
match serde_json::to_string(&discord_auth_url) {
|
match serde_json::to_string(&url) {
|
||||||
Ok(json) => Ok(json),
|
Ok(json) => {
|
||||||
|
log::trace!("Discord OAuth URL: {}", json);
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to serialize Discord OAuth URL: {e}");
|
log::error!("Failed to serialize Discord OAuth URL: {e}");
|
||||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
@@ -82,14 +140,26 @@ async fn discord_authorize(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Very small percent-encoder for the callback URL (replaces `:` and `/`).
|
||||||
|
fn urlencoding_encode(s: &str) -> String {
|
||||||
|
s.replace(':', "%3A").replace('/', "%2F")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the Discord OAuth callback.
|
||||||
|
///
|
||||||
|
/// Two modes depending on what was stored in the state cache:
|
||||||
|
/// - **Login** (`connecting_user_id = None`): look up (or create) the local
|
||||||
|
/// user for this Discord account, then issue a session cookie and redirect.
|
||||||
|
/// - **Connect** (`connecting_user_id = Some(id)`): link the Discord account
|
||||||
|
/// to the existing local user, then redirect (no new session needed).
|
||||||
async fn discord_callback(
|
async fn discord_callback(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<CallbackQuery>,
|
Query(query): Query<CallbackQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
jar: CookieJar,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match do_oauth_callback(state, query).await {
|
match do_oauth_callback(state, query, headers, jar).await {
|
||||||
Ok((token, ui_redirect_uri)) => {
|
Ok(response) => response,
|
||||||
Redirect::temporary(&format!("{}?token={}", ui_redirect_uri, token)).into_response()
|
|
||||||
}
|
|
||||||
Err((e, ui_redirect_uri)) => {
|
Err((e, ui_redirect_uri)) => {
|
||||||
log::error!("OAuth callback error: {:?}", e);
|
log::error!("OAuth callback error: {:?}", e);
|
||||||
let fallback = ui_redirect_uri.unwrap_or_else(|| "/".to_string());
|
let fallback = ui_redirect_uri.unwrap_or_else(|| "/".to_string());
|
||||||
@@ -98,33 +168,37 @@ async fn discord_callback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CallbackErr = (Error, Option<String>);
|
||||||
|
|
||||||
async fn do_oauth_callback(
|
async fn do_oauth_callback(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
query: CallbackQuery,
|
query: CallbackQuery,
|
||||||
) -> Result<(String, String), (crate::error::Error, Option<String>)> {
|
headers: HeaderMap,
|
||||||
// Validate the state and retrieve the associated UI redirect URI
|
jar: CookieJar,
|
||||||
let ui_redirect_uri = {
|
) -> std::result::Result<axum::response::Response, CallbackErr> {
|
||||||
let mut oauth_states = state.discord_authorize_cache.lock().await;
|
// Validate state & retrieve stored data
|
||||||
|
let stored = {
|
||||||
|
let mut cache = state.discord_authorize_cache.lock().await;
|
||||||
match query.state {
|
match query.state {
|
||||||
Some(ref oauth_state) => match oauth_states.remove(oauth_state) {
|
Some(ref s) => match cache.remove(s) {
|
||||||
Some(uri) => uri,
|
Some(v) => v,
|
||||||
None => return Err((StatusCode::UNAUTHORIZED.into(), None)),
|
None => return Err((StatusCode::UNAUTHORIZED.into(), None)),
|
||||||
},
|
},
|
||||||
None => return Err((StatusCode::UNAUTHORIZED.into(), None)),
|
None => return Err((StatusCode::UNAUTHORIZED.into(), None)),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
log::trace!("Discord callback: query={:?} state={:?}", query, stored);
|
||||||
|
|
||||||
// Helper closure to tag errors with the redirect URI we already know
|
let ui_redirect_uri = stored.redirect_uri.clone();
|
||||||
let redirect = ui_redirect_uri.clone();
|
let err_redirect = |s: StatusCode| -> std::result::Result<_, CallbackErr> {
|
||||||
let err = |s: StatusCode| -> Result<_, (crate::error::Error, Option<String>)> {
|
Err((s.into(), Some(ui_redirect_uri.clone())))
|
||||||
Err((s.into(), Some(redirect.clone())))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The discord redirect_uri in the token exchange must match what was sent in /authorize
|
// The redirect_uri sent to Discord must exactly match /authorize
|
||||||
let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH);
|
let discord_callback_url = format!("{}{}", state.base_url, DISCORD_REDIRECT_PATH);
|
||||||
|
|
||||||
// Exchange code for an access token
|
// Exchange code for Discord access token
|
||||||
let token_response = state
|
let token_resp = state
|
||||||
.client
|
.client
|
||||||
.post("https://discord.com/api/oauth2/token")
|
.post("https://discord.com/api/oauth2/token")
|
||||||
.form(&[
|
.form(&[
|
||||||
@@ -136,90 +210,229 @@ async fn do_oauth_callback(
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
||||||
|
|
||||||
if !token_response.status().is_success() {
|
if !token_resp.status().is_success() {
|
||||||
log::error!(
|
log::error!("Token exchange failed: {:?}", token_resp.text().await);
|
||||||
"Failed to exchange token: {:?}",
|
return err_redirect(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
token_response.text().await
|
|
||||||
);
|
|
||||||
return err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let token_data: DiscordTokenResponse = token_response
|
let token_data: DiscordTokenResponse = token_resp
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
||||||
|
|
||||||
// Fetch user information from Discord
|
// Fetch Discord user info
|
||||||
let user_response = state
|
let user_resp = state
|
||||||
.client
|
.client
|
||||||
.get("https://discord.com/api/users/@me")
|
.get("https://discord.com/api/users/@me")
|
||||||
.bearer_auth(token_data.access_token)
|
.bearer_auth(token_data.access_token)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
||||||
|
|
||||||
if !user_response.status().is_success() {
|
if !user_resp.status().is_success() {
|
||||||
log::error!(
|
log::error!("Discord user fetch failed: {:?}", user_resp.text().await);
|
||||||
"Failed to fetch user information: {:?}",
|
return err_redirect(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
user_response.text().await
|
|
||||||
);
|
|
||||||
return err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_data: DiscordUser = user_response
|
let discord_user: DiscordUser = user_resp
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
.map_err(|_| err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
||||||
|
|
||||||
log::debug!("User authenticated: {:?}", user_data);
|
log::debug!("Discord OAuth user: {:?}", discord_user);
|
||||||
|
|
||||||
let user_id: i64 = user_data
|
|
||||||
.id
|
|
||||||
.parse::<i64>()
|
|
||||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
|
||||||
|
|
||||||
// Upsert the Discord user into the local users table
|
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO users (id, username, avatar, updated_at)
|
match stored.connecting_user_id {
|
||||||
VALUES ($1, $2, $3, NOW())
|
// Handle connecting an existing local user to a new Discord account
|
||||||
ON CONFLICT (id) DO UPDATE
|
Some(connecting_user_id) => {
|
||||||
SET username = EXCLUDED.username,
|
// Make sure this Discord account isn't already linked to a DIFFERENT user
|
||||||
avatar = EXCLUDED.avatar,
|
let existing_owner: Option<Uuid> = sqlx::query_scalar(
|
||||||
updated_at = NOW()",
|
"SELECT user_id FROM user_connections \
|
||||||
|
WHERE provider = 'discord' AND provider_user_id = $1",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(&discord_user.id)
|
||||||
.bind(&user_data.username)
|
.fetch_optional(pool)
|
||||||
.bind(&user_data.avatar)
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("DB error checking connection: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(owner_id) = existing_owner {
|
||||||
|
if owner_id != connecting_user_id {
|
||||||
|
return err_redirect(StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the connection
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_connections \
|
||||||
|
(user_id, provider, provider_user_id, provider_username, provider_avatar) \
|
||||||
|
VALUES ($1, 'discord', $2, $3, $4) \
|
||||||
|
ON CONFLICT (user_id, provider) DO UPDATE \
|
||||||
|
SET provider_user_id = EXCLUDED.provider_user_id, \
|
||||||
|
provider_username = EXCLUDED.provider_username, \
|
||||||
|
provider_avatar = EXCLUDED.provider_avatar",
|
||||||
|
)
|
||||||
|
.bind(connecting_user_id)
|
||||||
|
.bind(&discord_user.id)
|
||||||
|
.bind(&discord_user.username)
|
||||||
|
.bind(&discord_user.avatar)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
log::error!("Failed to upsert user: {e}");
|
log::error!("DB error upserting connection: {e}");
|
||||||
err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Create and insert the session
|
// No new session — redirect back to account page with existing cookie
|
||||||
let session = Session::new(user_id as u64, user_data.username.clone());
|
Ok(Redirect::temporary(&ui_redirect_uri).into_response())
|
||||||
session
|
}
|
||||||
.insert()
|
|
||||||
.await
|
|
||||||
.map_err(|e| (e, Some(ui_redirect_uri.clone())))?;
|
|
||||||
|
|
||||||
let issued_at = chrono::Utc::now();
|
// ------------------------------------------------------------------ //
|
||||||
let claims = BearerTokenClaims {
|
// LOGIN MODE: look up (or create) the local user for this Discord account
|
||||||
sub: session.user_id,
|
// ------------------------------------------------------------------ //
|
||||||
name: session.user_name.clone(),
|
None => {
|
||||||
iat: issued_at.timestamp(),
|
// Find existing connection → local user_id
|
||||||
exp: session.expires_at.timestamp(),
|
let local_user_id: Option<(Uuid, String)> = sqlx::query_as(
|
||||||
jti: session.session_id.clone(),
|
"SELECT u.id, u.username \
|
||||||
|
FROM user_connections uc \
|
||||||
|
JOIN users u ON u.id = uc.user_id \
|
||||||
|
WHERE uc.provider = 'discord' AND uc.provider_user_id = $1",
|
||||||
|
)
|
||||||
|
.bind(&discord_user.id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("DB error looking up discord user: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (user_id, username) = match local_user_id {
|
||||||
|
// Already linked — use the existing local user
|
||||||
|
Some(row) => {
|
||||||
|
// Keep provider fields up to date
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE user_connections \
|
||||||
|
SET provider_username = $1, provider_avatar = $2 \
|
||||||
|
WHERE user_id = $3 AND provider = 'discord'",
|
||||||
|
)
|
||||||
|
.bind(&discord_user.username)
|
||||||
|
.bind(&discord_user.avatar)
|
||||||
|
.bind(row.0)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("DB error updating connection: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
// First login — create a local user + connection
|
||||||
|
None => {
|
||||||
|
let base_username = &discord_user.username;
|
||||||
|
let username = generate_unique_username(pool, base_username)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("DB error generating username: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Create user (no password_hash — OAuth only)
|
||||||
|
let new_id: Uuid =
|
||||||
|
sqlx::query_scalar("INSERT INTO users (username) VALUES ($1) RETURNING id")
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("DB error creating user: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_connections \
|
||||||
|
(user_id, provider, provider_user_id, provider_username, provider_avatar) \
|
||||||
|
VALUES ($1, 'discord', $2, $3, $4)",
|
||||||
|
)
|
||||||
|
.bind(new_id)
|
||||||
|
.bind(&discord_user.id)
|
||||||
|
.bind(&discord_user.username)
|
||||||
|
.bind(&discord_user.avatar)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("DB error inserting connection: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
(new_id, username)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
// Build fingerprint from the callback request's headers
|
||||||
let encoding_key = jsonwebtoken::EncodingKey::from_secret(jwt_secret.as_bytes());
|
let ip = extract_ip(&headers);
|
||||||
let token = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &encoding_key)
|
let user_agent = headers
|
||||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err())?;
|
.get("user-agent")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let fingerprint = compute_fingerprint(&ip, &user_agent);
|
||||||
|
|
||||||
Ok((token, ui_redirect_uri))
|
// Issue session
|
||||||
|
let session = Session::new(user_id, username, fingerprint);
|
||||||
|
session.insert().await.map_err(|e| {
|
||||||
|
log::error!("Redis error inserting session: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let token = issue_jwt(&session).map_err(|e| {
|
||||||
|
log::error!("JWT error: {e}");
|
||||||
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cookie = build_session_cookie(token);
|
||||||
|
let new_jar = jar.add(cookie);
|
||||||
|
|
||||||
|
Ok((new_jar, Redirect::temporary(&ui_redirect_uri)).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Return a username derived from `base` that does not yet exist in `users`.
|
||||||
|
async fn generate_unique_username(pool: &sqlx::PgPool, base: &str) -> crate::error::Result<String> {
|
||||||
|
// Truncate to 28 chars to leave room for the `_XXXX` suffix
|
||||||
|
let base = if base.len() > 28 { &base[..28] } else { base };
|
||||||
|
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = $1")
|
||||||
|
.bind(base)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(base.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..20 {
|
||||||
|
let candidate = format!("{}_{}", base, csprng(4));
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = $1")
|
||||||
|
.bind(&candidate)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::internal_server_error(
|
||||||
|
"Could not generate a unique username".into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
474
crates/siren-api/src/auth/local.rs
Normal file
474
crates/siren-api/src/auth/local.rs
Normal 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 1–32 characters".into()));
|
||||||
|
}
|
||||||
|
if payload.password.len() < 8 {
|
||||||
|
return Err(Error::new(
|
||||||
|
422,
|
||||||
|
"Password must be at least 8 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = data::pool();
|
||||||
|
|
||||||
|
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)")
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if exists {
|
||||||
|
return Err(Error::new(409, "Username already taken".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = hash_password(&payload.password)?;
|
||||||
|
let user_id: Uuid =
|
||||||
|
sqlx::query_scalar("INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id")
|
||||||
|
.bind(&username)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ip = extract_ip(&headers);
|
||||||
|
let user_agent = headers
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let fingerprint = compute_fingerprint(&ip, &user_agent);
|
||||||
|
|
||||||
|
let session = Session::new(user_id, username, fingerprint);
|
||||||
|
session.insert().await?;
|
||||||
|
|
||||||
|
let token = issue_jwt(&session)?;
|
||||||
|
let cookie = build_session_cookie(token);
|
||||||
|
|
||||||
|
Ok((jar.add(cookie), StatusCode::CREATED))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(
|
||||||
|
headers: HeaderMap,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(payload): Json<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(¤t, &hash) {
|
||||||
|
return Err(Error::new(401, "Current password is incorrect".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// OAuth-only account — allow setting a password without current_password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_hash = hash_password(&payload.new_password)?;
|
||||||
|
sqlx::query("UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2")
|
||||||
|
.bind(&new_hash)
|
||||||
|
.bind(session.user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disconnect_provider(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
Path(provider): Path<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)
|
||||||
|
}
|
||||||
@@ -4,63 +4,23 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
http::{Method, StatusCode, request::Parts},
|
http::{HeaderMap, StatusCode, request::Parts},
|
||||||
};
|
|
||||||
use axum_extra::{
|
|
||||||
TypedHeader,
|
|
||||||
headers::{Authorization, authorization::Bearer},
|
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
pub const COOKIE_NAME: &str = "siren_session";
|
||||||
// AuthorizationMiddleware — rejects unauthenticated requests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct AuthorizationMiddleware;
|
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for AuthorizationMiddleware
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = StatusCode;
|
|
||||||
|
|
||||||
async fn from_request_parts(
|
|
||||||
parts: &mut Parts,
|
|
||||||
state: &S,
|
|
||||||
) -> std::result::Result<Self, Self::Rejection> {
|
|
||||||
// For options requests browsers will not send the authorization header.
|
|
||||||
if parts.method == Method::OPTIONS {
|
|
||||||
return Ok(Self);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for a Bearer token in the `Authorization` header.
|
|
||||||
if let Ok(TypedHeader(Authorization(bearer))) =
|
|
||||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
|
|
||||||
{
|
|
||||||
return match check_bearer_auth(bearer.token()).await {
|
|
||||||
Ok(session) => {
|
|
||||||
parts.extensions.insert(session);
|
|
||||||
Ok(Self)
|
|
||||||
}
|
|
||||||
Err(_) => Err(StatusCode::UNAUTHORIZED),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(StatusCode::UNAUTHORIZED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// OptionalAuth — extracts a Session if present, otherwise None
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Wraps an optional authenticated session.
|
/// Wraps an optional authenticated session.
|
||||||
/// Handlers that use this extractor work for both authenticated and
|
///
|
||||||
/// unauthenticated callers; callers with a valid Bearer token get a `Some(session)`.
|
/// Handlers using this extractor work for both authenticated and
|
||||||
pub struct OptionalAuth(pub Option<Session>);
|
/// unauthenticated callers. A valid `siren_session` cookie grants a
|
||||||
|
/// `Some(session)`.
|
||||||
|
pub struct SessionAuthorization(pub Option<Session>);
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for OptionalAuth
|
impl<S> FromRequestParts<S> for SessionAuthorization
|
||||||
where
|
where
|
||||||
S: Send + Sync,
|
S: Send + Sync,
|
||||||
{
|
{
|
||||||
@@ -70,38 +30,103 @@ where
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &S,
|
state: &S,
|
||||||
) -> std::result::Result<Self, Self::Rejection> {
|
) -> std::result::Result<Self, Self::Rejection> {
|
||||||
if let Ok(TypedHeader(Authorization(bearer))) =
|
let jar = CookieJar::from_request_parts(parts, state).await.unwrap();
|
||||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
|
|
||||||
{
|
if let Some(cookie) = jar.get(COOKIE_NAME) {
|
||||||
if let Ok(session) = check_bearer_auth(bearer.token()).await {
|
let ip = extract_ip(&parts.headers);
|
||||||
|
let user_agent = parts
|
||||||
|
.headers
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Ok(session) = check_cookie_auth(cookie.value(), &ip, &user_agent).await {
|
||||||
parts.extensions.insert(session.clone());
|
parts.extensions.insert(session.clone());
|
||||||
return Ok(Self(Some(session)));
|
return Ok(Self(Some(session)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self(None))
|
Ok(Self(None))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/// Extract the client IP from common proxy headers, falling back to "unknown".
|
||||||
// Shared helper
|
pub fn extract_ip(headers: &HeaderMap) -> String {
|
||||||
// ---------------------------------------------------------------------------
|
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||||
|
if let Ok(val) = forwarded.to_str() {
|
||||||
|
if let Some(ip) = val.split(',').next() {
|
||||||
|
return ip.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||||
|
if let Ok(val) = real_ip.to_str() {
|
||||||
|
return val.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unknown".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn check_bearer_auth(bearer_token: &str) -> Result<Session> {
|
/// Compute a fingerprint from client IP and User-Agent
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set in the environment");
|
///
|
||||||
|
/// Stored in the Redis session at login time and re-checked on every
|
||||||
|
/// authenticated request so that a stolen cookie is detected when used
|
||||||
|
/// from a different device or IP.
|
||||||
|
pub fn compute_fingerprint(ip: &str, user_agent: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(format!("{ip}:{user_agent}"));
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a JWT cookie value, look up the Redis session, and verify the
|
||||||
|
/// fingerprint against the current request's IP / User-Agent.
|
||||||
|
pub async fn check_cookie_auth(token: &str, ip: &str, user_agent: &str) -> Result<Session> {
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes());
|
let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes());
|
||||||
|
|
||||||
let token_data = decode::<BearerTokenClaims>(bearer_token, &decoding_key, &Validation::default())
|
let token_data = decode::<BearerTokenClaims>(token, &decoding_key, &Validation::default())
|
||||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
let claims = token_data.claims;
|
let claims = token_data.claims;
|
||||||
|
|
||||||
let now = Utc::now().timestamp();
|
if claims.exp < Utc::now().timestamp() {
|
||||||
if claims.exp < now {
|
|
||||||
return Err(StatusCode::UNAUTHORIZED.into());
|
return Err(StatusCode::UNAUTHORIZED.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
match Session::find(&claims.jti).await {
|
let session = match Session::find(&claims.jti).await? {
|
||||||
Ok(Some(session)) => Ok(session),
|
Some(s) => s,
|
||||||
_ => Err(StatusCode::UNAUTHORIZED)?,
|
None => return Err(StatusCode::UNAUTHORIZED.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reject if the request comes from a different device / network
|
||||||
|
let expected = compute_fingerprint(ip, user_agent);
|
||||||
|
if session.fingerprint != expected {
|
||||||
|
log::warn!(
|
||||||
|
"Fingerprint mismatch for session {}: stored={} request={}",
|
||||||
|
claims.jti,
|
||||||
|
session.fingerprint,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
return Err(StatusCode::UNAUTHORIZED.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the raw `Cookie:` header string and validate the siren_session
|
||||||
|
/// value. Used by the WebSocket upgrade handler where we cannot use the
|
||||||
|
/// normal `FromRequestParts` machinery.
|
||||||
|
pub async fn check_cookie_from_header_str(
|
||||||
|
cookie_header: &str,
|
||||||
|
ip: &str,
|
||||||
|
user_agent: &str,
|
||||||
|
) -> Option<Session> {
|
||||||
|
for pair in cookie_header.split(';') {
|
||||||
|
let pair = pair.trim();
|
||||||
|
if let Some(value) = pair.strip_prefix("siren_session=") {
|
||||||
|
return check_cookie_auth(value, ip, user_agent).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use rand::RngExt;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
mod discord;
|
|
||||||
mod session;
|
|
||||||
pub use session::Session;
|
|
||||||
mod bearer_token;
|
mod bearer_token;
|
||||||
|
mod discord;
|
||||||
|
mod local;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
pub use local::UserInfo;
|
||||||
|
pub use session::Session;
|
||||||
|
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub use middleware::{AuthorizationMiddleware, OptionalAuth};
|
pub use middleware::SessionAuthorization;
|
||||||
|
|
||||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new().nest("/discord", discord::get_routes())
|
Router::new()
|
||||||
}
|
.merge(local::get_routes())
|
||||||
|
.nest("/discord", discord::get_routes())
|
||||||
pub fn csprng(take: usize) -> String {
|
|
||||||
// Generate a CSPRNG ID using alphanumeric characters (a-z, A-Z, 0-9)
|
|
||||||
rand::rng()
|
|
||||||
.sample_iter(rand::distr::Alphanumeric)
|
|
||||||
.take(take)
|
|
||||||
.map(char::from)
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
use crate::{auth::csprng, error::Result};
|
use crate::error::Result;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use redis::{AsyncCommands, RedisResult};
|
use redis::{AsyncCommands, RedisResult};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use siren_core::data;
|
use siren_core::{data, utils::csprng};
|
||||||
use std::{env, sync::OnceLock};
|
use std::{env, sync::OnceLock};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
static SESSION_TTL: OnceLock<i64> = OnceLock::new();
|
static SESSION_TTL: OnceLock<i64> = OnceLock::new();
|
||||||
|
|
||||||
fn get_session_ttl() -> i64 {
|
pub fn get_session_ttl() -> i64 {
|
||||||
// Initialize the SESSION_TTL value lazily
|
|
||||||
*SESSION_TTL.get_or_init(|| {
|
*SESSION_TTL.get_or_init(|| {
|
||||||
env::var("API_SESSION_TTL")
|
env::var("API_SESSION_TTL")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|val| val.parse::<i64>().ok())
|
.and_then(|val| val.parse::<i64>().ok())
|
||||||
.unwrap_or(3600) // Default to 3600 seconds (1 hour)
|
.unwrap_or(86400) // 24 hours
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A server-side session stored in Redis.
|
||||||
|
///
|
||||||
|
/// Contains the user's identity and a `fingerprint` (SHA-256 of
|
||||||
|
/// `{client_ip}:{user_agent}`) so that stolen cookies can be detected.
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub user_id: u64,
|
pub user_id: Uuid,
|
||||||
pub user_name: String,
|
pub user_name: String,
|
||||||
|
/// SHA-256 hex of `{client_ip}:{user_agent}` captured at login time.
|
||||||
|
pub fingerprint: String,
|
||||||
pub expires_at: DateTime<Utc>,
|
pub expires_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn new(user_id: u64, user_name: String) -> Session {
|
pub fn new(user_id: Uuid, user_name: String, fingerprint: String) -> Session {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let session_ttl = get_session_ttl();
|
let session_ttl = get_session_ttl();
|
||||||
Session {
|
Session {
|
||||||
session_id: csprng(32),
|
session_id: csprng(32),
|
||||||
user_id,
|
user_id,
|
||||||
user_name,
|
user_name,
|
||||||
|
fingerprint,
|
||||||
expires_at: now + chrono::Duration::seconds(session_ttl),
|
expires_at: now + chrono::Duration::seconds(session_ttl),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::{AuthorizationMiddleware, Session},
|
auth::SessionAuthorization,
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension,
|
|
||||||
Json,
|
Json,
|
||||||
Router,
|
Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
middleware::from_extractor,
|
http::StatusCode,
|
||||||
routing::post,
|
routing::post,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -18,9 +17,7 @@ use std::{fmt::Display, str::FromStr, sync::Arc};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new().route("/{guild_id}/track", post(add_track_dice))
|
||||||
.route("/{guild_id}/track", post(add_track_dice))
|
|
||||||
.route_layer(from_extractor::<AuthorizationMiddleware>())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABLE_NAME: &str = "dice_track";
|
const TABLE_NAME: &str = "dice_track";
|
||||||
@@ -156,16 +153,34 @@ impl InsertDiceTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_track_dice(
|
pub async fn add_track_dice(
|
||||||
Extension(session): Extension<Session>,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(guild_id): Path<u64>,
|
Path(guild_id): Path<u64>,
|
||||||
Json(payload): Json<DiceTrackPayload>,
|
Json(payload): Json<DiceTrackPayload>,
|
||||||
) -> Result<Json<QueryDiceTrack>> {
|
) -> Result<Json<QueryDiceTrack>> {
|
||||||
// Check if the user exists in the cache
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
let owner_id = session.user_id;
|
|
||||||
let owner_id = match state.cache.user(owner_id) {
|
// Resolve Discord snowflake for this local user and verify they exist in cache
|
||||||
|
let discord_snowflake: u64 = {
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
let pid: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT provider_user_id FROM user_connections \
|
||||||
|
WHERE user_id = $1 AND provider = 'discord'",
|
||||||
|
)
|
||||||
|
.bind(session.user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
pid
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or_else(|| Error::not_found("Discord account not connected".to_string()))?
|
||||||
|
};
|
||||||
|
let owner_id = match state.cache.user(discord_snowflake) {
|
||||||
Some(user) => user.id,
|
Some(user) => user.id,
|
||||||
None => return Err(Error::not_found("User not found".to_string())),
|
None => {
|
||||||
|
return Err(Error::not_found(
|
||||||
|
"User not found in Discord cache".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate if the guild exists in the cache
|
// Validate if the guild exists in the cache
|
||||||
@@ -182,10 +197,7 @@ pub async fn add_track_dice(
|
|||||||
dice: format_roll(dice.0, dice.1, dice.2),
|
dice: format_roll(dice.0, dice.1, dice.2),
|
||||||
user_id: payload.user_id,
|
user_id: payload.user_id,
|
||||||
value: payload.value,
|
value: payload.value,
|
||||||
operator: match payload.operator {
|
operator: payload.operator.map(|s| s.to_string()),
|
||||||
None => None,
|
|
||||||
Some(s) => Some(s.to_string()),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for existing dice tracks
|
// Check for existing dice tracks
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ pub mod model;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::{OptionalAuth, Session, csprng, middleware::check_bearer_auth},
|
auth::{Session, SessionAuthorization, middleware::check_cookie_from_header_str},
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -10,81 +10,98 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
extract::{
|
extract::{
|
||||||
Path,
|
Path,
|
||||||
Query,
|
|
||||||
State,
|
State,
|
||||||
WebSocketUpgrade,
|
WebSocketUpgrade,
|
||||||
ws::{Message, WebSocket},
|
ws::{Message, WebSocket},
|
||||||
},
|
},
|
||||||
http::StatusCode,
|
http::{HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use model::{
|
use model::{
|
||||||
|
AccessRequestWithUser,
|
||||||
ClientMessage,
|
ClientMessage,
|
||||||
|
CreateAccessRequestPayload,
|
||||||
CreateMapPayload,
|
CreateMapPayload,
|
||||||
GridCell,
|
GridCell,
|
||||||
GridMap,
|
GridMap,
|
||||||
GridToken,
|
GridToken,
|
||||||
MapPermission,
|
ListedMap,
|
||||||
MapRole,
|
MapRole,
|
||||||
MapState,
|
MapState,
|
||||||
|
PermissionWithUser,
|
||||||
|
ResolveAccessRequestPayload,
|
||||||
ServerMessage,
|
ServerMessage,
|
||||||
|
UpdateMapPayload,
|
||||||
UpdatePermissionPayload,
|
UpdatePermissionPayload,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use siren_core::utils::csprng;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/maps", get(list_maps))
|
.route("/maps", get(list_maps))
|
||||||
.route("/maps", post(create_map))
|
.route("/maps", post(create_map))
|
||||||
.route("/maps/{id}", get(get_map))
|
.route("/maps/{id}", get(get_map))
|
||||||
|
.route("/maps/{id}", put(update_map))
|
||||||
.route("/maps/{id}", delete(delete_map))
|
.route("/maps/{id}", delete(delete_map))
|
||||||
.route("/maps/{id}/permissions", get(list_permissions))
|
.route("/maps/{id}/permissions", get(list_permissions))
|
||||||
.route("/maps/{id}/permissions", put(update_permission))
|
.route("/maps/{id}/permissions", put(update_permission))
|
||||||
|
.route("/maps/{id}/favorite", post(favorite_map))
|
||||||
|
.route("/maps/{id}/favorite", delete(unfavorite_map))
|
||||||
|
.route("/maps/{id}/access-requests", post(create_access_request))
|
||||||
|
.route("/maps/{id}/access-requests", get(list_access_requests))
|
||||||
|
.route(
|
||||||
|
"/maps/{id}/access-requests/{request_id}",
|
||||||
|
put(resolve_access_request),
|
||||||
|
)
|
||||||
.route("/maps/{id}/ws", get(ws_handler))
|
.route("/maps/{id}/ws", get(ws_handler))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Permission helpers
|
// Access helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Fetch the role of `user_id` on `map_id`, or `None` if no record exists.
|
/// Fetch the role of `user_id` on `map_id`, or `None` if no record exists.
|
||||||
async fn get_user_role(map_id: &str, user_id: i64) -> crate::error::Result<Option<MapRole>> {
|
async fn get_user_role(map_id: &str, user_id: Uuid) -> Result<Option<MapRole>> {
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
let perm: Option<MapPermission> = sqlx::query_as(
|
let role: Option<String> =
|
||||||
"SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1 AND user_id = $2",
|
sqlx::query_scalar("SELECT role FROM map_permissions WHERE map_id = $1 AND user_id = $2")
|
||||||
)
|
|
||||||
.bind(map_id)
|
.bind(map_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(perm.map(|p| p.role))
|
Ok(role.and_then(|r| match r.as_str() {
|
||||||
|
"owner" => Some(MapRole::Owner),
|
||||||
|
"editor" => Some(MapRole::Editor),
|
||||||
|
"viewer" => Some(MapRole::Viewer),
|
||||||
|
_ => None,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether the caller can view the map:
|
/// Returns whether the caller can view the map.
|
||||||
/// - Public maps: always true.
|
|
||||||
/// - Private maps: true only if the user has any role.
|
|
||||||
async fn can_view(map: &GridMap, session: &Option<Session>) -> bool {
|
async fn can_view(map: &GridMap, session: &Option<Session>) -> bool {
|
||||||
if map.is_public {
|
if map.public_access == "public_view" || map.public_access == "public_edit" {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let Some(s) = session else { return false };
|
let Some(s) = session else { return false };
|
||||||
let user_id = s.user_id as i64;
|
get_user_role(&map.id, s.user_id)
|
||||||
get_user_role(&map.id, user_id)
|
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.is_some()
|
.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether the caller can edit the map (editor or owner role).
|
/// Returns whether the caller can edit the map (editor or owner role, or public_edit).
|
||||||
async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
|
async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
|
||||||
|
if map.public_access == "public_edit" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let Some(s) = session else { return false };
|
let Some(s) = session else { return false };
|
||||||
let user_id = s.user_id as i64;
|
get_user_role(&map.id, s.user_id)
|
||||||
get_user_role(&map.id, user_id)
|
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -95,8 +112,7 @@ async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
|
|||||||
/// Returns whether the caller is the owner.
|
/// Returns whether the caller is the owner.
|
||||||
async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
|
async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
|
||||||
let Some(s) = session else { return false };
|
let Some(s) = session else { return false };
|
||||||
let user_id = s.user_id as i64;
|
get_user_role(&map.id, s.user_id)
|
||||||
get_user_role(&map.id, user_id)
|
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -105,60 +121,68 @@ async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// REST handlers
|
// Map CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub async fn list_maps(OptionalAuth(session): OptionalAuth) -> Result<Json<Vec<GridMap>>> {
|
pub async fn list_maps(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
) -> Result<Json<Vec<ListedMap>>> {
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
let maps: Vec<GridMap> = match &session {
|
let maps: Vec<ListedMap> = match &session {
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
let user_id = s.user_id as i64;
|
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT DISTINCT gm.*
|
"SELECT
|
||||||
|
gm.id, gm.name, gm.public_access, gm.owner_id,
|
||||||
|
u.username AS owner_username,
|
||||||
|
gm.colors, gm.created_at, gm.updated_at,
|
||||||
|
mp.role AS user_role,
|
||||||
|
(mf.user_id IS NOT NULL) AS is_favorited
|
||||||
FROM grid_maps gm
|
FROM grid_maps gm
|
||||||
|
JOIN users u ON u.id = gm.owner_id
|
||||||
LEFT JOIN map_permissions mp ON mp.map_id = gm.id AND mp.user_id = $1
|
LEFT JOIN map_permissions mp ON mp.map_id = gm.id AND mp.user_id = $1
|
||||||
WHERE gm.is_public = TRUE OR mp.user_id IS NOT NULL
|
LEFT JOIN map_favorites mf ON mf.map_id = gm.id AND mf.user_id = $1
|
||||||
ORDER BY gm.created_at DESC",
|
WHERE mp.user_id IS NOT NULL OR mf.user_id IS NOT NULL
|
||||||
|
ORDER BY gm.updated_at DESC",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(s.user_id)
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
sqlx::query_as("SELECT * FROM grid_maps WHERE is_public = TRUE ORDER BY created_at DESC")
|
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
|
None => vec![],
|
||||||
};
|
};
|
||||||
Ok(Json(maps))
|
Ok(Json(maps))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_map(
|
pub async fn create_map(
|
||||||
OptionalAuth(session): OptionalAuth,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
Json(payload): Json<CreateMapPayload>,
|
Json(payload): Json<CreateMapPayload>,
|
||||||
) -> Result<(StatusCode, Json<GridMap>)> {
|
) -> Result<(StatusCode, Json<GridMap>)> {
|
||||||
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
|
||||||
let user_id = session.user_id as i64;
|
let public_access = payload.public_access.as_str();
|
||||||
|
if !matches!(public_access, "private" | "public_view" | "public_edit") {
|
||||||
|
return Err(Error::new(422, "Invalid public_access value".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let map_id = csprng(32);
|
let map_id = csprng(32);
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
let map: GridMap = sqlx::query_as(
|
let map: GridMap = sqlx::query_as(
|
||||||
"INSERT INTO grid_maps (id, name, is_public, owner_id)
|
"INSERT INTO grid_maps (id, name, public_access, owner_id)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *",
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(&map_id)
|
.bind(&map_id)
|
||||||
.bind(&payload.name)
|
.bind(&payload.name)
|
||||||
.bind(payload.is_public)
|
.bind(&payload.public_access)
|
||||||
.bind(user_id)
|
.bind(session.user_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Auto-assign the creator as owner in map_permissions
|
// Auto-assign the creator as owner in map_permissions
|
||||||
sqlx::query("INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, 'owner')")
|
sqlx::query("INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, 'owner')")
|
||||||
.bind(&map_id)
|
.bind(&map_id)
|
||||||
.bind(user_id)
|
.bind(session.user_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -166,7 +190,7 @@ pub async fn create_map(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_map(
|
pub async fn get_map(
|
||||||
OptionalAuth(session): OptionalAuth,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<MapState>> {
|
) -> Result<Json<MapState>> {
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
@@ -195,8 +219,51 @@ pub async fn get_map(
|
|||||||
Ok(Json(MapState { map, cells, tokens }))
|
Ok(Json(MapState { map, cells, tokens }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_map(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(payload): Json<UpdateMapPayload>,
|
||||||
|
) -> Result<Json<GridMap>> {
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
|
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
|
||||||
|
|
||||||
|
if !is_owner(&map, &session).await {
|
||||||
|
return Err(StatusCode::FORBIDDEN.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref pa) = payload.public_access {
|
||||||
|
if !matches!(pa.as_str(), "private" | "public_view" | "public_edit") {
|
||||||
|
return Err(Error::new(422, "Invalid public_access value".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_name = payload.name.as_deref().unwrap_or(&map.name);
|
||||||
|
let new_pa = payload
|
||||||
|
.public_access
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&map.public_access);
|
||||||
|
|
||||||
|
let updated: GridMap = sqlx::query_as(
|
||||||
|
"UPDATE grid_maps SET name = $1, public_access = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3 RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(new_name)
|
||||||
|
.bind(new_pa)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(updated))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_map(
|
pub async fn delete_map(
|
||||||
OptionalAuth(session): OptionalAuth,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<StatusCode> {
|
) -> Result<StatusCode> {
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
@@ -225,9 +292,9 @@ pub async fn delete_map(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub async fn list_permissions(
|
pub async fn list_permissions(
|
||||||
OptionalAuth(session): OptionalAuth,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Vec<MapPermission>>> {
|
) -> Result<Json<Vec<PermissionWithUser>>> {
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
|
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
|
||||||
@@ -241,8 +308,13 @@ pub async fn list_permissions(
|
|||||||
return Err(StatusCode::FORBIDDEN.into());
|
return Err(StatusCode::FORBIDDEN.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let perms: Vec<MapPermission> =
|
let perms: Vec<PermissionWithUser> = sqlx::query_as(
|
||||||
sqlx::query_as("SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1")
|
"SELECT mp.map_id, mp.user_id, u.username, mp.role
|
||||||
|
FROM map_permissions mp
|
||||||
|
JOIN users u ON u.id = mp.user_id
|
||||||
|
WHERE mp.map_id = $1
|
||||||
|
ORDER BY mp.role, u.username",
|
||||||
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -251,7 +323,7 @@ pub async fn list_permissions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_permission(
|
pub async fn update_permission(
|
||||||
OptionalAuth(session): OptionalAuth,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(payload): Json<UpdatePermissionPayload>,
|
Json(payload): Json<UpdatePermissionPayload>,
|
||||||
) -> Result<StatusCode> {
|
) -> Result<StatusCode> {
|
||||||
@@ -268,11 +340,24 @@ pub async fn update_permission(
|
|||||||
return Err(StatusCode::FORBIDDEN.into());
|
return Err(StatusCode::FORBIDDEN.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent the owner from removing their own owner record
|
// Resolve username → user_id
|
||||||
let caller_id = session.as_ref().map(|s| s.user_id as i64).unwrap_or(0);
|
let target_id: Option<Uuid> = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
|
||||||
if payload.user_id == caller_id && payload.role.as_ref().map(|r| r.is_owner()) == Some(false) {
|
.bind(&payload.username)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let target_id = target_id.ok_or_else(|| Error::not_found("User not found".into()))?;
|
||||||
|
|
||||||
|
// Prevent the owner from stripping their own owner record
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
if target_id == s.user_id {
|
||||||
|
if let Some(ref role) = payload.role {
|
||||||
|
if !role.is_owner() {
|
||||||
return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY));
|
return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match payload.role {
|
match payload.role {
|
||||||
Some(role) => {
|
Some(role) => {
|
||||||
@@ -282,7 +367,7 @@ pub async fn update_permission(
|
|||||||
ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role",
|
ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(payload.user_id)
|
.bind(target_id)
|
||||||
.bind(role)
|
.bind(role)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -290,7 +375,7 @@ pub async fn update_permission(
|
|||||||
None => {
|
None => {
|
||||||
sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2")
|
sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(payload.user_id)
|
.bind(target_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -299,26 +384,215 @@ pub async fn update_permission(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn favorite_map(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode> {
|
||||||
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
|
// Verify the map exists and is viewable
|
||||||
|
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
|
||||||
|
if !can_view(&map, &Some(session.clone())).await {
|
||||||
|
return Err(StatusCode::FORBIDDEN.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO map_favorites (user_id, map_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(session.user_id)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfavorite_map(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode> {
|
||||||
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM map_favorites WHERE user_id = $1 AND map_id = $2")
|
||||||
|
.bind(session.user_id)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Access Requests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn create_access_request(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(payload): Json<CreateAccessRequestPayload>,
|
||||||
|
) -> Result<StatusCode> {
|
||||||
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
|
||||||
|
// Only editor and viewer roles can be requested
|
||||||
|
if matches!(payload.role, MapRole::Owner) {
|
||||||
|
return Err(Error::new(422, "Cannot request owner role".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
|
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
map.ok_or_else(|| Error::not_found("Map not found".into()))?;
|
||||||
|
|
||||||
|
// Check if user already has a direct permission
|
||||||
|
let existing_role = get_user_role(&id, session.user_id).await?;
|
||||||
|
if existing_role.is_some() {
|
||||||
|
return Err(Error::new(
|
||||||
|
409,
|
||||||
|
"You already have access to this map".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the request (update role if they change their mind)
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO map_access_requests (map_id, user_id, requested_role, status, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'pending', NOW())
|
||||||
|
ON CONFLICT (map_id, user_id)
|
||||||
|
DO UPDATE SET requested_role = EXCLUDED.requested_role,
|
||||||
|
status = 'pending',
|
||||||
|
updated_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(session.user_id)
|
||||||
|
.bind(&payload.role)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_access_requests(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AccessRequestWithUser>>> {
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
|
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
|
||||||
|
|
||||||
|
if !is_owner(&map, &session).await {
|
||||||
|
return Err(StatusCode::FORBIDDEN.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests: Vec<AccessRequestWithUser> = sqlx::query_as(
|
||||||
|
"SELECT mar.id, mar.map_id, mar.user_id, u.username,
|
||||||
|
mar.requested_role, mar.status, mar.created_at, mar.updated_at
|
||||||
|
FROM map_access_requests mar
|
||||||
|
JOIN users u ON u.id = mar.user_id
|
||||||
|
WHERE mar.map_id = $1 AND mar.status = 'pending'
|
||||||
|
ORDER BY mar.created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(requests))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_access_request(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
Path((map_id, request_id)): Path<(String, Uuid)>,
|
||||||
|
Json(payload): Json<ResolveAccessRequestPayload>,
|
||||||
|
) -> Result<StatusCode> {
|
||||||
|
let pool = siren_core::data::pool();
|
||||||
|
|
||||||
|
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
|
||||||
|
.bind(&map_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
|
||||||
|
|
||||||
|
if !is_owner(&map, &session).await {
|
||||||
|
return Err(StatusCode::FORBIDDEN.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(payload.action.as_str(), "approve" | "deny") {
|
||||||
|
return Err(Error::new(422, "action must be 'approve' or 'deny'".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the request
|
||||||
|
let req: Option<(Uuid, String)> = sqlx::query_as(
|
||||||
|
"SELECT user_id, requested_role FROM map_access_requests WHERE id = $1 AND map_id = $2",
|
||||||
|
)
|
||||||
|
.bind(request_id)
|
||||||
|
.bind(&map_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (user_id, role) = req.ok_or_else(|| Error::not_found("Access request not found".into()))?;
|
||||||
|
|
||||||
|
if payload.action == "approve" {
|
||||||
|
// Grant the requested role
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO map_permissions (map_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role",
|
||||||
|
)
|
||||||
|
.bind(&map_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&role)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark request as resolved
|
||||||
|
let new_status = if payload.action == "approve" {
|
||||||
|
"approved"
|
||||||
|
} else {
|
||||||
|
"denied"
|
||||||
|
};
|
||||||
|
sqlx::query("UPDATE map_access_requests SET status = $1, updated_at = NOW() WHERE id = $2")
|
||||||
|
.bind(new_status)
|
||||||
|
.bind(request_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// WebSocket handler
|
// WebSocket handler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WsQuery {
|
|
||||||
/// Optional Bearer token passed as a query parameter for WS auth.
|
|
||||||
token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ws_handler(
|
pub async fn ws_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(map_id): Path<String>,
|
Path(map_id): Path<String>,
|
||||||
Query(query): Query<WsQuery>,
|
headers: HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Resolve the session from query param (WS can't easily send headers)
|
let session: Option<Session> = {
|
||||||
let session: Option<Session> = match query.token {
|
let ip = crate::auth::middleware::extract_ip(&headers);
|
||||||
Some(ref tok) => check_bearer_auth(tok).await.ok(),
|
let user_agent = headers
|
||||||
None => None,
|
.get("user-agent")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
if let Some(cookie_header) = headers.get("cookie").and_then(|h| h.to_str().ok()) {
|
||||||
|
check_cookie_from_header_str(cookie_header, &ip, user_agent).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state, map_id, session))
|
ws.on_upgrade(move |socket| handle_socket(socket, state, map_id, session))
|
||||||
@@ -330,20 +604,17 @@ async fn handle_socket(
|
|||||||
map_id: String,
|
map_id: String,
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
) {
|
) {
|
||||||
// Load the map and verify the caller can view it
|
|
||||||
let map_state = match fetch_map_state(&map_id).await {
|
let map_state = match fetch_map_state(&map_id).await {
|
||||||
Ok(ms) => ms,
|
Ok(ms) => ms,
|
||||||
Err(_) => return, // map doesn't exist
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !can_view(&map_state.map, &session).await {
|
if !can_view(&map_state.map, &session).await {
|
||||||
// Refuse the connection silently (upgrade already happened; just close)
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let editor = can_edit(&map_state.map, &session).await;
|
let editor = can_edit(&map_state.map, &session).await;
|
||||||
|
|
||||||
// Get or create a broadcast channel for this map
|
|
||||||
let tx = state
|
let tx = state
|
||||||
.map_rooms
|
.map_rooms
|
||||||
.entry(map_id.clone())
|
.entry(map_id.clone())
|
||||||
@@ -356,7 +627,6 @@ async fn handle_socket(
|
|||||||
|
|
||||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
|
||||||
// Send the current full map state to the newly connected client
|
|
||||||
let init_msg = ServerMessage::State {
|
let init_msg = ServerMessage::State {
|
||||||
cells: map_state.cells,
|
cells: map_state.cells,
|
||||||
tokens: map_state.tokens,
|
tokens: map_state.tokens,
|
||||||
@@ -366,7 +636,6 @@ async fn handle_socket(
|
|||||||
let _ = ws_tx.send(Message::Text(json.into())).await;
|
let _ = ws_tx.send(Message::Text(json.into())).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 1: forward broadcast messages to this socket
|
|
||||||
let mut send_task = tokio::spawn(async move {
|
let mut send_task = tokio::spawn(async move {
|
||||||
while let Ok(json) = rx.recv().await {
|
while let Ok(json) = rx.recv().await {
|
||||||
if ws_tx.send(Message::Text(json.into())).await.is_err() {
|
if ws_tx.send(Message::Text(json.into())).await.is_err() {
|
||||||
@@ -375,7 +644,6 @@ async fn handle_socket(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task 2: receive messages from this client, persist, and broadcast
|
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx.clone();
|
||||||
let mut recv_task = tokio::spawn(async move {
|
let mut recv_task = tokio::spawn(async move {
|
||||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||||
@@ -430,7 +698,6 @@ async fn handle_client_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// All mutating messages require editor or owner role
|
|
||||||
if !can_edit {
|
if !can_edit {
|
||||||
let err = ServerMessage::Error {
|
let err = ServerMessage::Error {
|
||||||
message: "You do not have permission to edit this map.".into(),
|
message: "You do not have permission to edit this map.".into(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Map Role / Permission
|
// Map Role / Permission
|
||||||
@@ -26,10 +27,12 @@ impl MapRole {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
/// A permission record with the associated user's username included (for display).
|
||||||
pub struct MapPermission {
|
#[derive(Serialize, sqlx::FromRow, Clone, Debug)]
|
||||||
|
pub struct PermissionWithUser {
|
||||||
pub map_id: String,
|
pub map_id: String,
|
||||||
pub user_id: i64,
|
pub user_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
pub role: MapRole,
|
pub role: MapRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,32 +40,92 @@ pub struct MapPermission {
|
|||||||
// Grid Map
|
// Grid Map
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Core map record as stored/returned by create, get, and update endpoints.
|
||||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
||||||
pub struct GridMap {
|
pub struct GridMap {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_public: bool,
|
/// One of: "private", "public_view", "public_edit"
|
||||||
pub owner_id: i64,
|
pub public_access: String,
|
||||||
|
pub owner_id: Uuid,
|
||||||
pub colors: Vec<String>,
|
pub colors: Vec<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
/// Extended map record returned by the list endpoint.
|
||||||
pub struct CreateMapPayload {
|
/// Includes the owner's username, the caller's role (if any), and a
|
||||||
|
/// favorited flag.
|
||||||
|
#[derive(Serialize, sqlx::FromRow, Clone, Debug)]
|
||||||
|
pub struct ListedMap {
|
||||||
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
pub public_access: String,
|
||||||
pub is_public: bool,
|
pub owner_id: Uuid,
|
||||||
|
pub owner_username: String,
|
||||||
|
pub colors: Vec<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
/// The authenticated caller's role on this map, or NULL if they only have it
|
||||||
|
/// via a favorite (no explicit permission).
|
||||||
|
pub user_role: Option<MapRole>,
|
||||||
|
pub is_favorited: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct CreateMapPayload {
|
||||||
|
pub name: String,
|
||||||
|
/// Defaults to "private" when omitted.
|
||||||
|
#[serde(default = "default_private")]
|
||||||
|
pub public_access: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_private() -> String {
|
||||||
|
"private".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct UpdateMapPayload {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub public_access: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
pub struct UpdatePermissionPayload {
|
pub struct UpdatePermissionPayload {
|
||||||
/// Discord user ID of the target user.
|
/// Username of the target user (looked up server-side).
|
||||||
pub user_id: i64,
|
pub username: String,
|
||||||
/// New role to assign. Omit (null) to remove the permission entry.
|
/// New role to assign. `null` removes the permission entry.
|
||||||
pub role: Option<MapRole>,
|
pub role: Option<MapRole>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Map Access Requests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// An access-request row joined with the requesting user's username.
|
||||||
|
#[derive(Serialize, sqlx::FromRow, Clone, Debug)]
|
||||||
|
pub struct AccessRequestWithUser {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub map_id: String,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub requested_role: MapRole,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct CreateAccessRequestPayload {
|
||||||
|
pub role: MapRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct ResolveAccessRequestPayload {
|
||||||
|
/// "approve" or "deny"
|
||||||
|
pub action: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Grid Cell (no id column — composite PK in DB)
|
// Grid Cell (no id column — composite PK in DB)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use serenity::all::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub async fn process_message(ctx: &Context, command: &CommandInteraction, private: bool) {
|
pub async fn process_message(ctx: &Context, command: &CommandInteraction, private: bool) {
|
||||||
create_message_response(&ctx, &command, "Processing...".to_string(), private).await;
|
create_message_response(ctx, command, "Processing...".to_string(), private).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_dm(ctx: &Context, user_id: &UserId, content: String) -> Option<Message> {
|
pub async fn user_dm(ctx: &Context, user_id: &UserId, content: String) -> Option<Message> {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ fn find_voice_channel(
|
|||||||
|
|
||||||
match guild
|
match guild
|
||||||
.voice_states
|
.voice_states
|
||||||
.get(&user_id)
|
.get(user_id)
|
||||||
.and_then(|voice_state| voice_state.channel_id)
|
.and_then(|voice_state| voice_state.channel_id)
|
||||||
{
|
{
|
||||||
Some(channel) => Ok(channel),
|
Some(channel) => Ok(channel),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use serenity::{
|
|||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, false).await;
|
process_message(ctx, command, false).await;
|
||||||
|
|
||||||
// Get the songbird manager
|
// Get the songbird manager
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
@@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Some(guild_id) => guild_id,
|
Some(guild_id) => guild_id,
|
||||||
None => {
|
None => {
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
"Unable to find the current server ID".to_string(),
|
"Unable to find the current server ID".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -36,14 +36,14 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if is_muted {
|
if is_muted {
|
||||||
log::debug!("<{guild_id}> Unmuted");
|
log::debug!("<{guild_id}> Unmuted");
|
||||||
edit_response(&ctx, &command, "Unmuted".to_string()).await;
|
edit_response(ctx, command, "Unmuted".to_string()).await;
|
||||||
} else {
|
} else {
|
||||||
log::debug!("<{guild_id}> Muted");
|
log::debug!("<{guild_id}> Muted");
|
||||||
edit_response(&ctx, &command, "Muted".to_string()).await;
|
edit_response(ctx, command, "Muted".to_string()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
edit_response(&ctx, &command, format!("Failed to mute: {}", err)).await;
|
edit_response(ctx, command, format!("Failed to mute: {}", err)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, false).await;
|
process_message(ctx, command, false).await;
|
||||||
|
|
||||||
// Get the songbird manager
|
// Get the songbird manager
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
@@ -22,8 +22,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Some(guild_id) => guild_id,
|
Some(guild_id) => guild_id,
|
||||||
None => {
|
None => {
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
"Unable to find the current server ID".to_string(),
|
"Unable to find the current server ID".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -35,9 +35,9 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
match pause_track(manager, guild_id).await {
|
match pause_track(manager, guild_id).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::debug!("<{guild_id}> Paused the track");
|
log::debug!("<{guild_id}> Paused the track");
|
||||||
edit_response(&ctx, &command, "Pausing the track".to_string()).await;
|
edit_response(ctx, command, "Pausing the track".to_string()).await;
|
||||||
}
|
}
|
||||||
Err(err) => edit_response(&ctx, &command, format!("Failed to pause: {}", err)).await,
|
Err(err) => edit_response(ctx, command, format!("Failed to pause: {}", err)).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
command.guild_id.unwrap(),
|
command.guild_id.unwrap(),
|
||||||
command.user.id.get()
|
command.user.id.get()
|
||||||
);
|
);
|
||||||
create_message_response(&ctx, &command, "Track option is missing".to_string(), false).await;
|
create_message_response(ctx, command, "Track option is missing".to_string(), false).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, false).await;
|
process_message(ctx, command, false).await;
|
||||||
|
|
||||||
// Get the songbird manager
|
// Get the songbird manager
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
@@ -48,8 +48,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Some(guild_id) => guild_id,
|
Some(guild_id) => guild_id,
|
||||||
None => {
|
None => {
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
"Unable to find the current server ID".to_string(),
|
"Unable to find the current server ID".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -58,7 +58,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Join the user's voice channel
|
// Join the user's voice channel
|
||||||
match join_voice_channel(&ctx.cache, &manager, guild_id, &command.user.id).await {
|
match join_voice_channel(&ctx.cache, manager, guild_id, &command.user.id).await {
|
||||||
Ok(channel_id) => {
|
Ok(channel_id) => {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}"
|
"<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}"
|
||||||
@@ -67,29 +67,29 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
match enqueue_track(manager, guild_id.to_owned(), track_url).await {
|
match enqueue_track(manager, guild_id.to_owned(), track_url).await {
|
||||||
Ok(items) => {
|
Ok(items) => {
|
||||||
let mut message = format!("Added {} tracks", items.len());
|
let mut message = format!("Added {} tracks", items.len());
|
||||||
if items.len() == 0 {
|
if items.is_empty() {
|
||||||
message = "No tracks were played".to_string();
|
message = "No tracks were played".to_string();
|
||||||
log::warn!("<{guild_id}> No tracks were played");
|
log::warn!("<{guild_id}> No tracks were played");
|
||||||
if let Err(err) = leave_voice_channel(&manager, guild_id).await {
|
if let Err(err) = leave_voice_channel(manager, guild_id).await {
|
||||||
log::error!("Failed to leave voice channel: {}", err);
|
log::error!("Failed to leave voice channel: {}", err);
|
||||||
};
|
};
|
||||||
} else if items.len() == 1 {
|
} else if items.len() == 1 {
|
||||||
message = format!("Added **{}**", items[0].get_title());
|
message = format!("Added **{}**", items[0].get_title());
|
||||||
}
|
}
|
||||||
edit_response(&ctx, &command, message).await;
|
edit_response(ctx, command, message).await;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("Failed to play track: {}", err);
|
log::error!("Failed to play track: {}", err);
|
||||||
if let Err(err) = leave_voice_channel(&manager, guild_id).await {
|
if let Err(err) = leave_voice_channel(manager, guild_id).await {
|
||||||
log::error!("Failed to leave voice channel: {}", err);
|
log::error!("Failed to leave voice channel: {}", err);
|
||||||
}
|
}
|
||||||
edit_response(&ctx, &command, format!("Failed to play track: {}", err)).await;
|
edit_response(ctx, command, format!("Failed to play track: {}", err)).await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("<{guild_id}> Failed to join voice channel: {}", err);
|
log::warn!("<{guild_id}> Failed to join voice channel: {}", err);
|
||||||
edit_response(&ctx, &command, format!("{}", err)).await;
|
edit_response(ctx, command, format!("{}", err)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ pub async fn enqueue_track(
|
|||||||
if let Some(handler_lock) = manager.get(guild_id) {
|
if let Some(handler_lock) = manager.get(guild_id) {
|
||||||
let mut handler = handler_lock.lock().await;
|
let mut handler = handler_lock.lock().await;
|
||||||
let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
|
let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
|
||||||
let valid = is_valid_url(&track_url);
|
let valid = is_valid_url(track_url);
|
||||||
|
|
||||||
// Check if the URL is valid
|
// Check if the URL is valid
|
||||||
if !valid {
|
if !valid {
|
||||||
@@ -111,7 +111,7 @@ pub async fn enqueue_track(
|
|||||||
return Err(Error::new(422, format!("Invalid track url: {}", track_url)));
|
return Err(Error::new(422, format!("Invalid track url: {}", track_url)));
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist_items = get_ytdlp_items(&track_url)?;
|
playlist_items = get_ytdlp_items(track_url)?;
|
||||||
|
|
||||||
// Add each track to the queue
|
// Add each track to the queue
|
||||||
for item in &playlist_items {
|
for item in &playlist_items {
|
||||||
@@ -122,8 +122,7 @@ pub async fn enqueue_track(
|
|||||||
let input: Input = source.into();
|
let input: Input = source.into();
|
||||||
let track_title = item.get_title().to_owned();
|
let track_title = item.get_title().to_owned();
|
||||||
|
|
||||||
let track_handle: TrackHandle;
|
let track_handle: TrackHandle = handler.enqueue_input(input).await;
|
||||||
track_handle = handler.enqueue_input(input).await;
|
|
||||||
|
|
||||||
// Set the volume
|
// Set the volume
|
||||||
let _ = track_handle.set_volume(volume);
|
let _ = track_handle.set_volume(volume);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, false).await;
|
process_message(ctx, command, false).await;
|
||||||
|
|
||||||
// Get the songbird manager
|
// Get the songbird manager
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
@@ -22,8 +22,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Some(guild_id) => guild_id,
|
Some(guild_id) => guild_id,
|
||||||
None => {
|
None => {
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
"Unable to find the current server ID".to_string(),
|
"Unable to find the current server ID".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -35,9 +35,9 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
match resume_track(manager, guild_id).await {
|
match resume_track(manager, guild_id).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::debug!("<{guild_id}> Resumed the track");
|
log::debug!("<{guild_id}> Resumed the track");
|
||||||
edit_response(&ctx, &command, "resuming the track".to_string()).await;
|
edit_response(ctx, command, "resuming the track".to_string()).await;
|
||||||
}
|
}
|
||||||
Err(err) => edit_response(&ctx, &command, format!("Failed to resume: {}", err)).await,
|
Err(err) => edit_response(ctx, command, format!("Failed to resume: {}", err)).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use serenity::{
|
|||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, false).await;
|
process_message(ctx, command, false).await;
|
||||||
|
|
||||||
// Get the songbird manager
|
// Get the songbird manager
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
@@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Some(guild_id) => guild_id,
|
Some(guild_id) => guild_id,
|
||||||
None => {
|
None => {
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
"Unable to find the current server ID".to_string(),
|
"Unable to find the current server ID".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -34,10 +34,10 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
match handler.queue().skip() {
|
match handler.queue().skip() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::debug!("<{guild_id}> Skipped the track");
|
log::debug!("<{guild_id}> Skipped the track");
|
||||||
edit_response(&ctx, &command, "Skipping the track".to_string()).await;
|
edit_response(ctx, command, "Skipping the track".to_string()).await;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
edit_response(&ctx, &command, format!("Failed to skip: {}", err)).await;
|
edit_response(ctx, command, format!("Failed to skip: {}", err)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use serenity::{
|
|||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, false).await;
|
process_message(ctx, command, false).await;
|
||||||
|
|
||||||
// Get the songbird manager
|
// Get the songbird manager
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
@@ -19,8 +19,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Some(g) => g,
|
Some(g) => g,
|
||||||
None => {
|
None => {
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
"Unable to find the current server ID".to_string(),
|
"Unable to find the current server ID".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -33,7 +33,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
let handler = handler_lock.lock().await;
|
let handler = handler_lock.lock().await;
|
||||||
handler.queue().stop();
|
handler.queue().stop();
|
||||||
log::debug!("<{guild_id}> Stopped the track");
|
log::debug!("<{guild_id}> Stopped the track");
|
||||||
edit_response(&ctx, &command, "Stopping the tracks".to_string()).await;
|
edit_response(ctx, command, "Stopping the tracks".to_string()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,19 +20,13 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
"{} attempted to change the volume without a volume option",
|
"{} attempted to change the volume without a volume option",
|
||||||
command.user.id.get()
|
command.user.id.get()
|
||||||
);
|
);
|
||||||
create_message_response(
|
create_message_response(ctx, command, "Volume option is missing".to_string(), false).await;
|
||||||
&ctx,
|
|
||||||
&command,
|
|
||||||
"Volume option is missing".to_string(),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, false).await;
|
process_message(ctx, command, false).await;
|
||||||
|
|
||||||
// Get the songbird manager
|
// Get the songbird manager
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
@@ -42,8 +36,8 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
Some(guild_id) => guild_id,
|
Some(guild_id) => guild_id,
|
||||||
None => {
|
None => {
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
"Unable to find the current server ID".to_string(),
|
"Unable to find the current server ID".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -52,15 +46,14 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Set the volume
|
// Set the volume
|
||||||
set_volume(&manager, guild_id, volume).await;
|
set_volume(manager, guild_id, volume).await;
|
||||||
log::debug!("<{guild_id}> Setting the volume to {}", volume);
|
log::debug!("<{guild_id}> Setting the volume to {}", volume);
|
||||||
edit_response(&ctx, &command, format!("Setting the volume to {}", volume)).await;
|
edit_response(ctx, command, format!("Setting the volume to {}", volume)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_volume(manager: &Arc<Songbird>, guild_id: &GuildId, volume: i32) {
|
pub async fn set_volume(manager: &Arc<Songbird>, guild_id: &GuildId, volume: i32) {
|
||||||
// Format volume to f32 bound between 0.0 and 1.0
|
// Format volume to f32 bound between 0.0 and 1.0
|
||||||
let volume = std::cmp::min(100, std::cmp::max(0, volume));
|
let bound_volume = volume.clamp(0, 100) as f32 / 100.0;
|
||||||
let bound_volume = volume as f32 / 100.0;
|
|
||||||
|
|
||||||
// Update the guild cache
|
// Update the guild cache
|
||||||
let mut guild_cache = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
|
let mut guild_cache = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
|
||||||
@@ -70,7 +63,7 @@ pub async fn set_volume(manager: &Arc<Songbird>, guild_id: &GuildId, volume: i32
|
|||||||
// Update the volume of the songbird handler
|
// Update the volume of the songbird handler
|
||||||
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
|
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
|
||||||
let handler = handler_lock.lock().await;
|
let handler = handler_lock.lock().await;
|
||||||
for (_, track_handle) in handler.queue().current_queue().iter().enumerate() {
|
for track_handle in handler.queue().current_queue().iter() {
|
||||||
if let Err(err) = track_handle.set_volume(bound_volume) {
|
if let Err(err) = track_handle.set_volume(bound_volume) {
|
||||||
log::error!("Unable to set volume: {err}");
|
log::error!("Unable to set volume: {err}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ use siren_core::data::events::Event;
|
|||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
process_message(&ctx, &command, true).await;
|
process_message(ctx, command, true).await;
|
||||||
|
|
||||||
// Process the command options
|
// Process the command options
|
||||||
let title = command.data.options.get(0).unwrap().value.as_str().unwrap();
|
let title = command
|
||||||
|
.data
|
||||||
|
.options
|
||||||
|
.first()
|
||||||
|
.unwrap()
|
||||||
|
.value
|
||||||
|
.as_str()
|
||||||
|
.unwrap();
|
||||||
// let datetime_string = command.data.options.get(1).unwrap().value.as_str().unwrap();
|
// let datetime_string = command.data.options.get(1).unwrap().value.as_str().unwrap();
|
||||||
let description = command
|
let description = command
|
||||||
.data
|
.data
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
|
|
||||||
create_message_response(
|
create_message_response(
|
||||||
ctx,
|
ctx,
|
||||||
&command,
|
command,
|
||||||
format!("Sending request to {}", user_id.mention()),
|
format!("Sending request to {}", user_id.mention()),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
let dice_string = command
|
let dice_string = command
|
||||||
.data
|
.data
|
||||||
.options
|
.options
|
||||||
.get(0)
|
.first()
|
||||||
.and_then(|o| o.value.as_str())
|
.and_then(|o| o.value.as_str())
|
||||||
.map(|s| s.split_whitespace().collect::<String>())
|
.map(|s| s.split_whitespace().collect::<String>())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -79,7 +79,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
Err(why) => {
|
Err(why) => {
|
||||||
edit_response(ctx, &command, why.to_string()).await;
|
edit_response(ctx, command, why.to_string()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
.find(|opt| opt.name == "user")
|
.find(|opt| opt.name == "user")
|
||||||
.and_then(|o| o.value.as_mentionable());
|
.and_then(|o| o.value.as_mentionable());
|
||||||
|
|
||||||
create_message_response(ctx, &command, "Rolling...".to_string(), private).await;
|
create_message_response(ctx, command, "Rolling...".to_string(), private).await;
|
||||||
|
|
||||||
let dice_string = match command
|
let dice_string = match command
|
||||||
.data
|
.data
|
||||||
.options
|
.options
|
||||||
.get(0)
|
.first()
|
||||||
.and_then(|o| o.value.as_str())
|
.and_then(|o| o.value.as_str())
|
||||||
.map(|s| s.split_whitespace().collect::<String>())
|
.map(|s| s.split_whitespace().collect::<String>())
|
||||||
{
|
{
|
||||||
Some(dice_value) => dice_value,
|
Some(dice_value) => dice_value,
|
||||||
None => {
|
None => {
|
||||||
log::warn!("Missing or invalid dice option");
|
log::warn!("Missing or invalid dice option");
|
||||||
let _ = edit_response(&ctx, &command, "Dice option is missing".to_string()).await;
|
let _ = edit_response(ctx, command, "Dice option is missing".to_string()).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -63,18 +63,18 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
let roller_id = command.user.id;
|
let roller_id = command.user.id;
|
||||||
send_roll_message(ctx, total, user_id, roller_id, &response).await;
|
send_roll_message(ctx, total, user_id, roller_id, &response).await;
|
||||||
edit_response(
|
edit_response(
|
||||||
&ctx,
|
ctx,
|
||||||
command,
|
command,
|
||||||
format!("Sending dice roll results to {}", &user_id.mention()),
|
format!("Sending dice roll results to {}", &user_id.mention()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
None => edit_response(&ctx, &command, format!("🎲 {}\n-# {}", total, response)).await,
|
None => edit_response(ctx, command, format!("🎲 {}\n-# {}", total, response)).await,
|
||||||
};
|
};
|
||||||
// Check for dice tracks
|
// Check for dice tracks
|
||||||
}
|
}
|
||||||
Err(why) => {
|
Err(why) => {
|
||||||
edit_response(&ctx, &command, format!("Invalid dice string: {}", why)).await;
|
edit_response(ctx, command, format!("Invalid dice string: {}", why)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
create_message_response(&ctx, &command, "pong".to_string(), true).await;
|
create_message_response(ctx, command, "pong".to_string(), true).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register() -> CreateCommand {
|
pub fn register() -> CreateCommand {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ impl EventHandler for BotHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle direct messages
|
// Handle direct messages
|
||||||
if let None = msg.guild_id {
|
if msg.guild_id.is_none() {
|
||||||
log::trace!("Received DM from {}: {}", msg.author, msg.content);
|
log::trace!("Received DM from {}: {}", msg.author, msg.content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ pub struct YtDlp {
|
|||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for YtDlp {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl YtDlp {
|
impl YtDlp {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut cmd = Command::new(YOUTUBE_DL_COMMAND);
|
let mut cmd = Command::new(YOUTUBE_DL_COMMAND);
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ impl Condition {
|
|||||||
Condition::Simple(condition, values) => {
|
Condition::Simple(condition, values) => {
|
||||||
// Replace each instance of '?' with increasing numbered binds
|
// Replace each instance of '?' with increasing numbered binds
|
||||||
let mut numbered_condition = String::new();
|
let mut numbered_condition = String::new();
|
||||||
let mut chars = condition.chars().peekable();
|
let chars = condition.chars().peekable();
|
||||||
|
|
||||||
while let Some(c) = chars.next() {
|
for c in chars {
|
||||||
if c == '?' {
|
if c == '?' {
|
||||||
// Increment the counter and replace `?` with a numbered bind
|
// Increment the counter and replace `?` with a numbered bind
|
||||||
*counter += 1;
|
*counter += 1;
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ impl<'a> QueryBuilder<'a> {
|
|||||||
|
|
||||||
pub fn order_by(mut self, column: &str, direction: Option<OrderDirection>) -> Self {
|
pub fn order_by(mut self, column: &str, direction: Option<OrderDirection>) -> Self {
|
||||||
match direction {
|
match direction {
|
||||||
Some(order) => self
|
Some(order) => self.order_by.push(format!("{} {}", column, order)),
|
||||||
.order_by
|
|
||||||
.push(format!("{} {}", column, order.to_string())),
|
|
||||||
None => self.order_by.push(column.to_string()),
|
None => self.order_by.push(column.to_string()),
|
||||||
}
|
}
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -65,9 +65,8 @@ impl From<sqlx::Error> for Error {
|
|||||||
sqlx::Error::PoolClosed => Error::new(503, error.to_string()),
|
sqlx::Error::PoolClosed => Error::new(503, error.to_string()),
|
||||||
sqlx::Error::Database(err) => {
|
sqlx::Error::Database(err) => {
|
||||||
if let Some(code) = err.code() {
|
if let Some(code) = err.code() {
|
||||||
match code.trim() {
|
if code.trim() == "23503" {
|
||||||
"23505" => return Error::new(409, err.to_string()),
|
return Error::new(409, err.to_string());
|
||||||
_ => (),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Error::new(500, err.to_string())
|
Error::new(500, err.to_string())
|
||||||
|
|||||||
@@ -1,2 +1,13 @@
|
|||||||
pub mod text_utils;
|
pub mod text_utils;
|
||||||
|
|
||||||
|
use rand::RngExt;
|
||||||
pub use text_utils::*;
|
pub use text_utils::*;
|
||||||
|
|
||||||
|
/// Generate a CSPRNG ID using alphanumeric characters (a-z, A-Z, 0-9)
|
||||||
|
pub fn csprng(take: usize) -> String {
|
||||||
|
rand::rng()
|
||||||
|
.sample_iter(rand::distr::Alphanumeric)
|
||||||
|
.take(take)
|
||||||
|
.map(char::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ pub fn a_or_an(word: &str) -> &'static str {
|
|||||||
let lowercase_word = word.to_lowercase();
|
let lowercase_word = word.to_lowercase();
|
||||||
|
|
||||||
// Special cases where the article should be "a"
|
// Special cases where the article should be "a"
|
||||||
let special_cases_a = vec!["one"];
|
let special_cases_a = ["one"];
|
||||||
if special_cases_a.contains(&lowercase_word.as_str()) {
|
if special_cases_a.contains(&lowercase_word.as_str()) {
|
||||||
return "a";
|
return "a";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special cases where the article should be "an"
|
// Special cases where the article should be "an"
|
||||||
let special_cases_an = vec!["hour"];
|
let special_cases_an = ["hour"];
|
||||||
if special_cases_an.contains(&lowercase_word.as_str()) {
|
if special_cases_an.contains(&lowercase_word.as_str()) {
|
||||||
return "an";
|
return "an";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ fn initialize_environment() -> std::io::Result<()> {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
if file_name.starts_with(".env") && !file_name.ends_with(".example") && path.is_file() {
|
if file_name.starts_with(".env") && !file_name.ends_with(".example") && path.is_file() {
|
||||||
if let Err(err) = from_filename(&file_name) {
|
if let Err(err) = from_filename(file_name) {
|
||||||
eprintln!("Failed to load {}: {}", file_name, err);
|
eprintln!("Failed to load {}: {}", file_name, err);
|
||||||
} else {
|
} else {
|
||||||
println!("Loaded: {}", file_name);
|
println!("Loaded: {}", file_name);
|
||||||
|
|||||||
@@ -70,24 +70,43 @@ CREATE TABLE IF NOT EXISTS bestiary (
|
|||||||
-- Auth / Users
|
-- Auth / Users
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- Stores Discord user info, upserted on every successful OAuth login
|
-- Core local user accounts. password_hash is NULL for OAuth-only users.
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id BIGINT PRIMARY KEY NOT NULL,
|
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
|
||||||
username TEXT NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
avatar TEXT,
|
password_hash TEXT,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- External OAuth provider connections (Discord, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_connections (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_user_id TEXT NOT NULL,
|
||||||
|
provider_username TEXT,
|
||||||
|
provider_avatar TEXT,
|
||||||
|
PRIMARY KEY (user_id, provider),
|
||||||
|
UNIQUE (provider, provider_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Grid maps: unbounded canvas, CSPRNG TEXT ids, auth-aware
|
-- Grid maps: unbounded canvas, CSPRNG TEXT ids, auth-aware
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
|
-- public_access: 'private' | 'public_view' | 'public_edit'
|
||||||
|
-- private – only users with explicit map_permissions can see/edit
|
||||||
|
-- public_view – anyone with the link can view; only permissioned users can edit
|
||||||
|
-- public_edit – anyone with the link can view AND edit
|
||||||
CREATE TABLE IF NOT EXISTS grid_maps (
|
CREATE TABLE IF NOT EXISTS grid_maps (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
public_access TEXT NOT NULL DEFAULT 'private'
|
||||||
owner_id BIGINT NOT NULL REFERENCES users(id),
|
CHECK (public_access IN ('private', 'public_view', 'public_edit')),
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id),
|
||||||
colors TEXT[] NOT NULL DEFAULT ARRAY[
|
colors TEXT[] NOT NULL DEFAULT ARRAY[
|
||||||
'#6b7280',
|
'#6b7280',
|
||||||
'#92400e',
|
'#92400e',
|
||||||
@@ -106,11 +125,32 @@ CREATE TABLE IF NOT EXISTS grid_maps (
|
|||||||
-- Per-map role assignments; owner is auto-inserted on map creation
|
-- Per-map role assignments; owner is auto-inserted on map creation
|
||||||
CREATE TABLE IF NOT EXISTS map_permissions (
|
CREATE TABLE IF NOT EXISTS map_permissions (
|
||||||
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
||||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
|
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
|
||||||
PRIMARY KEY (map_id, user_id)
|
PRIMARY KEY (map_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Maps a user has favorited; makes them appear in the user's map list modal
|
||||||
|
-- even if they have no explicit map_permissions entry (e.g. public maps)
|
||||||
|
CREATE TABLE IF NOT EXISTS map_favorites (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, map_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pending/resolved requests from users wanting viewer or editor access
|
||||||
|
CREATE TABLE IF NOT EXISTS map_access_requests (
|
||||||
|
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (map_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
-- Composite primary key replaces the old UUID id column
|
-- Composite primary key replaces the old UUID id column
|
||||||
CREATE TABLE IF NOT EXISTS grid_cells (
|
CREATE TABLE IF NOT EXISTS grid_cells (
|
||||||
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
||||||
|
|||||||
28
ui/eslint.config.js
Normal file
28
ui/eslint.config.js
Normal 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -8,6 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,18 +5,28 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.4",
|
||||||
"react-icons": "^5.6.0"
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.1",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/node": "^25.5.2",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@types/react": "^19.2.14",
|
||||||
"typescript": "^5.5.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"vite": "^5.3.4"
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"prettier": "^3.7.2",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
ui/prettierrc.json
Normal file
8
ui/prettierrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "none",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
221
ui/src/App.css
221
ui/src/App.css
@@ -1,4 +1,4 @@
|
|||||||
/* ---- Full-viewport shell ---- */
|
/* ── Full-viewport shell ── */
|
||||||
.app {
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -6,132 +6,21 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Top header ---- */
|
/* ── App body (everything below the header) ── */
|
||||||
.app-header {
|
.app-body {
|
||||||
flex-shrink: 0;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
background: #1f2937;
|
|
||||||
border-bottom: 1px solid #374151;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-brand {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #f9fafb;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-brand span {
|
|
||||||
color: #818cf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-map-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.map-select {
|
|
||||||
background: #111827;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e5e7eb;
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
min-width: 160px;
|
|
||||||
max-width: 280px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-select:focus {
|
|
||||||
border-color: #6366f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-select option {
|
|
||||||
background: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-btn {
|
|
||||||
background: #374151;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e5e7eb;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 0.3rem 0.65rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-btn:hover {
|
|
||||||
background: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-btn.danger:hover {
|
|
||||||
background: #7f1d1d;
|
|
||||||
border-color: #ef4444;
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-map-form {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
overflow: hidden;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-map-form input {
|
/* ── Grid area (fills the app body) ── */
|
||||||
background: #111827;
|
|
||||||
border: 1px solid #6366f1;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e5e7eb;
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
width: 160px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-map-form button {
|
|
||||||
background: #6366f1;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 0.3rem 0.65rem;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-map-form button:hover {
|
|
||||||
background: #4f46e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-map-form .cancel-btn {
|
|
||||||
background: #374151;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-map-form .cancel-btn:hover {
|
|
||||||
background: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Grid area (fills remainder) ---- */
|
|
||||||
.app-grid-area {
|
.app-grid-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Floating panel stack – bottom-left corner ── */
|
/* ── Floating control panels – bottom-left corner ── */
|
||||||
.floating-panels-container {
|
.floating-panels-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 14px;
|
bottom: 14px;
|
||||||
@@ -142,7 +31,7 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- No-map placeholder ---- */
|
/* ── Empty state placeholder ── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -151,9 +40,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,34 +53,78 @@
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Auth area (right side of header) ---- */
|
/* ── Access denied state ── */
|
||||||
.app-auth {
|
.access-denied-state {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
justify-content: center;
|
||||||
margin-left: auto;
|
gap: 1rem;
|
||||||
flex-shrink: 0;
|
color: #4b5563;
|
||||||
}
|
|
||||||
|
|
||||||
.app-username {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Public checkbox in new-map form ---- */
|
|
||||||
.new-map-public {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-map-public input[type='checkbox'] {
|
.access-denied-title {
|
||||||
accent-color: #6366f1;
|
margin: 0;
|
||||||
cursor: pointer;
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-request-sent {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-request-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-request-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-request-access {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.45rem 1.1rem;
|
||||||
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-request-access:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.25);
|
||||||
|
border-color: rgba(99, 102, 241, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.link-btn:hover {
|
||||||
|
color: #a5b4fc;
|
||||||
}
|
}
|
||||||
|
|||||||
450
ui/src/App.tsx
450
ui/src/App.tsx
@@ -1,262 +1,253 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from "react";
|
||||||
import type { GridMap, Tool, TokenClaims } from './types';
|
import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types";
|
||||||
import type { GridHandle } from './components/Grid';
|
import type { GridHandle } from "./components/Grid";
|
||||||
import { api, auth, getToken, setToken, decodeToken } from './api';
|
import { api, auth } from "./api";
|
||||||
import ControlPanel from './components/ControlPanel.tsx';
|
import Header from "./components/Header";
|
||||||
import ColorPanel from './components/ColorPanel';
|
import ControlPanel from "./components/ControlPanel";
|
||||||
import Grid from './components/Grid';
|
import ColorPanel from "./components/ColorPanel";
|
||||||
import LoginButton from './components/LoginButton';
|
import Grid from "./components/Grid";
|
||||||
import './App.css';
|
import LoginModal from "./components/LoginModal";
|
||||||
|
import AccountPanel from "./components/AccountPanel";
|
||||||
|
import FloatingMapControls from "./components/FloatingMapControls";
|
||||||
|
import NewMapModal from "./components/NewMapModal";
|
||||||
|
import EditMapModal from "./components/EditMapModal";
|
||||||
|
import MapListModal from "./components/MapListModal";
|
||||||
|
import "./components/Modal.css";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
/** Default colors shown before a map's own colors load from the server. */
|
|
||||||
const DEFAULT_COLORS = [
|
const DEFAULT_COLORS = [
|
||||||
'#6b7280', // 1 – stone
|
"#6b7280",
|
||||||
'#92400e', // 2 – earth
|
"#92400e",
|
||||||
'#15803d', // 3 – grass
|
"#15803d",
|
||||||
'#1d4ed8', // 4 – water
|
"#1d4ed8",
|
||||||
'#7c3aed', // 5 – arcane
|
"#7c3aed",
|
||||||
'#dc2626', // 6 – lava
|
"#dc2626",
|
||||||
'#ca8a04', // 7 – sand
|
"#ca8a04",
|
||||||
'#0f172a', // 8 – void
|
"#0f172a",
|
||||||
'#f9fafb', // 9 – white
|
"#f9fafb",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Read the map ID from the current URL path (/map/:id). */
|
|
||||||
function getMapIdFromUrl(): string | null {
|
function getMapIdFromUrl(): string | null {
|
||||||
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
|
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a query parameter value from the current URL. */
|
|
||||||
function getQueryParam(key: string): string | null {
|
function getQueryParam(key: string): string | null {
|
||||||
return new URLSearchParams(window.location.search).get(key);
|
return new URLSearchParams(window.location.search).get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip a query parameter from the current URL without causing a reload. */
|
|
||||||
function removeQueryParam(key: string) {
|
function removeQueryParam(key: string) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.delete(key);
|
url.searchParams.delete(key);
|
||||||
window.history.replaceState(null, '', url.pathname + (url.search !== '?' ? url.search : ''));
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
url.pathname + (url.search !== "?" ? url.search : ""),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// ---- Auth state ----
|
// ── Auth state ──
|
||||||
const [user, setUser] = useState<TokenClaims | null>(() => {
|
const [user, setUser] = useState<UserInfo | null>(null);
|
||||||
const token = getToken();
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
return token ? decodeToken(token) : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Map state ----
|
// ── Map state ──
|
||||||
const [maps, setMaps] = useState<GridMap[]>([]);
|
const [maps, setMaps] = useState<ListedMap[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
|
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
|
||||||
|
/** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */
|
||||||
|
const [directMapInfo, setDirectMapInfo] = useState<GridMap | null>(null);
|
||||||
|
/** True when the current selectedId returned 403 (no access). */
|
||||||
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
|
const [accessRequestSent, setAccessRequestSent] = useState(false);
|
||||||
|
|
||||||
// Tool + unified active color (shared between draw and token)
|
// ── Tool + color ──
|
||||||
const [tool, setTool] = useState<Tool>('pan');
|
const [tool, setTool] = useState<Tool>("pan");
|
||||||
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
|
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
|
||||||
|
|
||||||
// Per-map color palette (updated from WS state on map load / color edits)
|
|
||||||
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
|
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
|
||||||
|
|
||||||
// Ref to Grid so App can push color updates through the WS
|
|
||||||
const gridRef = useRef<GridHandle>(null);
|
const gridRef = useRef<GridHandle>(null);
|
||||||
|
|
||||||
// New-map form
|
// ── Modal visibility ──
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [showAccountPanel, setShowAccountPanel] = useState(false);
|
||||||
const [showNewMap, setShowNewMap] = useState(false);
|
const [showNewMap, setShowNewMap] = useState(false);
|
||||||
const [newMapName, setNewMapName] = useState('');
|
const [showEditMap, setShowEditMap] = useState(false);
|
||||||
const [newMapPublic, setNewMapPublic] = useState(false);
|
const [showMapList, setShowMapList] = useState(false);
|
||||||
const newMapInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// ---- Handle OAuth callback: ?token= or ?error= ----
|
// ── Derived ──
|
||||||
|
const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null;
|
||||||
|
const selectedMapInfo: GridMap | ListedMap | null =
|
||||||
|
selectedMapFromList ?? directMapInfo;
|
||||||
|
const isOwner =
|
||||||
|
user !== null &&
|
||||||
|
selectedMapInfo !== null &&
|
||||||
|
selectedMapInfo.owner_id === user.id;
|
||||||
|
|
||||||
|
// ── On mount: load session + handle OAuth errors ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getQueryParam('token');
|
auth.me().then((u) => {
|
||||||
const error = getQueryParam('error');
|
setUser(u);
|
||||||
|
setAuthLoading(false);
|
||||||
if (token) {
|
});
|
||||||
setToken(token);
|
const error = getQueryParam("error");
|
||||||
const claims = decodeToken(token);
|
if (error) {
|
||||||
setUser(claims);
|
console.error("OAuth error:", error);
|
||||||
removeQueryParam('token');
|
removeQueryParam("error");
|
||||||
} else if (error) {
|
|
||||||
console.error('OAuth error:', error);
|
|
||||||
removeQueryParam('error');
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- Load map list ----
|
// ── Load map list after auth resolves ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!authLoading) {
|
||||||
api.listMaps().then(setMaps).catch(console.error);
|
api.listMaps().then(setMaps).catch(console.error);
|
||||||
}, [user]); // re-fetch when auth state changes
|
}
|
||||||
|
}, [user, authLoading]);
|
||||||
|
|
||||||
// Once maps load, validate the URL-sourced selectedId still exists
|
// ── Direct fetch for URL-accessed maps not in the user's list ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (maps.length === 0 && selectedId) {
|
if (!selectedId || authLoading) {
|
||||||
// Maps are still loading — skip
|
setDirectMapInfo(null);
|
||||||
|
setAccessDenied(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedId) {
|
const inList = maps.some((m) => m.id === selectedId);
|
||||||
const exists = maps.some(m => m.id === selectedId);
|
if (inList) {
|
||||||
if (!exists) {
|
setDirectMapInfo(null);
|
||||||
// Invalid or inaccessible map ID — reroute to /map
|
setAccessDenied(false);
|
||||||
setSelectedId(null);
|
return;
|
||||||
window.history.replaceState(null, '', '/map');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [maps]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// Keep the URL in sync with the selected map
|
setDirectMapInfo(null);
|
||||||
|
setAccessDenied(false);
|
||||||
|
setAccessRequestSent(false);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getMap(selectedId)
|
||||||
|
.then((state) => {
|
||||||
|
setDirectMapInfo(state.map);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.startsWith("403")) {
|
||||||
|
setAccessDenied(true);
|
||||||
|
} else {
|
||||||
|
// 404 or unknown — clear invalid URL
|
||||||
|
setSelectedId(null);
|
||||||
|
window.history.replaceState(null, "", "/map");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Keep URL in sync ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : '/map';
|
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map";
|
||||||
window.history.replaceState(null, '', path);
|
window.history.replaceState(null, "", path);
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
// ── Reset palette + access state when map deselected ──
|
||||||
if (showNewMap) newMapInputRef.current?.focus();
|
|
||||||
}, [showNewMap]);
|
|
||||||
|
|
||||||
// Reset palette to defaults when no map is selected
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
setMapColors(DEFAULT_COLORS);
|
setMapColors(DEFAULT_COLORS);
|
||||||
setActiveColor(DEFAULT_COLORS[0]);
|
setActiveColor(DEFAULT_COLORS[0]);
|
||||||
|
setAccessRequestSent(false);
|
||||||
}
|
}
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
// ---- Derived state ----
|
// ── Handlers ──
|
||||||
const selectedMap = maps.find(m => m.id === selectedId) ?? null;
|
|
||||||
|
|
||||||
// The current user is considered the owner if their Discord ID matches owner_id
|
async function handleCreate(name: string, publicAccess: PublicAccess) {
|
||||||
const isOwner = user !== null && selectedMap !== null && selectedMap.owner_id === user.sub;
|
const m = await api.createMap(name, publicAccess);
|
||||||
|
// Optimistically add to list as an owner entry
|
||||||
// ---- Handlers ----
|
const listed: ListedMap = {
|
||||||
async function handleCreate(e: React.FormEvent) {
|
...m,
|
||||||
e.preventDefault();
|
owner_username: user!.username,
|
||||||
const name = newMapName.trim();
|
user_role: "owner",
|
||||||
if (!name) return;
|
is_favorited: false,
|
||||||
try {
|
};
|
||||||
const m = await api.createMap(name, newMapPublic);
|
setMaps((prev) => [listed, ...prev]);
|
||||||
setMaps(prev => [m, ...prev]);
|
|
||||||
setSelectedId(m.id);
|
setSelectedId(m.id);
|
||||||
setShowNewMap(false);
|
|
||||||
setNewMapName('');
|
|
||||||
setNewMapPublic(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create map', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
if (!confirm('Delete this map? This cannot be undone.')) return;
|
if (!confirm("Delete this map? This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteMap(selectedId);
|
await api.deleteMap(selectedId);
|
||||||
setMaps(prev => prev.filter(m => m.id !== selectedId));
|
setMaps((prev) => prev.filter((m) => m.id !== selectedId));
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete map', err);
|
console.error("Failed to delete map", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapUpdated(updated: GridMap) {
|
||||||
|
setMaps((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === updated.id
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
name: updated.name,
|
||||||
|
public_access: updated.public_access,
|
||||||
|
updated_at: updated.updated_at,
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (directMapInfo?.id === updated.id) {
|
||||||
|
setDirectMapInfo(updated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called by Grid when the WS state/colors_updated message arrives. */
|
|
||||||
function handleColorsLoaded(colors: string[]) {
|
function handleColorsLoaded(colors: string[]) {
|
||||||
setMapColors(colors);
|
setMapColors(colors);
|
||||||
setActiveColor(prev => colors.includes(prev) ? prev : colors[0]);
|
setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called by ColorPanel when the user double-clicks and edits a swatch. */
|
|
||||||
function handleColorsChange(colors: string[]) {
|
function handleColorsChange(colors: string[]) {
|
||||||
setMapColors(colors);
|
setMapColors(colors);
|
||||||
gridRef.current?.sendColorUpdate(colors);
|
gridRef.current?.sendColorUpdate(colors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUserRefresh() {
|
||||||
|
const u = await auth.me();
|
||||||
|
setUser(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequestAccess(role: "viewer" | "editor") {
|
||||||
|
if (!selectedId) return;
|
||||||
|
try {
|
||||||
|
await api.requestAccess(selectedId, role);
|
||||||
|
setAccessRequestSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to request access", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* ── Header ── */}
|
<Header
|
||||||
<header className="app-header">
|
user={user}
|
||||||
<div className="app-brand">
|
authLoading={authLoading}
|
||||||
<span>SIREN</span>
|
selectedMapName={selectedMapInfo?.name ?? null}
|
||||||
</div>
|
onLoginClick={() => setShowLoginModal(true)}
|
||||||
|
onAccountClick={() => setShowAccountPanel(true)}
|
||||||
<div className="app-map-controls">
|
|
||||||
{/* Map selector */}
|
|
||||||
{maps.length > 0 && !showNewMap && (
|
|
||||||
<select
|
|
||||||
className="map-select"
|
|
||||||
value={selectedId ?? ''}
|
|
||||||
onChange={e => setSelectedId(e.target.value || null)}
|
|
||||||
>
|
|
||||||
<option value="">— Select a map —</option>
|
|
||||||
{maps.map(m => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}{m.is_public ? ' (Public)' : ' (Private)'}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New map form — only for authenticated users */}
|
|
||||||
{user && (
|
|
||||||
showNewMap ? (
|
|
||||||
<form className="new-map-form" onSubmit={handleCreate}>
|
|
||||||
<input
|
|
||||||
ref={newMapInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Map name…"
|
|
||||||
value={newMapName}
|
|
||||||
onChange={e => setNewMapName(e.target.value)}
|
|
||||||
maxLength={60}
|
|
||||||
/>
|
/>
|
||||||
<label className="new-map-public">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={newMapPublic}
|
|
||||||
onChange={e => setNewMapPublic(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Public
|
|
||||||
</label>
|
|
||||||
<button type="submit" disabled={!newMapName.trim()}>Create</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="cancel-btn"
|
|
||||||
onClick={() => { setShowNewMap(false); setNewMapName(''); setNewMapPublic(false); }}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<button className="header-btn" onClick={() => setShowNewMap(true)}>
|
|
||||||
+ New Map
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete current map — only for the owner */}
|
<div className="app-body">
|
||||||
{isOwner && !showNewMap && (
|
|
||||||
<button className="header-btn danger" onClick={handleDelete} title="Delete this map">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Auth area ── */}
|
|
||||||
<div className="app-auth">
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<span className="app-username">{user.name}</span>
|
|
||||||
<button className="header-btn" onClick={() => { auth.logout(); setUser(null); }}>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<LoginButton className="header-btn" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Grid area ── */}
|
|
||||||
<div className="app-grid-area">
|
<div className="app-grid-area">
|
||||||
{selectedId ? (
|
{/* Top-left floating map controls */}
|
||||||
|
<FloatingMapControls
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
hasSelectedMap={!!selectedId}
|
||||||
|
isOwner={isOwner}
|
||||||
|
onNewMap={() => setShowNewMap(true)}
|
||||||
|
onViewMaps={() => setShowMapList(true)}
|
||||||
|
onEditMap={() => setShowEditMap(true)}
|
||||||
|
onDeleteMap={handleDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedId && !accessDenied ? (
|
||||||
<>
|
<>
|
||||||
{/* key forces full remount (new WS + clear state) on map change */}
|
|
||||||
<Grid
|
<Grid
|
||||||
key={selectedId}
|
key={selectedId}
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
@@ -267,10 +258,7 @@ export default function App() {
|
|||||||
onColorsLoaded={handleColorsLoaded}
|
onColorsLoaded={handleColorsLoaded}
|
||||||
/>
|
/>
|
||||||
<div className="floating-panels-container">
|
<div className="floating-panels-container">
|
||||||
<ControlPanel
|
<ControlPanel tool={tool} onToolChange={setTool} />
|
||||||
tool={tool}
|
|
||||||
onToolChange={setTool}
|
|
||||||
/>
|
|
||||||
<ColorPanel
|
<ColorPanel
|
||||||
colors={mapColors}
|
colors={mapColors}
|
||||||
activeColor={activeColor}
|
activeColor={activeColor}
|
||||||
@@ -279,19 +267,105 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : accessDenied ? (
|
||||||
|
<div className="access-denied-state">
|
||||||
|
<p className="access-denied-title">
|
||||||
|
You don't have access to this map
|
||||||
|
</p>
|
||||||
|
{!user ? (
|
||||||
|
<p className="access-denied-hint">
|
||||||
|
<button
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => setShowLoginModal(true)}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>{" "}
|
||||||
|
to request access or view your permissions.
|
||||||
|
</p>
|
||||||
|
) : accessRequestSent ? (
|
||||||
|
<p className="access-denied-hint access-request-sent">
|
||||||
|
✓ Access request sent! The map owner will be notified.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="access-request-actions">
|
||||||
|
<p className="access-denied-hint">
|
||||||
|
Request access from the map owner:
|
||||||
|
</p>
|
||||||
|
<div className="access-request-btns">
|
||||||
|
<button
|
||||||
|
className="btn-request-access"
|
||||||
|
onClick={() => handleRequestAccess("viewer")}
|
||||||
|
>
|
||||||
|
Request Viewer Access
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-request-access"
|
||||||
|
onClick={() => handleRequestAccess("editor")}
|
||||||
|
>
|
||||||
|
Request Editor Access
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>Select or create a map to begin</p>
|
<p>Select or create a map to begin</p>
|
||||||
<p className="empty-hint">
|
<p className="empty-hint">
|
||||||
{!user
|
{!user
|
||||||
? 'Log in with Discord to create maps and access private maps'
|
? "Log in to create maps and access private maps"
|
||||||
: maps.length === 0
|
: maps.length === 0
|
||||||
? 'Click "+ New Map" in the header to get started'
|
? 'Click "+ New Map" in the top-left to get started'
|
||||||
: 'Choose a map from the header dropdown'}
|
: 'Click "Maps" in the top-left to choose a map'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Global modals (always available regardless of page) ── */}
|
||||||
|
{showLoginModal && (
|
||||||
|
<LoginModal
|
||||||
|
onClose={() => setShowLoginModal(false)}
|
||||||
|
onLogin={async (u) => {
|
||||||
|
setUser(u);
|
||||||
|
api.listMaps().then(setMaps).catch(console.error);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAccountPanel && user && (
|
||||||
|
<AccountPanel
|
||||||
|
user={user}
|
||||||
|
onClose={() => setShowAccountPanel(false)}
|
||||||
|
onRefresh={handleUserRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewMap && (
|
||||||
|
<NewMapModal
|
||||||
|
onClose={() => setShowNewMap(false)}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditMap && selectedMapInfo && (
|
||||||
|
<EditMapModal
|
||||||
|
map={selectedMapInfo}
|
||||||
|
onClose={() => setShowEditMap(false)}
|
||||||
|
onUpdated={handleMapUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMapList && (
|
||||||
|
<MapListModal
|
||||||
|
maps={maps}
|
||||||
|
selectedMapId={selectedId}
|
||||||
|
onSelect={(id) => setSelectedId(id)}
|
||||||
|
onClose={() => setShowMapList(false)}
|
||||||
|
onMapsChange={setMaps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
296
ui/src/api.ts
296
ui/src/api.ts
@@ -1,126 +1,232 @@
|
|||||||
import type { GridMap, MapPermission, MapRole, MapState, TokenClaims } from './types';
|
import type {
|
||||||
|
GridMap,
|
||||||
|
ListedMap,
|
||||||
|
MapAccessRequest,
|
||||||
|
MapPermission,
|
||||||
|
MapRole,
|
||||||
|
MapState,
|
||||||
|
PublicAccess,
|
||||||
|
UserInfo,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const BASE = '/api/grid';
|
const GRID_BASE = "/api/grid";
|
||||||
const AUTH_BASE = '/api/auth/discord';
|
const AUTH_BASE = "/api/auth";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Token helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const TOKEN_KEY = 'siren_token';
|
|
||||||
|
|
||||||
export function getToken(): string | null {
|
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToken(token: string): void {
|
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeToken(): void {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode the JWT payload without verifying the signature (client-side only). */
|
|
||||||
export function decodeToken(token: string): TokenClaims | null {
|
|
||||||
try {
|
|
||||||
const payload = token.split('.')[1];
|
|
||||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
||||||
return JSON.parse(json) as TokenClaims;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true if the stored token is present and not expired. */
|
|
||||||
// export function isAuthenticated(): boolean {
|
|
||||||
// const token = getToken();
|
|
||||||
// if (!token) return false;
|
|
||||||
// const claims = decodeToken(token);
|
|
||||||
// if (!claims) return false;
|
|
||||||
// return claims.exp > Date.now() / 1000;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Core fetch wrapper
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const token = getToken();
|
const res = await fetch(url, {
|
||||||
const headers: Record<string, string> = {
|
...init,
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
...(init?.headers as Record<string, string>),
|
...(init?.headers as Record<string, string>),
|
||||||
};
|
},
|
||||||
|
});
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, { ...init, headers });
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
// Token expired or invalid — clear local storage
|
|
||||||
removeToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => res.statusText);
|
const text = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(`${res.status}: ${text}`);
|
throw new Error(`${res.status}: ${text}`);
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T;
|
|
||||||
return res.json();
|
// Read the bdoy if it exists
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return undefined as T;
|
||||||
|
|
||||||
|
// Parse JSON if it exists
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} else if (contentType.includes("text/plain")) {
|
||||||
|
return text as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Expected JSON or text but got: ${contentType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Grid map API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
listMaps: (): Promise<GridMap[]> =>
|
/** List maps where the authenticated user has a direct role or has favorited. */
|
||||||
request<GridMap[]>(`${BASE}/maps`),
|
listMaps: (): Promise<ListedMap[]> =>
|
||||||
|
request<ListedMap[]>(`${GRID_BASE}/maps`),
|
||||||
|
|
||||||
createMap: (name: string, is_public = false): Promise<GridMap> =>
|
/** Create a new map (authenticated). */
|
||||||
request<GridMap>(`${BASE}/maps`, {
|
createMap: (
|
||||||
method: 'POST',
|
name: string,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
public_access: PublicAccess = "private",
|
||||||
body: JSON.stringify({ name, is_public }),
|
): Promise<GridMap> =>
|
||||||
|
request<GridMap>(`${GRID_BASE}/maps`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, public_access }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Get full map state (cells, tokens, colors). */
|
||||||
getMap: (id: string): Promise<MapState> =>
|
getMap: (id: string): Promise<MapState> =>
|
||||||
request<MapState>(`${BASE}/maps/${id}`),
|
request<MapState>(`${GRID_BASE}/maps/${id}`),
|
||||||
|
|
||||||
|
/** Update map name and/or public_access (owner only). */
|
||||||
|
updateMap: (
|
||||||
|
id: string,
|
||||||
|
payload: { name?: string; public_access?: PublicAccess },
|
||||||
|
): Promise<GridMap> =>
|
||||||
|
request<GridMap>(`${GRID_BASE}/maps/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Delete a map (owner only). */
|
||||||
deleteMap: (id: string): Promise<void> =>
|
deleteMap: (id: string): Promise<void> =>
|
||||||
request<void>(`${BASE}/maps/${id}`, { method: 'DELETE' }),
|
request<void>(`${GRID_BASE}/maps/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// ---- Permissions ----
|
// ---- Permissions ----
|
||||||
|
|
||||||
|
/** List all permissions for a map including usernames (owner only). */
|
||||||
listPermissions: (mapId: string): Promise<MapPermission[]> =>
|
listPermissions: (mapId: string): Promise<MapPermission[]> =>
|
||||||
request<MapPermission[]>(`${BASE}/maps/${mapId}/permissions`),
|
request<MapPermission[]>(`${GRID_BASE}/maps/${mapId}/permissions`),
|
||||||
|
|
||||||
updatePermission: (mapId: string, userId: number, role: MapRole | null): Promise<void> =>
|
/**
|
||||||
request<void>(`${BASE}/maps/${mapId}/permissions`, {
|
* Add or update a user's role by username.
|
||||||
method: 'PUT',
|
* Pass `role: null` to remove the user's permission entirely.
|
||||||
headers: { 'Content-Type': 'application/json' },
|
*/
|
||||||
body: JSON.stringify({ user_id: userId, role }),
|
updatePermission: (
|
||||||
|
mapId: string,
|
||||||
|
username: string,
|
||||||
|
role: MapRole | null,
|
||||||
|
): Promise<void> =>
|
||||||
|
request<void>(`${GRID_BASE}/maps/${mapId}/permissions`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, role }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Favorite a map (adds it to the user's map list). */
|
||||||
|
favoriteMap: (id: string): Promise<void> =>
|
||||||
|
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "POST" }),
|
||||||
|
|
||||||
|
/** Un-favorite a map. */
|
||||||
|
unfavoriteMap: (id: string): Promise<void> =>
|
||||||
|
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
/** Request viewer or editor access to a map. */
|
||||||
|
requestAccess: (mapId: string, role: "editor" | "viewer"): Promise<void> =>
|
||||||
|
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** List pending access requests for a map (owner only). */
|
||||||
|
listAccessRequests: (mapId: string): Promise<MapAccessRequest[]> =>
|
||||||
|
request<MapAccessRequest[]>(`${GRID_BASE}/maps/${mapId}/access-requests`),
|
||||||
|
|
||||||
|
/** Approve or deny a pending access request (owner only). */
|
||||||
|
resolveAccessRequest: (
|
||||||
|
mapId: string,
|
||||||
|
requestId: string,
|
||||||
|
action: "approve" | "deny",
|
||||||
|
): Promise<void> =>
|
||||||
|
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Auth API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const auth = {
|
export const auth = {
|
||||||
/** Fetches the Discord OAuth URL and redirects the browser to it.
|
/** Fetch the currently authenticated user's info. Returns null if not logged in. */
|
||||||
* Passes the current page's origin + /map as the UI redirect URI so
|
async me(): Promise<UserInfo | null> {
|
||||||
* the backend knows where to send the browser after login completes.
|
try {
|
||||||
*/
|
return await request<UserInfo>(`${AUTH_BASE}/me`);
|
||||||
async login(): Promise<void> {
|
} catch {
|
||||||
const redirectUri = encodeURIComponent(window.location.origin + '/map');
|
return null;
|
||||||
const url = await request<string>(`${AUTH_BASE}/authorize?redirect_uri=${redirectUri}`);
|
}
|
||||||
window.location.href = url;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
logout(): void {
|
/** Register a new local account. */
|
||||||
removeToken();
|
async register(username: string, password: string): Promise<void> {
|
||||||
window.location.href = '/map';
|
await request<void>(`${AUTH_BASE}/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Login with username and password. */
|
||||||
|
async loginLocal(username: string, password: string): Promise<void> {
|
||||||
|
await request<void>(`${AUTH_BASE}/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Start Discord OAuth login flow (anonymous). */
|
||||||
|
async loginDiscord(redirectUri?: string): Promise<void> {
|
||||||
|
const target = encodeURIComponent(redirectUri ?? window.location.href);
|
||||||
|
const response = await request<string>(
|
||||||
|
`${AUTH_BASE}/discord/authorize?redirect_uri=${target}`,
|
||||||
|
);
|
||||||
|
window.location.href = JSON.parse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Start Discord OAuth connect flow (authenticated users only). */
|
||||||
|
async connectDiscord(redirectUri?: string): Promise<void> {
|
||||||
|
const target = encodeURIComponent(
|
||||||
|
redirectUri ?? window.location.origin + "/account",
|
||||||
|
);
|
||||||
|
const response = await request<string>(
|
||||||
|
`${AUTH_BASE}/discord/connect?redirect_uri=${target}`,
|
||||||
|
);
|
||||||
|
window.location.href = JSON.parse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Clear the session cookie server-side and reload. */
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await request<void>(`${AUTH_BASE}/logout`, { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
window.location.href = "/map";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's first and/or last name.
|
||||||
|
* Pass an empty string to clear a field; pass undefined to leave it unchanged.
|
||||||
|
*/
|
||||||
|
async updateProfile(
|
||||||
|
firstName?: string,
|
||||||
|
lastName?: string,
|
||||||
|
): Promise<UserInfo> {
|
||||||
|
return request<UserInfo>(`${AUTH_BASE}/profile`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ first_name: firstName, last_name: lastName }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change or set the account password.
|
||||||
|
* `currentPassword` is required when the account already has a password,
|
||||||
|
* and can be omitted (null/undefined) for OAuth-only accounts setting a
|
||||||
|
* password for the first time.
|
||||||
|
*/
|
||||||
|
async changePassword(
|
||||||
|
currentPassword: string | null,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await request<void>(`${AUTH_BASE}/change-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: currentPassword ?? undefined,
|
||||||
|
new_password: newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Disconnect an OAuth provider (requires a password to be set first). */
|
||||||
|
async disconnectProvider(provider: string): Promise<void> {
|
||||||
|
await request<void>(`${AUTH_BASE}/connections/${provider}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
316
ui/src/components/AccountPanel.css
Normal file
316
ui/src/components/AccountPanel.css
Normal 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;
|
||||||
|
}
|
||||||
358
ui/src/components/AccountPanel.tsx
Normal file
358
ui/src/components/AccountPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@
|
|||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: transform 0.1s, border-color 0.1s;
|
transition:
|
||||||
|
transform 0.1s,
|
||||||
|
border-color 0.1s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from "react";
|
||||||
import './ColorPanel.css';
|
import "./ColorPanel.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
@@ -8,7 +8,12 @@ interface Props {
|
|||||||
onColorsChange: (colors: string[]) => void;
|
onColorsChange: (colors: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ColorPanel({ colors, activeColor, onColorChange, onColorsChange }: Props) {
|
export default function ColorPanel({
|
||||||
|
colors,
|
||||||
|
activeColor,
|
||||||
|
onColorChange,
|
||||||
|
onColorsChange,
|
||||||
|
}: Props) {
|
||||||
// One hidden color input ref per slot
|
// One hidden color input ref per slot
|
||||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
@@ -16,14 +21,18 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement
|
||||||
|
)
|
||||||
|
return;
|
||||||
const num = parseInt(e.key, 10);
|
const num = parseInt(e.key, 10);
|
||||||
if (num >= 1 && num <= colors.length) {
|
if (num >= 1 && num <= colors.length) {
|
||||||
onColorChange(colors[num - 1]);
|
onColorChange(colors[num - 1]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [colors, onColorChange]);
|
}, [colors, onColorChange]);
|
||||||
|
|
||||||
function handleDoubleClick(index: number) {
|
function handleDoubleClick(index: number) {
|
||||||
@@ -44,7 +53,7 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
|
|||||||
{colors.map((c, i) => (
|
{colors.map((c, i) => (
|
||||||
<div key={i} className="cp-swatch-wrapper">
|
<div key={i} className="cp-swatch-wrapper">
|
||||||
<button
|
<button
|
||||||
className={`cp-swatch ${activeColor === c ? 'selected' : ''}`}
|
className={`cp-swatch ${activeColor === c ? "selected" : ""}`}
|
||||||
style={{ background: c }}
|
style={{ background: c }}
|
||||||
onClick={() => onColorChange(c)}
|
onClick={() => onColorChange(c)}
|
||||||
onDoubleClick={() => handleDoubleClick(i)}
|
onDoubleClick={() => handleDoubleClick(i)}
|
||||||
@@ -54,10 +63,12 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
|
|||||||
</button>
|
</button>
|
||||||
{/* Hidden color picker for this slot */}
|
{/* Hidden color picker for this slot */}
|
||||||
<input
|
<input
|
||||||
ref={el => { inputRefs.current[i] = el; }}
|
ref={(el) => {
|
||||||
|
inputRefs.current[i] = el;
|
||||||
|
}}
|
||||||
type="color"
|
type="color"
|
||||||
value={c}
|
value={c}
|
||||||
onChange={e => handleColorEdit(i, e.target.value)}
|
onChange={(e) => handleColorEdit(i, e.target.value)}
|
||||||
className="cp-color-input"
|
className="cp-color-input"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background 0.12s, border-color 0.12s;
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
border-color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fp-tool-btn:hover {
|
.fp-tool-btn:hover {
|
||||||
|
|||||||
@@ -1,47 +1,85 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from 'react-icons/md';
|
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from "react-icons/md";
|
||||||
import type { Tool } from '../types';
|
import type { Tool } from "../types";
|
||||||
import './ControlPanel.css';
|
import "./ControlPanel.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
onToolChange: (t: Tool) => void;
|
onToolChange: (t: Tool) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOLS: { id: Tool; icon: React.ReactNode; title: string; shortcut: string }[] = [
|
const TOOLS: {
|
||||||
{ id: 'pan', icon: <MdPanTool />, title: 'Pan – drag to move the map', shortcut: 'Shift+1' },
|
id: Tool;
|
||||||
{ id: 'zoom', icon: <MdZoomIn />, title: 'Zoom – click to zoom in/out', shortcut: 'Shift+2' },
|
icon: React.ReactNode;
|
||||||
{ id: 'draw', icon: <MdBrush />, title: 'Draw – left-click to paint, right-click to erase, Shift+click to fill', shortcut: 'Shift+3' },
|
title: string;
|
||||||
{ id: 'token', icon: <MdPerson />, title: 'Token – click to place, drag to move, right-click to delete', shortcut: 'Shift+4' },
|
shortcut: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: "pan",
|
||||||
|
icon: <MdPanTool />,
|
||||||
|
title: "Pan – drag to move the map",
|
||||||
|
shortcut: "Shift+1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zoom",
|
||||||
|
icon: <MdZoomIn />,
|
||||||
|
title: "Zoom – click to zoom in/out",
|
||||||
|
shortcut: "Shift+2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "draw",
|
||||||
|
icon: <MdBrush />,
|
||||||
|
title:
|
||||||
|
"Draw – left-click to paint, right-click to erase, Shift+click to fill",
|
||||||
|
shortcut: "Shift+3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "token",
|
||||||
|
icon: <MdPerson />,
|
||||||
|
title: "Token – click to place, drag to move, right-click to delete",
|
||||||
|
shortcut: "Shift+4",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ControlPanel({ tool, onToolChange }: Props) {
|
export default function ControlPanel({ tool, onToolChange }: Props) {
|
||||||
// Keyboard shortcuts: Shift+1/2/3/4 for tools
|
// Keyboard shortcuts: Shift+1/2/3/4 for tools
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!e.shiftKey) return;
|
if (!e.shiftKey) return;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case '!': // Shift+1 on many layouts
|
case "!": // Shift+1 on many layouts
|
||||||
case '1': onToolChange('pan'); break;
|
case "1":
|
||||||
case '@': // Shift+2
|
onToolChange("pan");
|
||||||
case '2': onToolChange('zoom'); break;
|
break;
|
||||||
case '#': // Shift+3
|
case "@": // Shift+2
|
||||||
case '3': onToolChange('draw'); break;
|
case "2":
|
||||||
case '$': // Shift+4
|
onToolChange("zoom");
|
||||||
case '4': onToolChange('token'); break;
|
break;
|
||||||
|
case "#": // Shift+3
|
||||||
|
case "3":
|
||||||
|
onToolChange("draw");
|
||||||
|
break;
|
||||||
|
case "$": // Shift+4
|
||||||
|
case "4":
|
||||||
|
onToolChange("token");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [onToolChange]);
|
}, [onToolChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="floating-panel">
|
<div className="floating-panel">
|
||||||
{TOOLS.map(t => (
|
{TOOLS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={`fp-tool-btn ${tool === t.id ? 'active' : ''}`}
|
className={`fp-tool-btn ${tool === t.id ? "active" : ""}`}
|
||||||
onClick={() => onToolChange(t.id)}
|
onClick={() => onToolChange(t.id)}
|
||||||
title={`${t.title} (${t.shortcut})`}
|
title={`${t.title} (${t.shortcut})`}
|
||||||
>
|
>
|
||||||
|
|||||||
198
ui/src/components/EditMapModal.css
Normal file
198
ui/src/components/EditMapModal.css
Normal 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;
|
||||||
|
}
|
||||||
326
ui/src/components/EditMapModal.tsx
Normal file
326
ui/src/components/EditMapModal.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
ui/src/components/FloatingMapControls.css
Normal file
54
ui/src/components/FloatingMapControls.css
Normal 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;
|
||||||
|
}
|
||||||
75
ui/src/components/FloatingMapControls.tsx
Normal file
75
ui/src/components/FloatingMapControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
useRef, useEffect, useCallback, useState,
|
useRef,
|
||||||
forwardRef, useImperativeHandle,
|
useEffect,
|
||||||
} from 'react';
|
useCallback,
|
||||||
import type { GridCell, GridToken, Tool, ServerMessage, ClientMessage } from '../types';
|
useState,
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
forwardRef,
|
||||||
import TokenDialog from './TokenDialog';
|
useImperativeHandle,
|
||||||
import './Grid.css';
|
} from "react";
|
||||||
|
import type {
|
||||||
|
GridCell,
|
||||||
|
GridToken,
|
||||||
|
Tool,
|
||||||
|
ServerMessage,
|
||||||
|
ClientMessage,
|
||||||
|
} from "../types";
|
||||||
|
import { useWebSocket } from "../hooks/useWebSocket";
|
||||||
|
import TokenDialog from "./TokenDialog";
|
||||||
|
import "./Grid.css";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -16,9 +26,9 @@ const MIN_ZOOM = 8;
|
|||||||
const MAX_ZOOM = 160;
|
const MAX_ZOOM = 160;
|
||||||
const ZOOM_STEP = 1.12;
|
const ZOOM_STEP = 1.12;
|
||||||
|
|
||||||
const BG_COLOR = '#111827';
|
const BG_COLOR = "#111827";
|
||||||
const GRID_COLOR = 'rgba(255,255,255,0.07)';
|
const GRID_COLOR = "rgba(255,255,255,0.07)";
|
||||||
const GRID_COLOR_MAJOR = 'rgba(255,255,255,0.16)';
|
const GRID_COLOR_MAJOR = "rgba(255,255,255,0.16)";
|
||||||
|
|
||||||
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
|
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
|
||||||
const MAX_FLOOD_CELLS = 2500;
|
const MAX_FLOOD_CELLS = 2500;
|
||||||
@@ -56,14 +66,22 @@ function cellKey(x: number, y: number): string {
|
|||||||
return `${x},${y}`;
|
return `${x},${y}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasToCell(cx: number, cy: number, cam: Camera): { x: number; y: number } {
|
function canvasToCell(
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
cam: Camera,
|
||||||
|
): { x: number; y: number } {
|
||||||
return {
|
return {
|
||||||
x: Math.floor(cx / cam.zoom + cam.offsetX),
|
x: Math.floor(cx / cam.zoom + cam.offsetX),
|
||||||
y: Math.floor(cy / cam.zoom + cam.offsetY),
|
y: Math.floor(cy / cam.zoom + cam.offsetY),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellToCanvas(cellX: number, cellY: number, cam: Camera): { x: number; y: number } {
|
function cellToCanvas(
|
||||||
|
cellX: number,
|
||||||
|
cellY: number,
|
||||||
|
cam: Camera,
|
||||||
|
): { x: number; y: number } {
|
||||||
return {
|
return {
|
||||||
x: (cellX - cam.offsetX) * cam.zoom,
|
x: (cellX - cam.offsetX) * cam.zoom,
|
||||||
y: (cellY - cam.offsetY) * cam.zoom,
|
y: (cellY - cam.offsetY) * cam.zoom,
|
||||||
@@ -84,7 +102,7 @@ function drawToken(
|
|||||||
const cy = py + zoom / 2;
|
const cy = py + zoom / 2;
|
||||||
const r = zoom * 0.38;
|
const r = zoom * 0.38;
|
||||||
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
ctx.shadowColor = "rgba(0,0,0,0.6)";
|
||||||
ctx.shadowBlur = 5;
|
ctx.shadowBlur = 5;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -92,7 +110,7 @@ function drawToken(
|
|||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
|
ctx.strokeStyle = "rgba(255,255,255,0.55)";
|
||||||
ctx.lineWidth = Math.max(1, zoom * 0.04);
|
ctx.lineWidth = Math.max(1, zoom * 0.04);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
@@ -104,10 +122,10 @@ function drawToken(
|
|||||||
words.length >= 2
|
words.length >= 2
|
||||||
? (words[0][0] + words[1][0]).toUpperCase()
|
? (words[0][0] + words[1][0]).toUpperCase()
|
||||||
: label.slice(0, 2).toUpperCase();
|
: label.slice(0, 2).toUpperCase();
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
|
ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText(initials, cx, cy);
|
ctx.fillText(initials, cx, cy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,8 +151,10 @@ function floodFill(
|
|||||||
visited.add(cellKey(startX, startY));
|
visited.add(cellKey(startX, startY));
|
||||||
|
|
||||||
const dirs = [
|
const dirs = [
|
||||||
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
|
{ dx: 1, dy: 0 },
|
||||||
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
|
{ dx: -1, dy: 0 },
|
||||||
|
{ dx: 0, dy: 1 },
|
||||||
|
{ dx: 0, dy: -1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -180,8 +200,10 @@ function clampCameraToContent(
|
|||||||
|
|
||||||
cellLoop: for (const cell of cells.values()) {
|
cellLoop: for (const cell of cells.values()) {
|
||||||
if (
|
if (
|
||||||
cell.x + 1 > viewLeft && cell.x < viewRight &&
|
cell.x + 1 > viewLeft &&
|
||||||
cell.y + 1 > viewTop && cell.y < viewBottom
|
cell.x < viewRight &&
|
||||||
|
cell.y + 1 > viewTop &&
|
||||||
|
cell.y < viewBottom
|
||||||
) {
|
) {
|
||||||
anyVisible = true;
|
anyVisible = true;
|
||||||
break cellLoop;
|
break cellLoop;
|
||||||
@@ -191,8 +213,10 @@ function clampCameraToContent(
|
|||||||
if (!anyVisible) {
|
if (!anyVisible) {
|
||||||
for (const tok of tokens.values()) {
|
for (const tok of tokens.values()) {
|
||||||
if (
|
if (
|
||||||
tok.x + 1 > viewLeft && tok.x < viewRight &&
|
tok.x + 1 > viewLeft &&
|
||||||
tok.y + 1 > viewTop && tok.y < viewBottom
|
tok.x < viewRight &&
|
||||||
|
tok.y + 1 > viewTop &&
|
||||||
|
tok.y < viewBottom
|
||||||
) {
|
) {
|
||||||
anyVisible = true;
|
anyVisible = true;
|
||||||
break;
|
break;
|
||||||
@@ -203,8 +227,10 @@ function clampCameraToContent(
|
|||||||
if (anyVisible) return;
|
if (anyVisible) return;
|
||||||
|
|
||||||
// Find the bounding box of all content
|
// Find the bounding box of all content
|
||||||
let minX = Infinity, maxX = -Infinity;
|
let minX = Infinity,
|
||||||
let minY = Infinity, maxY = -Infinity;
|
maxX = -Infinity;
|
||||||
|
let minY = Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
|
||||||
for (const c of cells.values()) {
|
for (const c of cells.values()) {
|
||||||
if (c.x < minX) minX = c.x;
|
if (c.x < minX) minX = c.x;
|
||||||
@@ -250,17 +276,26 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const cameraRef = useRef<Camera>({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM });
|
const cameraRef = useRef<Camera>({
|
||||||
|
offsetX: -2,
|
||||||
|
offsetY: -2,
|
||||||
|
zoom: DEFAULT_ZOOM,
|
||||||
|
});
|
||||||
|
|
||||||
const cellsRef = useRef<Map<string, GridCell>>(new Map());
|
const cellsRef = useRef<Map<string, GridCell>>(new Map());
|
||||||
const tokensRef = useRef<Map<string, GridToken>>(new Map());
|
const tokensRef = useRef<Map<string, GridToken>>(new Map());
|
||||||
|
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
const redraw = useCallback(() => setTick(n => n + 1), []);
|
const redraw = useCallback(() => setTick((n) => n + 1), []);
|
||||||
|
|
||||||
// ---- Mouse interaction state (refs to avoid stale closures) ----
|
// ---- Mouse interaction state (refs to avoid stale closures) ----
|
||||||
const isPanning = useRef(false);
|
const isPanning = useRef(false);
|
||||||
const panStart = useRef<{ mx: number; my: number; ox: number; oy: number } | null>(null);
|
const panStart = useRef<{
|
||||||
|
mx: number;
|
||||||
|
my: number;
|
||||||
|
ox: number;
|
||||||
|
oy: number;
|
||||||
|
} | null>(null);
|
||||||
const isDrawing = useRef(false);
|
const isDrawing = useRef(false);
|
||||||
const isErasing = useRef(false);
|
const isErasing = useRef(false);
|
||||||
const lastPainted = useRef<string | null>(null);
|
const lastPainted = useRef<string | null>(null);
|
||||||
@@ -276,15 +311,17 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
// ---- Stable send ref so handlers never go stale ----
|
// ---- Stable send ref so handlers never go stale ----
|
||||||
const sendRef = useRef<(msg: ClientMessage) => void>(() => {});
|
const sendRef = useRef<(msg: ClientMessage) => void>(() => {});
|
||||||
|
|
||||||
const [cursor, setCursor] = useState<string>('default');
|
const [cursor, setCursor] = useState<string>("default");
|
||||||
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null);
|
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Imperative handle — lets App.tsx trigger a color WS update
|
// Imperative handle — lets App.tsx trigger a color WS update
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
sendColorUpdate(colors: string[]) {
|
sendColorUpdate(colors: string[]) {
|
||||||
sendRef.current({ type: 'update_colors', colors });
|
sendRef.current({ type: "update_colors", colors });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -313,53 +350,64 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Keep a stable ref to the callback so handleMessage doesn't re-create
|
// Keep a stable ref to the callback so handleMessage doesn't re-create
|
||||||
const onColorsLoadedRef = useRef(onColorsLoaded);
|
const onColorsLoadedRef = useRef(onColorsLoaded);
|
||||||
useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [onColorsLoaded]);
|
useEffect(() => {
|
||||||
|
onColorsLoadedRef.current = onColorsLoaded;
|
||||||
|
}, [onColorsLoaded]);
|
||||||
|
|
||||||
const handleMessage = useCallback((msg: ServerMessage) => {
|
const handleMessage = useCallback(
|
||||||
|
(msg: ServerMessage) => {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'state': {
|
case "state": {
|
||||||
cellsRef.current.clear();
|
cellsRef.current.clear();
|
||||||
tokensRef.current.clear();
|
tokensRef.current.clear();
|
||||||
msg.cells.forEach(c => cellsRef.current.set(cellKey(c.x, c.y), c));
|
msg.cells.forEach((c) => cellsRef.current.set(cellKey(c.x, c.y), c));
|
||||||
msg.tokens.forEach(t => tokensRef.current.set(t.id, t));
|
msg.tokens.forEach((t) => tokensRef.current.set(t.id, t));
|
||||||
onColorsLoadedRef.current(msg.colors);
|
onColorsLoadedRef.current(msg.colors);
|
||||||
redraw();
|
redraw();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'cell_painted': {
|
case "cell_painted": {
|
||||||
const key = cellKey(msg.x, msg.y);
|
const key = cellKey(msg.x, msg.y);
|
||||||
cellsRef.current.set(key, {
|
cellsRef.current.set(key, {
|
||||||
map_id: mapId,
|
map_id: mapId,
|
||||||
x: msg.x, y: msg.y, color: msg.color,
|
x: msg.x,
|
||||||
|
y: msg.y,
|
||||||
|
color: msg.color,
|
||||||
});
|
});
|
||||||
redraw();
|
redraw();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'cells_batch_painted': {
|
case "cells_batch_painted": {
|
||||||
msg.cells.forEach(c => {
|
msg.cells.forEach((c) => {
|
||||||
const key = cellKey(c.x, c.y);
|
const key = cellKey(c.x, c.y);
|
||||||
cellsRef.current.set(key, {
|
cellsRef.current.set(key, {
|
||||||
map_id: mapId,
|
map_id: mapId,
|
||||||
x: c.x, y: c.y, color: c.color,
|
x: c.x,
|
||||||
|
y: c.y,
|
||||||
|
color: c.color,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
redraw();
|
redraw();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'cell_erased': {
|
case "cell_erased": {
|
||||||
cellsRef.current.delete(cellKey(msg.x, msg.y));
|
cellsRef.current.delete(cellKey(msg.x, msg.y));
|
||||||
redraw();
|
redraw();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'token_added': {
|
case "token_added": {
|
||||||
tokensRef.current.set(msg.id, {
|
tokensRef.current.set(msg.id, {
|
||||||
id: msg.id, map_id: mapId,
|
id: msg.id,
|
||||||
x: msg.x, y: msg.y, label: msg.label, color: msg.color,
|
map_id: mapId,
|
||||||
|
x: msg.x,
|
||||||
|
y: msg.y,
|
||||||
|
label: msg.label,
|
||||||
|
color: msg.color,
|
||||||
});
|
});
|
||||||
redraw();
|
redraw();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'token_moved': {
|
case "token_moved": {
|
||||||
const tok = tokensRef.current.get(msg.id);
|
const tok = tokensRef.current.get(msg.id);
|
||||||
if (tok) {
|
if (tok) {
|
||||||
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
|
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
|
||||||
@@ -367,23 +415,27 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'token_deleted': {
|
case "token_deleted": {
|
||||||
tokensRef.current.delete(msg.id);
|
tokensRef.current.delete(msg.id);
|
||||||
redraw();
|
redraw();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'colors_updated': {
|
case "colors_updated": {
|
||||||
onColorsLoadedRef.current(msg.colors);
|
onColorsLoadedRef.current(msg.colors);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'error':
|
case "error":
|
||||||
console.error('[Grid WS]', msg.message);
|
console.error("[Grid WS]", msg.message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [mapId, redraw]);
|
},
|
||||||
|
[mapId, redraw],
|
||||||
|
);
|
||||||
|
|
||||||
const { send } = useWebSocket(mapId, handleMessage);
|
const { send } = useWebSocket(mapId, handleMessage);
|
||||||
useEffect(() => { sendRef.current = send; }, [send]);
|
useEffect(() => {
|
||||||
|
sendRef.current = send;
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Canvas draw
|
// Canvas draw
|
||||||
@@ -391,7 +443,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const W = canvas.width;
|
const W = canvas.width;
|
||||||
@@ -408,8 +460,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const endCY = Math.ceil(offsetY + H / zoom) + 1;
|
const endCY = Math.ceil(offsetY + H / zoom) + 1;
|
||||||
|
|
||||||
// Painted cells
|
// Painted cells
|
||||||
cellsRef.current.forEach(cell => {
|
cellsRef.current.forEach((cell) => {
|
||||||
if (cell.x < startCX || cell.x > endCX || cell.y < startCY || cell.y > endCY) return;
|
if (
|
||||||
|
cell.x < startCX ||
|
||||||
|
cell.x > endCX ||
|
||||||
|
cell.y < startCY ||
|
||||||
|
cell.y > endCY
|
||||||
|
)
|
||||||
|
return;
|
||||||
const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam);
|
const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam);
|
||||||
ctx.fillStyle = cell.color;
|
ctx.fillStyle = cell.color;
|
||||||
ctx.fillRect(px, py, zoom, zoom);
|
ctx.fillRect(px, py, zoom, zoom);
|
||||||
@@ -437,7 +495,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tokens (skip the one being dragged)
|
// Tokens (skip the one being dragged)
|
||||||
tokensRef.current.forEach(token => {
|
tokensRef.current.forEach((token) => {
|
||||||
if (isDragging.current && dragTokenId.current === token.id) return;
|
if (isDragging.current && dragTokenId.current === token.id) return;
|
||||||
drawToken(ctx, token.x, token.y, token.label, token.color, cam);
|
drawToken(ctx, token.x, token.y, token.label, token.color, cam);
|
||||||
});
|
});
|
||||||
@@ -447,7 +505,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const tok = tokensRef.current.get(dragTokenId.current);
|
const tok = tokensRef.current.get(dragTokenId.current);
|
||||||
if (tok) {
|
if (tok) {
|
||||||
ctx.globalAlpha = 0.6;
|
ctx.globalAlpha = 0.6;
|
||||||
drawToken(ctx, dragCellPos.current.x, dragCellPos.current.y, tok.label, tok.color, cam);
|
drawToken(
|
||||||
|
ctx,
|
||||||
|
dragCellPos.current.x,
|
||||||
|
dragCellPos.current.y,
|
||||||
|
tok.label,
|
||||||
|
tok.color,
|
||||||
|
cam,
|
||||||
|
);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,8 +525,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
clampCameraToContent(
|
clampCameraToContent(
|
||||||
cameraRef.current, cellsRef.current, tokensRef.current,
|
cameraRef.current,
|
||||||
canvas.width, canvas.height,
|
cellsRef.current,
|
||||||
|
tokensRef.current,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
redraw();
|
redraw();
|
||||||
@@ -492,8 +560,8 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||||
applyZoom(cx, cy, factor);
|
applyZoom(cx, cy, factor);
|
||||||
};
|
};
|
||||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
return () => canvas.removeEventListener('wheel', onWheel);
|
return () => canvas.removeEventListener("wheel", onWheel);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -508,7 +576,8 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dt = lastFrameTime.current !== null
|
const dt =
|
||||||
|
lastFrameTime.current !== null
|
||||||
? (timestamp - lastFrameTime.current) / 1000
|
? (timestamp - lastFrameTime.current) / 1000
|
||||||
: 0;
|
: 0;
|
||||||
lastFrameTime.current = timestamp;
|
lastFrameTime.current = timestamp;
|
||||||
@@ -516,26 +585,36 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const cam = cameraRef.current;
|
const cam = cameraRef.current;
|
||||||
const speed = WASD_PAN_SPEED;
|
const speed = WASD_PAN_SPEED;
|
||||||
|
|
||||||
if (keys.has('a')) cam.offsetX -= speed * dt;
|
if (keys.has("a")) cam.offsetX -= speed * dt;
|
||||||
if (keys.has('d')) cam.offsetX += speed * dt;
|
if (keys.has("d")) cam.offsetX += speed * dt;
|
||||||
if (keys.has('w')) cam.offsetY -= speed * dt;
|
if (keys.has("w")) cam.offsetY -= speed * dt;
|
||||||
if (keys.has('s')) cam.offsetY += speed * dt;
|
if (keys.has("s")) cam.offsetY += speed * dt;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
clampCameraToContent(cam, cellsRef.current, tokensRef.current, canvas.width, canvas.height);
|
clampCameraToContent(
|
||||||
|
cam,
|
||||||
|
cellsRef.current,
|
||||||
|
tokensRef.current,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setTick(n => n + 1);
|
setTick((n) => n + 1);
|
||||||
|
|
||||||
rafId.current = requestAnimationFrame(rafTick);
|
rafId.current = requestAnimationFrame(rafTick);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement
|
||||||
|
)
|
||||||
|
return;
|
||||||
// Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts)
|
// Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts)
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
if (['w', 'a', 's', 'd'].includes(key)) {
|
if (["w", "a", "s", "d"].includes(key)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
keysHeld.current.add(key);
|
keysHeld.current.add(key);
|
||||||
if (rafId.current === null) {
|
if (rafId.current === null) {
|
||||||
@@ -549,11 +628,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
keysHeld.current.delete(e.key.toLowerCase());
|
keysHeld.current.delete(e.key.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
window.addEventListener('keyup', onKeyUp);
|
window.addEventListener("keyup", onKeyUp);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
window.removeEventListener('keyup', onKeyUp);
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
if (rafId.current !== null) {
|
if (rafId.current !== null) {
|
||||||
cancelAnimationFrame(rafId.current);
|
cancelAnimationFrame(rafId.current);
|
||||||
rafId.current = null;
|
rafId.current = null;
|
||||||
@@ -585,22 +664,27 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||||
|
|
||||||
// ---- Pan tool ----
|
// ---- Pan tool ----
|
||||||
if (tool === 'pan' && e.button === 0) {
|
if (tool === "pan" && e.button === 0) {
|
||||||
isPanning.current = true;
|
isPanning.current = true;
|
||||||
panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY };
|
panStart.current = {
|
||||||
setCursor('grabbing');
|
mx,
|
||||||
|
my,
|
||||||
|
ox: cameraRef.current.offsetX,
|
||||||
|
oy: cameraRef.current.offsetY,
|
||||||
|
};
|
||||||
|
setCursor("grabbing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Zoom tool ----
|
// ---- Zoom tool ----
|
||||||
if (tool === 'zoom') {
|
if (tool === "zoom") {
|
||||||
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
|
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
|
||||||
else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP));
|
else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Draw tool ----
|
// ---- Draw tool ----
|
||||||
if (tool === 'draw') {
|
if (tool === "draw") {
|
||||||
if (e.button === 0) {
|
if (e.button === 0) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
// Shift+click → flood fill uncolored region
|
// Shift+click → flood fill uncolored region
|
||||||
@@ -609,11 +693,16 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
const region = floodFill(cell.x, cell.y, cellsRef.current);
|
const region = floodFill(cell.x, cell.y, cellsRef.current);
|
||||||
if (region === null || region.length === 1) {
|
if (region === null || region.length === 1) {
|
||||||
// Unbounded or trivially single cell → paint one cell
|
// Unbounded or trivially single cell → paint one cell
|
||||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
sendRef.current({
|
||||||
|
type: "paint_cell",
|
||||||
|
x: cell.x,
|
||||||
|
y: cell.y,
|
||||||
|
color: paintColor,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Bounded enclosed region → batch paint
|
// Bounded enclosed region → batch paint
|
||||||
sendRef.current({
|
sendRef.current({
|
||||||
type: 'paint_cells',
|
type: "paint_cells",
|
||||||
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
|
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -621,24 +710,29 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
} else {
|
} else {
|
||||||
isDrawing.current = true;
|
isDrawing.current = true;
|
||||||
lastPainted.current = cellKey(cell.x, cell.y);
|
lastPainted.current = cellKey(cell.x, cell.y);
|
||||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
sendRef.current({
|
||||||
|
type: "paint_cell",
|
||||||
|
x: cell.x,
|
||||||
|
y: cell.y,
|
||||||
|
color: paintColor,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (e.button === 2) {
|
} else if (e.button === 2) {
|
||||||
isErasing.current = true;
|
isErasing.current = true;
|
||||||
const key = cellKey(cell.x, cell.y);
|
const key = cellKey(cell.x, cell.y);
|
||||||
lastPainted.current = key;
|
lastPainted.current = key;
|
||||||
if (cellsRef.current.has(key)) {
|
if (cellsRef.current.has(key)) {
|
||||||
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
|
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Token tool ----
|
// ---- Token tool ----
|
||||||
if (tool === 'token') {
|
if (tool === "token") {
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
const tok = tokenAtCell(cell.x, cell.y);
|
const tok = tokenAtCell(cell.x, cell.y);
|
||||||
if (tok) sendRef.current({ type: 'delete_token', id: tok.id });
|
if (tok) sendRef.current({ type: "delete_token", id: tok.id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button === 0) {
|
if (e.button === 0) {
|
||||||
@@ -676,9 +770,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
if (lastPainted.current !== key) {
|
if (lastPainted.current !== key) {
|
||||||
lastPainted.current = key;
|
lastPainted.current = key;
|
||||||
if (isDrawing.current) {
|
if (isDrawing.current) {
|
||||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
sendRef.current({
|
||||||
|
type: "paint_cell",
|
||||||
|
x: cell.x,
|
||||||
|
y: cell.y,
|
||||||
|
color: paintColor,
|
||||||
|
});
|
||||||
} else if (isErasing.current && cellsRef.current.has(key)) {
|
} else if (isErasing.current && cellsRef.current.has(key)) {
|
||||||
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
|
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -687,7 +786,10 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
// Token drag
|
// Token drag
|
||||||
if (isDragging.current && dragCellPos.current) {
|
if (isDragging.current && dragCellPos.current) {
|
||||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||||
if (dragCellPos.current.x !== cell.x || dragCellPos.current.y !== cell.y) {
|
if (
|
||||||
|
dragCellPos.current.x !== cell.x ||
|
||||||
|
dragCellPos.current.y !== cell.y
|
||||||
|
) {
|
||||||
dragCellPos.current = { x: cell.x, y: cell.y };
|
dragCellPos.current = { x: cell.x, y: cell.y };
|
||||||
redraw();
|
redraw();
|
||||||
}
|
}
|
||||||
@@ -698,7 +800,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
if (isPanning.current) {
|
if (isPanning.current) {
|
||||||
isPanning.current = false;
|
isPanning.current = false;
|
||||||
panStart.current = null;
|
panStart.current = null;
|
||||||
setCursor('grab');
|
setCursor("grab");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,9 +813,12 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
|
|
||||||
if (isDragging.current && dragTokenId.current && dragCellPos.current) {
|
if (isDragging.current && dragTokenId.current && dragCellPos.current) {
|
||||||
const tok = tokensRef.current.get(dragTokenId.current);
|
const tok = tokensRef.current.get(dragTokenId.current);
|
||||||
if (tok && (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)) {
|
if (
|
||||||
|
tok &&
|
||||||
|
(tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)
|
||||||
|
) {
|
||||||
sendRef.current({
|
sendRef.current({
|
||||||
type: 'move_token',
|
type: "move_token",
|
||||||
id: dragTokenId.current,
|
id: dragTokenId.current,
|
||||||
x: dragCellPos.current.x,
|
x: dragCellPos.current.x,
|
||||||
y: dragCellPos.current.y,
|
y: dragCellPos.current.y,
|
||||||
@@ -743,16 +848,30 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
// Sync cursor CSS to active tool
|
// Sync cursor CSS to active tool
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case 'pan': setCursor('grab'); break;
|
case "pan":
|
||||||
case 'zoom': setCursor('zoom-in'); break;
|
setCursor("grab");
|
||||||
case 'draw': setCursor('crosshair'); break;
|
break;
|
||||||
case 'token': setCursor('crosshair'); break;
|
case "zoom":
|
||||||
|
setCursor("zoom-in");
|
||||||
|
break;
|
||||||
|
case "draw":
|
||||||
|
setCursor("crosshair");
|
||||||
|
break;
|
||||||
|
case "token":
|
||||||
|
setCursor("crosshair");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, [tool]);
|
}, [tool]);
|
||||||
|
|
||||||
function handleAddToken(label: string, color: string) {
|
function handleAddToken(label: string, color: string) {
|
||||||
if (!dialogPos) return;
|
if (!dialogPos) return;
|
||||||
sendRef.current({ type: 'add_token', x: dialogPos.x, y: dialogPos.y, label, color });
|
sendRef.current({
|
||||||
|
type: "add_token",
|
||||||
|
x: dialogPos.x,
|
||||||
|
y: dialogPos.y,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
});
|
||||||
setDialogPos(null);
|
setDialogPos(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,7 +885,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onContextMenu={e => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
/>
|
/>
|
||||||
{dialogPos && (
|
{dialogPos && (
|
||||||
<TokenDialog
|
<TokenDialog
|
||||||
|
|||||||
48
ui/src/components/Header.css
Normal file
48
ui/src/components/Header.css
Normal 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;
|
||||||
|
}
|
||||||
51
ui/src/components/Header.tsx
Normal file
51
ui/src/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import { auth } from '../api';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginButton({ className }: Props) {
|
/** A simple button that opens the login modal when clicked. */
|
||||||
async function handleLogin() {
|
export default function LoginButton({ className, onClick }: Props) {
|
||||||
try {
|
|
||||||
await auth.login();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to initiate login:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={className} onClick={handleLogin}>
|
<button className={className} onClick={onClick}>
|
||||||
Log in with Discord
|
Log In / Register
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
159
ui/src/components/LoginModal.css
Normal file
159
ui/src/components/LoginModal.css
Normal 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;
|
||||||
|
}
|
||||||
162
ui/src/components/LoginModal.tsx
Normal file
162
ui/src/components/LoginModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
183
ui/src/components/MapListModal.css
Normal file
183
ui/src/components/MapListModal.css
Normal 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;
|
||||||
|
}
|
||||||
183
ui/src/components/MapListModal.tsx
Normal file
183
ui/src/components/MapListModal.tsx
Normal 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
108
ui/src/components/Modal.css
Normal 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;
|
||||||
|
}
|
||||||
103
ui/src/components/NewMapModal.css
Normal file
103
ui/src/components/NewMapModal.css
Normal 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;
|
||||||
|
}
|
||||||
130
ui/src/components/NewMapModal.tsx
Normal file
130
ui/src/components/NewMapModal.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog label input[type='text'] {
|
.dialog label input[type="text"] {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
border: 1px solid #4b5563;
|
border: 1px solid #4b5563;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -48,11 +48,11 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog label input[type='text']:focus {
|
.dialog label input[type="text"]:focus {
|
||||||
border-color: #6366f1;
|
border-color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog label input[type='color'] {
|
.dialog label input[type="color"] {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from "react";
|
||||||
import './TokenDialog.css';
|
import "./TokenDialog.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
defaultColor: string;
|
defaultColor: string;
|
||||||
@@ -7,8 +7,12 @@ interface Props {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props) {
|
export default function TokenDialog({
|
||||||
const [label, setLabel] = useState('');
|
defaultColor,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
const [color, setColor] = useState(defaultColor);
|
const [color, setColor] = useState(defaultColor);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -24,12 +28,16 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
if (e.key === 'Escape') onCancel();
|
if (e.key === "Escape") onCancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dialog-overlay" onClick={onCancel} onKeyDown={handleKeyDown}>
|
<div
|
||||||
<div className="dialog" onClick={e => e.stopPropagation()}>
|
className="dialog-overlay"
|
||||||
|
onClick={onCancel}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Add Token</h3>
|
<h3>Add Token</h3>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
@@ -39,7 +47,7 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Strahd von Zarovich"
|
placeholder="e.g. Strahd von Zarovich"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={e => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -48,14 +56,18 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
|
|||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={color}
|
value={color}
|
||||||
onChange={e => setColor(e.target.value)}
|
onChange={(e) => setColor(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
<button type="button" onClick={onCancel} className="btn-secondary">
|
<button type="button" onClick={onCancel} className="btn-secondary">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary" disabled={!label.trim()}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={!label.trim()}
|
||||||
|
>
|
||||||
Place Token
|
Place Token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import type { ServerMessage, ClientMessage } from '../types';
|
import type { ServerMessage, ClientMessage } from "../types";
|
||||||
import { getToken } from '../api';
|
|
||||||
|
|
||||||
export function useWebSocket(
|
export function useWebSocket(
|
||||||
mapId: string,
|
mapId: string,
|
||||||
@@ -12,10 +11,10 @@ export function useWebSocket(
|
|||||||
onMessageRef.current = onMessage;
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const token = getToken();
|
// The browser automatically sends the siren_session cookie with the
|
||||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
// WebSocket upgrade request — no manual token query param needed.
|
||||||
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws${tokenParam}`;
|
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws`;
|
||||||
|
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
@@ -29,12 +28,12 @@ export function useWebSocket(
|
|||||||
const msg: ServerMessage = JSON.parse(event.data as string);
|
const msg: ServerMessage = JSON.parse(event.data as string);
|
||||||
onMessageRef.current(msg);
|
onMessageRef.current(msg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WS] Failed to parse message:', err);
|
console.error("[WS] Failed to parse message:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
ws.onerror = (err) => {
|
||||||
console.error('[WS] Error:', err);
|
console.error("[WS] Error:", err);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
*, *::before, *::after {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
background: #111827;
|
background: #111827;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from "react-dom/client";
|
||||||
import App from './App.tsx';
|
import App from "./App.tsx";
|
||||||
import './index.css';
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
123
ui/src/types.ts
123
ui/src/types.ts
@@ -1,27 +1,62 @@
|
|||||||
export interface User {
|
// ---------------------------------------------------------------------------
|
||||||
id: string; // Discord snowflake (stored as string)
|
// User / Auth
|
||||||
username: string;
|
// ---------------------------------------------------------------------------
|
||||||
avatar?: string;
|
|
||||||
|
export interface ConnectionInfo {
|
||||||
|
provider: string;
|
||||||
|
provider_username: string | null;
|
||||||
|
provider_avatar: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MapRole = 'owner' | 'editor' | 'viewer';
|
export interface UserInfo {
|
||||||
|
id: string; // UUID
|
||||||
|
username: string;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
/** True when the account has a local password (can log in without OAuth). */
|
||||||
|
has_password: boolean;
|
||||||
|
connections: ConnectionInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Maps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type MapRole = "owner" | "editor" | "viewer";
|
||||||
|
|
||||||
|
/** Map visibility / editability level. */
|
||||||
|
export type PublicAccess = "private" | "public_view" | "public_edit";
|
||||||
|
|
||||||
export interface MapPermission {
|
export interface MapPermission {
|
||||||
map_id: string;
|
map_id: string;
|
||||||
user_id: number;
|
user_id: string; // UUID
|
||||||
|
username: string;
|
||||||
role: MapRole;
|
role: MapRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Core map record (returned by create, get, update). */
|
||||||
export interface GridMap {
|
export interface GridMap {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
is_public: boolean;
|
public_access: PublicAccess;
|
||||||
owner_id: number;
|
owner_id: string; // UUID
|
||||||
colors: string[];
|
colors: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended map record returned by the list endpoint.
|
||||||
|
* Includes owner username, the caller's role, and a favorite flag.
|
||||||
|
*/
|
||||||
|
export interface ListedMap extends GridMap {
|
||||||
|
owner_username: string;
|
||||||
|
/** Null when the map is in the list only via a favorite (no explicit permission). */
|
||||||
|
user_role: MapRole | null;
|
||||||
|
is_favorited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GridCell {
|
export interface GridCell {
|
||||||
map_id: string;
|
map_id: string;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -44,36 +79,52 @@ export interface MapState {
|
|||||||
tokens: GridToken[];
|
tokens: GridToken[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tool = 'pan' | 'zoom' | 'draw' | 'token';
|
export interface MapAccessRequest {
|
||||||
|
id: string; // UUID
|
||||||
|
map_id: string;
|
||||||
|
user_id: string; // UUID
|
||||||
|
username: string;
|
||||||
|
requested_role: "editor" | "viewer";
|
||||||
|
status: "pending" | "approved" | "denied";
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- WebSocket message types ------------------------------------------------
|
export type Tool = "pan" | "zoom" | "draw" | "token";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebSocket message types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'paint_cell'; x: number; y: number; color: string }
|
| { type: "paint_cell"; x: number; y: number; color: string }
|
||||||
| { type: 'paint_cells'; cells: Array<{ x: number; y: number; color: string }> }
|
| {
|
||||||
| { type: 'erase_cell'; x: number; y: number }
|
type: "paint_cells";
|
||||||
| { type: 'add_token'; x: number; y: number; label: string; color: string }
|
cells: Array<{ x: number; y: number; color: string }>;
|
||||||
| { type: 'move_token'; id: string; x: number; y: number }
|
}
|
||||||
| { type: 'delete_token'; id: string }
|
| { type: "erase_cell"; x: number; y: number }
|
||||||
| { type: 'update_colors'; colors: string[] };
|
| { type: "add_token"; x: number; y: number; label: string; color: string }
|
||||||
|
| { type: "move_token"; id: string; x: number; y: number }
|
||||||
|
| { type: "delete_token"; id: string }
|
||||||
|
| { type: "update_colors"; colors: string[] };
|
||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| { type: 'state'; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
|
| { type: "state"; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
|
||||||
| { type: 'cell_painted'; x: number; y: number; color: string }
|
| { type: "cell_painted"; x: number; y: number; color: string }
|
||||||
| { type: 'cells_batch_painted'; cells: Array<{ x: number; y: number; color: string }> }
|
| {
|
||||||
| { type: 'cell_erased'; x: number; y: number }
|
type: "cells_batch_painted";
|
||||||
| { type: 'token_added'; id: string; x: number; y: number; label: string; color: string }
|
cells: Array<{ x: number; y: number; color: string }>;
|
||||||
| { type: 'token_moved'; id: string; x: number; y: number }
|
}
|
||||||
| { type: 'token_deleted'; id: string }
|
| { type: "cell_erased"; x: number; y: number }
|
||||||
| { type: 'colors_updated'; colors: string[] }
|
| {
|
||||||
| { type: 'error'; message: string };
|
type: "token_added";
|
||||||
|
id: string;
|
||||||
// ---- Auth token payload (JWT claims) ----------------------------------------
|
x: number;
|
||||||
|
y: number;
|
||||||
export interface TokenClaims {
|
label: string;
|
||||||
sub: number; // Discord user ID
|
color: string;
|
||||||
name: string;
|
}
|
||||||
iat: number;
|
| { type: "token_moved"; id: string; x: number; y: number }
|
||||||
exp: number;
|
| { type: "token_deleted"; id: string }
|
||||||
jti: string;
|
| { type: "colors_updated"; colors: string[] }
|
||||||
}
|
| { type: "error"; message: string };
|
||||||
|
|||||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@api': path.resolve(__dirname, './src/api'),
|
||||||
|
'@components': path.resolve(__dirname, './src/components'),
|
||||||
|
'@hooks': path.resolve(__dirname, './src/hooks'),
|
||||||
|
'@types': path.resolve(__dirname, './src/types'),
|
||||||
|
}
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
// Proxy REST calls and WebSocket upgrades to the Axum backend
|
// Proxy REST calls and WebSocket upgrades to the API
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user