Refactor authentication from database to direct CalDAV authentication

Major architectural change to simplify authentication by authenticating directly against CalDAV servers instead of maintaining a local user database.

Backend changes:
- Remove SQLite database dependencies and user storage
- Refactor AuthService to authenticate directly against CalDAV servers
- Update JWT tokens to store CalDAV server info instead of user IDs
- Implement proper CalDAV calendar discovery with XML parsing
- Fix URL construction for CalDAV REPORT requests
- Add comprehensive debug logging for authentication flow

Frontend changes:
- Add server URL input field to login form
- Remove registration functionality entirely
- Update calendar service to pass CalDAV passwords via headers
- Store CalDAV credentials in localStorage for API calls

Key improvements:
- Simplified architecture eliminates database complexity
- Direct CalDAV authentication ensures credentials always work
- Proper calendar discovery automatically finds user calendars
- Robust error handling and debug logging for troubleshooting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 18:40:22 -04:00
parent 0741afd0b2
commit d85898cae7
12 changed files with 276 additions and 582 deletions

View File

@@ -9,8 +9,6 @@ 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"] }

View File

@@ -1,210 +1,118 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{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};
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
use crate::config::CalDAVConfig;
use crate::calendar::CalDAVClient;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // Subject (user ID)
pub username: String,
pub server_url: String,
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 fn new(jwt_secret: String) -> Self {
Self { 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> {
/// Authenticate user directly against CalDAV server
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, ApiError> {
// Validate input
self.validate_registration(&request)?;
self.validate_login(&request)?;
println!("✅ Input validation passed");
// 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()));
}
// Create CalDAV config with provided credentials
let caldav_config = CalDAVConfig {
server_url: request.server_url.clone(),
username: request.username.clone(),
password: request.password.clone(),
calendar_path: None,
tasks_path: None,
};
println!("📝 Created CalDAV config");
// 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?;
// Test authentication against CalDAV server
let caldav_client = CalDAVClient::new(caldav_config.clone());
println!("🔗 Created CalDAV client, attempting to discover calendars...");
Ok(UserInfo {
id: user.id,
username: user.username,
email: user.email,
// Try to discover calendars as an authentication test
match caldav_client.discover_calendars().await {
Ok(calendars) => {
println!("✅ Authentication successful! Found {} calendars", calendars.len());
// Authentication successful, generate JWT token
let token = self.generate_token(&request.username, &request.server_url)?;
Ok(AuthResponse {
token,
username: request.username,
server_url: request.server_url,
})
}
Err(err) => {
println!("❌ Authentication failed: {:?}", err);
// Authentication failed
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
}
}
}
/// Verify JWT token and extract CalDAV credentials info
pub fn verify_token(&self, token: &str) -> Result<Claims, ApiError> {
self.decode_token(token)
}
/// Create CalDAV config from token
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
let claims = self.verify_token(token)?;
Ok(CalDAVConfig {
server_url: claims.server_url,
username: claims.username,
password: password.to_string(),
calendar_path: None,
tasks_path: None,
})
}
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> {
fn validate_login(&self, request: &CalDAVLoginRequest) -> 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.password.trim().is_empty() {
return Err(ApiError::BadRequest("Password is required".to_string()));
}
if request.email.trim().is_empty() || !request.email.contains('@') {
return Err(ApiError::BadRequest("Valid email is required".to_string()));
if request.server_url.trim().is_empty() {
return Err(ApiError::BadRequest("Server URL is required".to_string()));
}
if request.password.len() < 6 {
return Err(ApiError::BadRequest("Password must be at least 6 characters".to_string()));
// Basic URL validation
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
}
Ok(())
}
fn generate_token(&self, user: &User) -> Result<String, ApiError> {
fn generate_token(&self, username: &str, server_url: &str) -> 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(),
username: username.to_string(),
server_url: server_url.to_string(),
exp: expires_at.timestamp(),
iat: now.timestamp(),
username: user.username.clone(),
email: user.email.clone(),
};
let token = encode(

View File

@@ -149,12 +149,26 @@ impl CalDAVClient {
let url = if calendar_path.starts_with("http") {
calendar_path.to_string()
} else {
format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path)
// Extract the base URL (scheme + host + port) from server_url
let server_url = &self.config.server_url;
// Find the first '/' after "https://" or "http://"
let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 };
if let Some(path_start) = server_url[scheme_end..].find('/') {
let base_url = &server_url[..scheme_end + path_start];
format!("{}{}", base_url, calendar_path)
} else {
// No path in server_url, so just append the calendar_path
format!("{}{}", server_url.trim_end_matches('/'), calendar_path)
}
};
let basic_auth = self.config.get_basic_auth();
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
println!("🌐 REPORT URL: {}", url);
let response = self.http_client
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
.header("Authorization", format!("Basic {}", basic_auth))
.header("Content-Type", "application/xml")
.header("Depth", "1")
.header("User-Agent", "calendar-app/0.1.0")
@@ -296,7 +310,7 @@ impl CalDAVClient {
// Parse end time (optional - use start time if not present)
let end = if let Some(dtend) = properties.get("DTEND") {
Some(self.parse_datetime(dtend, properties.get("DTEND"))?)
} else if let Some(duration) = properties.get("DURATION") {
} else if let Some(_duration) = properties.get("DURATION") {
// TODO: Parse duration and add to start time
Some(start)
} else {
@@ -443,18 +457,16 @@ impl CalDAVClient {
println!("Using configured calendar path: {}", calendar_path);
return Ok(vec![calendar_path.clone()]);
}
println!("No calendar path configured, discovering calendars...");
// Try different common CalDAV discovery paths
// Note: paths should be relative to the server URL base
let user_calendar_path = format!("/calendars/{}/", self.config.username);
let user_dav_calendar_path = format!("/dav.php/calendars/{}/", self.config.username);
let discovery_paths = vec![
"/calendars/",
user_calendar_path.as_str(),
user_dav_calendar_path.as_str(),
"/dav.php/calendars/",
];
let mut all_calendars = Vec::new();
@@ -499,6 +511,7 @@ impl CalDAVClient {
.map_err(CalDAVError::RequestError)?;
if response.status().as_u16() != 207 {
println!("❌ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16());
return Err(CalDAVError::ServerError(response.status().as_u16()));
}
@@ -512,15 +525,33 @@ impl CalDAVClient {
if let Some(end_pos) = response_block.find("</d:response>") {
let response_content = &response_block[..end_pos];
// Look for actual calendar collections (not just containers)
if response_content.contains("<c:supported-calendar-component-set") ||
(response_content.contains("<d:collection/>") &&
response_content.contains("calendar")) {
if let Some(href) = self.extract_xml_content(response_content, "href") {
// Only include actual calendar paths, not container directories
if href.ends_with('/') && href.contains("calendar") && !href.ends_with("/calendars/") {
// Extract href first
if let Some(href) = self.extract_xml_content(response_content, "href") {
println!("🔍 Checking resource: {}", href);
// Check if this is a calendar collection by looking for supported-calendar-component-set
// This indicates it's an actual calendar that can contain events
let has_supported_components = response_content.contains("supported-calendar-component-set") &&
(response_content.contains("VEVENT") || response_content.contains("VTODO"));
let has_calendar_resourcetype = response_content.contains("<cal:calendar") || response_content.contains("<c:calendar");
let is_calendar = has_supported_components || has_calendar_resourcetype;
// Also check resourcetype for collection
let has_collection = response_content.contains("<d:collection") || response_content.contains("<collection");
if is_calendar && has_collection {
// Exclude system directories like inbox, outbox, and root calendar directories
if !href.contains("/inbox/") && !href.contains("/outbox/") &&
!href.ends_with("/calendars/") && href.ends_with('/') {
println!("📅 Found calendar collection: {}", href);
calendar_paths.push(href);
} else {
println!("❌ Skipping system/root directory: {}", href);
}
} else {
println!(" Not a calendar collection: {} (is_calendar: {}, has_collection: {})",
href, is_calendar, has_collection);
}
}
}

View File

@@ -7,9 +7,8 @@ use serde::Deserialize;
use std::sync::Arc;
use chrono::Datelike;
use crate::{AppState, models::{LoginRequest, RegisterRequest, AuthResponse, ApiError}};
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError}};
use crate::calendar::{CalDAVClient, CalendarEvent};
use crate::config::CalDAVConfig;
#[derive(Deserialize)]
pub struct CalendarQuery {
@@ -18,31 +17,17 @@ pub struct CalendarQuery {
}
pub async fn get_calendar_events(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Query(params): Query<CalendarQuery>,
headers: HeaderMap,
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
// Verify authentication (extract token from Authorization header)
let _token = if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if auth_str.starts_with("Bearer ") {
auth_str.strip_prefix("Bearer ").unwrap().to_string()
} else {
return Err(ApiError::Unauthorized("Invalid authorization format".to_string()));
}
} else {
return Err(ApiError::Unauthorized("Missing authorization header".to_string()));
};
// TODO: Validate JWT token here
// Load CalDAV configuration
let config = CalDAVConfig::from_env()
.map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?;
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
println!("🔑 API call with password length: {}", password.len());
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
@@ -74,31 +59,16 @@ pub async fn get_calendar_events(
}
pub async fn refresh_event(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Path(uid): Path<String>,
headers: HeaderMap,
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
// Verify authentication (extract token from Authorization header)
let _token = if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if auth_str.starts_with("Bearer ") {
auth_str.strip_prefix("Bearer ").unwrap().to_string()
} else {
return Err(ApiError::Unauthorized("Invalid authorization format".to_string()));
}
} else {
return Err(ApiError::Unauthorized("Missing authorization header".to_string()));
};
// TODO: Validate JWT token here
// Load CalDAV configuration
let config = CalDAVConfig::from_env()
.map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?;
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
@@ -119,18 +89,15 @@ pub async fn refresh_event(
Ok(Json(event))
}
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>,
Json(request): Json<CalDAVLoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
println!("🔐 Login attempt:");
println!(" Server URL: {}", request.server_url);
println!(" Username: {}", request.username);
println!(" Password length: {}", request.password.len());
let response = state.auth_service.login(request).await?;
Ok(Json(response))
}
@@ -139,25 +106,40 @@ 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?;
let token = extract_bearer_token(&headers)?;
let claims = state.auth_service.verify_token(&token)?;
Ok(Json(serde_json::json!({
"valid": true,
"user": user_info
"username": claims.username,
"server_url": claims.server_url
})))
}
// Helper functions
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if let Some(token) = auth_str.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(ApiError::Unauthorized("Authorization header must start with 'Bearer '".to_string()))
}
} else {
Err(ApiError::Unauthorized("Authorization header required".to_string()))
}
}
fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(password_header) = headers.get("x-caldav-password") {
let password = password_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid password header".to_string()))?;
Ok(password.to_string())
} else {
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
}
}

View File

@@ -3,7 +3,6 @@ use axum::{
routing::{get, post},
Router,
};
use sqlx::sqlite::SqlitePool;
use tower_http::cors::{CorsLayer, Any};
use std::sync::Arc;
@@ -23,25 +22,12 @@ pub struct AppState {
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 auth_service = AuthService::new(jwt_secret);
let app_state = AppState { auth_service };
@@ -49,7 +35,6 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
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))
.route("/api/calendar/events", get(handlers::get_calendar_events))

View File

@@ -3,44 +3,21 @@ use axum::{
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 struct CalDAVLoginRequest {
pub username: String,
pub password: String,
pub server_url: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserInfo,
pub username: String,
pub server_url: String,
}
// Error handling

View File

@@ -1,7 +1,7 @@
use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use crate::components::{Login, Register, Calendar};
use crate::components::{Login, Calendar};
use crate::services::{CalendarService, CalendarEvent};
use std::collections::HashMap;
use chrono::{Local, NaiveDate, Datelike};
@@ -12,8 +12,6 @@ enum Route {
Home,
#[at("/login")]
Login,
#[at("/register")]
Register,
#[at("/calendar")]
Calendar,
}
@@ -56,7 +54,6 @@ pub fn App() -> Html {
html! {
<nav>
<Link<Route> to={Route::Login}>{"Login"}</Link<Route>>
<Link<Route> to={Route::Register}>{"Register"}</Link<Route>>
</nav>
}
}
@@ -83,13 +80,6 @@ pub fn App() -> Html {
html! { <Login {on_login} /> }
}
}
Route::Register => {
if auth_token.is_some() {
html! { <Redirect<Route> to={Route::Calendar}/> }
} else {
html! { <Register on_register={on_login.clone()} /> }
}
}
Route::Calendar => {
if auth_token.is_some() {
html! { <CalendarView /> }
@@ -136,7 +126,18 @@ fn CalendarView() -> Html {
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
match calendar_service.refresh_event(&token, &uid).await {
// Get password from stored credentials
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.refresh_event(&token, &password, &uid).await {
Ok(Some(refreshed_event)) => {
// If this is a recurring event, we need to regenerate all occurrences
let mut updated_events = (*events).clone();
@@ -203,7 +204,18 @@ fn CalendarView() -> Html {
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
match calendar_service.fetch_events_for_month(&token, current_year, current_month).await {
// Get password from stored credentials
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.fetch_events_for_month(&token, &password, current_year, current_month).await {
Ok(calendar_events) => {
let grouped_events = CalendarService::group_events_by_date(calendar_events);
events.set(grouped_events);

View File

@@ -4,29 +4,9 @@ use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
#[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 struct CalDAVLoginRequest {
pub server_url: String,
pub username: String,
pub password: String,
}
@@ -34,7 +14,8 @@ pub struct LoginRequest {
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserInfo,
pub username: String,
pub server_url: String,
}
#[derive(Debug, Deserialize)]
@@ -57,11 +38,7 @@ impl AuthService {
Self { base_url }
}
pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, String> {
self.post_json("/auth/register", &request).await
}
pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, String> {
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
self.post_json("/auth/login", &request).await
}

View File

@@ -9,14 +9,24 @@ pub struct LoginProps {
#[function_component]
pub fn Login(props: &LoginProps) -> Html {
let server_url = use_state(String::new);
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 server_url_ref = use_node_ref();
let username_ref = use_node_ref();
let password_ref = use_node_ref();
let on_server_url_change = {
let server_url = server_url.clone();
Callback::from(move |e: Event| {
let target = e.target_unchecked_into::<HtmlInputElement>();
server_url.set(target.value());
})
};
let on_username_change = {
let username = username.clone();
Callback::from(move |e: Event| {
@@ -34,6 +44,7 @@ pub fn Login(props: &LoginProps) -> Html {
};
let on_submit = {
let server_url = server_url.clone();
let username = username.clone();
let password = password.clone();
let error_message = error_message.clone();
@@ -43,6 +54,7 @@ pub fn Login(props: &LoginProps) -> Html {
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let server_url = (*server_url).clone();
let username = (*username).clone();
let password = (*password).clone();
let error_message = error_message.clone();
@@ -50,7 +62,7 @@ pub fn Login(props: &LoginProps) -> Html {
let on_login = on_login.clone();
// Basic client-side validation
if username.trim().is_empty() || password.is_empty() {
if server_url.trim().is_empty() || username.trim().is_empty() || password.is_empty() {
error_message.set(Some("Please fill in all fields".to_string()));
return;
}
@@ -59,19 +71,27 @@ pub fn Login(props: &LoginProps) -> Html {
error_message.set(None);
wasm_bindgen_futures::spawn_local(async move {
match perform_login(username, password).await {
Ok(token) => {
// Store token in local storage
web_sys::console::log_1(&"🚀 Starting login process...".into());
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
Ok((token, credentials)) => {
web_sys::console::log_1(&"✅ Login successful!".into());
// Store token and credentials 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;
}
if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) {
error_message.set(Some("Failed to store credentials".to_string()));
is_loading.set(false);
return;
}
is_loading.set(false);
on_login.emit(token);
}
Err(err) => {
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
error_message.set(Some(err));
is_loading.set(false);
}
@@ -83,8 +103,21 @@ pub fn Login(props: &LoginProps) -> Html {
html! {
<div class="login-container">
<div class="login-form">
<h2>{"Sign In"}</h2>
<h2>{"Sign In to CalDAV"}</h2>
<form onsubmit={on_submit}>
<div class="form-group">
<label for="server_url">{"CalDAV Server URL"}</label>
<input
ref={server_url_ref}
type="text"
id="server_url"
placeholder="https://your-caldav-server.com/dav/"
value={(*server_url).clone()}
onchange={on_server_url_change}
disabled={*is_loading}
/>
</div>
<div class="form-group">
<label for="username">{"Username"}</label>
<input
@@ -131,22 +164,43 @@ pub fn Login(props: &LoginProps) -> Html {
</form>
<div class="auth-links">
<p>{"Don't have an account? "}<a href="/register">{"Sign up here"}</a></p>
<p>{"Enter your CalDAV server credentials to connect to your calendar"}</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};
/// Perform login using the CalDAV auth service
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
use crate::auth::{AuthService, CalDAVLoginRequest};
use serde_json;
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
let auth_service = AuthService::new();
let request = LoginRequest { username, password };
let request = CalDAVLoginRequest {
server_url: server_url.clone(),
username: username.clone(),
password: password.clone()
};
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
match auth_service.login(request).await {
Ok(response) => Ok(response.token),
Err(err) => Err(err),
Ok(response) => {
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
// Create credentials object to store
let credentials = serde_json::json!({
"server_url": server_url,
"username": username,
"password": password
});
Ok((response.token, credentials.to_string()))
},
Err(err) => {
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
Err(err)
},
}
}

View File

@@ -1,9 +1,7 @@
pub mod login;
pub mod register;
pub mod calendar;
pub mod event_modal;
pub use login::Login;
pub use register::Register;
pub use calendar::Calendar;
pub use event_modal::EventModal;

View File

@@ -1,235 +0,0 @@
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),
}
}

View File

@@ -139,6 +139,7 @@ impl CalendarService {
pub async fn fetch_events_for_month(
&self,
token: &str,
password: &str,
year: i32,
month: u32
) -> Result<Vec<CalendarEvent>, String> {
@@ -154,6 +155,9 @@ impl CalendarService {
request.headers().set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
request.headers().set("X-CalDAV-Password", password)
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
@@ -407,7 +411,7 @@ impl CalendarService {
}
/// Refresh a single event by UID from the CalDAV server
pub async fn refresh_event(&self, token: &str, uid: &str) -> Result<Option<CalendarEvent>, String> {
pub async fn refresh_event(&self, token: &str, password: &str, uid: &str) -> Result<Option<CalendarEvent>, String> {
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
@@ -420,6 +424,9 @@ impl CalendarService {
request.headers().set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
request.headers().set("X-CalDAV-Password", password)
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await