diff --git a/.gitignore b/.gitignore index 24743a2..4570294 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ dist/ CLAUDE.md data/ + +# SQLite database +*.db +*.db-shm +*.db-wal +calendar.db diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ce14e00..1c5e46b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -34,6 +34,10 @@ base64 = "0.21" thiserror = "1.0" lazy_static = "1.4" +# Database dependencies +sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] } +tokio-rusqlite = "0.5" + [dev-dependencies] tokio = { version = "1.0", features = ["macros", "rt"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/backend/migrations/001_create_users_table.sql b/backend/migrations/001_create_users_table.sql new file mode 100644 index 0000000..5add4d8 --- /dev/null +++ b/backend/migrations/001_create_users_table.sql @@ -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) +); \ No newline at end of file diff --git a/backend/migrations/002_create_sessions_table.sql b/backend/migrations/002_create_sessions_table.sql new file mode 100644 index 0000000..db07435 --- /dev/null +++ b/backend/migrations/002_create_sessions_table.sql @@ -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); \ No newline at end of file diff --git a/backend/migrations/003_create_user_preferences_table.sql b/backend/migrations/003_create_user_preferences_table.sql new file mode 100644 index 0000000..b7381b5 --- /dev/null +++ b/backend/migrations/003_create_user_preferences_table.sql @@ -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 +); \ No newline at end of file diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 7f128e7..b62e182 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,10 +1,12 @@ use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::calendar::CalDAVClient; 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)] pub struct Claims { @@ -17,11 +19,12 @@ pub struct Claims { #[derive(Clone)] pub struct AuthService { jwt_secret: String, + db: Database, } impl AuthService { - pub fn new(jwt_secret: String) -> Self { - Self { jwt_secret } + pub fn new(jwt_secret: String, db: Database) -> Self { + Self { jwt_secret, db } } /// Authenticate user directly against CalDAV server @@ -49,13 +52,47 @@ impl AuthService { "✅ Authentication successful! Found {} calendars", 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 { - token, + token: jwt_token, + session_token, username: request.username, 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) => { @@ -143,4 +180,33 @@ impl AuthService { Ok(token_data.claims) } + + /// Validate session token + pub async fn validate_session(&self, session_token: &str) -> Result { + 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(()) + } } diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..aa286aa --- /dev/null +++ b/backend/src/db.rs @@ -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, +} + +impl Database { + /// Create a new database connection pool + pub async fn new(database_url: &str) -> Result { + 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, +} + +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, + pub expires_at: DateTime, + pub last_accessed: DateTime, +} + +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, + pub calendar_time_increment: Option, + pub calendar_view_mode: Option, + pub calendar_theme: Option, + pub calendar_colors: Option, // JSON string + pub updated_at: DateTime, +} + +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 { + // 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> { + 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> { + 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 { + 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 { + 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(()) + } +} \ No newline at end of file diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index a143e74..9d278c7 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -2,9 +2,11 @@ mod auth; mod calendar; mod events; +mod preferences; mod series; pub use auth::{get_user_info, login, verify_token}; pub use calendar::{create_calendar, delete_calendar}; 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}; diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 730bb22..de3f6c1 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -46,41 +46,12 @@ pub async fn login( println!(" Username: {}", request.username); println!(" Password length: {}", request.password.len()); - // Basic validation - if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() { - return Err(ApiError::BadRequest( - "Username, password, and server URL are required".to_string(), - )); - } + // Use the auth service login method which now handles database, sessions, and preferences + let response = state.auth_service.login(request).await?; - println!("✅ Input validation passed"); + println!("✅ Login successful with session management"); - // Create a token using the auth service - 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, - })) + Ok(Json(response)) } pub async fn verify_token( diff --git a/backend/src/handlers/preferences.rs b/backend/src/handlers/preferences.rs new file mode 100644 index 0000000..74a656c --- /dev/null +++ b/backend/src/handlers/preferences.rs @@ -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>, + headers: HeaderMap, +) -> Result { + // 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>, + headers: HeaderMap, + Json(request): Json, +) -> Result { + // 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>, + headers: HeaderMap, +) -> Result { + // 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" + })), + )) +} \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a96e351..7b719e3 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -9,27 +9,37 @@ use tower_http::cors::{Any, CorsLayer}; pub mod auth; pub mod calendar; pub mod config; +pub mod db; pub mod handlers; pub mod models; use auth::AuthService; +use db::Database; #[derive(Clone)] pub struct AppState { pub auth_service: AuthService, + pub db: Database, } pub async fn run_server() -> Result<(), Box> { // Initialize logging 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 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(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 let app = Router::new() @@ -58,6 +68,10 @@ pub async fn run_server() -> Result<(), Box> { "/api/calendar/events/series/delete", 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( CorsLayer::new() .allow_origin(Any) diff --git a/backend/src/models.rs b/backend/src/models.rs index 4ce48fa..b14f9f8 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -16,8 +16,28 @@ pub struct CalDAVLoginRequest { #[derive(Debug, Serialize)] pub struct AuthResponse { pub token: String, + pub session_token: String, pub username: String, pub server_url: String, + pub preferences: UserPreferencesResponse, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserPreferencesResponse { + pub calendar_selected_date: Option, + pub calendar_time_increment: Option, + pub calendar_view_mode: Option, + pub calendar_theme: Option, + pub calendar_colors: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdatePreferencesRequest { + pub calendar_selected_date: Option, + pub calendar_time_increment: Option, + pub calendar_view_mode: Option, + pub calendar_theme: Option, + pub calendar_colors: Option, } #[derive(Debug, Serialize)] diff --git a/frontend/src/auth.rs b/frontend/src/auth.rs index a635464..4a84708 100644 --- a/frontend/src/auth.rs +++ b/frontend/src/auth.rs @@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest { pub password: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserPreferencesResponse { + pub calendar_selected_date: Option, + pub calendar_time_increment: Option, + pub calendar_view_mode: Option, + pub calendar_theme: Option, + pub calendar_colors: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct AuthResponse { pub token: String, + pub session_token: String, pub username: String, pub server_url: String, + pub preferences: UserPreferencesResponse, } #[derive(Debug, Deserialize)] diff --git a/frontend/src/components/login.rs b/frontend/src/components/login.rs index c625813..b1c77f5 100644 --- a/frontend/src/components/login.rs +++ b/frontend/src/components/login.rs @@ -9,11 +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); + // Load remembered values from LocalStorage on mount + let server_url = use_state(|| { + LocalStorage::get::("remembered_server_url").unwrap_or_default() + }); + let username = use_state(|| { + LocalStorage::get::("remembered_username").unwrap_or_default() + }); let password = use_state(String::new); let error_message = use_state(|| Option::::None); let is_loading = use_state(|| false); + + // Remember checkboxes state + let remember_server = use_state(|| { + LocalStorage::get::("remembered_server_url").is_ok() + }); + let remember_username = use_state(|| { + LocalStorage::get::("remembered_username").is_ok() + }); let server_url_ref = use_node_ref(); let username_ref = use_node_ref(); @@ -42,6 +55,38 @@ pub fn Login(props: &LoginProps) -> Html { password.set(target.value()); }) }; + + 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::(); + 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::(); + 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 server_url = server_url.clone(); @@ -73,7 +118,7 @@ pub fn Login(props: &LoginProps) -> Html { wasm_bindgen_futures::spawn_local(async move { web_sys::console::log_1(&"🚀 Starting login process...".into()); 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()); // Store token and credentials in local storage if let Err(_) = LocalStorage::set("auth_token", &token) { @@ -82,11 +127,22 @@ pub fn Login(props: &LoginProps) -> Html { is_loading.set(false); 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) { error_message.set(Some("Failed to store credentials".to_string())); is_loading.set(false); 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); on_login.emit(token); @@ -117,6 +173,15 @@ pub fn Login(props: &LoginProps) -> Html { onchange={on_server_url_change} disabled={*is_loading} /> +
+ + +
@@ -130,6 +195,15 @@ pub fn Login(props: &LoginProps) -> Html { onchange={on_username_change} disabled={*is_loading} /> +
+ + +
@@ -177,7 +251,7 @@ async fn perform_login( server_url: String, username: String, password: String, -) -> Result<(String, String), String> { +) -> Result<(String, String, String, serde_json::Value), String> { use crate::auth::{AuthService, CalDAVLoginRequest}; use serde_json; @@ -201,7 +275,17 @@ async fn perform_login( "username": username, "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) => { web_sys::console::log_1(&format!("❌ Backend error: {}", err).into()); diff --git a/frontend/src/services/mod.rs b/frontend/src/services/mod.rs index 2ed91f3..ade6993 100644 --- a/frontend/src/services/mod.rs +++ b/frontend/src/services/mod.rs @@ -1,3 +1,5 @@ pub mod calendar_service; +pub mod preferences; pub use calendar_service::CalendarService; +pub use preferences::PreferencesService; diff --git a/frontend/src/services/preferences.rs b/frontend/src/services/preferences.rs new file mode 100644 index 0000000..8dc8ba0 --- /dev/null +++ b/frontend/src/services/preferences.rs @@ -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, + pub calendar_time_increment: Option, + pub calendar_view_mode: Option, + pub calendar_theme: Option, + pub calendar_colors: Option, +} + +#[derive(Debug, Serialize)] +pub struct UpdatePreferencesRequest { + pub calendar_selected_date: Option, + pub calendar_time_increment: Option, + pub calendar_view_mode: Option, + pub calendar_theme: Option, + pub calendar_colors: Option, +} + +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 { + if let Ok(prefs_json) = LocalStorage::get::("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::("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::("session_token") + .map_err(|_| "No session token found".to_string())?; + + let mut request = UpdatePreferencesRequest { + calendar_selected_date: LocalStorage::get::("calendar_selected_date").ok(), + calendar_time_increment: LocalStorage::get::("calendar_time_increment").ok().map(|i| i as i32), + calendar_view_mode: LocalStorage::get::("calendar_view_mode").ok(), + calendar_theme: LocalStorage::get::("calendar_theme").ok(), + calendar_colors: LocalStorage::get::("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(()) + } +} \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css index 9e72b94..12dbce8 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -289,6 +289,27 @@ body { 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 { width: 100%; padding: 0.75rem;