 73567c185c
			
		
	
	73567c185c
	
	
	
		
			
			This commit adds a complete style system alongside the existing theme system, allowing users to switch between different UI styles while maintaining theme color variations. **Core Features:** - Style enum (Default, Google Calendar) separate from Theme enum - Hot-swappable stylesheets with dynamic loading - Style preference persistence (localStorage + database) - Style picker UI in sidebar below theme picker **Frontend Implementation:** - Add Style enum to sidebar.rs with value/display methods - Implement dynamic stylesheet loading in app.rs - Add style picker dropdown with proper styling - Handle style state management and persistence - Add web-sys features for HtmlLinkElement support **Backend Integration:** - Add calendar_style column to user_preferences table - Update all database operations (insert/update/select) - Extend API models for style preference - Add migration for existing users **Google Calendar Style:** - Clean Material Design-inspired interface - White sidebar with proper contrast - Enhanced calendar grid with subtle shadows - Improved event styling with hover effects - Google Sans typography throughout - Professional color scheme and spacing **Technical Details:** - Trunk asset management for stylesheet copying - High CSS specificity to override theme styles - Modular CSS architecture for easy extensibility - Comprehensive text contrast fixes - Enhanced calendar cells and navigation Users can now choose between the original gradient design (Default) and a clean Google Calendar-inspired interface (Google Calendar), with full preference persistence across sessions. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
		
			
				
	
	
		
			214 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			214 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
| 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::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
 | |
| use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
 | |
| 
 | |
| #[derive(Debug, Serialize, Deserialize)]
 | |
| pub struct Claims {
 | |
|     pub username: String,
 | |
|     pub server_url: String,
 | |
|     pub exp: i64, // Expiration time
 | |
|     pub iat: i64, // Issued at
 | |
| }
 | |
| 
 | |
| #[derive(Clone)]
 | |
| pub struct AuthService {
 | |
|     jwt_secret: String,
 | |
|     db: Database,
 | |
| }
 | |
| 
 | |
| impl AuthService {
 | |
|     pub fn new(jwt_secret: String, db: Database) -> Self {
 | |
|         Self { jwt_secret, db }
 | |
|     }
 | |
| 
 | |
|     /// Authenticate user directly against CalDAV server
 | |
|     pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, ApiError> {
 | |
|         // Validate input
 | |
|         self.validate_login(&request)?;
 | |
|         println!("✅ Input validation passed");
 | |
| 
 | |
|         // Create CalDAV config with provided credentials
 | |
|         let caldav_config = CalDAVConfig::new(
 | |
|             request.server_url.clone(),
 | |
|             request.username.clone(),
 | |
|             request.password.clone(),
 | |
|         );
 | |
|         println!("📝 Created CalDAV config");
 | |
| 
 | |
|         // Test authentication against CalDAV server
 | |
|         let caldav_client = CalDAVClient::new(caldav_config.clone());
 | |
|         println!("🔗 Created CalDAV client, attempting to discover calendars...");
 | |
| 
 | |
|         // Try to discover calendars as an authentication test
 | |
|         match caldav_client.discover_calendars().await {
 | |
|             Ok(calendars) => {
 | |
|                 println!(
 | |
|                     "✅ Authentication successful! Found {} calendars",
 | |
|                     calendars.len()
 | |
|                 );
 | |
|                 
 | |
|                 // 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: 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_style: preferences.calendar_style,
 | |
|                         calendar_colors: preferences.calendar_colors,
 | |
|                     },
 | |
|                 })
 | |
|             }
 | |
|             Err(err) => {
 | |
|                 println!("❌ Authentication failed: {:?}", err);
 | |
|                 // Authentication failed
 | |
|                 Err(ApiError::Unauthorized(
 | |
|                     "Invalid CalDAV credentials or server unavailable".to_string(),
 | |
|                 ))
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /// Verify JWT token and extract CalDAV credentials info
 | |
|     pub fn verify_token(&self, token: &str) -> Result<Claims, ApiError> {
 | |
|         self.decode_token(token)
 | |
|     }
 | |
| 
 | |
|     /// Create CalDAV config from token
 | |
|     pub fn caldav_config_from_token(
 | |
|         &self,
 | |
|         token: &str,
 | |
|         password: &str,
 | |
|     ) -> Result<CalDAVConfig, ApiError> {
 | |
|         let claims = self.verify_token(token)?;
 | |
| 
 | |
|         Ok(CalDAVConfig::new(
 | |
|             claims.server_url,
 | |
|             claims.username,
 | |
|             password.to_string(),
 | |
|         ))
 | |
|     }
 | |
| 
 | |
|     fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
 | |
|         if request.username.trim().is_empty() {
 | |
|             return Err(ApiError::BadRequest("Username is required".to_string()));
 | |
|         }
 | |
| 
 | |
|         if request.password.trim().is_empty() {
 | |
|             return Err(ApiError::BadRequest("Password is required".to_string()));
 | |
|         }
 | |
| 
 | |
|         if request.server_url.trim().is_empty() {
 | |
|             return Err(ApiError::BadRequest("Server URL is required".to_string()));
 | |
|         }
 | |
| 
 | |
|         // Basic URL validation
 | |
|         if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
 | |
|         {
 | |
|             return Err(ApiError::BadRequest(
 | |
|                 "Server URL must start with http:// or https://".to_string(),
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         Ok(())
 | |
|     }
 | |
| 
 | |
|     pub fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
 | |
|         let now = Utc::now();
 | |
|         let expires_at = now + Duration::hours(24); // Token valid for 24 hours
 | |
| 
 | |
|         let claims = Claims {
 | |
|             username: username.to_string(),
 | |
|             server_url: server_url.to_string(),
 | |
|             exp: expires_at.timestamp(),
 | |
|             iat: now.timestamp(),
 | |
|         };
 | |
| 
 | |
|         let token = encode(
 | |
|             &Header::default(),
 | |
|             &claims,
 | |
|             &EncodingKey::from_secret(self.jwt_secret.as_bytes()),
 | |
|         )
 | |
|         .map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?;
 | |
| 
 | |
|         Ok(token)
 | |
|     }
 | |
| 
 | |
|     fn decode_token(&self, token: &str) -> Result<Claims, ApiError> {
 | |
|         let token_data = decode::<Claims>(
 | |
|             token,
 | |
|             &DecodingKey::from_secret(self.jwt_secret.as_bytes()),
 | |
|             &Validation::new(Algorithm::HS256),
 | |
|         )
 | |
|         .map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
 | |
| 
 | |
|         Ok(token_data.claims)
 | |
|     }
 | |
|     
 | |
|     /// 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(())
 | |
|     }
 | |
| }
 |