Improved auth system #6
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -22,3 +22,9 @@ dist/ | |||||||
| CLAUDE.md | CLAUDE.md | ||||||
|  |  | ||||||
| data/ | data/ | ||||||
|  |  | ||||||
|  | # SQLite database | ||||||
|  | *.db | ||||||
|  | *.db-shm | ||||||
|  | *.db-wal | ||||||
|  | calendar.db | ||||||
|   | |||||||
| @@ -34,6 +34,10 @@ base64 = "0.21" | |||||||
| thiserror = "1.0" | thiserror = "1.0" | ||||||
| lazy_static = "1.4" | lazy_static = "1.4" | ||||||
|  |  | ||||||
|  | # Database dependencies | ||||||
|  | sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] } | ||||||
|  | tokio-rusqlite = "0.5" | ||||||
|  |  | ||||||
| [dev-dependencies] | [dev-dependencies] | ||||||
| tokio = { version = "1.0", features = ["macros", "rt"] } | tokio = { version = "1.0", features = ["macros", "rt"] } | ||||||
| reqwest = { version = "0.11", features = ["json"] } | reqwest = { version = "0.11", features = ["json"] } | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								backend/migrations/001_create_users_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								backend/migrations/001_create_users_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | -- Create users table | ||||||
|  | CREATE TABLE IF NOT EXISTS users ( | ||||||
|  |     id TEXT PRIMARY KEY, | ||||||
|  |     username TEXT NOT NULL, | ||||||
|  |     server_url TEXT NOT NULL, | ||||||
|  |     created_at TEXT NOT NULL, | ||||||
|  |     UNIQUE(username, server_url) | ||||||
|  | ); | ||||||
							
								
								
									
										16
									
								
								backend/migrations/002_create_sessions_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								backend/migrations/002_create_sessions_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | -- Create sessions table | ||||||
|  | CREATE TABLE IF NOT EXISTS sessions ( | ||||||
|  |     id TEXT PRIMARY KEY, | ||||||
|  |     user_id TEXT NOT NULL, | ||||||
|  |     token TEXT NOT NULL UNIQUE, | ||||||
|  |     created_at TEXT NOT NULL, | ||||||
|  |     expires_at TEXT NOT NULL, | ||||||
|  |     last_accessed TEXT NOT NULL, | ||||||
|  |     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Index for faster token lookups | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); | ||||||
|  |  | ||||||
|  | -- Index for cleanup of expired sessions | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); | ||||||
							
								
								
									
										11
									
								
								backend/migrations/003_create_user_preferences_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/migrations/003_create_user_preferences_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | -- Create user preferences table | ||||||
