From a3844401b2452c99008f5aa203fba4433b97f494 Mon Sep 17 00:00:00 2001 From: Brandon Flaherty Date: Sun, 12 Jul 2026 19:50:27 -0700 Subject: [PATCH 1/2] feat: show users on login screens --- crates/remux-dashboard/assets/theme.css | 72 +++++++++++++++++++++++ crates/remux-dashboard/src/main.rs | 72 ++++++++++++++++++++++- crates/remux-dashboard/src/pages/users.rs | 55 +++++++++-------- crates/remux-sdks/src/remux/mod.rs | 10 ++++ crates/remux-server/src/api/startup.rs | 3 + crates/remux-server/src/api/users.rs | 20 ++++++- 6 files changed, 205 insertions(+), 27 deletions(-) diff --git a/crates/remux-dashboard/assets/theme.css b/crates/remux-dashboard/assets/theme.css index 9304590a8..564ec73e9 100644 --- a/crates/remux-dashboard/assets/theme.css +++ b/crates/remux-dashboard/assets/theme.css @@ -443,6 +443,78 @@ html, body { padding: 20px 0; } +/* ── Public User Selection Grid ─────────────────────────────────── */ +.public-users-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin: 4px 0 16px 0; + padding: 8px; + background: var(--panel-elevated); + border-radius: var(--radius-md); + border: 1px solid var(--border); +} + +.public-user-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + cursor: pointer; + width: 72px; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.public-user-card:hover { + transform: translateY(-2px); +} + +.public-user-avatar-wrapper { + width: 52px; + height: 52px; + border-radius: 50%; + overflow: hidden; + background: var(--border); + display: flex; + align-items: center; + justify-content: center; + border: 2px solid transparent; + transition: all 0.2s ease; + box-shadow: var(--shadow-sm); +} + +.public-user-card:hover .public-user-avatar-wrapper { + border-color: var(--primary); + box-shadow: 0 0 12px rgba(var(--primary-rgb), 0.3); +} + +.public-user-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.public-user-avatar-placeholder { + font-size: 1.5rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + user-select: none; +} + +.public-user-name { + font-size: 0.72rem; + font-weight: 600; + color: var(--text); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + /* ── Task groups ─────────────────────────────────────────────────── */ .task-group + .task-group { margin-top: 16px; diff --git a/crates/remux-dashboard/src/main.rs b/crates/remux-dashboard/src/main.rs index 439ef22b3..40402c00c 100644 --- a/crates/remux-dashboard/src/main.rs +++ b/crates/remux-dashboard/src/main.rs @@ -1,9 +1,10 @@ use dioxus::prelude::*; +use web_sys::wasm_bindgen::JsCast; use remux_sdks::{ remux::{ AuthenticateUserByName, CountryInfo, GetCountries, GetStartupConfiguration, JellyfinAuth, PostStartupComplete, PostStartupConfiguration, PostStartupUser, - PublicSystemInfo, StartupConfiguration, StartupUser, Username, + PublicSystemInfo, StartupConfiguration, StartupUser, Username, GetPublicUsers, UserDto, }, ClientError, }; @@ -87,6 +88,23 @@ fn Login(on_login: EventHandler) -> Element { let mut password = use_signal(String::new); let mut error = use_signal(|| Option::::None); let mut loading = use_signal(|| false); + let mut public_users = use_signal(Vec::::new); + + let server_url_effect = server_url.clone(); + use_effect(move || { + let url_opt = server_url_effect.read().clone(); + if let Some(url) = url_opt { + if !url.is_empty() { + spawn(async move { + if let Ok(client) = remux_sdks::remux::client(&url) { + if let Ok(users) = client.execute(GetPublicUsers).await { + public_users.set(users); + } + } + }); + } + } + }); use_effect(move || { spawn(async move { @@ -202,6 +220,58 @@ fn Login(on_login: EventHandler) -> Element { onsubmit: on_submit, style: "display:flex;flex-direction:column;gap:14px;", + if !public_users.read().is_empty() { + div { + style: "text-align:center;margin-bottom:6px;", + span { + style: "font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em", + "Select User" + } + } + div { class: "public-users-container", + for user in public_users.read().clone() { + { + let username_val = user.name.clone(); + let has_img = user.primary_image_tag.is_some(); + let user_id = user.id; + let url = server_url.read().clone().unwrap_or_default(); + rsx! { + div { + class: "public-user-card", + key: "{user_id}", + onclick: move |_| { + username.set(username_val.clone()); + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Some(element) = document.get_element_by_id("password") { + if let Ok(html_element) = element.dyn_into::() { + let _ = html_element.focus(); + } + } + } + } + }, + div { class: "public-user-avatar-wrapper", + if has_img { + img { + class: "public-user-avatar-img", + src: "{url}/users/{user_id}/images/primary", + alt: "{username_val}", + } + } else { + span { class: "public-user-avatar-placeholder", + "{username_val.chars().next().unwrap_or('?')}" + } + } + } + span { class: "public-user-name", "{username_val}" } + } + } + } + } + } + } + if server_url.read().as_deref() == Some("") { div { class: "field", label { class: "field-label", r#for: "host", "Server URL" } diff --git a/crates/remux-dashboard/src/pages/users.rs b/crates/remux-dashboard/src/pages/users.rs index 1726ae834..41eb0b94f 100644 --- a/crates/remux-dashboard/src/pages/users.rs +++ b/crates/remux-dashboard/src/pages/users.rs @@ -268,6 +268,12 @@ pub fn UserForm( }) .unwrap_or(true) }); + let mut hide_from_login = use_signal(|| { + existing + .as_ref() + .map(|u| u.policy.is_hidden) + .unwrap_or(false) + }); let on_submit = move |e: Event| { e.prevent_default(); @@ -309,6 +315,7 @@ pub fn UserForm( let remote_search_snapshot = *enable_remote_search.peek(); let max_sessions_snapshot = *max_active_sessions.peek(); let video_transcoding_snapshot = *enable_video_transcoding.peek(); + let hide_from_login_snapshot = *hide_from_login.peek(); saving.set(true); err.set(None); @@ -354,6 +361,7 @@ pub fn UserForm( .policy .clone(); policy.is_administrator = admin; + policy.is_hidden = hide_from_login_snapshot; policy.filter_rules = filter_rules.clone(); policy.stream_filter = stream_filter.clone(); policy.enable_remote_search = remote_search_snapshot; @@ -380,30 +388,23 @@ pub fn UserForm( let new_user = client .execute(CreateUser { name, password: pw }) .await?; - if admin - || filter_rules.is_some() - || stream_filter.is_some() - || !remote_search_snapshot - || max_sessions_snapshot > 0 - || !video_transcoding_snapshot - { - let mut policy = new_user - .policy - .clone(); - policy.is_administrator = admin; - policy.filter_rules = filter_rules.clone(); - policy.stream_filter = stream_filter.clone(); - policy.enable_remote_search = remote_search_snapshot; - policy.max_active_sessions = max_sessions_snapshot; - policy.enable_video_playback_transcoding = - video_transcoding_snapshot; - client - .execute(UpdateUserPolicy { - user_id: new_user.id, - policy, - }) - .await?; - } + let mut policy = new_user + .policy + .clone(); + policy.is_administrator = admin; + policy.is_hidden = hide_from_login_snapshot; + policy.filter_rules = filter_rules.clone(); + policy.stream_filter = stream_filter.clone(); + policy.enable_remote_search = remote_search_snapshot; + policy.max_active_sessions = max_sessions_snapshot; + policy.enable_video_playback_transcoding = + video_transcoding_snapshot; + client + .execute(UpdateUserPolicy { + user_id: new_user.id, + policy, + }) + .await?; } Ok(()) } @@ -487,6 +488,12 @@ pub fn UserForm( on_change: move |v| enable_video_transcoding.set(v), } + ToggleRow { + label: "Hide user from login screens", + checked: *hide_from_login.read(), + on_change: move |v| hide_from_login.set(v), + } + div { class: "field", label { class: "field-label", r#for: "u-max-streams", "Max Concurrent Streams" } input { diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 03a729451..1a6430bfc 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -4889,6 +4889,16 @@ impl Endpoint for GetUsers { } } +#[derive(Debug, Clone)] +pub struct GetPublicUsers; + +impl Endpoint for GetPublicUsers { + type Output = Vec; + fn path(&self) -> String { + "/users/public".into() + } +} + #[derive(Debug, Clone)] pub struct CreateUser { pub name: String, diff --git a/crates/remux-server/src/api/startup.rs b/crates/remux-server/src/api/startup.rs index 2edba083b..907d1b943 100644 --- a/crates/remux-server/src/api/startup.rs +++ b/crates/remux-server/src/api/startup.rs @@ -105,6 +105,9 @@ pub async fn post_startup_user( None, )?; user.is_admin = true; + let mut policy = crate::api::UserPolicy::default(); + policy.is_hidden = true; + user.policy = Some(sqlx::types::Json(policy)); user.save_by_username( &state .ctx diff --git a/crates/remux-server/src/api/users.rs b/crates/remux-server/src/api/users.rs index 5b054b838..a919d3642 100644 --- a/crates/remux-server/src/api/users.rs +++ b/crates/remux-server/src/api/users.rs @@ -761,8 +761,24 @@ pub async fn update_user( // ===== Route aliases (same handler, different path) ===== #[get("/users/public")] -pub async fn users_public() -> Result { - Ok(Json::>(vec![]).into_response()) +pub async fn users_public( + State(state): State, +) -> Result { + let all_users = sqlx::query_as::<_, User>("SELECT * FROM users") + .fetch_all(&state.ctx.db) + .await?; + + let dtos: Vec = all_users + .into_iter() + .filter(|u| { + let policy = u.policy.as_ref().map(|p| &p.0); + let is_hidden = policy.map(|p| p.is_hidden).unwrap_or(false); + !is_hidden + }) + .map(|u| api::db_user_to_dto(&state.ctx.config.data_dir, u)) + .collect(); + + Ok(Json(dtos).into_response()) } #[get("/users/{user_id}")] From 00b4b99d89e3422e95763b213deb34c6f5bf8e89 Mon Sep 17 00:00:00 2001 From: BrandonItaly Date: Mon, 13 Jul 2026 03:50:48 -0700 Subject: [PATCH 2/2] apply suggestions from code review --- crates/remux-dashboard/assets/theme.css | 14 +++- crates/remux-dashboard/src/main.rs | 3 +- crates/remux-server/src/api/users.rs | 93 ++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/crates/remux-dashboard/assets/theme.css b/crates/remux-dashboard/assets/theme.css index 564ec73e9..06fb752b9 100644 --- a/crates/remux-dashboard/assets/theme.css +++ b/crates/remux-dashboard/assets/theme.css @@ -465,6 +465,17 @@ html, body { cursor: pointer; width: 72px; transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); + background: transparent; + border: none; + padding: 0; + color: inherit; + font: inherit; + border-radius: var(--radius-sm); +} + +.public-user-card:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 4px; } .public-user-card:hover { @@ -485,7 +496,8 @@ html, body { box-shadow: var(--shadow-sm); } -.public-user-card:hover .public-user-avatar-wrapper { +.public-user-card:hover .public-user-avatar-wrapper, +.public-user-card:focus-visible .public-user-avatar-wrapper { border-color: var(--primary); box-shadow: 0 0 12px rgba(var(--primary-rgb), 0.3); } diff --git a/crates/remux-dashboard/src/main.rs b/crates/remux-dashboard/src/main.rs index 40402c00c..7e93f5e11 100644 --- a/crates/remux-dashboard/src/main.rs +++ b/crates/remux-dashboard/src/main.rs @@ -236,7 +236,8 @@ fn Login(on_login: EventHandler) -> Element { let user_id = user.id; let url = server_url.read().clone().unwrap_or_default(); rsx! { - div { + button { + r#type: "button", class: "public-user-card", key: "{user_id}", onclick: move |_| { diff --git a/crates/remux-server/src/api/users.rs b/crates/remux-server/src/api/users.rs index a919d3642..cfebc30a2 100644 --- a/crates/remux-server/src/api/users.rs +++ b/crates/remux-server/src/api/users.rs @@ -772,10 +772,18 @@ pub async fn users_public( .into_iter() .filter(|u| { let policy = u.policy.as_ref().map(|p| &p.0); - let is_hidden = policy.map(|p| p.is_hidden).unwrap_or(false); + let is_hidden = policy.map(|p| p.is_hidden).unwrap_or(true); !is_hidden }) - .map(|u| api::db_user_to_dto(&state.ctx.config.data_dir, u)) + .map(|u| { + let mut dto = api::db_user_to_dto(&state.ctx.config.data_dir, u); + dto.policy = api::UserPolicy { + is_hidden: dto.policy.is_hidden, + ..Default::default() + }; + dto.configuration = None; + dto + }) .collect(); Ok(Json(dtos).into_response()) @@ -2919,4 +2927,85 @@ mod e2e_tests { "null-date episode must not be counted as unplayed when the release-date filter is active" ); } + + /// Verifies that the public users endpoint correctly filters out users + /// marked as hidden, defaults users without policies to hidden, and + /// redacts sensitive configuration and policy fields. + #[tokio::test] + async fn test_users_public() { + let (server, ctx) = new_test_server().await.unwrap(); + let db = &ctx.0.db; + + // Create a user with no stored policy (defaults to hidden). + let id_no_policy = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO users (id, username, password_hash, is_admin, policy) VALUES (?, ?, ?, ?, NULL)" + ) + .bind(id_no_policy) + .bind("user_no_policy") + .bind("hash") + .bind(false) + .execute(db) + .await + .unwrap(); + + // Create a visible user with is_hidden set to false. + let id_visible = uuid::Uuid::new_v4(); + let mut policy_visible = crate::api::UserPolicy::default(); + policy_visible.is_hidden = false; + sqlx::query( + "INSERT INTO users (id, username, password_hash, is_admin, policy) VALUES (?, ?, ?, ?, ?)" + ) + .bind(id_visible) + .bind("user_visible") + .bind("hash") + .bind(false) + .bind(sqlx::types::Json(policy_visible)) + .execute(db) + .await + .unwrap(); + + // Create a hidden user with is_hidden set to true. + let id_hidden = uuid::Uuid::new_v4(); + let mut policy_hidden = crate::api::UserPolicy::default(); + policy_hidden.is_hidden = true; + sqlx::query( + "INSERT INTO users (id, username, password_hash, is_admin, policy) VALUES (?, ?, ?, ?, ?)" + ) + .bind(id_hidden) + .bind("user_hidden") + .bind("hash") + .bind(false) + .bind(sqlx::types::Json(policy_hidden)) + .execute(db) + .await + .unwrap(); + + // Query the public users endpoint. + let response = server.get("/users/public").await; + let body: serde_json::Value = response.json(); + + // Assert that the response is a JSON array. + let users = body.as_array().expect("Response must be a JSON array"); + + // Inspect the names of the public users returned. + let names: Vec<&str> = users + .iter() + .map(|u| u["Name"].as_str().unwrap()) + .collect(); + + // Assert that only the visible user is returned and hidden/policy-less users are filtered. + assert!(names.contains(&"user_visible"), "user_visible must be present in public users"); + assert!(!names.contains(&"user_hidden"), "user_hidden must be filtered out"); + assert!(!names.contains(&"user_no_policy"), "user_no_policy must be filtered out as default policy is hidden"); + + // Verify that sensitive configuration and policy fields are redacted. + for user in users { + if user["Name"].as_str().unwrap() == "user_visible" { + assert!(user["Configuration"].is_null()); + assert!(user["Policy"]["BlockedTags"].as_array().unwrap().is_empty()); + assert!(user["Policy"]["FilterRules"].is_null()); + } + } + } }