diff --git a/crates/jellyswarrm-proxy/src/federated_users.rs b/crates/jellyswarrm-proxy/src/federated_users.rs index 528be92..cc9bb66 100644 --- a/crates/jellyswarrm-proxy/src/federated_users.rs +++ b/crates/jellyswarrm-proxy/src/federated_users.rs @@ -12,6 +12,7 @@ use crate::{ pub enum SyncStatus { Created, AlreadyExists, + ExistsWithDifferentPassword, Failed, Skipped, Deleted, @@ -145,42 +146,65 @@ impl FederatedUserService { .await { Ok((remote_user_id, created)) => { - let status = if created { - SyncStatus::Created + let (status, should_map) = if created { + (SyncStatus::Created, true) } else { - SyncStatus::AlreadyExists + // User exists. Check if password matches. + match self + .check_user_password(server.url.as_ref(), username, password) + .await + { + Ok(true) => (SyncStatus::AlreadyExists, true), + Ok(false) => (SyncStatus::ExistsWithDifferentPassword, false), + Err(e) => { + warn!( + "Failed to check password for existing user {} on {}: {}", + username, server.name, e + ); + // Assume mismatch or failure, don't map to be safe + (SyncStatus::ExistsWithDifferentPassword, false) + } + } }; info!( - "Synced user {} to server {} (Remote ID: {}, Created: {})", - username, server.name, remote_user_id, created + "Synced user {} to server {} (Remote ID: {}, Status: {:?})", + username, server.name, remote_user_id, status ); - if let Err(e) = self - .user_authorization - .add_server_mapping( - user_id, - server.url.as_str(), - username, - password, - Some(password), // Encrypt with their own password so they can use it - ) - .await - { - error!( - "Failed to create local mapping for synced user on server {}: {}", - server.name, e - ); - results.push(ServerSyncResult { - server_name: server.name.clone(), - status: SyncStatus::Failed, - message: Some(format!("Failed to save local mapping: {}", e)), - }); + if should_map { + if let Err(e) = self + .user_authorization + .add_server_mapping( + user_id, + server.url.as_str(), + username, + password, + Some(password), // Encrypt with their own password so they can use it + ) + .await + { + error!( + "Failed to create local mapping for synced user on server {}: {}", + server.name, e + ); + results.push(ServerSyncResult { + server_name: server.name.clone(), + status: SyncStatus::Failed, + message: Some(format!("Failed to save local mapping: {}", e)), + }); + } else { + results.push(ServerSyncResult { + server_name: server.name.clone(), + status, + message: None, + }); + } } else { results.push(ServerSyncResult { server_name: server.name.clone(), status, - message: None, + message: Some("User exists with different password".to_string()), }); } } @@ -321,6 +345,45 @@ impl FederatedUserService { results } + async fn check_user_password( + &self, + server_url: &str, + username: &str, + password: &str, + ) -> Result { + let auth_url = format!( + "{}/Users/AuthenticateByName", + server_url.trim_end_matches('/') + ); + + let auth_header = format!( + "MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"", + env!("CARGO_PKG_VERSION") + ); + + let response = self + .reqwest_client + .post(&auth_url) + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "Username": username, + "Pw": password + })) + .send() + .await?; + + if response.status().is_success() { + Ok(true) + } else if response.status() == reqwest::StatusCode::UNAUTHORIZED { + Ok(false) + } else { + Err(anyhow::anyhow!( + "Authentication check failed: {}", + response.status() + )) + } + } + async fn authenticate_as_admin( &self, server_url: &str, diff --git a/crates/jellyswarrm-proxy/src/ui/resources/custom.css b/crates/jellyswarrm-proxy/src/ui/resources/custom.css new file mode 100644 index 0000000..9cd9004 --- /dev/null +++ b/crates/jellyswarrm-proxy/src/ui/resources/custom.css @@ -0,0 +1,135 @@ +/* Global Styles for Jellyswarrm */ + +/* --- Components --- */ + +/* Icon Buttons */ +.icon-btn { + --_size: 1.1rem; + font-size: var(--_size); + line-height: 1; + padding: .5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--pico-muted-border-color, #555); + background: transparent; + cursor: pointer; + border-radius: 0.25rem; + transition: all 0.2s; + color: var(--pico-color); +} + +.icon-btn:hover { + background: var(--pico-card-background-color); + transform: translateY(-1px); +} + +.icon-btn.danger { + color: var(--pico-del-color, #e55353); + border-color: var(--pico-del-color, #e55353); +} + +.icon-btn.danger:hover { + background: rgba(229, 83, 83, 0.1); +} + +/* Badges & Chips */ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .25em .55em; + font-size: .75rem; + font-weight: 500; + line-height: 1; + border-radius: 1rem; + color: #fff; + background: var(--pico-primary-background); +} + +.badge.muted, .status-chip { + background: var(--pico-muted-border-color, rgba(127, 127, 127, .15)); + color: var(--pico-color); +} + +.badge.success { background-color: #2e7d32; color: white; } +.badge.warning { background-color: #ffc107; color: black; } +.badge.danger { background-color: #dc3545; color: white; } +.badge.secondary { background-color: #6c757d; color: white; } + +/* Status Container */ +.status-container { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Filter Bar */ +.filter-bar { + margin-bottom: 1rem; +} + +/* User Key */ +.user-key { + font-size: .6rem; + opacity: .6; + word-break: break-all; + padding-top: .25em; +} + +/* Danger Text/Icon */ +.text-danger, .danger { + color: var(--pico-del-color, #d9534f); +} +i.danger { + cursor: pointer; +} + +/* Empty State */ +.empty-state { + padding: 1rem; + border: 2px dashed var(--pico-border-color, #ccc); + border-radius: .6rem; + text-align: center; +} + +/* Sync Report */ +.sync-report { + margin-bottom: 1rem; + padding: 1rem; + background: var(--pico-card-background-color); + border: 1px solid var(--pico-card-border-color); + border-radius: var(--pico-border-radius); +} +.sync-report ul { + margin-bottom: 0; + padding-left: 1rem; +} + +/* --- Animations --- */ +.htmx-added { + animation: fadeInSoft 160ms ease-out, highlightSoft 900ms ease-out; +} +.htmx-swapping { + animation: fadeOutSoft 140ms ease-in forwards; + pointer-events: none; +} + +@keyframes fadeInSoft { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeOutSoft { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-2px); } +} + +@keyframes highlightSoft { + 0% { background: var(--pico-mark-background-color, #fff8d9); } + 100% { background: transparent; } +} + +@media (prefers-reduced-motion: reduce) { + .htmx-added, .htmx-swapping { animation: none; } +} diff --git a/crates/jellyswarrm-proxy/src/ui/templates/admin/server_list.html b/crates/jellyswarrm-proxy/src/ui/templates/admin/server_list.html index 590e2c7..0aaf0d5 100644 --- a/crates/jellyswarrm-proxy/src/ui/templates/admin/server_list.html +++ b/crates/jellyswarrm-proxy/src/ui/templates/admin/server_list.html @@ -1,13 +1,3 @@ - - {% if servers.is_empty() %}

No servers configured

@@ -47,7 +37,7 @@ Checking... {% if item.has_admin %} - + Federated {% endif %} diff --git a/crates/jellyswarrm-proxy/src/ui/templates/admin/user_list.html b/crates/jellyswarrm-proxy/src/ui/templates/admin/user_list.html index d7539d6..5078d01 100644 --- a/crates/jellyswarrm-proxy/src/ui/templates/admin/user_list.html +++ b/crates/jellyswarrm-proxy/src/ui/templates/admin/user_list.html @@ -1,84 +1,3 @@ - - {% if let Some(report) = sync_report %}
Federated Sync Results: @@ -88,17 +7,19 @@ {{ result.server_name }}: {% match result.status %} {% when crate::federated_users::SyncStatus::Created %} - Created + Created {% when crate::federated_users::SyncStatus::AlreadyExists %} - Exists + Exists + {% when crate::federated_users::SyncStatus::ExistsWithDifferentPassword %} + Exists (Password Mismatch) {% when crate::federated_users::SyncStatus::Failed %} - Failed + Failed {% when crate::federated_users::SyncStatus::Skipped %} - Skipped + Skipped {% when crate::federated_users::SyncStatus::Deleted %} - Deleted + Deleted {% when crate::federated_users::SyncStatus::NotFound %} - Not Found + Not Found {% endmatch %} {% if let Some(msg) = result.message %} ({{ msg }}) diff --git a/crates/jellyswarrm-proxy/src/ui/templates/base.html b/crates/jellyswarrm-proxy/src/ui/templates/base.html index 16eab0a..d9e33a8 100644 --- a/crates/jellyswarrm-proxy/src/ui/templates/base.html +++ b/crates/jellyswarrm-proxy/src/ui/templates/base.html @@ -11,6 +11,9 @@ + + + diff --git a/crates/jellyswarrm-proxy/src/ui/user/media.rs b/crates/jellyswarrm-proxy/src/ui/user/media.rs index 5f6a577..f2c159c 100644 --- a/crates/jellyswarrm-proxy/src/ui/user/media.rs +++ b/crates/jellyswarrm-proxy/src/ui/user/media.rs @@ -23,6 +23,20 @@ struct MediaFoldersResponse { items: Vec, } +#[derive(Deserialize)] +struct AuthResponse { + #[serde(rename = "AccessToken")] + access_token: String, + #[serde(rename = "User")] + user: JellyfinUser, +} + +#[derive(Deserialize)] +struct JellyfinUser { + #[serde(rename = "Id")] + id: String, +} + pub struct ServerLibraries { pub server_name: String, pub libraries: Vec, @@ -67,18 +81,24 @@ pub async fn get_user_media( }; for server in servers { + let mut libraries = Vec::new(); + let mut error_msg = None; + // Find session for this server let session = sessions .iter() .filter(|(_, s)| s.id == server.id) .max_by_key(|(auth, _)| auth.updated_at); - if let Some((auth, _)) = session { + let mut token = session.map(|(auth, _)| auth.jellyfin_token.clone()); + + // 1. Try to use existing token if available + if let Some(t) = &token { let url = join_server_url(&server.url, "/Library/MediaFolders"); let auth_header = format!( "MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"", env!("CARGO_PKG_VERSION"), - auth.jellyfin_token + t ); match client @@ -90,50 +110,121 @@ pub async fn get_user_media( Ok(resp) if resp.status().is_success() => { match resp.json::().await { Ok(folders) => { - server_libraries.push(ServerLibraries { - server_name: server.name.clone(), - libraries: folders.items, - error: None, - }); + libraries = folders.items; } Err(e) => { - server_libraries.push(ServerLibraries { - server_name: server.name.clone(), - libraries: Vec::new(), - error: Some(format!("Failed to parse: {}", e)), - }); + error_msg = Some(format!("Failed to parse: {}", e)); } } } + Ok(resp) if resp.status() == StatusCode::FORBIDDEN || resp.status() == StatusCode::UNAUTHORIZED => { + // Token expired, clear it to trigger re-login + token = None; + } Ok(resp) => { - let error_msg = if resp.status() == StatusCode::FORBIDDEN - || resp.status() == StatusCode::UNAUTHORIZED - { - "Session expired, please reconnect".to_string() - } else { - format!("HTTP {}", resp.status()) - }; - server_libraries.push(ServerLibraries { - server_name: server.name.clone(), - libraries: Vec::new(), - error: Some(error_msg), - }); + error_msg = Some(format!("HTTP {}", resp.status())); } Err(e) => { - server_libraries.push(ServerLibraries { - server_name: server.name.clone(), - libraries: Vec::new(), - error: Some(format!("Network error: {}", e)), - }); + error_msg = Some(format!("Network error: {}", e)); } } - } else { - server_libraries.push(ServerLibraries { - server_name: server.name.clone(), - libraries: Vec::new(), - error: Some("Not connected".to_string()), - }); } + + // 2. If no token or expired, try to login using mapping + if token.is_none() && (libraries.is_empty() && error_msg.is_none() || error_msg.as_deref() == Some("HTTP 401") || error_msg.as_deref() == Some("HTTP 403")) { + // Clear previous error if we are retrying + error_msg = None; + + match state.user_authorization.get_server_mapping(&user.id, &server.url.as_str()).await { + Ok(Some(mapping)) => { + // Decrypt password + let config = state.config.read().await; + let admin_password = &config.password; + + let decrypted_password = state.user_authorization.decrypt_server_mapping_password( + &mapping, + &user.password, + admin_password + ); + + // Perform login + let auth_url = join_server_url(&server.url, "/Users/AuthenticateByName"); + let body = serde_json::json!({ + "Username": mapping.mapped_username, + "Pw": decrypted_password + }); + + let auth_header = format!( + "MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"", + env!("CARGO_PKG_VERSION") + ); + + match client.post(auth_url.as_str()).header("Authorization", auth_header).json(&body).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(auth_resp) => { + // Store new session + let auth = crate::models::Authorization { + client: "Jellyswarrm Proxy".to_string(), + device: "Server".to_string(), + device_id: "jellyswarrm-proxy".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + token: None, + }; + + if let Err(e) = state.user_authorization.store_authorization_session( + &user.id, + server.url.as_str(), + &auth, + auth_resp.access_token.clone(), + auth_resp.user.id, + None + ).await { + error!("Failed to store session: {}", e); + } + + // Fetch libraries with new token + let url = join_server_url(&server.url, "/Library/MediaFolders"); + let auth_header = format!( + "MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"", + env!("CARGO_PKG_VERSION"), + auth_resp.access_token + ); + + match client.get(url.as_str()).header("Authorization", auth_header).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(folders) => { + libraries = folders.items; + } + Err(e) => error_msg = Some(format!("Failed to parse: {}", e)), + } + } + Ok(resp) => error_msg = Some(format!("HTTP {}", resp.status())), + Err(e) => error_msg = Some(format!("Network error: {}", e)), + } + } + Err(e) => error_msg = Some(format!("Login response error: {}", e)), + } + } + Ok(resp) => error_msg = Some(format!("Login failed: HTTP {}", resp.status())), + Err(e) => error_msg = Some(format!("Login network error: {}", e)), + } + } + Ok(None) => { + error_msg = Some("Not connected".to_string()); + } + Err(e) => { + error_msg = Some(format!("Database error: {}", e)); + } + } + } + + server_libraries.push(ServerLibraries { + server_name: server.name.clone(), + libraries, + error: error_msg, + }); } let template = UserMediaTemplate {