|  | CREATE TABLE IF NOT EXISTS user_preferences ( | ||||||
|  |     user_id TEXT PRIMARY KEY, | ||||||
|  |     calendar_selected_date TEXT, | ||||||
|  |     calendar_time_increment INTEGER, | ||||||
|  |     calendar_view_mode TEXT, | ||||||
|  |     calendar_theme TEXT, | ||||||
|  |     calendar_colors TEXT, -- JSON string for calendar color mappings | ||||||
|  |     updated_at TEXT NOT NULL, | ||||||
|  |     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | ||||||
|  | ); | ||||||
| @@ -1,10 +1,12 @@ | |||||||
| use chrono::{Duration, Utc}; | use chrono::{Duration, Utc}; | ||||||
| use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; | use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  | use uuid::Uuid; | ||||||
|  |  | ||||||
| use crate::calendar::CalDAVClient; | use crate::calendar::CalDAVClient; | ||||||
| use crate::config::CalDAVConfig; | use crate::config::CalDAVConfig; | ||||||
| use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest}; | use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository}; | ||||||
|  | use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse}; | ||||||
|  |  | ||||||
| #[derive(Debug, Serialize, Deserialize)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| pub struct Claims { | pub struct Claims { | ||||||
| @@ -17,11 +19,12 @@ pub struct Claims { | |||||||
| #[derive(Clone)] | #[derive(Clone)] | ||||||
| pub struct AuthService { | pub struct AuthService { | ||||||
|     jwt_secret: String, |     jwt_secret: String, | ||||||
|  |     db: Database, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl AuthService { | impl AuthService { | ||||||
|     pub fn new(jwt_secret: String) -> Self { |     pub fn new(jwt_secret: String, db: Database) -> Self { | ||||||
|         Self { jwt_secret } |         Self { jwt_secret, db } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Authenticate user directly against CalDAV server |     /// Authenticate user directly against CalDAV server | ||||||
| @@ -49,13 +52,47 @@ impl AuthService { | |||||||
|                     "✅ Authentication successful! Found {} calendars", |                     "✅ Authentication successful! Found {} calendars", | ||||||
|                     calendars.len() |                     calendars.len() | ||||||
|                 ); |                 ); | ||||||
|                 // Authentication successful, generate JWT token |                  | ||||||
|                 let token = self.generate_token(&request.username, &request.server_url)?; |                 // Find or create user in database | ||||||
|  |                 let user_repo = UserRepository::new(&self.db); | ||||||
|  |                 let user = user_repo | ||||||
|  |                     .find_or_create(&request.username, &request.server_url) | ||||||
|  |                     .await | ||||||
|  |                     .map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?; | ||||||
|  |                  | ||||||
|  |                 // Generate JWT token | ||||||
|  |                 let jwt_token = self.generate_token(&request.username, &request.server_url)?; | ||||||
|  |                  | ||||||
|  |                 // Generate session token | ||||||
|  |                 let session_token = format!("sess_{}", Uuid::new_v4()); | ||||||
|  |                  | ||||||
|  |                 // Create session in database | ||||||
|  |                 let session = Session::new(user.id.clone(), session_token.clone(), 24); | ||||||
|  |                 let session_repo = SessionRepository::new(&self.db); | ||||||
|  |                 session_repo | ||||||
|  |                     .create(&session) | ||||||
|  |                     .await | ||||||
|  |                     .map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?; | ||||||
|  |                  | ||||||
|  |                 // Get or create user preferences | ||||||
|  |                 let prefs_repo = PreferencesRepository::new(&self.db); | ||||||
|  |                 let preferences = prefs_repo | ||||||
|  |                     .get_or_create(&user.id) | ||||||
|  |                     .await | ||||||
|  |                     .map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?; | ||||||
|                  |                  | ||||||
|                 Ok(AuthResponse { |                 Ok(AuthResponse { | ||||||
|                     token, |                     token: jwt_token, | ||||||
|  |                     session_token, | ||||||
|                     username: request.username, |                     username: request.username, | ||||||
|                     server_url: request.server_url, |                     server_url: request.server_url, | ||||||
|  |                     preferences: UserPreferencesResponse { | ||||||
|  |                         calendar_selected_date: preferences.calendar_selected_date, | ||||||
|  |                         calendar_time_increment: preferences.calendar_time_increment, | ||||||
|  |                         calendar_view_mode: preferences.calendar_view_mode, | ||||||
|  |                         calendar_theme: preferences.calendar_theme, | ||||||
|  |                         calendar_colors: preferences.calendar_colors, | ||||||
|  |                     }, | ||||||
|                 }) |                 }) | ||||||
|             } |             } | ||||||
|             Err(err) => { |             Err(err) => { | ||||||
| @@ -143,4 +180,33 @@ impl AuthService { | |||||||
|  |  | ||||||
|         Ok(token_data.claims) |         Ok(token_data.claims) | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     /// Validate session token | ||||||
|  |     pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> { | ||||||
|  |         let session_repo = SessionRepository::new(&self.db); | ||||||
|  |          | ||||||
|  |         let session = session_repo | ||||||
|  |             .find_by_token(session_token) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))? | ||||||
|  |             .ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?; | ||||||
|  |          | ||||||
|  |         if session.is_expired() { | ||||||
|  |             return Err(ApiError::Unauthorized("Session expired".to_string())); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         Ok(session.user_id) | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Logout user by deleting session | ||||||
|  |     pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> { | ||||||
|  |         let session_repo = SessionRepository::new(&self.db); | ||||||
|  |          | ||||||
|  |         session_repo | ||||||
|  |             .delete(session_token) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?; | ||||||
|  |          | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										305
									
								
								backend/src/db.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										305
									
								
								backend/src/db.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,305 @@ | |||||||
|  | use chrono::{DateTime, Utc}; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; | ||||||
|  | use sqlx::{FromRow, Result}; | ||||||
|  | use std::sync::Arc; | ||||||
|  | use uuid::Uuid; | ||||||
|  |  | ||||||
|  | /// Database connection pool wrapper | ||||||
|  | #[derive(Clone)] | ||||||
|  | pub struct Database { | ||||||
|  |     pool: Arc<SqlitePool>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Database { | ||||||
|  |     /// Create a new database connection pool | ||||||
|  |     pub async fn new(database_url: &str) -> Result<Self> { | ||||||
|  |         let pool = SqlitePoolOptions::new() | ||||||
|  |             .max_connections(5) | ||||||
|  |             .connect(database_url) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         // Run migrations | ||||||
|  |         sqlx::migrate!("./migrations").run(&pool).await?; | ||||||
|  |  | ||||||
|  |         Ok(Self { | ||||||
|  |             pool: Arc::new(pool), | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Get a reference to the connection pool | ||||||
|  |     pub fn pool(&self) -> &SqlitePool { | ||||||
|  |         &self.pool | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// User model representing a CalDAV user | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||||
|  | pub struct User { | ||||||
|  |     pub id: String, // UUID as string for SQLite | ||||||
|  |     pub username: String, | ||||||
|  |     pub server_url: String, | ||||||
|  |     pub created_at: DateTime<Utc>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl User { | ||||||
|  |     /// Create a new user with generated UUID | ||||||
|  |     pub fn new(username: String, server_url: String) -> Self { | ||||||
|  |         Self { | ||||||
|  |             id: Uuid::new_v4().to_string(), | ||||||
|  |             username, | ||||||
|  |             server_url, | ||||||
|  |             created_at: Utc::now(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Session model for user sessions | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||||
|  | pub struct Session { | ||||||
|  |     pub id: String,      // UUID as string | ||||||
|  |     pub user_id: String, // Foreign key to User | ||||||
|  |     pub token: String,   // Session token | ||||||
|  |     pub created_at: DateTime<Utc>, | ||||||
|  |     pub expires_at: DateTime<Utc>, | ||||||
|  |     pub last_accessed: DateTime<Utc>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Session { | ||||||
|  |     /// Create a new session for a user | ||||||
|  |     pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self { | ||||||
|  |         let now = Utc::now(); | ||||||
|  |         Self { | ||||||
|  |             id: Uuid::new_v4().to_string(), | ||||||
|  |             user_id, | ||||||
|  |             token, | ||||||
|  |             created_at: now, | ||||||
|  |             expires_at: now + chrono::Duration::hours(expires_in_hours), | ||||||
|  |             last_accessed: now, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Check if the session has expired | ||||||
|  |     pub fn is_expired(&self) -> bool { | ||||||
|  |         Utc::now() > self.expires_at | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// User preferences model | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||||
|  | pub struct UserPreferences { | ||||||
|  |     pub user_id: String, | ||||||
|  |     pub calendar_selected_date: Option<String>, | ||||||
|  |     pub calendar_time_increment: Option<i32>, | ||||||
|  |     pub calendar_view_mode: Option<String>, | ||||||
|  |     pub calendar_theme: Option<String>, | ||||||
|  |     pub calendar_colors: Option<String>, // JSON string | ||||||
|  |     pub updated_at: DateTime<Utc>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl UserPreferences { | ||||||
|  |     /// Create default preferences for a new user | ||||||
|  |     pub fn default_for_user(user_id: String) -> Self { | ||||||
|  |         Self { | ||||||
|  |             user_id, | ||||||
|  |             calendar_selected_date: None, | ||||||
|  |             calendar_time_increment: Some(15), | ||||||
|  |             calendar_view_mode: Some("month".to_string()), | ||||||
|  |             calendar_theme: Some("light".to_string()), | ||||||
|  |             calendar_colors: None, | ||||||
|  |             updated_at: Utc::now(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Repository for User operations | ||||||
|  | pub struct UserRepository<'a> { | ||||||
|  |     db: &'a Database, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<'a> UserRepository<'a> { | ||||||
|  |     pub fn new(db: &'a Database) -> Self { | ||||||
|  |         Self { db } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Find or create a user by username and server URL | ||||||
|  |     pub async fn find_or_create( | ||||||
|  |         &self, | ||||||
|  |         username: &str, | ||||||
|  |         server_url: &str, | ||||||
|  |     ) -> Result<User> { | ||||||
|  |         // Try to find existing user | ||||||
|  |         let existing = sqlx::query_as::<_, User>( | ||||||
|  |             "SELECT * FROM users WHERE username = ? AND server_url = ?", | ||||||
|  |         ) | ||||||
|  |         .bind(username) | ||||||
|  |         .bind(server_url) | ||||||
|  |         .fetch_optional(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         if let Some(user) = existing { | ||||||
|  |             Ok(user) | ||||||
|  |         } else { | ||||||
|  |             // Create new user | ||||||
|  |             let user = User::new(username.to_string(), server_url.to_string()); | ||||||
|  |              | ||||||
|  |             sqlx::query( | ||||||
|  |                 "INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)", | ||||||
|  |             ) | ||||||
|  |             .bind(&user.id) | ||||||
|  |             .bind(&user.username) | ||||||
|  |             .bind(&user.server_url) | ||||||
|  |             .bind(&user.created_at) | ||||||
|  |             .execute(self.db.pool()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |             Ok(user) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Find a user by ID | ||||||
|  |     pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> { | ||||||
|  |         sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") | ||||||
|  |             .bind(user_id) | ||||||
|  |             .fetch_optional(self.db.pool()) | ||||||
|  |             .await | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Repository for Session operations | ||||||
|  | pub struct SessionRepository<'a> { | ||||||
|  |     db: &'a Database, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<'a> SessionRepository<'a> { | ||||||
|  |     pub fn new(db: &'a Database) -> Self { | ||||||
|  |         Self { db } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Create a new session | ||||||
|  |     pub async fn create(&self, session: &Session) -> Result<()> { | ||||||
|  |         sqlx::query( | ||||||
|  |             "INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)  | ||||||
|  |              VALUES (?, ?, ?, ?, ?, ?)", | ||||||
|  |         ) | ||||||
|  |         .bind(&session.id) | ||||||
|  |         .bind(&session.user_id) | ||||||
|  |         .bind(&session.token) | ||||||
|  |         .bind(&session.created_at) | ||||||
|  |         .bind(&session.expires_at) | ||||||
|  |         .bind(&session.last_accessed) | ||||||
|  |         .execute(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Find a session by token and update last_accessed | ||||||
|  |     pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> { | ||||||
|  |         let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?") | ||||||
|  |             .bind(token) | ||||||
|  |             .fetch_optional(self.db.pool()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         if let Some(ref s) = session { | ||||||
|  |             if !s.is_expired() { | ||||||
|  |                 // Update last_accessed time | ||||||
|  |                 sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?") | ||||||
|  |                     .bind(Utc::now()) | ||||||
|  |                     .bind(&s.id) | ||||||
|  |                     .execute(self.db.pool()) | ||||||
|  |                     .await?; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(session) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Delete a session (logout) | ||||||
|  |     pub async fn delete(&self, token: &str) -> Result<()> { | ||||||
|  |         sqlx::query("DELETE FROM sessions WHERE token = ?") | ||||||
|  |             .bind(token) | ||||||
|  |             .execute(self.db.pool()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Clean up expired sessions | ||||||
|  |     pub async fn cleanup_expired(&self) -> Result<u64> { | ||||||
|  |         let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?") | ||||||
|  |             .bind(Utc::now()) | ||||||
|  |             .execute(self.db.pool()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         Ok(result.rows_affected()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Repository for UserPreferences operations | ||||||
|  | pub struct PreferencesRepository<'a> { | ||||||
|  |     db: &'a Database, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<'a> PreferencesRepository<'a> { | ||||||
|  |     pub fn new(db: &'a Database) -> Self { | ||||||
|  |         Self { db } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Get user preferences, creating defaults if not exist | ||||||
|  |     pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> { | ||||||
|  |         let existing = sqlx::query_as::<_, UserPreferences>( | ||||||
|  |             "SELECT * FROM user_preferences WHERE user_id = ?", | ||||||
|  |         ) | ||||||
|  |         .bind(user_id) | ||||||
|  |         .fetch_optional(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         if let Some(prefs) = existing { | ||||||
|  |             Ok(prefs) | ||||||
|  |         } else { | ||||||
|  |             // Create default preferences | ||||||
|  |             let prefs = UserPreferences::default_for_user(user_id.to_string()); | ||||||
|  |              | ||||||
|  |             sqlx::query( | ||||||
|  |                 "INSERT INTO user_preferences  | ||||||
|  |                  (user_id, calendar_selected_date, calendar_time_increment,  | ||||||
|  |                   calendar_view_mode, calendar_theme, calendar_colors, updated_at)  | ||||||
|  |                  VALUES (?, ?, ?, ?, ?, ?, ?)", | ||||||
|  |             ) | ||||||
|  |             .bind(&prefs.user_id) | ||||||
|  |             .bind(&prefs.calendar_selected_date) | ||||||
|  |             .bind(&prefs.calendar_time_increment) | ||||||
|  |             .bind(&prefs.calendar_view_mode) | ||||||
|  |             .bind(&prefs.calendar_theme) | ||||||
|  |             .bind(&prefs.calendar_colors) | ||||||
|  |             .bind(&prefs.updated_at) | ||||||
|  |             .execute(self.db.pool()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |             Ok(prefs) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Update user preferences | ||||||
|  |     pub async fn update(&self, prefs: &UserPreferences) -> Result<()> { | ||||||
|  |         sqlx::query( | ||||||
|  |             "UPDATE user_preferences  | ||||||
|  |              SET calendar_selected_date = ?, calendar_time_increment = ?,  | ||||||
|  |                  calendar_view_mode = ?, calendar_theme = ?,  | ||||||
|  |                  calendar_colors = ?, updated_at = ? | ||||||
|  |              WHERE user_id = ?", | ||||||
|  |         ) | ||||||
|  |         .bind(&prefs.calendar_selected_date) | ||||||
|  |         .bind(&prefs.calendar_time_increment) | ||||||
|  |         .bind(&prefs.calendar_view_mode) | ||||||
|  |         .bind(&prefs.calendar_theme) | ||||||
|  |         .bind(&prefs.calendar_colors) | ||||||
|  |         .bind(Utc::now()) | ||||||
|  |         .bind(&prefs.user_id) | ||||||
|  |         .execute(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,9 +2,11 @@ | |||||||
| mod auth; | mod auth; | ||||||
| mod calendar; | mod calendar; | ||||||
| mod events; | mod events; | ||||||
|  | mod preferences; | ||||||
| mod series; | mod series; | ||||||
|  |  | ||||||
| pub use auth::{get_user_info, login, verify_token}; | pub use auth::{get_user_info, login, verify_token}; | ||||||
| pub use calendar::{create_calendar, delete_calendar}; | pub use calendar::{create_calendar, delete_calendar}; | ||||||
| pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; | pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; | ||||||
|  | pub use preferences::{get_preferences, logout, update_preferences}; | ||||||
| pub use series::{create_event_series, delete_event_series, update_event_series}; | pub use series::{create_event_series, delete_event_series, update_event_series}; | ||||||
|   | |||||||
| @@ -46,41 +46,12 @@ pub async fn login( | |||||||
|     println!("  Username: {}", request.username); |     println!("  Username: {}", request.username); | ||||||
|     println!("  Password length: {}", request.password.len()); |     println!("  Password length: {}", request.password.len()); | ||||||
|  |  | ||||||
|     // Basic validation |     // Use the auth service login method which now handles database, sessions, and preferences | ||||||
|     if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() { |     let response = state.auth_service.login(request).await?; | ||||||
|         return Err(ApiError::BadRequest( |  | ||||||
|             "Username, password, and server URL are required".to_string(), |  | ||||||
|         )); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     println!("✅ Input validation passed"); |     println!("✅ Login successful with session management"); | ||||||
|  |  | ||||||
|     // Create a token using the auth service |     Ok(Json(response)) | ||||||
|     println!("📝 Created CalDAV config"); |  | ||||||
|  |  | ||||||
|     // First verify the credentials are valid by attempting to discover calendars |  | ||||||
|     let config = CalDAVConfig::new( |  | ||||||
|         request.server_url.clone(), |  | ||||||
|         request.username.clone(), |  | ||||||
|         request.password.clone(), |  | ||||||
|     ); |  | ||||||
|     let client = CalDAVClient::new(config); |  | ||||||
|     client |  | ||||||
|         .discover_calendars() |  | ||||||
|         .await |  | ||||||
|         .map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?; |  | ||||||
|  |  | ||||||
|     let token = state |  | ||||||
|         .auth_service |  | ||||||
|         .generate_token(&request.username, &request.server_url)?; |  | ||||||
|  |  | ||||||
|     println!("🔗 Created CalDAV client, attempting to discover calendars..."); |  | ||||||
|  |  | ||||||
|     Ok(Json(AuthResponse { |  | ||||||
|         token, |  | ||||||
|         username: request.username, |  | ||||||
|         server_url: request.server_url, |  | ||||||
|     })) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| pub async fn verify_token( | pub async fn verify_token( | ||||||
|   | |||||||
							
								
								
									
										123
									
								
								backend/src/handlers/preferences.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								backend/src/handlers/preferences.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | |||||||
|  | use axum::{ | ||||||
|  |     extract::State, | ||||||
|  |     http::{HeaderMap, StatusCode}, | ||||||
|  |     response::IntoResponse, | ||||||
|  |     Json, | ||||||
|  | }; | ||||||
|  | use std::sync::Arc; | ||||||
|  |  | ||||||
|  | use crate::{ | ||||||
|  |     db::PreferencesRepository, | ||||||
|  |     models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse}, | ||||||
|  |     AppState, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /// Get user preferences | ||||||
|  | pub async fn get_preferences( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> Result<impl IntoResponse, ApiError> { | ||||||
|  |     // Extract session token from headers | ||||||
|  |     let session_token = headers | ||||||
|  |         .get("X-Session-Token") | ||||||
|  |         .and_then(|h| h.to_str().ok()) | ||||||
|  |         .ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?; | ||||||
|  |  | ||||||
|  |     // Validate session and get user ID | ||||||
|  |     let user_id = state.auth_service.validate_session(session_token).await?; | ||||||
|  |  | ||||||
|  |     // Get preferences from database | ||||||
|  |     let prefs_repo = PreferencesRepository::new(&state.db); | ||||||
|  |     let preferences = prefs_repo | ||||||
|  |         .get_or_create(&user_id) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(UserPreferencesResponse { | ||||||
|  |         calendar_selected_date: preferences.calendar_selected_date, | ||||||
|  |         calendar_time_increment: preferences.calendar_time_increment, | ||||||
|  |         calendar_view_mode: preferences.calendar_view_mode, | ||||||
|  |         calendar_theme: preferences.calendar_theme, | ||||||
|  |         calendar_colors: preferences.calendar_colors, | ||||||
|  |     })) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Update user preferences | ||||||
|  | pub async fn update_preferences( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  |     Json(request): Json<UpdatePreferencesRequest>, | ||||||
|  | ) -> Result<impl IntoResponse, ApiError> { | ||||||
|  |     // Extract session token from headers | ||||||
|  |     let session_token = headers | ||||||
|  |         .get("X-Session-Token") | ||||||
|  |         .and_then(|h| h.to_str().ok()) | ||||||
|  |         .ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?; | ||||||
|  |  | ||||||
|  |     // Validate session and get user ID | ||||||
|  |     let user_id = state.auth_service.validate_session(session_token).await?; | ||||||
|  |  | ||||||
|  |     // Update preferences in database | ||||||
|  |     let prefs_repo = PreferencesRepository::new(&state.db); | ||||||
|  |      | ||||||
|  |     let mut preferences = prefs_repo | ||||||
|  |         .get_or_create(&user_id) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?; | ||||||
|  |  | ||||||
|  |     // Update only provided fields | ||||||
|  |     if request.calendar_selected_date.is_some() { | ||||||
|  |         preferences.calendar_selected_date = request.calendar_selected_date; | ||||||
|  |     } | ||||||
|  |     if request.calendar_time_increment.is_some() { | ||||||
|  |         preferences.calendar_time_increment = request.calendar_time_increment; | ||||||
|  |     } | ||||||
|  |     if request.calendar_view_mode.is_some() { | ||||||
|  |         preferences.calendar_view_mode = request.calendar_view_mode; | ||||||
|  |     } | ||||||
|  |     if request.calendar_theme.is_some() { | ||||||
|  |         preferences.calendar_theme = request.calendar_theme; | ||||||
|  |     } | ||||||
|  |     if request.calendar_colors.is_some() { | ||||||
|  |         preferences.calendar_colors = request.calendar_colors; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     prefs_repo | ||||||
|  |         .update(&preferences) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(( | ||||||
|  |         StatusCode::OK, | ||||||
|  |         Json(UserPreferencesResponse { | ||||||
|  |             calendar_selected_date: preferences.calendar_selected_date, | ||||||
|  |             calendar_time_increment: preferences.calendar_time_increment, | ||||||
|  |             calendar_view_mode: preferences.calendar_view_mode, | ||||||
|  |             calendar_theme: preferences.calendar_theme, | ||||||
|  |             calendar_colors: preferences.calendar_colors, | ||||||
|  |         }), | ||||||
|  |     )) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Logout user | ||||||
|  | pub async fn logout( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> Result<impl IntoResponse, ApiError> { | ||||||
|  |     // Extract session token from headers | ||||||
|  |     let session_token = headers | ||||||
|  |         .get("X-Session-Token") | ||||||
|  |         .and_then(|h| h.to_str().ok()) | ||||||
|  |         .ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?; | ||||||
|  |  | ||||||
|  |     // Delete session | ||||||
|  |     state.auth_service.logout(session_token).await?; | ||||||
|  |  | ||||||
|  |     Ok(( | ||||||
|  |         StatusCode::OK, | ||||||
|  |         Json(serde_json::json!({ | ||||||
|  |             "success": true, | ||||||
|  |             "message": "Logged out successfully" | ||||||
|  |         })), | ||||||
|  |     )) | ||||||
|  | } | ||||||
| @@ -9,27 +9,37 @@ use tower_http::cors::{Any, CorsLayer}; | |||||||
| pub mod auth; | pub mod auth; | ||||||
| pub mod calendar; | pub mod calendar; | ||||||
| pub mod config; | pub mod config; | ||||||
|  | pub mod db; | ||||||
| pub mod handlers; | pub mod handlers; | ||||||
| pub mod models; | pub mod models; | ||||||
|  |  | ||||||
| use auth::AuthService; | use auth::AuthService; | ||||||
|  | use db::Database; | ||||||
|  |  | ||||||
| #[derive(Clone)] | #[derive(Clone)] | ||||||
| pub struct AppState { | pub struct AppState { | ||||||
|     pub auth_service: AuthService, |     pub auth_service: AuthService, | ||||||
|  |     pub db: Database, | ||||||
| } | } | ||||||
|  |  | ||||||
| pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | ||||||
|     // Initialize logging |     // Initialize logging | ||||||
|     println!("🚀 Starting Calendar Backend Server"); |     println!("🚀 Starting Calendar Backend Server"); | ||||||
|  |  | ||||||
|  |     // Initialize database | ||||||
|  |     let database_url = std::env::var("DATABASE_URL") | ||||||
|  |         .unwrap_or_else(|_| "sqlite:calendar.db".to_string()); | ||||||
|  |      | ||||||
|  |     let db = Database::new(&database_url).await?; | ||||||
|  |     println!("✅ Database initialized"); | ||||||
|  |  | ||||||
|     // Create auth service |     // Create auth service | ||||||
|     let jwt_secret = std::env::var("JWT_SECRET") |     let jwt_secret = std::env::var("JWT_SECRET") | ||||||
|         .unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()); |         .unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()); | ||||||
|  |  | ||||||
|     let auth_service = AuthService::new(jwt_secret); |     let auth_service = AuthService::new(jwt_secret, db.clone()); | ||||||
|  |  | ||||||
|     let app_state = AppState { auth_service }; |     let app_state = AppState { auth_service, db }; | ||||||
|  |  | ||||||
|     // Build our application with routes |     // Build our application with routes | ||||||
|     let app = Router::new() |     let app = Router::new() | ||||||
| @@ -58,6 +68,10 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | |||||||
|             "/api/calendar/events/series/delete", |             "/api/calendar/events/series/delete", | ||||||
|             post(handlers::delete_event_series), |             post(handlers::delete_event_series), | ||||||
|         ) |         ) | ||||||
|  |         // User preferences endpoints | ||||||
|  |         .route("/api/preferences", get(handlers::get_preferences)) | ||||||
|  |         .route("/api/preferences", post(handlers::update_preferences)) | ||||||
|  |         .route("/api/auth/logout", post(handlers::logout)) | ||||||
|         .layer( |         .layer( | ||||||
|             CorsLayer::new() |             CorsLayer::new() | ||||||
|                 .allow_origin(Any) |                 .allow_origin(Any) | ||||||
|   | |||||||
| @@ -16,8 +16,28 @@ pub struct CalDAVLoginRequest { | |||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| pub struct AuthResponse { | pub struct AuthResponse { | ||||||
|     pub token: String, |     pub token: String, | ||||||
|  |     pub session_token: String, | ||||||
|     pub username: String, |     pub username: String, | ||||||
|     pub server_url: String, |     pub server_url: String, | ||||||
|  |     pub preferences: UserPreferencesResponse, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Clone)] | ||||||
|  | pub struct UserPreferencesResponse { | ||||||
|  |     pub calendar_selected_date: Option<String>, | ||||||
|  |     pub calendar_time_increment: Option<i32>, | ||||||
|  |     pub calendar_view_mode: Option<String>, | ||||||
|  |     pub calendar_theme: Option<String>, | ||||||
|  |     pub calendar_colors: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct UpdatePreferencesRequest { | ||||||
|  |     pub calendar_selected_date: Option<String>, | ||||||
|  |     pub calendar_time_increment: Option<i32>, | ||||||
|  |     pub calendar_view_mode: Option<String>, | ||||||
|  |     pub calendar_theme: Option<String>, | ||||||
|  |     pub calendar_colors: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
|   | |||||||
| @@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest { | |||||||
|     pub password: String, |     pub password: String, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Clone)] | ||||||
|  | pub struct UserPreferencesResponse { | ||||||
|  |     pub calendar_selected_date: Option<String>, | ||||||
|  |     pub calendar_time_increment: Option<i32>, | ||||||
|  |     pub calendar_view_mode: Option<String>, | ||||||
|  |     pub calendar_theme: Option<String>, | ||||||
|  |     pub calendar_colors: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(Debug, Serialize, Deserialize)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| pub struct AuthResponse { | pub struct AuthResponse { | ||||||
|     pub token: String, |     pub token: String, | ||||||
|  |     pub session_token: String, | ||||||
|     pub username: String, |     pub username: String, | ||||||
|     pub server_url: String, |     pub server_url: String, | ||||||
|  |     pub preferences: UserPreferencesResponse, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|   | |||||||
| @@ -9,12 +9,25 @@ pub struct LoginProps { | |||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| pub fn Login(props: &LoginProps) -> Html { | pub fn Login(props: &LoginProps) -> Html { | ||||||
|     let server_url = use_state(String::new); |     // Load remembered values from LocalStorage on mount | ||||||
|     let username = use_state(String::new); |     let server_url = use_state(|| { | ||||||
|  |         LocalStorage::get::<String>("remembered_server_url").unwrap_or_default() | ||||||
|  |     }); | ||||||
|  |     let username = use_state(|| { | ||||||
|  |         LocalStorage::get::<String>("remembered_username").unwrap_or_default() | ||||||
|  |     }); | ||||||
|     let password = use_state(String::new); |     let password = use_state(String::new); | ||||||
|     let error_message = use_state(|| Option::<String>::None); |     let error_message = use_state(|| Option::<String>::None); | ||||||
|     let is_loading = use_state(|| false); |     let is_loading = use_state(|| false); | ||||||
|      |      | ||||||
|  |     // Remember checkboxes state | ||||||
|  |     let remember_server = use_state(|| { | ||||||
|  |         LocalStorage::get::<String>("remembered_server_url").is_ok() | ||||||
|  |     }); | ||||||
|  |     let remember_username = use_state(|| { | ||||||
|  |         LocalStorage::get::<String>("remembered_username").is_ok() | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     let server_url_ref = use_node_ref(); |     let server_url_ref = use_node_ref(); | ||||||
|     let username_ref = use_node_ref(); |     let username_ref = use_node_ref(); | ||||||
|     let password_ref = use_node_ref(); |     let password_ref = use_node_ref(); | ||||||
| @@ -43,6 +56,38 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|      |      | ||||||
|  |     let on_remember_server_change = { | ||||||
|  |         let remember_server = remember_server.clone(); | ||||||
|  |         let server_url = server_url.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             let target = e.target_unchecked_into::<HtmlInputElement>(); | ||||||
|  |             let checked = target.checked(); | ||||||
|  |             remember_server.set(checked); | ||||||
|  |              | ||||||
|  |             if checked { | ||||||
|  |                 let _ = LocalStorage::set("remembered_server_url", (*server_url).clone()); | ||||||
|  |             } else { | ||||||
|  |                 let _ = LocalStorage::delete("remembered_server_url"); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     let on_remember_username_change = { | ||||||
|  |         let remember_username = remember_username.clone(); | ||||||
|  |         let username = username.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             let target = e.target_unchecked_into::<HtmlInputElement>(); | ||||||
|  |             let checked = target.checked(); | ||||||
|  |             remember_username.set(checked); | ||||||
|  |              | ||||||
|  |             if checked { | ||||||
|  |                 let _ = LocalStorage::set("remembered_username", (*username).clone()); | ||||||
|  |             } else { | ||||||
|  |                 let _ = LocalStorage::delete("remembered_username"); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     let on_submit = { |     let on_submit = { | ||||||
|         let server_url = server_url.clone(); |         let server_url = server_url.clone(); | ||||||
|         let username = username.clone(); |         let username = username.clone(); | ||||||
| @@ -73,7 +118,7 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|             wasm_bindgen_futures::spawn_local(async move { |             wasm_bindgen_futures::spawn_local(async move { | ||||||
|                 web_sys::console::log_1(&"🚀 Starting login process...".into()); |                 web_sys::console::log_1(&"🚀 Starting login process...".into()); | ||||||
|                 match perform_login(server_url.clone(), username.clone(), password.clone()).await { |                 match perform_login(server_url.clone(), username.clone(), password.clone()).await { | ||||||
|                     Ok((token, credentials)) => { |                     Ok((token, session_token, credentials, preferences)) => { | ||||||
|                         web_sys::console::log_1(&"✅ Login successful!".into()); |                         web_sys::console::log_1(&"✅ Login successful!".into()); | ||||||
|                         // Store token and credentials in local storage |                         // Store token and credentials in local storage | ||||||
|                         if let Err(_) = LocalStorage::set("auth_token", &token) { |                         if let Err(_) = LocalStorage::set("auth_token", &token) { | ||||||
| @@ -82,12 +127,23 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|                             is_loading.set(false); |                             is_loading.set(false); | ||||||
|                             return; |                             return; | ||||||
|                         } |                         } | ||||||
|  |                         if let Err(_) = LocalStorage::set("session_token", &session_token) { | ||||||
|  |                             error_message | ||||||
|  |                                 .set(Some("Failed to store session token".to_string())); | ||||||
|  |                             is_loading.set(false); | ||||||
|  |                             return; | ||||||
|  |                         } | ||||||
|                         if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) { |                         if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) { | ||||||
|                             error_message.set(Some("Failed to store credentials".to_string())); |                             error_message.set(Some("Failed to store credentials".to_string())); | ||||||
|                             is_loading.set(false); |                             is_loading.set(false); | ||||||
|                             return; |                             return; | ||||||
|                         } |                         } | ||||||
|                          |                          | ||||||
|  |                         // Store preferences from database | ||||||
|  |                         if let Ok(prefs_json) = serde_json::to_string(&preferences) { | ||||||
|  |                             let _ = LocalStorage::set("user_preferences", &prefs_json); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|                         is_loading.set(false); |                         is_loading.set(false); | ||||||
|                         on_login.emit(token); |                         on_login.emit(token); | ||||||
|                     } |                     } | ||||||
| @@ -117,6 +173,15 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|                             onchange={on_server_url_change} |                             onchange={on_server_url_change} | ||||||
|                             disabled={*is_loading} |                             disabled={*is_loading} | ||||||
|                         /> |                         /> | ||||||
|  |                         <div class="remember-checkbox"> | ||||||
|  |                             <input | ||||||
|  |                                 type="checkbox" | ||||||
|  |                                 id="remember_server" | ||||||
|  |                                 checked={*remember_server} | ||||||
|  |                                 onchange={on_remember_server_change} | ||||||
|  |                             /> | ||||||
|  |                             <label for="remember_server">{"Remember server"}</label> | ||||||
|  |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|  |  | ||||||
|                     <div class="form-group"> |                     <div class="form-group"> | ||||||
| @@ -130,6 +195,15 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|                             onchange={on_username_change} |                             onchange={on_username_change} | ||||||
|                             disabled={*is_loading} |                             disabled={*is_loading} | ||||||
|                         /> |                         /> | ||||||
|  |                         <div class="remember-checkbox"> | ||||||
|  |                             <input | ||||||
|  |                                 type="checkbox" | ||||||
|  |                                 id="remember_username" | ||||||
|  |                                 checked={*remember_username} | ||||||
|  |                                 onchange={on_remember_username_change} | ||||||
|  |                             /> | ||||||
|  |                             <label for="remember_username">{"Remember username"}</label> | ||||||
|  |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|  |  | ||||||
|                     <div class="form-group"> |                     <div class="form-group"> | ||||||
| @@ -177,7 +251,7 @@ async fn perform_login( | |||||||
|     server_url: String, |     server_url: String, | ||||||
|     username: String, |     username: String, | ||||||
|     password: String, |     password: String, | ||||||
| ) -> Result<(String, String), String> { | ) -> Result<(String, String, String, serde_json::Value), String> { | ||||||
|     use crate::auth::{AuthService, CalDAVLoginRequest}; |     use crate::auth::{AuthService, CalDAVLoginRequest}; | ||||||
|     use serde_json; |     use serde_json; | ||||||
|  |  | ||||||
| @@ -201,7 +275,17 @@ async fn perform_login( | |||||||
|                 "username": username, |                 "username": username, | ||||||
|                 "password": password |                 "password": password | ||||||
|             }); |             }); | ||||||
|             Ok((response.token, credentials.to_string())) |              | ||||||
|  |             // Extract preferences as JSON | ||||||
|  |             let preferences = serde_json::json!({ | ||||||
|  |                 "calendar_selected_date": response.preferences.calendar_selected_date, | ||||||
|  |                 "calendar_time_increment": response.preferences.calendar_time_increment, | ||||||
|  |                 "calendar_view_mode": response.preferences.calendar_view_mode, | ||||||
|  |                 "calendar_theme": response.preferences.calendar_theme, | ||||||
|  |                 "calendar_colors": response.preferences.calendar_colors, | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             Ok((response.token, response.session_token, credentials.to_string(), preferences)) | ||||||
|         } |         } | ||||||
|         Err(err) => { |         Err(err) => { | ||||||
|             web_sys::console::log_1(&format!("❌ Backend error: {}", err).into()); |             web_sys::console::log_1(&format!("❌ Backend error: {}", err).into()); | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
| pub mod calendar_service; | pub mod calendar_service; | ||||||
|  | pub mod preferences; | ||||||
|  |  | ||||||
| pub use calendar_service::CalendarService; | pub use calendar_service::CalendarService; | ||||||
|  | pub use preferences::PreferencesService; | ||||||
|   | |||||||
							
								
								
									
										177
									
								
								frontend/src/services/preferences.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								frontend/src/services/preferences.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | |||||||
|  | use gloo_storage::{LocalStorage, Storage}; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use serde_json; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use wasm_bindgen_futures::JsFuture; | ||||||
|  | use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize, Clone)] | ||||||
|  | pub struct UserPreferences { | ||||||
|  |     pub calendar_selected_date: Option<String>, | ||||||
|  |     pub calendar_time_increment: Option<i32>, | ||||||
|  |     pub calendar_view_mode: Option<String>, | ||||||
|  |     pub calendar_theme: Option<String>, | ||||||
|  |     pub calendar_colors: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct UpdatePreferencesRequest { | ||||||
|  |     pub calendar_selected_date: Option<String>, | ||||||
|  |     pub calendar_time_increment: Option<i32>, | ||||||
|  |     pub calendar_view_mode: Option<String>, | ||||||
|  |     pub calendar_theme: Option<String>, | ||||||
|  |     pub calendar_colors: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub struct PreferencesService { | ||||||
|  |     base_url: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl PreferencesService { | ||||||
|  |     pub fn new() -> Self { | ||||||
|  |         let base_url = option_env!("BACKEND_API_URL") | ||||||
|  |             .unwrap_or("http://localhost:3000/api") | ||||||
|  |             .to_string(); | ||||||
|  |          | ||||||
|  |         Self { base_url } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Load preferences from LocalStorage (cached from login) | ||||||
|  |     pub fn load_cached() -> Option<UserPreferences> { | ||||||
|  |         if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") { | ||||||
|  |             serde_json::from_str(&prefs_json).ok() | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Update a single preference field and sync with backend | ||||||
|  |     pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> { | ||||||
|  |         // Get session token | ||||||
|  |         let session_token = LocalStorage::get::<String>("session_token") | ||||||
|  |             .map_err(|_| "No session token found".to_string())?; | ||||||
|  |          | ||||||
|  |         // Load current preferences | ||||||
|  |         let mut preferences = Self::load_cached().unwrap_or(UserPreferences { | ||||||
|  |             calendar_selected_date: None, | ||||||
|  |             calendar_time_increment: None, | ||||||
|  |             calendar_view_mode: None, | ||||||
|  |             calendar_theme: None, | ||||||
|  |             calendar_colors: None, | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         // Update the specific field | ||||||
|  |         match field { | ||||||
|  |             "calendar_selected_date" => { | ||||||
|  |                 preferences.calendar_selected_date = value.as_str().map(|s| s.to_string()); | ||||||
|  |             } | ||||||
|  |             "calendar_time_increment" => { | ||||||
|  |                 preferences.calendar_time_increment = value.as_i64().map(|i| i as i32); | ||||||
|  |             } | ||||||
|  |             "calendar_view_mode" => { | ||||||
|  |                 preferences.calendar_view_mode = value.as_str().map(|s| s.to_string()); | ||||||
|  |             } | ||||||
|  |             "calendar_theme" => { | ||||||
|  |                 preferences.calendar_theme = value.as_str().map(|s| s.to_string()); | ||||||
|  |             } | ||||||
|  |             "calendar_colors" => { | ||||||
|  |                 preferences.calendar_colors = value.as_str().map(|s| s.to_string()); | ||||||
|  |             } | ||||||
|  |             _ => return Err(format!("Unknown preference field: {}", field)), | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Save to LocalStorage cache | ||||||
|  |         if let Ok(prefs_json) = serde_json::to_string(&preferences) { | ||||||
|  |             let _ = LocalStorage::set("user_preferences", &prefs_json); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Sync with backend | ||||||
|  |         let request = UpdatePreferencesRequest { | ||||||
|  |             calendar_selected_date: preferences.calendar_selected_date.clone(), | ||||||
|  |             calendar_time_increment: preferences.calendar_time_increment, | ||||||
|  |             calendar_view_mode: preferences.calendar_view_mode.clone(), | ||||||
|  |             calendar_theme: preferences.calendar_theme.clone(), | ||||||
|  |             calendar_colors: preferences.calendar_colors.clone(), | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         self.sync_preferences(&session_token, &request).await | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Sync all preferences with backend | ||||||
|  |     async fn sync_preferences( | ||||||
|  |         &self, | ||||||
|  |         session_token: &str, | ||||||
|  |         request: &UpdatePreferencesRequest, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |          | ||||||
|  |         let json_body = serde_json::to_string(request) | ||||||
|  |             .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!("{}/preferences", self.base_url); | ||||||
|  |         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))?; | ||||||
|  |          | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("X-Session-Token", session_token) | ||||||
|  |             .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))?; | ||||||
|  |          | ||||||
|  |         if resp.ok() { | ||||||
|  |             Ok(()) | ||||||
|  |         } else { | ||||||
|  |             Err(format!("Failed to update preferences: {}", resp.status())) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Migrate preferences from LocalStorage to backend (on first login after update) | ||||||
|  |     pub async fn migrate_from_local_storage(&self) -> Result<(), String> { | ||||||
|  |         let session_token = LocalStorage::get::<String>("session_token") | ||||||
|  |             .map_err(|_| "No session token found".to_string())?; | ||||||
|  |          | ||||||
|  |         let mut request = UpdatePreferencesRequest { | ||||||
|  |             calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(), | ||||||
|  |             calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32), | ||||||
|  |             calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(), | ||||||
|  |             calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(), | ||||||
|  |             calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(), | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // Only migrate if we have some preferences to migrate | ||||||
|  |         if request.calendar_selected_date.is_some() | ||||||
|  |             || request.calendar_time_increment.is_some() | ||||||
|  |             || request.calendar_view_mode.is_some() | ||||||
|  |             || request.calendar_theme.is_some() | ||||||
|  |             || request.calendar_colors.is_some() | ||||||
|  |         { | ||||||
|  |             self.sync_preferences(&session_token, &request).await?; | ||||||
|  |              | ||||||
|  |             // Clear old LocalStorage entries after successful migration | ||||||
|  |             let _ = LocalStorage::delete("calendar_selected_date"); | ||||||
|  |             let _ = LocalStorage::delete("calendar_time_increment"); | ||||||
|  |             let _ = LocalStorage::delete("calendar_view_mode"); | ||||||
|  |             let _ = LocalStorage::delete("calendar_theme"); | ||||||
|  |             let _ = LocalStorage::delete("calendar_colors"); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -289,6 +289,27 @@ body { | |||||||
|     cursor: not-allowed; |     cursor: not-allowed; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .remember-checkbox { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 0.5rem; | ||||||
|  |     margin-top: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .remember-checkbox input[type="checkbox"] { | ||||||
|  |     width: auto; | ||||||
|  |     margin: 0; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .remember-checkbox label { | ||||||
|  |     margin: 0; | ||||||
|  |     font-size: 0.875rem; | ||||||
|  |     color: #666; | ||||||
|  |     cursor: pointer; | ||||||
|  |     user-select: none; | ||||||
|  | } | ||||||
|  |  | ||||||
| .login-button, .register-button { | .login-button, .register-button { | ||||||
|     width: 100%; |     width: 100%; | ||||||
|     padding: 0.75rem; |     padding: 0.75rem; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user