add the ability to register from inside the user-ui
This commit is contained in:
parent
31e01292f9
commit
ed1a1f10f9
12 changed files with 655 additions and 5 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
277
crates/chattyness-user-ui/src/components/register_modal.rs
Normal file
277
crates/chattyness-user-ui/src/components/register_modal.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
¤t_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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue