add the ability to register from inside the user-ui

This commit is contained in:
Evan Carroll 2026-01-21 00:11:50 -06:00
parent 31e01292f9
commit ed1a1f10f9
12 changed files with 655 additions and 5 deletions

View file

@ -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<PgPool>,
session: Session,
AuthUser(user): AuthUser,
Json(req): Json<RegisterGuestRequest>,
) -> Result<Json<RegisterGuestResponse>, 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,
}))
}

View file

@ -27,6 +27,10 @@ pub fn api_router() -> Router<AppState> {
"/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))

View file

@ -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) => {

View file

@ -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::*;

View file

@ -177,6 +177,12 @@ pub fn ChatInput(
/// Callback to send a mod command.
#[prop(optional)]
on_mod_command: Option<Callback<(String, Vec<String>)>>,
/// Whether the current user is a guest (can register).
#[prop(default = Signal::derive(|| false))]
is_guest: Signal<bool>,
/// Callback to open registration modal.
#[prop(optional)]
on_open_register: Option<Callback<()>>,
) -> 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(
<span class="text-purple-400">"/"</span>
<span class="text-purple-400">"mod"</span>
</Show>
// Show /register hint for guests
<Show when=move || is_guest.get()>
<span class="text-gray-600 mx-2">"|"</span>
<span class="text-gray-400">"/"</span>
<span class="text-green-400">"r"</span>
<span class="text-gray-500">"[egister]"</span>
</Show>
</div>
</Show>

View file

@ -14,6 +14,7 @@ pub fn TextInput(
#[prop(optional)] minlength: Option<i32>,
#[prop(optional)] maxlength: Option<i32>,
#[prop(optional)] pattern: &'static str,
#[prop(optional)] autocomplete: &'static str,
#[prop(optional)] class: &'static str,
#[prop(into)] value: Signal<String>,
on_input: Callback<String>,
@ -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()

View file

@ -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<bool>,
on_close: Callback<()>,
#[prop(optional)] on_success: Option<Callback<String>>,
) -> 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::<String>::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::<RegisterGuestResponse>().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::<serde_json::Value>().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! {
<div
class=outer_class
role="dialog"
aria-modal="true"
aria-labelledby="register-modal-title"
aria-hidden=move || (!open.get()).to_string()
>
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
// Header with title and close button
<div class="flex items-center justify-between mb-4">
<h2 id="register-modal-title" class="text-xl font-bold text-white">
"Create Account"
</h2>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<p class="text-gray-400 mb-6">
"Convert your guest account to a full account. You'll keep all your current chat history and settings."
</p>
// Error display
<ErrorAlert message=Signal::derive(move || error.get()) />
// Registration form
<form on:submit=on_submit class="space-y-4">
<TextInput
name="username"
label="Username"
placeholder="Choose a username"
help_text="3-30 characters, letters, numbers, and underscores only"
required=true
minlength=3
maxlength=30
pattern=r"^[a-zA-Z0-9_]+$"
autocomplete="username"
value=Signal::derive(move || username.get())
on_input=Callback::new(move |val| set_username.set(val))
/>
<TextInput
name="email"
label="Email"
input_type="email"
placeholder="your@email.com"
help_text="Optional, for account recovery"
required=false
autocomplete="email"
value=Signal::derive(move || email.get())
on_input=Callback::new(move |val| set_email.set(val))
/>
<TextInput
name="password"
label="Password"
input_type="password"
placeholder="Choose a password"
help_text="At least 8 characters"
required=true
minlength=8
autocomplete="new-password"
value=Signal::derive(move || password.get())
on_input=Callback::new(move |val| set_password.set(val))
/>
<TextInput
name="confirm_password"
label="Confirm Password"
input_type="password"
placeholder="Confirm your password"
required=true
minlength=8
autocomplete="new-password"
value=Signal::derive(move || confirm_password.get())
on_input=Callback::new(move |val| set_confirm_password.set(val))
/>
<div class="pt-4">
<SubmitButton
text="Create Account"
loading_text="Creating account..."
pending=Signal::derive(move || pending.get())
/>
</div>
</form>
</div>
</div>
}
}

View file

@ -108,6 +108,17 @@ pub struct ModCommandResultInfo {
pub message: String,
}
/// Member identity update (e.g., guest registered as user).
#[derive(Clone, Debug)]
pub struct MemberIdentityInfo {
/// User ID of the member.
pub user_id: uuid::Uuid,
/// New display name.
pub display_name: String,
/// Whether the member is still a guest.
pub is_guest: bool,
}
/// Hook to manage WebSocket connection for a channel.
///
/// Returns a tuple of:
@ -129,6 +140,7 @@ pub fn use_channel_websocket(
on_teleport_approved: Option<Callback<TeleportInfo>>,
on_summoned: Option<Callback<SummonInfo>>,
on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
) -> (Signal<WsState>, WsSenderStorage) {
use std::cell::RefCell;
use std::rc::Rc;
@ -244,6 +256,7 @@ pub fn use_channel_websocket(
let on_teleport_approved_clone = on_teleport_approved.clone();
let on_summoned_clone = on_summoned.clone();
let on_mod_command_result_clone = on_mod_command_result.clone();
let on_member_identity_updated_clone = on_member_identity_updated.clone();
// For starting heartbeat on Welcome
let ws_ref_for_heartbeat = ws_ref.clone();
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
@ -319,6 +332,7 @@ pub fn use_channel_websocket(
&on_teleport_approved_clone,
&on_summoned_clone,
&on_mod_command_result_clone,
&on_member_identity_updated_clone,
&current_user_id_for_msg,
);
}
@ -430,6 +444,7 @@ fn handle_server_message(
on_teleport_approved: &Option<Callback<TeleportInfo>>,
on_summoned: &Option<Callback<SummonInfo>>,
on_mod_command_result: &Option<Callback<ModCommandResultInfo>>,
on_member_identity_updated: &Option<Callback<MemberIdentityInfo>>,
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
) {
let mut members_vec = members.borrow_mut();
@ -624,6 +639,29 @@ fn handle_server_message(
callback.run(ModCommandResultInfo { success, message });
}
}
ServerMessage::MemberIdentityUpdated {
user_id,
display_name,
is_guest,
} => {
// Update the internal members list so subsequent updates don't overwrite
if let Some(member) = members_vec
.iter_mut()
.find(|m| m.member.user_id == Some(user_id))
{
member.member.display_name = display_name.clone();
member.member.is_guest = is_guest;
}
on_update.run(members_vec.clone());
if let Some(callback) = on_member_identity_updated {
callback.run(MemberIdentityInfo {
user_id,
display_name,
is_guest,
});
}
}
}
}
@ -644,6 +682,7 @@ pub fn use_channel_websocket(
_on_teleport_approved: Option<Callback<TeleportInfo>>,
_on_summoned: Option<Callback<SummonInfo>>,
_on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
_on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
) -> (Signal<WsState>, WsSenderStorage) {
let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None);

