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..afdd99f 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -17,6 +17,7 @@ pub mod layout; pub mod log_popup; pub mod modals; pub mod notification_history; +pub mod register_modal; pub mod notifications; pub mod scene_list_popup; pub mod scene_viewer; @@ -44,6 +45,7 @@ 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/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/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! { + @@ -1289,6 +1324,39 @@ pub fn RealmPage() -> impl IntoView { } } + // Registration modal for guest-to-user conversion + { + #[cfg(feature = "hydrate")] + let ws_sender_for_register = ws_sender.clone(); + view! { + + } + } + // Notification toast for cross-scene whispers