chattyness/crates/chattyness-user-ui/src/pages/login.rs

499 lines
21 KiB
Rust

//! Login page for realm users.
use leptos::ev::SubmitEvent;
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
#[cfg(feature = "hydrate")]
use leptos_router::hooks::use_navigate;
use crate::components::{Card, CenteredLayout, ErrorAlert, JoinRealmModal, SubmitButton};
use chattyness_db::models::RealmSummary;
/// Main login page component.
#[component]
pub fn LoginPage() -> impl IntoView {
view! {
<CenteredLayout>
<div class="w-full max-w-lg">
// Logo and title
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-white mb-2">
<span aria-hidden="true">"Chattyness"</span>
</h1>
<p class="text-gray-400">"Sign in to explore virtual community spaces"</p>
</div>
<Card class="p-6">
<RealmLoginForm />
</Card>
</div>
</CenteredLayout>
}
}
/// Realm login form component.
#[component]
fn RealmLoginForm() -> impl IntoView {
#[cfg(feature = "hydrate")]
let navigate = use_navigate();
#[cfg(feature = "hydrate")]
let navigate_for_submit = navigate.clone();
#[cfg(feature = "hydrate")]
let navigate_for_join = navigate.clone();
#[cfg(feature = "hydrate")]
let navigate_for_guest = navigate.clone();
// Form state
let (username, set_username) = signal(String::new());
let (password, set_password) = signal(String::new());
let (selected_realm, set_selected_realm) = signal(Option::<String>::None);
let (private_realm, set_private_realm) = signal(String::new());
let (error, set_error) = signal(Option::<String>::None);
let (pending, set_pending) = signal(false);
let (guest_pending, set_guest_pending) = signal(false);
// Join modal state
let (show_join_modal, set_show_join_modal) = signal(false);
let (join_pending, set_join_pending) = signal(false);
let (pending_realm, set_pending_realm) = signal(Option::<RealmSummary>::None);
// Fetch public realms
let realms = LocalResource::new(move || async move {
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
#[derive(serde::Deserialize)]
struct ListResponse {
realms: Vec<RealmSummary>,
}
let response = Request::get("/api/realms?include_nsfw=false&limit=20")
.send()
.await;
match response {
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
None::<Vec<RealmSummary>>
}
});
// Get the realm slug to use
let realm_slug = Signal::derive(move || {
if !private_realm.get().is_empty() {
Some(private_realm.get())
} else {
selected_realm.get()
}
});
// Handle login submission
let on_submit = move |ev: SubmitEvent| {
ev.prevent_default();
set_error.set(None);
let slug = realm_slug.get();
if slug.is_none() {
set_error.set(Some(
"Please select a realm or enter a private realm name".to_string(),
));
return;
}
#[cfg(feature = "hydrate")]
let slug = slug.unwrap();
let uname = username.get();
let pwd = password.get();
if uname.is_empty() || pwd.is_empty() {
set_error.set(Some("Username and password are required".to_string()));
return;
}
set_pending.set(true);
#[cfg(feature = "hydrate")]
{
use chattyness_db::models::{LoginRequest, LoginResponse, LoginType};
use gloo_net::http::Request;
let navigate = navigate_for_submit.clone();
spawn_local(async move {
let request = LoginRequest {
username: uname,
password: pwd,
login_type: LoginType::Realm,
realm_slug: Some(slug),
};
let response = Request::post("/api/auth/login")
.json(&request)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
if let Ok(login_resp) = resp.json::<LoginResponse>().await {
if login_resp.requires_pw_reset {
navigate(&login_resp.redirect_url, Default::default());
} else if login_resp.is_member == Some(false) {
if let Some(realm) = login_resp.realm {
set_pending_realm.set(Some(realm));
set_show_join_modal.set(true);
}
} else {
navigate(&login_resp.redirect_url, Default::default());
}
}
}
Ok(resp) => {
let status = resp.status();
if status == 401 {
set_error.set(Some("Invalid username or password".to_string()));
} else if status == 403 {
set_error.set(Some("Your account is suspended or banned".to_string()));
} else {
set_error.set(Some("Login failed. Please try again.".to_string()));
}
}
Err(_) => {
set_error.set(Some(
"Network error. Please check your connection.".to_string(),
));
}
}
});
}
};
// Handle join confirmation
let on_join_confirm = move |_| {
if pending_realm.get().is_some() {
set_join_pending.set(true);
#[cfg(feature = "hydrate")]
{
use chattyness_db::models::JoinRealmRequest;
use gloo_net::http::Request;
let realm = pending_realm.get().unwrap();
let navigate = navigate_for_join.clone();
let realm_id = realm.id;
let realm_slug = realm.slug.clone();
spawn_local(async move {
let request = JoinRealmRequest { realm_id };
let response = Request::post("/api/auth/join-realm")
.json(&request)
.unwrap()
.send()
.await;
set_join_pending.set(false);
set_show_join_modal.set(false);
match response {
Ok(resp) if resp.ok() => {
navigate(&format!("/realms/{}", realm_slug), Default::default());
}
Ok(resp) => {
let status = resp.status();
if status == 403 {
set_error.set(Some("Cannot join this realm".to_string()));
} else {
set_error.set(Some(
"Failed to join realm. Please try again.".to_string(),
));
}
}
Err(_) => {
set_error.set(Some(
"Network error. Please check your connection.".to_string(),
));
}
}
});
}
}
};
let on_join_cancel = move |_| {
set_show_join_modal.set(false);
set_pending_realm.set(None);
};
// Handle guest login
let on_guest_click = move |_| {
set_error.set(None);
let slug = realm_slug.get();
if slug.is_none() {
set_error.set(Some("Please select a realm first".to_string()));
return;
}
set_guest_pending.set(true);
#[cfg(feature = "hydrate")]
{
use chattyness_db::models::{GuestLoginRequest, GuestLoginResponse};
use gloo_net::http::Request;
let navigate = navigate_for_guest.clone();
let realm_slug_val = slug.unwrap();
spawn_local(async move {
let request = GuestLoginRequest {
realm_slug: realm_slug_val,
};
let response = Request::post("/api/auth/guest")
.json(&request)
.unwrap()
.send()
.await;
set_guest_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
if let Ok(guest_resp) = resp.json::<GuestLoginResponse>().await {
navigate(&guest_resp.redirect_url, Default::default());
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_error.set(Some(err.error));
} else {
set_error.set(Some(
"Guest access not available for this realm".to_string(),
));
}
}
Err(_) => {
set_error.set(Some(
"Network error. Please check your connection.".to_string(),
));
}
}
});
}
};
view! {
<form on:submit=on_submit class="space-y-6">
// Realm selection
<fieldset>
<legend class="text-sm font-medium text-gray-300 mb-3">"Choose a Realm"</legend>
// Public realm list
<Suspense fallback=move || {
view! { <p class="text-gray-400">"Loading realms..."</p> }
}>
{move || {
realms
.get()
.map(|maybe_realms: Option<Vec<RealmSummary>>| {
match maybe_realms {
Some(realms) if !realms.is_empty() => {
view! {
<div class="space-y-2 max-h-48 overflow-y-auto mb-4">
{realms
.into_iter()
.map(|realm| {
let slug = realm.slug.clone();
let slug_for_click = slug.clone();
let is_selected = Signal::derive(move || {
selected_realm.get() == Some(slug.clone())
});
view! {
<button
type="button"
class=move || {
let base = "w-full text-left p-3 rounded-lg border transition-colors";
if is_selected.get() {
format!("{} border-blue-500 bg-blue-500/10", base)
} else {
format!(
"{} border-gray-700 hover:border-gray-600 hover:bg-gray-700/50",
base,
)
}
}
on:click=move |_| {
set_selected_realm.set(Some(slug_for_click.clone()));
set_private_realm.set(String::new());
}
>
<div class="flex items-center justify-between">
<div>
<span class="text-white font-medium">
{realm.name.clone()}
</span>
<span class="text-gray-500 text-sm ml-2">
{format!("/{}", realm.slug)}
</span>
</div>
<span class="text-gray-400 text-sm">
{realm.current_user_count}
" online"
</span>
</div>
{realm
.tagline
.as_ref()
.map(|t: &String| {
view! {
<p class="text-gray-400 text-sm mt-1">{t.clone()}</p>
}
})}
</button>
}
})
.collect_view()}
</div>
}
.into_any()
}
_ => {
view! {
<p class="text-gray-400 mb-4">"No public realms available"</p>
}
.into_any()
}
}
})
}}
</Suspense>
// Private realm input
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="text-gray-500">"/"</span>
</div>
<input
type="text"
placeholder="Or enter a private realm name"
class="input-base pl-6"
prop:value=move || private_realm.get()
on:input=move |ev| {
set_private_realm.set(event_target_value(&ev));
set_selected_realm.set(None);
}
/>
</div>
</fieldset>
// Credentials
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">
"Username"
<span class="text-red-400" aria-hidden="true">"*"</span>
</label>
<input
type="text"
id="username"
name="username"
required=true
autocomplete="username"
class="input-base"
prop:value=move || username.get()
on:input=move |ev| set_username.set(event_target_value(&ev))
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
"Password"
<span class="text-red-400" aria-hidden="true">"*"</span>
</label>
<input
type="password"
id="password"
name="password"
required=true
autocomplete="current-password"
class="input-base"
prop:value=move || password.get()
on:input=move |ev| set_password.set(event_target_value(&ev))
/>
</div>
</div>
// Error message
<ErrorAlert message=Signal::derive(move || error.get()) />
// Submit button
<SubmitButton
text="Enter Realm"
loading_text="Signing in..."
pending=Signal::derive(move || pending.get())
/>
// Divider
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-700"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-gray-800 text-gray-400">"or"</span>
</div>
</div>
// Guest button
<button
type="button"
class="w-full py-3 px-4 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 hover:border-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled=move || guest_pending.get() || realm_slug.get().is_none()
on:click=on_guest_click
>
{move || {
if guest_pending.get() { "Joining as guest..." } else { "Continue as Guest" }
}}
</button>
// Sign up link
<p class="text-center text-gray-400 text-sm">
"Don't have an account? "
<a href="/signup" class="text-blue-400 hover:underline">
"Sign up"
</a>
</p>
</form>
// Join modal
{
let on_join_confirm = on_join_confirm.clone();
let on_join_cancel = on_join_cancel.clone();
move || {
let on_join_confirm = on_join_confirm.clone();
let on_join_cancel = on_join_cancel.clone();
pending_realm
.get()
.map(|realm| {
view! {
<JoinRealmModal
open=Signal::derive(move || show_join_modal.get())
realm_name=realm.name.clone()
realm_slug=realm.slug.clone()
pending=Signal::derive(move || join_pending.get())
on_confirm=Callback::new(on_join_confirm.clone())
on_cancel=Callback::new(on_join_cancel.clone())
/>
}
})
}
}
}
}