diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d0c600a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,135 @@ +[workspace] +resolver = "2" +members = [ + "crates/chattyness-db", + "crates/chattyness-error", + "crates/chattyness-shared", + "crates/chattyness-admin-ui", + "crates/chattyness-user-ui", + "apps/chattyness-owner", + "apps/chattyness-app", +] +exclude = ["chattyness-webserver"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/your-org/chattyness" + +[workspace.dependencies] +# Internal crates +chattyness-db = { path = "crates/chattyness-db" } +chattyness-error = { path = "crates/chattyness-error" } +chattyness-shared = { path = "crates/chattyness-shared" } +chattyness-admin-ui = { path = "crates/chattyness-admin-ui" } +chattyness-user-ui = { path = "crates/chattyness-user-ui" } + +# Leptos framework +leptos = "0.8" +leptos_meta = "0.8" +leptos_router = "0.8" +leptos_axum = "0.8" + +# Axum web server +axum = { version = "0.8", features = ["macros", "ws"] } +axum-extra = { version = "0.10", features = ["multipart"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "compression-gzip"] } + +# Session management +tower-sessions = "0.14" +tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } + +# Database +sqlx = { version = "0.8", features = [ + "runtime-tokio", + "tls-rustls", + "postgres", + "uuid", + "chrono", + "json" +] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Error handling +thiserror = "2" + +# CLI +clap = { version = "4", features = ["derive", "env"] } + +# Async runtime +tokio = { version = "1", features = ["full"] } +futures = "0.3" + +# Concurrent data structures +dashmap = "6" + +# Configuration +toml = "0.8" + +# Utilities +uuid = { version = "1", features = ["v4", "serde", "js"] } +chrono = { version = "0.4", features = ["serde"] } +dotenvy = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +http = "1" +regex = "1" + +# Password hashing +argon2 = "0.5" + +# Image processing +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } + +# Random token generation +rand = "0.8" +sha2 = "0.10" +hex = "0.4" + +# WASM dependencies +console_error_panic_hook = "0.1" +wasm-bindgen = "0.2" +gloo-net = "0.6" +gloo-timers = "0.3" +urlencoding = "2" +web-sys = { version = "0.3", features = [ + "Blob", + "CanvasRenderingContext2d", + "CloseEvent", + "Document", + "DomRect", + "Element", + "ErrorEvent", + "EventTarget", + "File", + "FileList", + "FormData", + "HtmlCanvasElement", + "HtmlCollection", + "HtmlElement", + "HtmlFormElement", + "HtmlImageElement", + "HtmlInputElement", + "ImageData", + "KeyboardEvent", + "Location", + "MessageEvent", + "MouseEvent", + "Node", + "Storage", + "TextMetrics", + "WebSocket", + "Window", +] } +js-sys = "0.3" + +[profile.release] +lto = true +opt-level = 'z' +codegen-units = 1 diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 3dee106..bb1c4bd 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -1619,6 +1619,43 @@ pub struct GuestLoginResponse { pub realm: RealmSummary, } +/// Request to register a guest as a full user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterGuestRequest { + pub username: String, + pub email: Option, + pub password: String, + pub confirm_password: String, +} + +#[cfg(feature = "ssr")] +impl RegisterGuestRequest { + /// Validate the registration request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_username(&self.username)?; + + // Email: basic format if provided and non-empty + if let Some(ref email) = self.email { + let email_trimmed = email.trim(); + if !email_trimmed.is_empty() && !validation::is_valid_email(email_trimmed) { + return Err(AppError::Validation("Invalid email address".to_string())); + } + } + + validation::validate_password(&self.password)?; + validation::validate_passwords_match(&self.password, &self.confirm_password)?; + + Ok(()) + } +} + +/// Response after successful guest registration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterGuestResponse { + pub success: bool, + pub username: String, +} + /// Request to join a realm. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JoinRealmRequest { diff --git a/crates/chattyness-db/src/queries/users.rs b/crates/chattyness-db/src/queries/users.rs index 609d4ee..cddeb96 100644 --- a/crates/chattyness-db/src/queries/users.rs +++ b/crates/chattyness-db/src/queries/users.rs @@ -1,6 +1,6 @@ //! User-related database queries. -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use uuid::Uuid; use crate::models::{StaffMember, User, UserWithAuth}; @@ -499,6 +499,74 @@ pub async fn get_staff_member( Ok(staff) } +/// Upgrade a guest user to a full user account. +/// +/// Updates the user's username, password, and optionally email, +/// and removes the 'guest' tag. Returns error if the user is not a guest. +pub async fn upgrade_guest_to_user( + pool: &PgPool, + user_id: Uuid, + username: &str, + password: &str, + email: Option<&str>, +) -> Result<(), AppError> { + let mut conn = pool.acquire().await?; + upgrade_guest_to_user_conn(&mut *conn, user_id, username, password, email).await +} + +/// Upgrade a guest user to a full user account using an existing connection. +/// +/// Updates the user's username, password, and optionally email, +/// and removes the 'guest' tag. Returns error if the user is not a guest. +/// Use this version when you need RLS context set on the connection. +pub async fn upgrade_guest_to_user_conn( + conn: &mut PgConnection, + user_id: Uuid, + username: &str, + password: &str, + email: Option<&str>, +) -> Result<(), AppError> { + use argon2::{ + Argon2, PasswordHasher, + password_hash::{SaltString, rand_core::OsRng}, + }; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))? + .to_string(); + + let result: sqlx::postgres::PgQueryResult = sqlx::query( + r#" + UPDATE auth.users + SET username = $2, + password_hash = $3, + email = $4, + display_name = $2, + tags = array_remove(tags, 'guest'), + auth_provider = 'local', + updated_at = now() + WHERE id = $1 AND 'guest' = ANY(tags) + "#, + ) + .bind(user_id) + .bind(username) + .bind(&password_hash) + .bind(email) + .execute(&mut *conn) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::Forbidden( + "User is not a guest or does not exist".to_string(), + )); + } + + Ok(()) +} + /// Create a guest user (no password required). /// /// Guests are created with the 'guest' tag and no password. diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 350ca0f..1b16cad 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -98,6 +98,10 @@ pub enum ClientMessage { /// Arguments for the subcommand. args: Vec, }, + + /// Request to refresh identity after registration (guest → user conversion). + /// Server will fetch updated user data and broadcast to all members. + RefreshIdentity, } /// Server-to-client WebSocket messages. @@ -260,4 +264,14 @@ pub enum ServerMessage { /// Human-readable result message. message: String, }, + + /// A member's identity was updated (e.g., guest registered as user). + MemberIdentityUpdated { + /// User ID of the member. + user_id: Uuid, + /// New display name. + display_name: String, + /// Whether the member is still a guest. + is_guest: bool, + }, } diff --git a/crates/chattyness-user-ui/src/api/auth.rs b/crates/chattyness-user-ui/src/api/auth.rs index 689fd6a..a40d82b 100644 --- a/crates/chattyness-user-ui/src/api/auth.rs +++ b/crates/chattyness-user-ui/src/api/auth.rs @@ -9,7 +9,7 @@ use chattyness_db::{ AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest, GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse, LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary, - SignupRequest, SignupResponse, UserSummary, + RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary, }, queries::{guests, memberships, realms, users}, }; @@ -471,3 +471,62 @@ pub async fn reset_password( redirect_url, })) } + +/// Register guest handler. +/// +/// Upgrades a guest user to a full user account with username and password. +pub async fn register_guest( + rls_conn: crate::auth::RlsConn, + State(pool): State, + session: Session, + AuthUser(user): AuthUser, + Json(req): Json, +) -> Result, AppError> { + // Validate the request + req.validate()?; + + // Verify user is a guest + if !user.is_guest() { + return Err(AppError::Forbidden( + "Only guests can register for an account".to_string(), + )); + } + + // Check username availability + if users::username_exists(&pool, &req.username).await? { + return Err(AppError::Conflict("Username already taken".to_string())); + } + + // Check email availability if provided + if let Some(ref email) = req.email { + let email_trimmed = email.trim(); + if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? { + return Err(AppError::Conflict("Email already registered".to_string())); + } + } + + // Get email as Option<&str> + let email_opt = req.email.as_ref().and_then(|e| { + let trimmed = e.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }); + + // Upgrade the guest to a full user using RLS connection + let mut conn = rls_conn.acquire().await; + users::upgrade_guest_to_user_conn(&mut *conn, user.id, &req.username, &req.password, email_opt).await?; + + // Update session login type from 'guest' to 'realm' + session + .insert(SESSION_LOGIN_TYPE_KEY, "realm") + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + Ok(Json(RegisterGuestResponse { + success: true, + username: req.username, + })) +} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index dd528e4..292ae39 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -27,6 +27,10 @@ pub fn api_router() -> Router { "/auth/reset-password", axum::routing::post(auth::reset_password), ) + .route( + "/auth/register-guest", + axum::routing::post(auth::register_guest), + ) // Realm routes (READ-ONLY) .route("/realms", get(realms::list_realms)) .route("/realms/{slug}", get(realms::get_realm)) diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 1473f24..3791d71 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use chattyness_db::{ models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, - queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes}, + queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes, users}, ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, }; use chattyness_error::AppError; @@ -1074,6 +1074,35 @@ async fn handle_socket( } } } + ClientMessage::RefreshIdentity => { + // Fetch updated user info from database + match users::get_user_by_id(&pool, user_id).await { + Ok(Some(updated_user)) => { + let is_guest: bool = updated_user.is_guest(); + let display_name = updated_user.display_name.clone(); + + tracing::info!( + "[WS] User {} refreshed identity: display_name={}, is_guest={}", + user_id, + display_name, + is_guest + ); + + // Broadcast identity update to all channel members + let _ = tx.send(ServerMessage::MemberIdentityUpdated { + user_id, + display_name, + is_guest, + }); + } + Ok(None) => { + tracing::warn!("[WS] RefreshIdentity: user {} not found", user_id); + } + Err(e) => { + tracing::error!("[WS] RefreshIdentity failed for user {}: {:?}", user_id, e); + } + } + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index ec189ea..363a261 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -16,8 +16,8 @@ pub mod keybindings_popup; pub mod layout; pub mod log_popup; pub mod modals; -pub mod notification_history; pub mod notifications; +pub mod register_modal; pub mod scene_list_popup; pub mod scene_viewer; pub mod settings; @@ -42,8 +42,8 @@ pub use keybindings_popup::*; pub use layout::*; pub use log_popup::*; pub use modals::*; -pub use notification_history::*; pub use notifications::*; +pub use register_modal::*; pub use reconnection_overlay::*; pub use scene_list_popup::*; pub use scene_viewer::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 3f3dfe7..066698d 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -177,6 +177,12 @@ pub fn ChatInput( /// Callback to send a mod command. #[prop(optional)] on_mod_command: Option)>>, + /// Whether the current user is a guest (can register). + #[prop(default = Signal::derive(|| false))] + is_guest: Signal, + /// Callback to open registration modal. + #[prop(optional)] + on_open_register: Option>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); @@ -371,6 +377,11 @@ pub fn ChatInput( && "mod".starts_with(&cmd) && cmd != "mod"; + // Show /register in slash hints when just starting to type it (only for guests) + let is_partial_register = is_guest.get_untracked() + && !cmd.is_empty() + && "register".starts_with(&cmd); + if is_complete_whisper || is_complete_teleport { // User is typing the argument part, no hint needed set_command_mode.set(CommandMode::None); @@ -392,6 +403,7 @@ pub fn ChatInput( || cmd.starts_with("t ") || cmd.starts_with("teleport ") || is_partial_mod + || is_partial_register { set_command_mode.set(CommandMode::ShowingSlashHint); } else { @@ -410,6 +422,7 @@ pub fn ChatInput( let on_open_settings = on_open_settings.clone(); let on_open_inventory = on_open_inventory.clone(); let on_open_log = on_open_log.clone(); + let on_open_register = on_open_register.clone(); move |ev: web_sys::KeyboardEvent| { let key = ev.key(); let current_mode = command_mode.get_untracked(); @@ -560,6 +573,19 @@ pub fn ChatInput( ev.prevent_default(); return; } + // Autocomplete to /register if /r, /re, /reg, etc. (only for guests) + if is_guest.get_untracked() + && !cmd.is_empty() + && "register".starts_with(&cmd) + && cmd != "register" + { + set_message.set("/register".to_string()); + if let Some(input) = input_ref.get() { + input.set_value("/register"); + } + ev.prevent_default(); + return; + } } // Always prevent Tab from moving focus when in input ev.prevent_default(); @@ -618,6 +644,24 @@ pub fn ChatInput( return; } + // /r, /re, /reg, /regi, /regis, /regist, /registe, /register - open register modal (only for guests) + if is_guest.get_untracked() + && !cmd.is_empty() + && "register".starts_with(&cmd) + { + if let Some(ref callback) = on_open_register { + callback.run(()); + } + set_message.set(String::new()); + set_command_mode.set(CommandMode::None); + if let Some(input) = input_ref.get() { + input.set_value(""); + let _ = input.blur(); + } + ev.prevent_default(); + return; + } + // /w NAME message or /whisper NAME message if let Some((target_name, whisper_content)) = parse_whisper_command(&msg) { if !whisper_content.trim().is_empty() { @@ -839,6 +883,13 @@ pub fn ChatInput( "/" "mod" + // Show /register hint for guests + + "|" + "/" + "r" + "[egister]" + diff --git a/crates/chattyness-user-ui/src/components/chat_types.rs b/crates/chattyness-user-ui/src/components/chat_types.rs index 60b3f14..d5bfbb1 100644 --- a/crates/chattyness-user-ui/src/components/chat_types.rs +++ b/crates/chattyness-user-ui/src/components/chat_types.rs @@ -31,6 +31,9 @@ pub struct ChatMessage { /// For whispers: true = same scene (show as bubble), false = cross-scene (show as notification). #[serde(default = "default_true")] pub is_same_scene: bool, + /// Whether this is a system/admin message (teleport, summon, mod commands). + #[serde(default)] + pub is_system: bool, } /// Default function for serde that returns true. diff --git a/crates/chattyness-user-ui/src/components/forms.rs b/crates/chattyness-user-ui/src/components/forms.rs index eb0decb..61a6f90 100644 --- a/crates/chattyness-user-ui/src/components/forms.rs +++ b/crates/chattyness-user-ui/src/components/forms.rs @@ -14,6 +14,7 @@ pub fn TextInput( #[prop(optional)] minlength: Option, #[prop(optional)] maxlength: Option, #[prop(optional)] pattern: &'static str, + #[prop(optional)] autocomplete: &'static str, #[prop(optional)] class: &'static str, #[prop(into)] value: Signal, on_input: Callback, @@ -41,6 +42,7 @@ pub fn TextInput( minlength=minlength maxlength=maxlength pattern=if pattern.is_empty() { None } else { Some(pattern) } + autocomplete=if autocomplete.is_empty() { None } else { Some(autocomplete) } aria-describedby=if has_help { Some(help_id.clone()) } else { None } class="input-base" prop:value=move || value.get() diff --git a/crates/chattyness-user-ui/src/components/log_popup.rs b/crates/chattyness-user-ui/src/components/log_popup.rs index c4b09f1..23c8426 100644 --- a/crates/chattyness-user-ui/src/components/log_popup.rs +++ b/crates/chattyness-user-ui/src/components/log_popup.rs @@ -1,6 +1,7 @@ //! Message log popup component. //! //! Displays a filterable chronological log of received messages. +//! Includes persistent whisper history via LocalStorage. use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; @@ -8,6 +9,77 @@ use leptos::reactive::owner::LocalStorage; use super::chat_types::{ChatMessage, MessageLog}; use super::modals::Modal; +/// LocalStorage key for persistent whisper history. +const WHISPER_STORAGE_KEY: &str = "chattyness_whisper_history"; +/// Maximum number of whispers to keep in persistent history. +const MAX_WHISPER_HISTORY: usize = 100; + +/// Load whisper history from LocalStorage. +#[cfg(feature = "hydrate")] +pub fn load_whisper_history() -> Vec { + let window = match web_sys::window() { + Some(w) => w, + None => return Vec::new(), + }; + + let storage = match window.local_storage() { + Ok(Some(s)) => s, + _ => return Vec::new(), + }; + + let json = match storage.get_item(WHISPER_STORAGE_KEY) { + Ok(Some(j)) => j, + _ => return Vec::new(), + }; + + serde_json::from_str(&json).unwrap_or_default() +} + +/// Save whisper history to LocalStorage. +#[cfg(feature = "hydrate")] +pub fn save_whisper_history(whispers: &[ChatMessage]) { + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + + let storage = match window.local_storage() { + Ok(Some(s)) => s, + _ => return, + }; + + if let Ok(json) = serde_json::to_string(whispers) { + let _ = storage.set_item(WHISPER_STORAGE_KEY, &json); + } +} + +/// Add a whisper to persistent history, maintaining max size. +/// Deduplicates by message_id. +#[cfg(feature = "hydrate")] +pub fn add_whisper_to_history(msg: ChatMessage) { + let mut history = load_whisper_history(); + // Avoid duplicates by message_id + if !history.iter().any(|m| m.message_id == msg.message_id) { + history.insert(0, msg); + if history.len() > MAX_WHISPER_HISTORY { + history.truncate(MAX_WHISPER_HISTORY); + } + save_whisper_history(&history); + } +} + +/// SSR stubs for persistence functions. +#[cfg(not(feature = "hydrate"))] +pub fn load_whisper_history() -> Vec { + Vec::new() +} + +#[cfg(not(feature = "hydrate"))] +pub fn save_whisper_history(_whispers: &[ChatMessage]) {} + +#[cfg(not(feature = "hydrate"))] +pub fn add_whisper_to_history(_msg: ChatMessage) {} + /// Filter mode for message log display. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum LogFilter { @@ -18,16 +90,23 @@ pub enum LogFilter { Chat, /// Show only whispers. Whispers, + /// Show only system/admin messages (teleports, summons, mod commands). + System, } /// Message log popup component. /// -/// Displays a filterable list of messages from the session. +/// Displays a filterable list of messages from the session, +/// with persistent whisper history from LocalStorage. #[component] pub fn LogPopup( #[prop(into)] open: Signal, message_log: StoredValue, on_close: Callback<()>, + /// Callback when user wants to reply to a whisper. + on_reply: Callback, + /// Callback when user wants to see conversation context. + on_context: Callback, ) -> impl IntoView { let (filter, set_filter) = signal(LogFilter::All); @@ -38,17 +117,46 @@ pub fn LogPopup( // Reading open ensures we re-fetch messages when modal opens let _ = open.get(); let current_filter = filter.get(); - message_log.with_value(|log| { - log.all_messages() - .iter() - .filter(|msg| match current_filter { - LogFilter::All => true, - LogFilter::Chat => !msg.is_whisper, - LogFilter::Whispers => msg.is_whisper, + + match current_filter { + LogFilter::Whispers => { + // For whispers, merge session messages with persistent history + let mut session_whispers: Vec = message_log.with_value(|log| { + log.all_messages() + .iter() + .filter(|msg| msg.is_whisper) + .cloned() + .collect() + }); + + // Load persistent whispers and merge (avoiding duplicates by message_id) + let persistent = load_whisper_history(); + for msg in persistent { + if !session_whispers.iter().any(|m| m.message_id == msg.message_id) { + session_whispers.push(msg); + } + } + + // Sort by timestamp (oldest first for display) + session_whispers.sort_by_key(|m| m.timestamp); + session_whispers + } + _ => { + // For All, Chat, or System, use session messages only + message_log.with_value(|log| { + log.all_messages() + .iter() + .filter(|msg| match current_filter { + LogFilter::All => true, + LogFilter::Chat => !msg.is_whisper && !msg.is_system, + LogFilter::System => msg.is_system, + LogFilter::Whispers => unreachable!(), + }) + .cloned() + .collect::>() }) - .cloned() - .collect::>() - }) + } + } }; // Auto-scroll to bottom when modal opens @@ -111,6 +219,12 @@ pub fn LogPopup( > "Whispers" + // Message list @@ -127,52 +241,114 @@ pub fn LogPopup( - - "[" - {format_timestamp(timestamp)} - "] " - - - {display_name} - - - - "(whisper)" - - - - ": " - - - {content} - - +
+
+ + "[" + {format_timestamp(timestamp)} + "] " + + + {display_name} + + + + "(system)" + + + + + "(whisper)" + + + + ": " + + + {content} + +
+ +
+ + +
+
+
+ + } } } /> diff --git a/crates/chattyness-user-ui/src/components/notification_history.rs b/crates/chattyness-user-ui/src/components/notification_history.rs deleted file mode 100644 index f1c9fdf..0000000 --- a/crates/chattyness-user-ui/src/components/notification_history.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Notification history component with LocalStorage persistence. -//! -//! Shows last 100 notifications across sessions. - -use leptos::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::chat_types::ChatMessage; -use super::modals::Modal; - -const STORAGE_KEY: &str = "chattyness_notification_history"; -const MAX_HISTORY_SIZE: usize = 100; - -/// A stored notification entry for history. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct HistoryEntry { - pub id: Uuid, - pub sender_name: String, - pub content: String, - pub timestamp: i64, - pub is_whisper: bool, - /// Type of notification (e.g., "whisper", "system", "mod"). - pub notification_type: String, -} - -impl HistoryEntry { - /// Create from a whisper chat message. - pub fn from_whisper(msg: &ChatMessage) -> Self { - Self { - id: Uuid::new_v4(), - sender_name: msg.display_name.clone(), - content: msg.content.clone(), - timestamp: msg.timestamp, - is_whisper: true, - notification_type: "whisper".to_string(), - } - } -} - -/// Load history from LocalStorage. -#[cfg(feature = "hydrate")] -pub fn load_history() -> Vec { - let window = match web_sys::window() { - Some(w) => w, - None => return Vec::new(), - }; - - let storage = match window.local_storage() { - Ok(Some(s)) => s, - _ => return Vec::new(), - }; - - let json = match storage.get_item(STORAGE_KEY) { - Ok(Some(j)) => j, - _ => return Vec::new(), - }; - - serde_json::from_str(&json).unwrap_or_default() -} - -/// Save history to LocalStorage. -#[cfg(feature = "hydrate")] -pub fn save_history(history: &[HistoryEntry]) { - let window = match web_sys::window() { - Some(w) => w, - None => return, - }; - - let storage = match window.local_storage() { - Ok(Some(s)) => s, - _ => return, - }; - - if let Ok(json) = serde_json::to_string(history) { - let _ = storage.set_item(STORAGE_KEY, &json); - } -} - -/// Add an entry to history, maintaining max size. -#[cfg(feature = "hydrate")] -pub fn add_to_history(entry: HistoryEntry) { - let mut history = load_history(); - history.insert(0, entry); - if history.len() > MAX_HISTORY_SIZE { - history.truncate(MAX_HISTORY_SIZE); - } - save_history(&history); -} - -/// SSR stubs -#[cfg(not(feature = "hydrate"))] -pub fn load_history() -> Vec { - Vec::new() -} - -#[cfg(not(feature = "hydrate"))] -pub fn save_history(_history: &[HistoryEntry]) {} - -#[cfg(not(feature = "hydrate"))] -pub fn add_to_history(_entry: HistoryEntry) {} - -/// Notification history modal component. -#[component] -pub fn NotificationHistoryModal( - #[prop(into)] open: Signal, - on_close: Callback<()>, - /// Callback when user wants to reply to a message. - on_reply: Callback, - /// Callback when user wants to see conversation context. - on_context: Callback, -) -> impl IntoView { - // Load history when modal opens - let history = Signal::derive(move || { - if open.get() { - load_history() - } else { - Vec::new() - } - }); - - view! { - -
- - "No notifications yet" -

