From 070337577c6b8817e40c33c339ceb89063b5749a Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Sat, 4 Apr 2026 14:33:07 -0400 Subject: [PATCH] Formatting and cleanup --- .env.example | 71 +++++-- README.md | 44 ++-- Taskfile.yml | 2 +- crates/siren-api/src/auth/discord.rs | 7 - crates/siren-api/src/auth/local.rs | 41 ---- crates/siren-api/src/grid/mod.rs | 20 -- crates/siren-api/src/grid/model.rs | 42 +--- crates/siren-core/src/config.rs | 26 +-- crates/siren-core/src/data/messages/mod.rs | 3 - crates/siren-core/src/data/messages/model.rs | 74 ------- crates/siren-core/src/data/mod.rs | 1 - crates/siren/src/main.rs | 2 +- migrations/000_initial.sql | 24 +-- ui/src/components/AccountPanel.tsx | 17 +- ui/src/components/FloatingMapControls.tsx | 2 +- ui/src/components/Grid.tsx | 43 ---- ui/src/components/LoginModal.tsx | 9 +- ui/src/components/MapListModal.css | 6 + ui/src/components/MapListModal.tsx | 208 ++++++++++++------- ui/src/types.ts | 16 +- 20 files changed, 237 insertions(+), 421 deletions(-) delete mode 100644 crates/siren-core/src/data/messages/mod.rs delete mode 100644 crates/siren-core/src/data/messages/model.rs diff --git a/.env.example b/.env.example index beb110f..3fd12e9 100644 --- a/.env.example +++ b/.env.example @@ -1,40 +1,67 @@ +# ----------------------------------------------------------- +# Logging +# ----------------------------------------------------------- +# Rust log filter directive (e.g. warn,siren=info) RUST_LOG=warn,siren=info +# ----------------------------------------------------------- +# Discord +# ----------------------------------------------------------- +# Bot token from the Discord Developer Portal → Bot tab → Reset Token DISCORD_BOT_TOKEN= +# OAuth2 client secret from the Discord Developer Portal → OAuth2 tab DISCORD_CLIENT_SECRET= +# ----------------------------------------------------------- +# Security +# ----------------------------------------------------------- +# Secret used to sign JWT tokens — change this before deploying JWT_SECRET=changeme +# ----------------------------------------------------------- +# Database +# ----------------------------------------------------------- POSTGRES_USER=siren POSTGRES_PASSWORD=changeme POSTGRES_DB=siren_db +# Use "siren-postgres" when running inside Docker Compose POSTGRES_HOST=localhost POSTGRES_PORT=5432 -API_BASE_URL=http://localhost:3000 -API_PORT=3000 -API_SESSION_TTL=86400 - -# Set to a specific origin (e.g. https://yourapp.com) when deploying to -# production with a separate frontend origin. Use "*" (the default) in -# development with the Vite proxy, where CORS is not an issue. -CORS_ORIGIN=* - -UI_PORT=8080 - +# ----------------------------------------------------------- +# Cache (Valkey) +# ----------------------------------------------------------- +# Use "siren-valkey" when running inside Docker Compose VALKEY_HOST=localhost VALKEY_PORT=6379 -MINIO_ROOT_USER=siren -MINIO_ROOT_PASSWORD=changeme -MINIO_HOST=localhost -MINIO_PORT=9000 -MINIO_PORT_INTERNAL=9001 +# ----------------------------------------------------------- +# API +# ----------------------------------------------------------- +# Base URL of the REST API (used to build OAuth2 redirect URIs, etc.) +API_BASE_URL=http://localhost:3000 +API_PORT=3000 +# OAuth2 session TTL in seconds +API_SESSION_TTL=86400 -# Siren Data integration (Optional) +# ----------------------------------------------------------- +# UI +# ----------------------------------------------------------- +# 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=* +# Port the UI dev server (Vite) listens on +UI_PORT=5173 + +# ----------------------------------------------------------- +# Bot +# ----------------------------------------------------------- +# Re-register slash commands with Discord on every startup +FORCE_COMMAND_REGISTER=false + +# ----------------------------------------------------------- +# Data (Optional) +# ----------------------------------------------------------- +# Path to a local directory for optional Siren Data integration DATA_DIR_PATH=./data - -FORCE_REGISTER=false -DEFAULT_API_KEY=test_api_key -DEFAULT_SERVER= -DEFAULT_USER= diff --git a/README.md b/README.md index 5108d30..78b97de 100644 --- a/README.md +++ b/README.md @@ -51,27 +51,29 @@ task setup ### Environment variables -| Variable | Required | Description | -|-------------------------|----------|-------------------------------------------------------------------------| -| `DISCORD_BOT_TOKEN` | Yes | Bot token from the Discord Developer Portal | -| `DISCORD_CLIENT_SECRET` | Yes | OAuth2 client secret | -| `JWT_SECRET` | Yes | Secret used to sign JWT tokens — change from default | -| `POSTGRES_USER` | Yes | PostgreSQL username | -| `POSTGRES_PASSWORD` | Yes | PostgreSQL password — change from default | -| `POSTGRES_DB` | Yes | PostgreSQL database name | -| `POSTGRES_HOST` | Yes | PostgreSQL host (`localhost` for local dev, `siren-postgres` in Docker) | -| `POSTGRES_PORT` | Yes | PostgreSQL port (default `5432`) | -| `VALKEY_HOST` | Yes | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) | -| `VALKEY_PORT` | Yes | Valkey port (default `6379`) | -| `API_PORT` | Yes | Port the REST API listens on (default `3000`) | -| `API_CALLBACK_URI` | Yes | OAuth2 redirect URI (e.g. `http://localhost:3000/api/oauth/callback`) | -| `API_SESSION_TTL` | | OAuth2 session TTL in seconds (default `86400`) | -| `RUST_LOG` | | Log filter (e.g. `warn,siren=info`) | -| `FORCE_REGISTER` | | Re-register slash commands on every startup (`true`/`false`) | -| `DATA_DIR_PATH` | | Path to optional local data directory | -| `DEFAULT_API_KEY` | | Seed API key created on startup | -| `DEFAULT_SERVER` | | Seed guild ID | -| `DEFAULT_USER` | | Seed user ID | +| Variable | Required | Description | +|--------------------------|----------|---------------------------------------------------------------------------| +| `DISCORD_BOT_TOKEN` | Yes | Bot token from the Discord Developer Portal | +| `DISCORD_CLIENT_SECRET` | Yes | OAuth2 client secret | +| `JWT_SECRET` | Yes | Secret used to sign JWT tokens — change from default | +| `POSTGRES_USER` | Yes | PostgreSQL username | +| `POSTGRES_PASSWORD` | Yes | PostgreSQL password — change from default | +| `POSTGRES_DB` | Yes | PostgreSQL database name | +| `POSTGRES_HOST` | Yes | PostgreSQL host (`localhost` for local dev, `siren-postgres` in Docker) | +| `POSTGRES_PORT` | | PostgreSQL port (default `5432`) | +| `VALKEY_HOST` | | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) | +| `VALKEY_PORT` | | Valkey port (default `6379`) | +| `API_BASE_URL` | Yes | Base URL of the API (e.g. `http://localhost:3000`) | +| `API_PORT` | | Port the REST API listens on (default `3000`) | +| `API_SESSION_TTL` | | OAuth2 session TTL in seconds (default `86400`) | +| `CORS_ORIGIN` | | Allowed CORS origin (`*` for dev, specific URL for production) | +| `UI_PORT` | | Port the UI dev server listens on (default `5173`) | +| `RUST_LOG` | | Log filter (e.g. `warn,siren=info`) | +| `FORCE_COMMAND_REGISTER` | | Re-register slash commands with Discord on every startup (`true`/`false`) | +| `DATA_DIR_PATH` | | Path to optional local data directory | +| `DEFAULT_API_KEY` | | Seed API key created on startup | +| `DEFAULT_GUILD_ID` | | Seed Discord guild (server) ID | +| `DEFAULT_USER_ID` | | Seed Discord user ID | --- diff --git a/Taskfile.yml b/Taskfile.yml index a2c6df2..99dcf0c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -177,7 +177,7 @@ tasks: ngrok: desc: Start ngrok tunnel vars: - UI_PORT: '{{.UI_PORT | default "8080"}}' + UI_PORT: '{{.UI_PORT | default "5173"}}' cmds: - ngrok http {{.UI_PORT}} silent: true diff --git a/crates/siren-api/src/auth/discord.rs b/crates/siren-api/src/auth/discord.rs index bb59777..f7bf6f5 100644 --- a/crates/siren-api/src/auth/discord.rs +++ b/crates/siren-api/src/auth/discord.rs @@ -292,9 +292,6 @@ async fn do_oauth_callback( Ok(Redirect::temporary(&ui_redirect_uri).into_response()) } - // ------------------------------------------------------------------ // - // LOGIN MODE: look up (or create) the local user for this Discord account - // ------------------------------------------------------------------ // None => { // Find existing connection → local user_id let local_user_id: Option<(Uuid, String)> = sqlx::query_as( @@ -403,10 +400,6 @@ async fn do_oauth_callback( } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - /// Return a username derived from `base` that does not yet exist in `users`. async fn generate_unique_username(pool: &sqlx::PgPool, base: &str) -> crate::error::Result { // Truncate to 28 chars to leave room for the `_XXXX` suffix diff --git a/crates/siren-api/src/auth/local.rs b/crates/siren-api/src/auth/local.rs index 59eec3e..928ff48 100644 --- a/crates/siren-api/src/auth/local.rs +++ b/crates/siren-api/src/auth/local.rs @@ -41,10 +41,6 @@ pub fn get_routes() -> Router> { .route("/connections/{provider}", delete(disconnect_provider)) } -// --------------------------------------------------------------------------- -// Payloads -// --------------------------------------------------------------------------- - #[derive(Deserialize)] struct RegisterPayload { username: String, @@ -71,10 +67,6 @@ struct ChangePasswordPayload { new_password: String, } -// --------------------------------------------------------------------------- -// Response types -// --------------------------------------------------------------------------- - #[derive(Serialize)] pub struct ConnectionInfo { pub provider: String, @@ -95,10 +87,6 @@ pub struct UserInfo { pub connections: Vec, } -// --------------------------------------------------------------------------- -// DB row types -// --------------------------------------------------------------------------- - #[derive(sqlx::FromRow)] struct DbUser { id: Uuid, @@ -116,10 +104,6 @@ struct DbConnection { provider_avatar: Option, } -// --------------------------------------------------------------------------- -// Password helpers -// --------------------------------------------------------------------------- - /// Hash and salt a plaintext password with Argon2. pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); @@ -139,10 +123,6 @@ pub fn verify_password(password: &str, hash: &str) -> bool { .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)) @@ -192,10 +172,6 @@ pub async fn create_session_and_cookie( Ok((jar, ())) } -// --------------------------------------------------------------------------- -// Helper: load full UserInfo for a given user_id -// --------------------------------------------------------------------------- - async fn load_user_info(user_id: Uuid) -> Result { let pool = data::pool(); @@ -232,10 +208,6 @@ async fn load_user_info(user_id: Uuid) -> Result { }) } -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - async fn register( headers: HeaderMap, jar: CookieJar, @@ -245,12 +217,6 @@ async fn register( 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(); @@ -402,13 +368,6 @@ async fn change_password( ) -> Result { let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; - if payload.new_password.len() < 8 { - return Err(Error::new( - 422, - "New password must be at least 8 characters".into(), - )); - } - let pool = data::pool(); let existing_hash: Option = diff --git a/crates/siren-api/src/grid/mod.rs b/crates/siren-api/src/grid/mod.rs index 5c382e4..676a658 100644 --- a/crates/siren-api/src/grid/mod.rs +++ b/crates/siren-api/src/grid/mod.rs @@ -61,10 +61,6 @@ pub fn get_routes() -> Router> { .route("/maps/{id}/ws", get(ws_handler)) } -// --------------------------------------------------------------------------- -// Access helpers -// --------------------------------------------------------------------------- - /// Fetch the role of `user_id` on `map_id`, or `None` if no record exists. async fn get_user_role(map_id: &str, user_id: Uuid) -> Result> { let pool = siren_core::data::pool(); @@ -120,10 +116,6 @@ async fn is_owner(map: &GridMap, session: &Option) -> bool { .unwrap_or(false) } -// --------------------------------------------------------------------------- -// Map CRUD -// --------------------------------------------------------------------------- - pub async fn list_maps( SessionAuthorization(session): SessionAuthorization, ) -> Result>> { @@ -287,10 +279,6 @@ pub async fn delete_map( Ok(StatusCode::NO_CONTENT) } -// --------------------------------------------------------------------------- -// Permission management -// --------------------------------------------------------------------------- - pub async fn list_permissions( SessionAuthorization(session): SessionAuthorization, Path(id): Path, @@ -429,10 +417,6 @@ pub async fn unfavorite_map( Ok(StatusCode::NO_CONTENT) } -// --------------------------------------------------------------------------- -// Access Requests -// --------------------------------------------------------------------------- - pub async fn create_access_request( SessionAuthorization(session): SessionAuthorization, Path(id): Path, @@ -572,10 +556,6 @@ pub async fn resolve_access_request( Ok(StatusCode::NO_CONTENT) } -// --------------------------------------------------------------------------- -// WebSocket handler -// --------------------------------------------------------------------------- - pub async fn ws_handler( ws: WebSocketUpgrade, State(state): State>, diff --git a/crates/siren-api/src/grid/model.rs b/crates/siren-api/src/grid/model.rs index 4207f11..a96e90f 100644 --- a/crates/siren-api/src/grid/model.rs +++ b/crates/siren-api/src/grid/model.rs @@ -1,11 +1,7 @@ -use chrono::NaiveDateTime; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -// --------------------------------------------------------------------------- -// Map Role / Permission -// --------------------------------------------------------------------------- - #[derive(Serialize, Deserialize, sqlx::Type, Clone, Debug, PartialEq, Eq)] #[sqlx(type_name = "text", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] @@ -36,10 +32,6 @@ pub struct PermissionWithUser { pub role: MapRole, } -// --------------------------------------------------------------------------- -// Grid Map -// --------------------------------------------------------------------------- - /// Core map record as stored/returned by create, get, and update endpoints. #[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)] pub struct GridMap { @@ -49,8 +41,8 @@ pub struct GridMap { pub public_access: String, pub owner_id: Uuid, pub colors: Vec, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, + pub created_at: DateTime, + pub updated_at: DateTime, } /// Extended map record returned by the list endpoint. @@ -64,8 +56,8 @@ pub struct ListedMap { pub owner_id: Uuid, pub owner_username: String, pub colors: Vec, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, + pub created_at: DateTime, + pub updated_at: DateTime, /// 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, @@ -98,10 +90,6 @@ pub struct UpdatePermissionPayload { pub role: Option, } -// --------------------------------------------------------------------------- -// Map Access Requests -// --------------------------------------------------------------------------- - /// An access-request row joined with the requesting user's username. #[derive(Serialize, sqlx::FromRow, Clone, Debug)] pub struct AccessRequestWithUser { @@ -111,8 +99,8 @@ pub struct AccessRequestWithUser { pub username: String, pub requested_role: MapRole, pub status: String, - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(Deserialize, Clone, Debug)] @@ -126,10 +114,6 @@ pub struct ResolveAccessRequestPayload { pub action: String, } -// --------------------------------------------------------------------------- -// Grid Cell (no id column — composite PK in DB) -// --------------------------------------------------------------------------- - #[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)] pub struct GridCell { pub map_id: String, @@ -146,10 +130,6 @@ pub struct CellPatch { pub color: String, } -// --------------------------------------------------------------------------- -// Grid Token -// --------------------------------------------------------------------------- - #[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)] pub struct GridToken { pub id: String, @@ -160,10 +140,6 @@ pub struct GridToken { pub color: String, } -// --------------------------------------------------------------------------- -// Full map state (used on initial WS connect and REST GET) -// --------------------------------------------------------------------------- - #[derive(Serialize, Deserialize, Clone, Debug)] pub struct MapState { pub map: GridMap, @@ -171,10 +147,6 @@ pub struct MapState { pub tokens: Vec, } -// --------------------------------------------------------------------------- -// WebSocket message types -// --------------------------------------------------------------------------- - #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ClientMessage { diff --git a/crates/siren-core/src/config.rs b/crates/siren-core/src/config.rs index 55986ce..7c064ad 100644 --- a/crates/siren-core/src/config.rs +++ b/crates/siren-core/src/config.rs @@ -16,16 +16,8 @@ pub struct EnvironmentConfiguration { pub api_session_ttl: u64, pub valkey_host: String, pub valkey_port: u16, - pub minio_root_user: String, - pub minio_root_password: String, - pub minio_host: String, - pub minio_port: u16, - pub minio_port_internal: u16, pub data_dir_path: Option, - pub force_register: bool, - pub default_api_key: String, - pub default_server: Option, - pub default_user: Option, + pub force_command_register: bool, } impl EnvironmentConfiguration { @@ -57,25 +49,11 @@ impl EnvironmentConfiguration { .unwrap_or_else(|_| "6379".to_string()) .parse() .unwrap_or(6379), - minio_root_user: env::var("MINIO_ROOT_USER")?, - minio_root_password: env::var("MINIO_ROOT_PASSWORD")?, - minio_host: env::var("MINIO_HOST").unwrap_or_else(|_| "localhost".to_string()), - minio_port: env::var("MINIO_PORT") - .unwrap_or_else(|_| "9000".to_string()) - .parse() - .unwrap_or(9000), - minio_port_internal: env::var("MINIO_PORT_INTERNAL") - .unwrap_or_else(|_| "9001".to_string()) - .parse() - .unwrap_or(9001), data_dir_path: env::var("DATA_DIR_PATH").ok().filter(|s| !s.is_empty()), - force_register: env::var("FORCE_REGISTER") + force_command_register: env::var("FORCE_COMMAND_REGISTER") .ok() .map(|v| v.to_lowercase() == "true") .unwrap_or(false), - default_api_key: env::var("DEFAULT_API_KEY").unwrap_or_default(), - default_server: env::var("DEFAULT_SERVER").ok().filter(|s| !s.is_empty()), - default_user: env::var("DEFAULT_USER").ok().filter(|s| !s.is_empty()), }) } } diff --git a/crates/siren-core/src/data/messages/mod.rs b/crates/siren-core/src/data/messages/mod.rs deleted file mode 100644 index 4a7ebf6..0000000 --- a/crates/siren-core/src/data/messages/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod model; - -pub use model::*; diff --git a/crates/siren-core/src/data/messages/model.rs b/crates/siren-core/src/data/messages/model.rs deleted file mode 100644 index a149b97..0000000 --- a/crates/siren-core/src/data/messages/model.rs +++ /dev/null @@ -1,74 +0,0 @@ -use crate::error::Result; -use serde::{Deserialize, Serialize}; - -const TABLE_NAME: &str = "messages"; - -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct MessageCache { - pub id: String, - pub guild_id: i64, - pub channel_id: i64, - pub author_id: i64, - pub created: i64, - pub model: String, - pub request: String, - pub response: String, - pub request_tags: Vec, - pub response_tags: Vec, -} - -impl MessageCache { - pub async fn insert(&self) -> Result<()> { - let pool = crate::data::pool(); - sqlx::query(&format!( - "INSERT INTO {} ( - id, - guild_id, - channel_id, - author_id, - created, - model, - request, - response, - request_tags, - response_tags - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 - )", - TABLE_NAME - )) - .bind(&self.id) - .bind(self.guild_id) - .bind(self.channel_id) - .bind(self.author_id) - .bind(self.created) - .bind(&self.model) - .bind(&self.request) - .bind(&self.response) - .bind(&self.request_tags) - .bind(&self.response_tags) - .execute(pool) - .await?; - Ok(()) - } - - pub async fn find( - guild_id: i64, - channel_id: i64, - author_id: i64, - limit: i64, - ) -> Result> { - let pool = crate::data::pool(); - let messages = sqlx::query_as::<_, MessageCache>(&format!( - "SELECT * FROM {} WHERE guild_id = $1 AND channel_id = $2 AND author_id = $3 ORDER BY created ASC LIMIT $4", - TABLE_NAME - )) - .bind(guild_id) - .bind(channel_id) - .bind(author_id) - .bind(limit) - .fetch_all(pool) - .await?; - Ok(messages) - } -} diff --git a/crates/siren-core/src/data/mod.rs b/crates/siren-core/src/data/mod.rs index a033d5f..2e7ff98 100644 --- a/crates/siren-core/src/data/mod.rs +++ b/crates/siren-core/src/data/mod.rs @@ -9,7 +9,6 @@ pub mod events; mod executable_query; pub mod guilds; pub mod insert; -pub mod messages; pub mod query; pub mod update; use crate::config::EnvironmentConfiguration; diff --git a/crates/siren/src/main.rs b/crates/siren/src/main.rs index 9bc5e0f..d7b81a4 100644 --- a/crates/siren/src/main.rs +++ b/crates/siren/src/main.rs @@ -21,7 +21,7 @@ async fn main() -> std::result::Result<(), Box> { let config = EnvironmentConfiguration::load()?; siren_core::data::initialize(&config).await?; - let handler = BotHandler::new(config.force_register); + let handler = BotHandler::new(config.force_command_register); let songbird = Songbird::serenity(); let intents: GatewayIntents = GatewayIntents::all(); diff --git a/migrations/000_initial.sql b/migrations/000_initial.sql index 3b357fb..9cc152c 100644 --- a/migrations/000_initial.sql +++ b/migrations/000_initial.sql @@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS events ( guild_id BIGINT NOT NULL, author_id BIGINT NOT NULL, title TEXT NOT NULL, - date_time TIMESTAMP NOT NULL, + date_time TIMESTAMPTZ NOT NULL, description TEXT, rsvp BIGINT[] NOT NULL ); @@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT UNIQUE, first_name TEXT, last_name TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- External OAuth provider connections (Discord, etc.) @@ -93,14 +93,6 @@ CREATE TABLE IF NOT EXISTS user_connections ( UNIQUE (provider, provider_user_id) ); --- ============================================================ --- Grid maps: unbounded canvas, CSPRNG TEXT ids, auth-aware --- ============================================================ - --- public_access: 'private' | 'public_view' | 'public_edit' --- private – only users with explicit map_permissions can see/edit --- public_view – anyone with the link can view; only permissioned users can edit --- public_edit – anyone with the link can view AND edit CREATE TABLE IF NOT EXISTS grid_maps ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, @@ -118,8 +110,8 @@ CREATE TABLE IF NOT EXISTS grid_maps ( '#0f172a', '#f9fafb' ], - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Per-map role assignments; owner is auto-inserted on map creation @@ -135,7 +127,7 @@ CREATE TABLE IF NOT EXISTS map_permissions ( 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(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, map_id) ); @@ -146,8 +138,8 @@ CREATE TABLE IF NOT EXISTS map_access_requests ( 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(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (map_id, user_id) ); diff --git a/ui/src/components/AccountPanel.tsx b/ui/src/components/AccountPanel.tsx index 4f2b67f..6c229af 100644 --- a/ui/src/components/AccountPanel.tsx +++ b/ui/src/components/AccountPanel.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { auth } from "../api"; import type { UserInfo } from "../types"; import "./AccountPanel.css"; +import { FaDiscord } from "react-icons/fa6"; interface Props { user: UserInfo; @@ -43,7 +44,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) { setProfileSuccess(false); } - async function handleSaveProfile(e: React.SubmitEvent) { + async function handleSaveProfile(e: React.SubmitEvent) { e.preventDefault(); setProfileSaving(true); setProfileError(null); @@ -63,7 +64,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) { } } - async function handleChangePassword(e: React.FormEvent) { + async function handleChangePassword(e: React.SubmitEvent) { e.preventDefault(); setPwError(null); setPwSuccess(false); @@ -72,10 +73,6 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) { setPwError("Passwords do not match"); return; } - if (pwNew.length < 8) { - setPwError("Password must be at least 8 characters"); - return; - } setPwSaving(true); try { @@ -296,13 +293,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {

