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:
Connor Johnstone
2025-09-01 18:55:09 -04:00
parent 79f287ed61
commit 03c0011445
17 changed files with 888 additions and 47 deletions

305
backend/src/db.rs Normal file
View 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(())
}
}