- } - > -
    - -
    -
    -
    - - {sender_name} - - - {format_timestamp(entry.timestamp)} - - - - "whisper" - - -
    -

    - {entry.content.clone()} -

    -
    -
    - - -
    -
    - - } - } - } - /> -
-
-
- - // Footer hint -
- "Press " "Esc" " to close" -
-
- } -} - -/// Format a timestamp for display. -fn format_timestamp(timestamp: i64) -> String { - #[cfg(feature = "hydrate")] - { - let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(timestamp as f64)); - let hours = date.get_hours(); - let minutes = date.get_minutes(); - format!("{:02}:{:02}", hours, minutes) - } - #[cfg(not(feature = "hydrate"))] - { - let _ = timestamp; - String::new() - } -} diff --git a/crates/chattyness-user-ui/src/components/notifications.rs b/crates/chattyness-user-ui/src/components/notifications.rs index f060463..a87c84e 100644 --- a/crates/chattyness-user-ui/src/components/notifications.rs +++ b/crates/chattyness-user-ui/src/components/notifications.rs @@ -56,8 +56,6 @@ pub fn NotificationToast( on_reply: Callback, /// Callback when user wants to see context (press 'c'). on_context: Callback, - /// Callback when user wants to see history (press 'h'). - on_history: Callback<()>, /// Callback when notification is dismissed. on_dismiss: Callback, ) -> impl IntoView { @@ -139,7 +137,6 @@ pub fn NotificationToast( let on_reply = on_reply.clone(); let on_context = on_context.clone(); - let on_history = on_history.clone(); let on_dismiss = on_dismiss.clone(); let closure = wasm_bindgen::closure::Closure::::new( @@ -156,11 +153,6 @@ pub fn NotificationToast( on_context.run(display_name.clone()); on_dismiss.run(notif_id); } - "h" | "H" => { - ev.prevent_default(); - on_history.run(()); - on_dismiss.run(notif_id); - } "Escape" => { ev.prevent_default(); on_dismiss.run(notif_id); @@ -217,10 +209,6 @@ pub fn NotificationToast( "c" " context" - - "h" - " history" - diff --git a/crates/chattyness-user-ui/src/components/register_modal.rs b/crates/chattyness-user-ui/src/components/register_modal.rs new file mode 100644 index 0000000..2214f80 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/register_modal.rs @@ -0,0 +1,277 @@ +//! Registration modal component for guest-to-user conversion. + +use leptos::prelude::*; + +use super::forms::{ErrorAlert, SubmitButton, TextInput}; +use crate::utils::use_escape_key; + +/// Response from the register-guest API endpoint. +#[derive(Clone, serde::Deserialize)] +pub struct RegisterGuestResponse { + pub success: bool, + pub username: String, +} + +/// Registration modal for converting a guest account to a full user account. +/// +/// Props: +/// - `open`: Signal controlling modal visibility +/// - `on_close`: Callback when modal should close +/// - `on_success`: Callback when registration succeeds (receives new username) +#[component] +pub fn RegisterModal( + #[prop(into)] open: Signal, + on_close: Callback<()>, + #[prop(optional)] on_success: Option>, +) -> impl IntoView { + let (username, set_username) = signal(String::new()); + let (email, set_email) = signal(String::new()); + let (password, set_password) = signal(String::new()); + let (confirm_password, set_confirm_password) = signal(String::new()); + let (error, set_error) = signal(Option::::None); + let (pending, set_pending) = signal(false); + + // Handle escape key + use_escape_key(open, on_close.clone()); + + let on_close_backdrop = on_close.clone(); + let on_close_button = on_close.clone(); + + // Client-side validation + let validate = move || -> Result<(), String> { + let username_val = username.get(); + let password_val = password.get(); + let confirm_val = confirm_password.get(); + let email_val = email.get(); + + // Username validation: 3-30 chars, alphanumeric + underscore + if username_val.len() < 3 || username_val.len() > 30 { + return Err("Username must be between 3 and 30 characters".to_string()); + } + if !username_val + .chars() + .all(|c| c.is_alphanumeric() || c == '_') + { + return Err("Username can only contain letters, numbers, and underscores".to_string()); + } + + // Password validation: 8+ chars + if password_val.len() < 8 { + return Err("Password must be at least 8 characters".to_string()); + } + + // Confirm password + if password_val != confirm_val { + return Err("Passwords do not match".to_string()); + } + + // Email validation (optional, but must be valid if provided) + if !email_val.is_empty() && !email_val.contains('@') { + return Err("Please enter a valid email address".to_string()); + } + + Ok(()) + }; + + // Submit handler + #[cfg(feature = "hydrate")] + let on_submit = { + let on_close = on_close.clone(); + move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + + // Validate first + if let Err(msg) = validate() { + set_error.set(Some(msg)); + return; + } + + set_error.set(None); + set_pending.set(true); + + let username_val = username.get(); + let email_val = email.get(); + let password_val = password.get(); + let confirm_val = confirm_password.get(); + + let on_close = on_close.clone(); + let on_success = on_success.clone(); + + leptos::task::spawn_local(async move { + use gloo_net::http::Request; + + let request_body = serde_json::json!({ + "username": username_val, + "email": if email_val.is_empty() { None } else { Some(email_val) }, + "password": password_val, + "confirm_password": confirm_val, + }); + + let result = Request::post("/api/auth/register-guest") + .header("Content-Type", "application/json") + .body(request_body.to_string()) + .unwrap() + .send() + .await; + + set_pending.set(false); + + match result { + Ok(resp) => { + if resp.ok() { + if let Ok(data) = resp.json::().await { + // Clear form + set_username.set(String::new()); + set_email.set(String::new()); + set_password.set(String::new()); + set_confirm_password.set(String::new()); + + // Call success callback + if let Some(ref callback) = on_success { + callback.run(data.username); + } + + // Close modal + on_close.run(()); + } + } else { + // Try to parse error message + if let Ok(error_data) = + resp.json::().await + { + let msg = error_data + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Registration failed"); + set_error.set(Some(msg.to_string())); + } else { + set_error.set(Some("Registration failed".to_string())); + } + } + } + Err(e) => { + set_error.set(Some(format!("Network error: {}", e))); + } + } + }); + } + }; + + #[cfg(not(feature = "hydrate"))] + let on_submit = move |_ev: leptos::ev::SubmitEvent| {}; + + // Use CSS-based visibility + let outer_class = move || { + if open.get() { + "fixed inset-0 z-50 flex items-center justify-center" + } else { + "hidden" + } + }; + + view! { +