499 lines
21 KiB
Rust
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())
|
|
/>
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|