Connected Accounts

- - - +
Discord diff --git a/ui/src/components/FloatingMapControls.tsx b/ui/src/components/FloatingMapControls.tsx index 92958c0..6aee4d9 100644 --- a/ui/src/components/FloatingMapControls.tsx +++ b/ui/src/components/FloatingMapControls.tsx @@ -24,7 +24,7 @@ export default function FloatingMapControls({ return (
{/* Always visible for logged-in users */} -
diff --git a/ui/src/components/MapListModal.css b/ui/src/components/MapListModal.css index 6f9b9ea..d585832 100644 --- a/ui/src/components/MapListModal.css +++ b/ui/src/components/MapListModal.css @@ -108,6 +108,12 @@ color: #f59e0b; } +.map-list-updated { + font-size: 0.68rem; + color: #4b5563; + margin-left: auto; +} + /* Role badge reused from EditMapModal */ .perm-role-badge { font-size: 0.7rem; diff --git a/ui/src/components/MapListModal.tsx b/ui/src/components/MapListModal.tsx index f7b7a51..8060d73 100644 --- a/ui/src/components/MapListModal.tsx +++ b/ui/src/components/MapListModal.tsx @@ -2,6 +2,21 @@ import { useState } from "react"; import { api } from "../api"; import type { ListedMap } from "../types"; import "./MapListModal.css"; +import { FaTrash } from "react-icons/fa6"; + +function timeAgo(date: Date): string { + const diff = Date.now() - new Date(date).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(months / 12)}y ago`; +} interface Props { maps: ListedMap[]; @@ -83,7 +98,7 @@ export default function MapListModal({ onClick={(e) => e.stopPropagation()} >
-

My Maps

+

Maps

@@ -95,86 +110,123 @@ export default function MapListModal({

) : (
- {maps.map((map) => ( -
handleSelect(map)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === "Enter" && handleSelect(map)} - > -
- {map.name} -
- - by {map.owner_username} - - - {accessLabel(map.public_access)} - - {map.user_role && ( - - {map.user_role} + {[...maps] + .sort( + (a, b) => + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime(), + ) + .map((map) => ( +
handleSelect(map)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && handleSelect(map)} + > +
+ {map.name} +
+ + by {map.owner_username} - )} - {map.is_favorited && !map.user_role && ( - ★ Favorited - )} + + {accessLabel(map.public_access)} + + {map.user_role && ( + + {map.user_role} + + )} + {map.is_favorited && !map.user_role && ( + ★ Favorited + )} + + {timeAgo(map.updated_at)} + +
+
+ +
+ {/* Favorite toggle */} + + + {/* Copy link */} + + + {/* Delete map button */} +
- -
- {/* Favorite toggle */} - - - {/* Copy link */} - -
-
- ))} + ))}
)}
diff --git a/ui/src/types.ts b/ui/src/types.ts index 85c600d..d2075d0 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -1,7 +1,3 @@ -// --------------------------------------------------------------------------- -// User / Auth -// --------------------------------------------------------------------------- - export interface ConnectionInfo { provider: string; provider_username: string | null; @@ -19,10 +15,6 @@ export interface UserInfo { connections: ConnectionInfo[]; } -// --------------------------------------------------------------------------- -// Maps -// --------------------------------------------------------------------------- - export type MapRole = "owner" | "editor" | "viewer"; /** Map visibility / editability level. */ @@ -42,8 +34,8 @@ export interface GridMap { public_access: PublicAccess; owner_id: string; // UUID colors: string[]; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } /** @@ -92,10 +84,6 @@ export interface MapAccessRequest { export type Tool = "pan" | "zoom" | "draw" | "token"; -// --------------------------------------------------------------------------- -// WebSocket message types -// --------------------------------------------------------------------------- - export type ClientMessage = | { type: "paint_cell"; x: number; y: number; color: string } | {