Implement complete full-stack authentication system
- Restructure project with separate frontend/backend architecture - Create dedicated backend with Axum, SQLite, JWT authentication - Implement real API endpoints for register/login/verify - Update frontend to use HTTP requests instead of mock auth - Add bcrypt password hashing and secure token generation - Separate Cargo.toml files for frontend and backend builds - Fix Trunk compilation by isolating WASM-incompatible dependencies - Create demo user in database for easy testing - Both servers running: frontend (8081), backend (3000) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
13
Cargo.toml
13
Cargo.toml
@@ -1,8 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "yew-app"
|
name = "calendar-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
# Frontend binary only
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
yew = { version = "0.21", features = ["csr"] }
|
yew = { version = "0.21", features = ["csr"] }
|
||||||
web-sys = "0.3"
|
web-sys = "0.3"
|
||||||
@@ -38,16 +40,9 @@ base64 = "0.21"
|
|||||||
# XML/Regex parsing
|
# XML/Regex parsing
|
||||||
regex = "1.0"
|
regex = "1.0"
|
||||||
|
|
||||||
# Frontend authentication (backend removed for WASM compatibility)
|
# Yew routing and local storage (WASM only)
|
||||||
# sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] }
|
|
||||||
# bcrypt = "0.15"
|
|
||||||
# jsonwebtoken = "9.0"
|
|
||||||
|
|
||||||
# Yew routing and local storage
|
|
||||||
yew-router = "0.18"
|
yew-router = "0.18"
|
||||||
gloo-storage = "0.3"
|
gloo-storage = "0.3"
|
||||||
gloo-timers = "0.3"
|
gloo-timers = "0.3"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
|
||||||
@@ -4,6 +4,7 @@ dist = "dist"
|
|||||||
|
|
||||||
[watch]
|
[watch]
|
||||||
watch = ["src", "Cargo.toml"]
|
watch = ["src", "Cargo.toml"]
|
||||||
|
ignore = ["backend/"]
|
||||||
|
|
||||||
[serve]
|
[serve]
|
||||||
address = "127.0.0.1"
|
address = "127.0.0.1"
|
||||||
|
|||||||
29
backend/Cargo.toml
Normal file
29
backend/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "calendar-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "backend"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Backend authentication dependencies
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] }
|
||||||
|
bcrypt = "0.15"
|
||||||
|
jsonwebtoken = "9.0"
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
axum = { version = "0.7", features = ["json"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
|
hyper = { version = "1.0", features = ["full"] }
|
||||||
|
|
||||||
|
# Shared dependencies
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||||
230
backend/src/auth.rs
Normal file
230
backend/src/auth.rs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{User, UserInfo, LoginRequest, RegisterRequest, AuthResponse, ApiError};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String, // Subject (user ID)
|
||||||
|
pub exp: i64, // Expiration time
|
||||||
|
pub iat: i64, // Issued at
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthService {
|
||||||
|
db: SqlitePool,
|
||||||
|
jwt_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
pub fn new(db: SqlitePool, jwt_secret: String) -> Self {
|
||||||
|
Self { db, jwt_secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init_db(&self) -> Result<(), ApiError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, ApiError> {
|
||||||
|
// Validate input
|
||||||
|
self.validate_registration(&request)?;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
if self.user_exists(&request.username, &request.email).await? {
|
||||||
|
return Err(ApiError::Conflict("Username or email already exists".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
let password_hash = hash(&request.password, DEFAULT_COST)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {}", e)))?;
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
let user_id = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (id, username, email, password_hash, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&request.username)
|
||||||
|
.bind(&request.email)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// Get the created user
|
||||||
|
let user = self.get_user_by_id(&user_id).await?;
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
let token = self.generate_token(&user)?;
|
||||||
|
|
||||||
|
Ok(AuthResponse {
|
||||||
|
token,
|
||||||
|
user: UserInfo {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, ApiError> {
|
||||||
|
// Get user by username
|
||||||
|
let user = self.get_user_by_username(&request.username).await?;
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
let is_valid = verify(&request.password, &user.password_hash)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Password verification failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !is_valid {
|
||||||
|
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
let token = self.generate_token(&user)?;
|
||||||
|
|
||||||
|
Ok(AuthResponse {
|
||||||
|
token,
|
||||||
|
user: UserInfo {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(&self, token: &str) -> Result<UserInfo, ApiError> {
|
||||||
|
let claims = self.decode_token(token)?;
|
||||||
|
let user = self.get_user_by_id(&claims.sub).await?;
|
||||||
|
|
||||||
|
Ok(UserInfo {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_by_username(&self, username: &str) -> Result<User, ApiError> {
|
||||||
|
let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE username = ?")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_one(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||||
|
|
||||||
|
self.row_to_user(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_by_id(&self, user_id: &str) -> Result<User, ApiError> {
|
||||||
|
let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::NotFound("User not found".to_string()))?;
|
||||||
|
|
||||||
|
self.row_to_user(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_user(&self, row: sqlx::sqlite::SqliteRow) -> Result<User, ApiError> {
|
||||||
|
Ok(User {
|
||||||
|
id: row.try_get("id").map_err(|e| ApiError::Database(e.to_string()))?,
|
||||||
|
username: row.try_get("username").map_err(|e| ApiError::Database(e.to_string()))?,
|
||||||
|
email: row.try_get("email").map_err(|e| ApiError::Database(e.to_string()))?,
|
||||||
|
password_hash: row.try_get("password_hash").map_err(|e| ApiError::Database(e.to_string()))?,
|
||||||
|
created_at: row.try_get("created_at").map_err(|e| ApiError::Database(e.to_string()))?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_exists(&self, username: &str, email: &str) -> Result<bool, ApiError> {
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE username = ? OR email = ?"
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(email)
|
||||||
|
.fetch_one(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_registration(&self, request: &RegisterRequest) -> Result<(), ApiError> {
|
||||||
|
if request.username.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Username is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.username.len() < 3 {
|
||||||
|
return Err(ApiError::BadRequest("Username must be at least 3 characters".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.email.trim().is_empty() || !request.email.contains('@') {
|
||||||
|
return Err(ApiError::BadRequest("Valid email is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.password.len() < 6 {
|
||||||
|
return Err(ApiError::BadRequest("Password must be at least 6 characters".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_token(&self, user: &User) -> Result<String, ApiError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user.id.clone(),
|
||||||
|
exp: expires_at.timestamp(),
|
||||||
|
iat: now.timestamp(),
|
||||||
|
username: user.username.clone(),
|
||||||
|
email: user.email.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_token(&self, token: &str) -> Result<Claims, ApiError> {
|
||||||
|
let token_data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||||
|
&Validation::new(Algorithm::HS256),
|
||||||
|
)
|
||||||
|
.map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
|
||||||
|
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/src/handlers.rs
Normal file
57
backend/src/handlers.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{AppState, models::{LoginRequest, RegisterRequest, AuthResponse, ApiError}};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VerifyQuery {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<RegisterRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, ApiError> {
|
||||||
|
let response = state.auth_service.register(request).await?;
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, ApiError> {
|
||||||
|
let response = state.auth_service.login(request).await?;
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
// Try to get token from Authorization header
|
||||||
|
let token = if let Some(auth_header) = headers.get("authorization") {
|
||||||
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| ApiError::BadRequest("Invalid authorization header".to_string()))?;
|
||||||
|
|
||||||
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||||
|
token.to_string()
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::BadRequest("Authorization header must start with 'Bearer '".to_string()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::Unauthorized("Authorization header required".to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_info = state.auth_service.verify_token(&token).await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"valid": true,
|
||||||
|
"user": user_info
|
||||||
|
})))
|
||||||
|
}
|
||||||
85
backend/src/lib.rs
Normal file
85
backend/src/lib.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::Json,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{sqlite::SqlitePool, Row};
|
||||||
|
use tower_http::cors::{CorsLayer, Any};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod models;
|
||||||
|
mod handlers;
|
||||||
|
|
||||||
|
use auth::AuthService;
|
||||||
|
use models::{LoginRequest, RegisterRequest, AuthResponse, ApiError};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub auth_service: AuthService,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Initialize logging
|
||||||
|
println!("🚀 Starting Calendar Backend Server");
|
||||||
|
|
||||||
|
// Set up database
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite:calendar.db?mode=rwc".to_string());
|
||||||
|
|
||||||
|
let db_pool = SqlitePool::connect(&database_url).await?;
|
||||||
|
|
||||||
|
// Run migrations - create database file if it doesn't exist
|
||||||
|
// The migrate!() macro looks for migrations in the current directory
|
||||||
|
// so we don't need to run explicit migrations here since we handle it in init_db()
|
||||||
|
|
||||||
|
// Create auth service
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
|
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||||
|
|
||||||
|
let auth_service = AuthService::new(db_pool, jwt_secret);
|
||||||
|
|
||||||
|
// Initialize database schema
|
||||||
|
auth_service.init_db().await?;
|
||||||
|
|
||||||
|
let app_state = AppState { auth_service };
|
||||||
|
|
||||||
|
// Build our application with routes
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/api/health", get(health_check))
|
||||||
|
.route("/api/auth/register", post(handlers::register))
|
||||||
|
.route("/api/auth/login", post(handlers::login))
|
||||||
|
.route("/api/auth/verify", get(handlers::verify_token))
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.with_state(Arc::new(app_state));
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
|
println!("📡 Server listening on http://0.0.0.0:3000");
|
||||||
|
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"Calendar Backend API v0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "calendar-backend",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}))
|
||||||
|
}
|
||||||
7
backend/src/main.rs
Normal file
7
backend/src/main.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Backend main entry point
|
||||||
|
use calendar_backend::*;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
run_server().await
|
||||||
|
}
|
||||||
90
backend/src/models.rs
Normal file
90
backend/src/models.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Database models
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// API request/response types
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user: UserInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ApiError {
|
||||||
|
Database(String),
|
||||||
|
NotFound(String),
|
||||||
|
Unauthorized(String),
|
||||||
|
BadRequest(String),
|
||||||
|
Conflict(String),
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, error_message) = match self {
|
||||||
|
ApiError::Database(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
||||||
|
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
|
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||||
|
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
|
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg),
|
||||||
|
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = Json(serde_json::json!({
|
||||||
|
"error": error_message,
|
||||||
|
"status": status.as_u16()
|
||||||
|
}));
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ApiError::Database(msg) => write!(f, "Database error: {}", msg),
|
||||||
|
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
|
||||||
|
ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
|
||||||
|
ApiError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
|
||||||
|
ApiError::Conflict(msg) => write!(f, "Conflict: {}", msg),
|
||||||
|
ApiError::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ApiError {}
|
||||||
BIN
calendar.db
Normal file
BIN
calendar.db
Normal file
Binary file not shown.
14
migrations/001_create_users_table.sql
Normal file
14
migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Create users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||||
195
src/auth.rs
195
src/auth.rs
@@ -1,5 +1,8 @@
|
|||||||
// Frontend-only authentication module (simplified for WASM compatibility)
|
// Frontend authentication module - connects to backend API
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
@@ -34,90 +37,140 @@ pub struct AuthResponse {
|
|||||||
pub user: UserInfo,
|
pub user: UserInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simplified frontend-only auth service
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AuthService;
|
pub struct ApiErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
pub status: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend auth service - connects to backend API
|
||||||
|
pub struct AuthService {
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl AuthService {
|
impl AuthService {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
// Default to localhost backend - could be configurable via env var in the future
|
||||||
|
Self {
|
||||||
|
base_url: "http://localhost:3000/api".to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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> {
|
pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, String> {
|
||||||
// Simulate API delay
|
self.post_json("/auth/register", &request).await
|
||||||
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> {
|
pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, String> {
|
||||||
// Simulate API delay
|
self.post_json("/auth/login", &request).await
|
||||||
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> {
|
pub async fn verify_token(&self, token: &str) -> Result<UserInfo, String> {
|
||||||
// Simulate API delay
|
let response = self.get_with_auth("/auth/verify", token).await?;
|
||||||
gloo_timers::future::TimeoutFuture::new(100).await;
|
let json_value: serde_json::Value = response;
|
||||||
|
|
||||||
// Mock token verification
|
if let Some(user_obj) = json_value.get("user") {
|
||||||
if token.starts_with("mock-jwt-token-") {
|
serde_json::from_value(user_obj.clone())
|
||||||
let username = token.strip_prefix("mock-jwt-token-").unwrap_or("unknown");
|
.map_err(|e| format!("Failed to parse user info: {}", e))
|
||||||
Ok(UserInfo {
|
|
||||||
id: format!("user-{}", username),
|
|
||||||
username: username.to_string(),
|
|
||||||
email: format!("{}@example.com", username),
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
Err("Invalid token".to_string())
|
Err("Invalid response format".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method for POST requests with JSON body
|
||||||
|
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
endpoint: &str,
|
||||||
|
body: &T,
|
||||||
|
) -> Result<R, String> {
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let json_body = serde_json::to_string(body)
|
||||||
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("POST");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body));
|
||||||
|
|
||||||
|
let url = format!("{}{}", self.base_url, endpoint);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request.headers().set("Content-Type", "application/json")
|
||||||
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value.dyn_into()
|
||||||
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text = JsFuture::from(resp.text()
|
||||||
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text_string = text.as_string()
|
||||||
|
.ok_or("Response text is not a string")?;
|
||||||
|
|
||||||
|
if resp.ok() {
|
||||||
|
serde_json::from_str::<R>(&text_string)
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {}", e))
|
||||||
|
} else {
|
||||||
|
// Try to parse error response
|
||||||
|
if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&text_string) {
|
||||||
|
Err(error_response.error)
|
||||||
|
} else {
|
||||||
|
Err(format!("Request failed with status {}", resp.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method for GET requests with Authorization header
|
||||||
|
async fn get_with_auth(
|
||||||
|
&self,
|
||||||
|
endpoint: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let url = format!("{}{}", self.base_url, endpoint);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value.dyn_into()
|
||||||
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text = JsFuture::from(resp.text()
|
||||||
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text_string = text.as_string()
|
||||||
|
.ok_or("Response text is not a string")?;
|
||||||
|
|
||||||
|
if resp.ok() {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&text_string)
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {}", e))
|
||||||
|
} else {
|
||||||
|
// Try to parse error response
|
||||||
|
if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&text_string) {
|
||||||
|
Err(error_response.error)
|
||||||
|
} else {
|
||||||
|
Err(format!("Request failed with status {}", resp.status()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod config;
|
|
||||||
mod calendar;
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user