Implement frontend authentication system with login/registration
- Add comprehensive authentication module with mock service - Create login and registration components with form validation - Implement protected routing with yew-router - Add responsive UI styling with gradient design - Enable JWT token persistence via localStorage - Support demo credentials (demo/password) and flexible auth for development 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
123
src/auth.rs
Normal file
123
src/auth.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Frontend-only authentication module (simplified for WASM compatibility)
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user: UserInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified frontend-only auth service
|
||||||
|
pub struct AuthService;
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock authentication methods for development
|
||||||
|
// In production, these would make HTTP requests to a backend API
|
||||||
|
|
||||||
|
pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, String> {
|
||||||
|
// Simulate API delay
|
||||||
|
gloo_timers::future::TimeoutFuture::new(500).await;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if request.username.trim().is_empty() || request.email.trim().is_empty() || request.password.is_empty() {
|
||||||
|
return Err("All fields are required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.password.len() < 6 {
|
||||||
|
return Err("Password must be at least 6 characters".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock successful registration
|
||||||
|
Ok(AuthResponse {
|
||||||
|
token: format!("mock-jwt-token-{}", request.username),
|
||||||
|
user: UserInfo {
|
||||||
|
id: "user-123".to_string(),
|
||||||
|
username: request.username,
|
||||||
|
email: request.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, String> {
|
||||||
|
// Simulate API delay
|
||||||
|
gloo_timers::future::TimeoutFuture::new(500).await;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if request.username.trim().is_empty() || request.password.is_empty() {
|
||||||
|
return Err("Username and password are required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock authentication - accept demo/password or any user/password combo
|
||||||
|
if request.username == "demo" && request.password == "password" {
|
||||||
|
Ok(AuthResponse {
|
||||||
|
token: "mock-jwt-token-demo".to_string(),
|
||||||
|
user: UserInfo {
|
||||||
|
id: "demo-user-123".to_string(),
|
||||||
|
username: request.username,
|
||||||
|
email: "demo@example.com".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if !request.password.is_empty() {
|
||||||
|
// Accept any non-empty password for development
|
||||||
|
let username = request.username.clone();
|
||||||
|
Ok(AuthResponse {
|
||||||
|
token: format!("mock-jwt-token-{}", username),
|
||||||
|
user: UserInfo {
|
||||||
|
id: format!("user-{}", username),
|
||||||
|
username: request.username,
|
||||||
|
email: format!("{}@example.com", username),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err("Invalid credentials".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(&self, token: &str) -> Result<UserInfo, String> {
|
||||||
|
// Simulate API delay
|
||||||
|
gloo_timers::future::TimeoutFuture::new(100).await;
|
||||||
|
|
||||||
|
// Mock token verification
|
||||||
|
if token.starts_with("mock-jwt-token-") {
|
||||||
|
let username = token.strip_prefix("mock-jwt-token-").unwrap_or("unknown");
|
||||||
|
Ok(UserInfo {
|
||||||
|
id: format!("user-{}", username),
|
||||||
|
username: username.to_string(),
|
||||||
|
email: format!("{}@example.com", username),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err("Invalid token".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/components/login.rs
Normal file
152
src/components/login.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct LoginProps {
|
||||||
|
pub on_login: Callback<String>, // Callback with JWT token
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn Login(props: &LoginProps) -> Html {
|
||||||
|
let username = use_state(String::new);
|
||||||
|
let password = use_state(String::new);
|
||||||
|
let error_message = use_state(|| Option::<String>::None);
|
||||||
|
let is_loading = use_state(|| false);
|
||||||
|
|
||||||
|
let username_ref = use_node_ref();
|
||||||
|
let password_ref = use_node_ref();
|
||||||
|
|
||||||
|
let on_username_change = {
|
||||||
|
let username = username.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
username.set(target.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_password_change = {
|
||||||
|
let password = password.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
password.set(target.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
let username = username.clone();
|
||||||
|
let password = password.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let on_login = props.on_login.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let username = (*username).clone();
|
||||||
|
let password = (*password).clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let on_login = on_login.clone();
|
||||||
|
|
||||||
|
// Basic client-side validation
|
||||||
|
if username.trim().is_empty() || password.is_empty() {
|
||||||
|
error_message.set(Some("Please fill in all fields".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_loading.set(true);
|
||||||
|
error_message.set(None);
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match perform_login(username, password).await {
|
||||||
|
Ok(token) => {
|
||||||
|
// Store token in local storage
|
||||||
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
|
error_message.set(Some("Failed to store authentication token".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_loading.set(false);
|
||||||
|
on_login.emit(token);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error_message.set(Some(err));
|
||||||
|
is_loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-form">
|
||||||
|
<h2>{"Sign In"}</h2>
|
||||||
|
<form onsubmit={on_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">{"Username"}</label>
|
||||||
|
<input
|
||||||
|
ref={username_ref}
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={(*username).clone()}
|
||||||
|
onchange={on_username_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">{"Password"}</label>
|
||||||
|
<input
|
||||||
|
ref={password_ref}
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={(*password).clone()}
|
||||||
|
onchange={on_password_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(error) = (*error_message).clone() {
|
||||||
|
html! { <div class="error-message">{error}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="submit" disabled={*is_loading} class="login-button">
|
||||||
|
{
|
||||||
|
if *is_loading {
|
||||||
|
"Signing in..."
|
||||||
|
} else {
|
||||||
|
"Sign In"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<p>{"Don't have an account? "}<a href="/register">{"Sign up here"}</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform login using the auth service
|
||||||
|
async fn perform_login(username: String, password: String) -> Result<String, String> {
|
||||||
|
use crate::auth::{AuthService, LoginRequest};
|
||||||
|
|
||||||
|
let auth_service = AuthService::new();
|
||||||
|
let request = LoginRequest { username, password };
|
||||||
|
|
||||||
|
match auth_service.login(request).await {
|
||||||
|
Ok(response) => Ok(response.token),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/components/mod.rs
Normal file
5
src/components/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod login;
|
||||||
|
pub mod register;
|
||||||
|
|
||||||
|
pub use login::Login;
|
||||||
|
pub use register::Register;
|
||||||
235
src/components/register.rs
Normal file
235
src/components/register.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct RegisterProps {
|
||||||
|
pub on_register: Callback<String>, // Callback with JWT token
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn Register(props: &RegisterProps) -> Html {
|
||||||
|
let username = use_state(String::new);
|
||||||
|
let email = use_state(String::new);
|
||||||
|
let password = use_state(String::new);
|
||||||
|
let confirm_password = use_state(String::new);
|
||||||
|
let error_message = use_state(|| Option::<String>::None);
|
||||||
|
let is_loading = use_state(|| false);
|
||||||
|
|
||||||
|
let username_ref = use_node_ref();
|
||||||
|
let email_ref = use_node_ref();
|
||||||
|
let password_ref = use_node_ref();
|
||||||
|
let confirm_password_ref = use_node_ref();
|
||||||
|
|
||||||
|
let on_username_change = {
|
||||||
|
let username = username.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
username.set(target.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_email_change = {
|
||||||
|
let email = email.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
email.set(target.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_password_change = {
|
||||||
|
let password = password.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
password.set(target.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_confirm_password_change = {
|
||||||
|
let confirm_password = confirm_password.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
confirm_password.set(target.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
let username = username.clone();
|
||||||
|
let email = email.clone();
|
||||||
|
let password = password.clone();
|
||||||
|
let confirm_password = confirm_password.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let on_register = props.on_register.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let username = (*username).clone();
|
||||||
|
let email = (*email).clone();
|
||||||
|
let password = (*password).clone();
|
||||||
|
let confirm_password = (*confirm_password).clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let on_register = on_register.clone();
|
||||||
|
|
||||||
|
// Client-side validation
|
||||||
|
if let Err(validation_error) = validate_registration(&username, &email, &password, &confirm_password) {
|
||||||
|
error_message.set(Some(validation_error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_loading.set(true);
|
||||||
|
error_message.set(None);
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match perform_registration(username, email, password).await {
|
||||||
|
Ok(token) => {
|
||||||
|
// Store token in local storage
|
||||||
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
|
error_message.set(Some("Failed to store authentication token".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_loading.set(false);
|
||||||
|
on_register.emit(token);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error_message.set(Some(err));
|
||||||
|
is_loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="register-container">
|
||||||
|
<div class="register-form">
|
||||||
|
<h2>{"Create Account"}</h2>
|
||||||
|
<form onsubmit={on_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">{"Username"}</label>
|
||||||
|
<input
|
||||||
|
ref={username_ref}
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
value={(*username).clone()}
|
||||||
|
onchange={on_username_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">{"Email"}</label>
|
||||||
|
<input
|
||||||
|
ref={email_ref}
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
value={(*email).clone()}
|
||||||
|
onchange={on_email_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">{"Password"}</label>
|
||||||
|
<input
|
||||||
|
ref={password_ref}
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
placeholder="Choose a password"
|
||||||
|
value={(*password).clone()}
|
||||||
|
onchange={on_password_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm-password">{"Confirm Password"}</label>
|
||||||
|
<input
|
||||||
|
ref={confirm_password_ref}
|
||||||
|
type="password"
|
||||||
|
id="confirm-password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
value={(*confirm_password).clone()}
|
||||||
|
onchange={on_confirm_password_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(error) = (*error_message).clone() {
|
||||||
|
html! { <div class="error-message">{error}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="submit" disabled={*is_loading} class="register-button">
|
||||||
|
{
|
||||||
|
if *is_loading {
|
||||||
|
"Creating Account..."
|
||||||
|
} else {
|
||||||
|
"Create Account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<p>{"Already have an account? "}<a href="/login">{"Sign in here"}</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate registration form data
|
||||||
|
fn validate_registration(username: &str, email: &str, password: &str, confirm_password: &str) -> Result<(), String> {
|
||||||
|
if username.trim().is_empty() {
|
||||||
|
return Err("Username is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if username.len() < 3 {
|
||||||
|
return Err("Username must be at least 3 characters long".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if email.trim().is_empty() {
|
||||||
|
return Err("Email is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !email.contains('@') {
|
||||||
|
return Err("Please enter a valid email address".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("Password is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if password.len() < 6 {
|
||||||
|
return Err("Password must be at least 6 characters long".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if password != confirm_password {
|
||||||
|
return Err("Passwords do not match".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform registration using the auth service
|
||||||
|
async fn perform_registration(username: String, email: String, password: String) -> Result<String, String> {
|
||||||
|
use crate::auth::{AuthService, RegisterRequest};
|
||||||
|
|
||||||
|
let auth_service = AuthService::new();
|
||||||
|
let request = RegisterRequest { username, email, password };
|
||||||
|
|
||||||
|
match auth_service.register(request).await {
|
||||||
|
Ok(response) => Ok(response.token),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user