View file

@ -15,12 +15,13 @@ use crate::components::{
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
NotificationHistoryModal, NotificationMessage, NotificationToast, RealmHeader,
RealmSceneViewer, ReconnectionOverlay, SettingsPopup, ViewerSettings,
RealmSceneViewer, ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings,
};
#[cfg(feature = "hydrate")]
use crate::components::{
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, use_channel_websocket,
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history,
use_channel_websocket,
};
use crate::utils::LocalStoragePersist;
#[cfg(feature = "hydrate")]
@ -89,6 +90,9 @@ pub fn RealmPage() -> impl IntoView {
// Store full avatar data for the editor
let (full_avatar, set_full_avatar) = signal(Option::<AvatarWithPaths>::None);
// Register modal state (for guest-to-user conversion)
let (register_modal_open, set_register_modal_open) = signal(false);
// Scene dimensions (extracted from bounds_wkt when scene loads)
let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64));
@ -508,6 +512,20 @@ pub fn RealmPage() -> impl IntoView {
timeout.forget();
});
// Callback for member identity updates (e.g., guest registered as user)
#[cfg(feature = "hydrate")]
let on_member_identity_updated = Callback::new(move |info: MemberIdentityInfo| {
// Update the member's display name in the members list
set_members.update(|members| {
if let Some(member) = members
.iter_mut()
.find(|m| m.member.user_id == Some(info.user_id))
{
member.member.display_name = info.display_name.clone();
}
});
});
#[cfg(feature = "hydrate")]
let (ws_state, ws_sender) = use_channel_websocket(
slug,
@ -524,6 +542,7 @@ pub fn RealmPage() -> impl IntoView {
Some(on_teleport_approved),
Some(on_summoned),
Some(on_mod_command_result),
Some(on_member_identity_updated),
);
// Set channel ID, current scene, and scene dimensions when entry scene loads
@ -783,6 +802,17 @@ pub fn RealmPage() -> impl IntoView {
return;
}
// If any input or textarea is focused (e.g., modal forms), skip hotkeys
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
if let Some(active) = document.active_element() {
let tag = active.tag_name().to_lowercase();
if tag == "input" || tag == "textarea" {
*e_pressed_clone.borrow_mut() = false;
return;
}
}
}
// Handle space to focus chat input (no prefix)
if key == " " {
set_focus_prefix.set(' ');
@ -1126,6 +1156,9 @@ pub fn RealmPage() -> impl IntoView {
let on_open_log_cb = Callback::new(move |_: ()| {
set_log_open.set(true);
});
let on_open_register_cb = Callback::new(move |_: ()| {
set_register_modal_open.set(true);
});
let on_whisper_request_cb = Callback::new(move |target: String| {
whisper_target.set(Some(target));
});
@ -1192,6 +1225,8 @@ pub fn RealmPage() -> impl IntoView {
on_teleport=on_teleport_cb
is_moderator=is_moderator_signal
on_mod_command=on_mod_command_cb
is_guest=Signal::derive(move || is_guest.get())
on_open_register=on_open_register_cb
/>
</div>
</div>
@ -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! {
<RegisterModal
open=Signal::derive(move || register_modal_open.get())
on_close=Callback::new(move |_: ()| set_register_modal_open.set(false))
on_success=Callback::new(move |username: String| {
// Update is_guest to false since they're now a registered user
set_is_guest.set(false);
// Send RefreshIdentity to update display name for all channel members
#[cfg(feature = "hydrate")]
ws_sender_for_register.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::RefreshIdentity);
}
});
// Show success notification
set_mod_notification.set(Some((true, format!("Welcome, {}! Your account has been created.", username))));
// Auto-dismiss after 5 seconds
#[cfg(feature = "hydrate")]
{
let timeout = gloo_timers::callback::Timeout::new(5000, move || {
set_mod_notification.set(None);
});
timeout.forget();
}
})
/>
}
}
// Notification toast for cross-scene whispers
<NotificationToast
notification=Signal::derive(move || current_notification.get())