- Database: Add external_calendars table with user relationships and CRUD operations - Backend: Implement REST API endpoints for external calendar management and ICS fetching - Frontend: Add external calendar modal, sidebar section with visibility toggles - Calendar integration: Merge external events with regular events in unified view - ICS parsing: Support multiple datetime formats, recurring events, and timezone handling - Authentication: Integrate with existing JWT token system for user-specific calendars - UI: Visual distinction with 📅 indicator and separate management section 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
226 lines
8.2 KiB
Rust
226 lines
8.2 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,
|
|
last_used_calendar: preferences.last_used_calendar,
|
|
},
|
|
})
|
|
}
|
|
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)
|
|
}
|
|
|
|
/// Get user from token
|
|
pub async fn get_user_from_token(&self, token: &str) -> Result<crate::db::User, ApiError> {
|
|
let claims = self.verify_token(token)?;
|
|
|
|
let user_repo = UserRepository::new(&self.db);
|
|
user_repo
|
|
.find_or_create(&claims.username, &claims.server_url)
|
|
.await
|
|
.map_err(|e| ApiError::Database(format!("Failed to get user: {}", e)))
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
}
|