Implement lightweight auth system with SQLite
Added SQLite database for session management and user preferences storage, allowing users to have consistent settings across different sessions and devices. Backend changes: - Added SQLite database with users, sessions, and preferences tables - Implemented session-based authentication alongside JWT tokens - Created preference storage/retrieval API endpoints - Database migrations for schema setup - Session validation and cleanup functionality Frontend changes: - Added "Remember server" and "Remember username" checkboxes to login - Created preferences service for syncing settings with backend - Updated auth flow to handle session tokens and preferences - Store remembered values in LocalStorage (not database) for convenience Key features: - User preferences persist across sessions and devices - CalDAV passwords never stored, only passed through - Sessions expire after 24 hours - Remember checkboxes only affect local browser storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		
							
								
								
									
										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(()) | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Connor Johnstone
					Connor Johnstone