Files
nx9-auth/ui/src/pages/auth/mod.rs
T

242 lines
10 KiB
Rust

//! Authentication pages.
//!
//! Login submits credentials via **POST JSON** only. The HTML form uses
//! `method="post"` so a native fallback never puts passwords in the URL.
use crate::components::forms::{PasswordInput, TextInput};
use crate::models::MeResponse;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, BootstrapState, ToastKind};
use dioxus::prelude::*;
#[component]
pub fn LoginPage() -> Element {
let state = use_context::<AppState>();
let auth = state.auth;
let nav = use_navigator();
let mut username = use_signal(String::new);
let mut password = use_signal(String::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| false);
// If already authenticated, leave the login screen.
use_effect(move || {
if auth().is_authenticated() {
nav.replace(Route::DashboardPage {});
}
});
let on_submit = move |evt: Event<FormData>| {
// Critical: prevent native form submission (which defaults to GET
// and would put credentials in the query string / browser history).
evt.prevent_default();
if loading() {
return;
}
let u = username().trim().to_string();
let p = password();
if u.is_empty() || p.is_empty() {
error.set(Some("Please enter username and password.".into()));
return;
}
loading.set(true);
error.set(None);
let mut auth = state.auth;
let mut loading = loading;
let mut error = error;
let mut password = password;
let nav = nav.clone();
spawn(async move {
match api::login(&u, &p).await {
Ok(login) => {
// Clear password from UI memory after successful submit.
password.set(String::new());
// Prefer /auth/me; fall back to login payload user info.
let me = match api::me().await {
Ok(Some(m)) => m,
_ => {
// Build MeResponse from login.user if present
if let Some(user_val) = login.user {
MeResponse {
user: crate::models::UserView {
id: user_val
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
username: user_val
.get("username")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
status: user_val
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("active")
.to_string(),
last_login_at: user_val
.get("last_login_at")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
created_at: user_val
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
updated_at: None,
},
roles: user_val
.get("roles")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
permissions: user_val
.get("permissions")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
}
} else {
error.set(Some(
"Signed in but session could not be verified. Try again."
.into(),
));
loading.set(false);
return;
}
}
};
auth.set(BootstrapState::Authenticated(me));
state.toast(ToastKind::Success, "Signed in successfully");
nav.replace(Route::DashboardPage {});
}
Err(e) => {
// Map API errors to a safe, non-enumerating message for creds.
let msg = match e {
api::ApiError::Unauthorized
| api::ApiError::InvalidInput(_)
| api::ApiError::Server(_) => {
// Prefer server body when it's the standard message
let s = e.to_string();
if s.to_lowercase().contains("invalid username")
|| s.to_lowercase().contains("unauthorized")
|| s.to_lowercase().contains("invalid credentials")
{
"Invalid username or password.".into()
} else {
s
}
}
other => other.to_string(),
};
error.set(Some(msg));
if !auth().is_authenticated() {
auth.set(BootstrapState::Anonymous);
}
}
}
loading.set(false);
});
};
rsx! {
div { class: "auth-page",
div { class: "auth-card",
div { class: "logo-row",
div { class: "brand-mark", style: "width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--accent),#7c3aed);display:grid;place-items:center;color:#fff;font-weight:800;",
"N9"
}
div {
h1 { "Sign in to nx9-auth" }
p { class: "subtitle", style: "margin:0;", "Identity & Access Management" }
}
}
if let Some(err) = error() {
div { class: "alert alert-error", role: "alert", "{err}" }
}
// method="post" is mandatory: HTML default is GET, which would
// put credentials in the URL if preventDefault failed.
form {
method: "post",
action: "#",
autocomplete: "on",
onsubmit: on_submit,
TextInput {
label: "Username",
name: "username",
value: username(),
oninput: move |v| username.set(v),
required: true,
autocomplete: "username",
placeholder: "admin",
}
PasswordInput {
label: "Password",
name: "password",
value: password(),
oninput: move |v| password.set(v),
required: true,
autocomplete: "current-password",
}
button {
class: "btn btn-primary",
r#type: "submit",
style: "width: 100%; margin-top: 0.5rem;",
disabled: loading() || username().trim().is_empty() || password().is_empty(),
if loading() {
span { class: "spinner", style: "width:14px;height:14px;border-width:2px;" }
}
if loading() { "Signing in…" } else { "Sign in" }
}
}
p { class: "text-muted", style: "margin-top: 1.25rem; font-size: 12px; text-align: center;",
"POST · Argon2id · Session tokens · No credentials in URLs"
}
}
}
}
}
#[component]
pub fn UnauthorizedPage() -> Element {
rsx! {
div { class: "auth-page",
div { class: "auth-card", style: "text-align:center;",
h1 { "401 — Unauthorized" }
p { class: "subtitle", "Your session is missing or has expired." }
Link { class: "btn btn-primary", to: Route::LoginPage {}, "Sign in" }
}
}
}
}
#[component]
pub fn ForbiddenPage() -> Element {
rsx! {
div { class: "auth-page",
div { class: "auth-card", style: "text-align:center;",
h1 { "403 — Forbidden" }
p { class: "subtitle", "You do not have permission to view this page." }
Link { class: "btn btn-primary", to: Route::DashboardPage {}, "Back to dashboard" }
}
}
}
}