diff --git a/src/api.rs b/src/api.rs index cfe236da..df365ef5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2565,6 +2565,7 @@ pub async fn ab_list_all( "path": folder.path, "has_children": folder.has_children, "description": folder.description, + "display_name": folder.display_name, "path_only": true, "entries": [], })); @@ -2596,6 +2597,7 @@ pub async fn ab_list_all( "path": folder.path, "has_children": folder.has_children, "description": folder.description, + "display_name": folder.display_name, "allowed_groups": config.as_ref().map(|c| &c.allowed_groups), // allowed_users is deliberately not listed: operators see this, // and a user who can open a folder should not learn who else can. @@ -2674,11 +2676,20 @@ pub async fn ab_search_index( Ok(n) => n, Err(_) => continue, }; + // The folder's human title (e.g. the NetBox tenant name), so the + // search results can show it over the folder key like the folder view. + let folder_display = vault + .get_folder_config(&scope, &path) + .await + .ok() + .map(|c| c.display_name) + .filter(|s| !s.is_empty()); for name in &names { if let Ok(entry) = vault.get_entry(&scope, &path, name).await { emitted.push(json!({ "scope": scope, "folder_path": path, + "folder_display_name": folder_display, "entry": crate::vault::EntryInfo::from((name.as_str(), &entry)), })); } @@ -3057,6 +3068,8 @@ pub struct CreateFolderRequest { pub allowed_users: Vec, #[serde(default)] pub description: String, + #[serde(default)] + pub display_name: String, /// "shared" or "instance" (default: "shared") #[serde(default = "default_scope")] pub scope: String, @@ -3177,6 +3190,7 @@ pub async fn ab_create_folder( allowed_groups: req.allowed_groups, allowed_users, description: req.description, + display_name: req.display_name.trim().to_string(), inherit_from_parent: req.inherit_from_parent, }; @@ -3223,6 +3237,8 @@ pub struct UpdateFolderRequest { #[serde(default)] pub description: String, #[serde(default)] + pub display_name: String, + #[serde(default)] pub inherit_from_parent: bool, } @@ -3267,6 +3283,7 @@ pub async fn ab_update_folder( allowed_groups: req.allowed_groups, allowed_users, description: req.description, + display_name: req.display_name.trim().to_string(), inherit_from_parent: req.inherit_from_parent, }; @@ -3325,6 +3342,7 @@ pub async fn ab_get_folder_config( "allowed_groups": cfg.allowed_groups, "allowed_users": cfg.allowed_users, "description": cfg.description, + "display_name": cfg.display_name, "inherit_from_parent": cfg.inherit_from_parent, })) .into_response(), diff --git a/src/import.rs b/src/import.rs index 47cfc35b..08d22fcc 100644 --- a/src/import.rs +++ b/src/import.rs @@ -309,6 +309,7 @@ pub async fn cmd_import_guacamole( allowed_groups: allowed_groups.to_vec(), allowed_users: Vec::new(), description: "Imported from Guacamole".to_string(), + display_name: String::new(), inherit_from_parent: false, }; if let Err(e) = client.put_folder_config(scope, folder, &root_config).await { @@ -339,6 +340,7 @@ pub async fn cmd_import_guacamole( allowed_groups: vec![], allowed_users: Vec::new(), description: format!("Imported from Guacamole: {}", sub), + display_name: String::new(), inherit_from_parent: true, }; if let Err(e) = client.put_folder_config(scope, &full, &cfg).await { diff --git a/src/main.rs b/src/main.rs index 06f018b9..86f1e0b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1396,7 +1396,13 @@ async fn run_server(config: Config, database: Db) { .route("/reports.html", get(serve_branded_page)) .route("/admin.html", get(serve_branded_page)) .route("/tokens.html", get(serve_branded_page)) - .route("/docs.html", get(serve_branded_page)); + .route("/docs.html", get(serve_branded_page)) + // Optional auth only: an anonymous request still gets the page (the + // page itself sends the user to log in), but a logged-in one gets + // the variant with its navigation already right. + .layer(middleware::from_fn(auth::optional_auth)) + .layer(Extension(ws_ticket_store.clone())) + .layer(Extension(database.clone())); // Build full router (all Router<()> at this point) let mut app: Router<()> = Router::new() @@ -1582,23 +1588,63 @@ fn build_guacd_tls(config: &Config) -> Option { Some(tokio_rustls::TlsConnector::from(Arc::new(tls_config))) } -/// One pre-branded HTML page and the ETag its bytes hash to. +/// One pre-branded HTML page, in one variant per role level, each with the +/// ETag its bytes hash to. /// /// The pages are branded once at startup and cannot change until a restart, -/// so the tag is computed there too -- serving one is a string compare. +/// so the tags are computed there too -- serving one is a string compare. +/// +/// The variants differ only in the `data-role-level` attribute written on +/// ``: the stylesheet gates navigation links and admin-only controls on +/// it, so the header is right on first paint for whoever the page is served +/// to, instead of hiding everything gated and showing it after `/api/me` +/// answers, which flashed and reflowed on every navigation. struct BrandedPage { + /// Indexed by role level, 0 (anonymous) to 4 (admin). + variants: Vec, +} + +struct BrandedVariant { html: String, etag: String, } +/// Highest role level, the index of the last page variant. +const MAX_ROLE_LEVEL: u8 = 4; + impl BrandedPage { fn new(html: String) -> Self { use sha2::{Digest, Sha256}; - // Quoted, because an ETag is a quoted-string on the wire. A bare hash - // tends to be ignored rather than rejected, which would look exactly - // like the stale-cache bug this exists to fix. - let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes()))); - Self { html, etag } + let variants = (0..=MAX_ROLE_LEVEL) + .map(|level| { + let html = role_variant_html(&html, level); + // Quoted, because an ETag is a quoted-string on the wire. A + // bare hash tends to be ignored rather than rejected, which + // would look exactly like the stale-cache bug this exists to + // fix. + let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes()))); + BrandedVariant { html, etag } + }) + .collect(); + Self { variants } + } + + fn variant(&self, level: u8) -> &BrandedVariant { + &self.variants[level.min(MAX_ROLE_LEVEL) as usize] + } +} + +/// Write the caller's role level onto the page's `` tag. A page that +/// has no bare `` start tag is returned unchanged. Extracted for test. +fn role_variant_html(html: &str, level: u8) -> String { + match html.find("") { + Some(i) => format!( + "{}{}", + &html[..i], + level, + &html[i + "".len()..] + ), + None => html.to_string(), } } @@ -1624,10 +1670,15 @@ fn etag_matches(if_none_match: &str, etag: &str) -> bool { /// costs a 304 and a rebuilt one lands on the next load. Without it these /// responses carry no freshness information at all and Chrome caches them /// heuristically, serving a stale page long after a restart. -fn page_response(page: &BrandedPage, headers: &axum::http::HeaderMap) -> axum::response::Response { +fn page_response( + page: &BrandedPage, + level: u8, + headers: &axum::http::HeaderMap, +) -> axum::response::Response { use axum::http::{header, StatusCode}; use axum::response::IntoResponse; + let page = page.variant(level); let cache_headers = [ (header::CACHE_CONTROL, "no-cache"), (header::ETAG, page.etag.as_str()), @@ -1745,7 +1796,12 @@ fn asset_hash( if !url.starts_with('/') || url.starts_with("//") || url.contains('?') || url.contains('#') { return None; } - if !(url.ends_with(".js") || url.ends_with(".css")) { + if !(url.ends_with(".js") + || url.ends_with(".css") + || url.ends_with(".svg") + || url.ends_with(".png") + || url.ends_with(".ico")) + { return None; } let rel = url.trim_start_matches('/'); @@ -1774,14 +1830,21 @@ fn asset_hash( /// Serve a branded HTML page from the pre-processed in-memory map. async fn serve_branded_page( Extension(pages): Extension, + identity: Option>, request: axum::extract::Request, ) -> axum::response::Response { use axum::http::StatusCode; use axum::response::IntoResponse; let path = request.uri().path().trim_start_matches('/'); let key = if path.is_empty() { "index.html" } else { path }; + // Whoever the page is for decides which navigation it carries. An API-key + // user sends no credential on the page GET and gets the anonymous + // variant; the page reconciles that from /api/me. + let level = identity + .map(|Extension(id)| auth::role_level(id.role())) + .unwrap_or(0); match pages.get(key) { - Some(page) => page_response(page, request.headers()), + Some(page) => page_response(page, level, request.headers()), None => StatusCode::NOT_FOUND.into_response(), } } @@ -1794,7 +1857,7 @@ async fn serve_client_page( ) -> axum::response::Response { use axum::response::IntoResponse; match pages.get("client.html") { - Some(page) => page_response(page, &headers), + Some(page) => page_response(page, 0, &headers), // Unreachable: the map always carries the embedded copy. None => Html(include_str!("../static/client.html")).into_response(), } @@ -1835,6 +1898,27 @@ mod tests { use super::*; + #[test] + fn role_variant_writes_level_on_html_tag_only() { + let page = "\n\nxtext"; + let v = role_variant_html(page, 4); + assert!(v.starts_with("\n\n")); + // only the first bare start tag is touched; the literal later on is content + assert_eq!(v.matches("data-role-level").count(), 1); + assert_eq!( + role_variant_html("

no html tag

", 2), + "

no html tag

" + ); + let branded = BrandedPage::new(page.to_string()); + assert_eq!(branded.variants.len(), 5); + assert_ne!(branded.variant(0).etag, branded.variant(4).etag); + assert_eq!( + branded.variant(9).etag, + branded.variant(4).etag, + "levels clamp to admin" + ); + } + #[test] fn test_csp_allows_data_and_blob_images() { // Safari and Firefox draw Guacamole image streams through data: URIs @@ -1870,6 +1954,21 @@ mod tests { assert!(out.ends_with(">")); } + #[test] + fn test_version_assets_versions_the_logo() { + // The logo used to be revalidated on every page load, which blanked + // it for a moment on each navigation in some browsers. It is content + // addressed like the scripts now. + let static_path = std::path::Path::new("static"); + let mut hashes = std::collections::HashMap::new(); + let out = version_assets( + "\"\"", + static_path, + &mut hashes, + ); + assert!(out.contains("src=\"/logo.svg?v="), "{out}"); + } + #[test] fn test_version_assets_leaves_everything_else_alone() { let static_path = std::path::Path::new("static"); @@ -1880,8 +1979,8 @@ mod tests { "", // Already says something about this URL. "", - // Not a kind that goes stale behind a stable name. - "", + // Not a kind we version (a page, a download). + "", // No such file: a hash of nothing is worse than none. "", // Traversal, which these paths have no business doing. @@ -1931,6 +2030,7 @@ mod tests { fn test_branded_page_etag_tracks_content() { let a = BrandedPage::new("one".to_string()); let b = BrandedPage::new("two".to_string()); + let (a, b) = (a.variant(0), b.variant(0)); assert_ne!(a.etag, b.etag); // Quoted, or caches ignore it. assert!(a.etag.starts_with('"') && a.etag.ends_with('"')); diff --git a/src/vault.rs b/src/vault.rs index 1cc97ab8..0a614f01 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -66,6 +66,11 @@ pub struct FolderConfig { pub allowed_users: Vec, #[serde(default)] pub description: String, + /// Human-readable title shown instead of the folder key (e.g. the NetBox + /// tenant name over its sanitised key). Omitted from the stored config + /// when empty, so existing configs are unchanged. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub display_name: String, /// When true, if the folder's own `allowed_groups` doesn't grant access, /// the access check walks up the parent path and tries each ancestor's /// config. New folders default to `true` in the UI; legacy configs @@ -623,6 +628,9 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { pub struct FolderInfo { pub name: String, pub description: String, + /// Title to show for the folder; empty when none is set (show the name). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub display_name: String, /// "shared" or "instance" pub scope: String, /// Full path from scope root (e.g. "Clients/Acme"). Same as name for top-level folders. @@ -864,6 +872,7 @@ impl VaultClient { folders.push(FolderInfo { name: name.to_string(), description: String::new(), + display_name: String::new(), scope: scope.to_string(), path: Some(name.to_string()), has_children: None, // enriched below @@ -880,6 +889,7 @@ impl VaultClient { for folder in &mut folders { if let Ok(config) = self.get_folder_config(&folder.scope, &folder.name).await { folder.description = config.description; + folder.display_name = config.display_name; } // Check for subfolders by listing children if let Ok(children) = self.list_children(&folder.scope, &folder.name).await { @@ -919,6 +929,7 @@ impl VaultClient { let mut info = FolderInfo { name: name.to_string(), description: String::new(), + display_name: String::new(), scope: scope.to_string(), path: Some(full_path.clone()), has_children: None, @@ -926,6 +937,7 @@ impl VaultClient { // Enrich with description if let Ok(config) = self.get_folder_config(scope, &full_path).await { info.description = config.description; + info.display_name = config.display_name; } // Check for grandchildren if let Ok(grandchildren) = self.list_children(scope, &full_path).await { @@ -1698,11 +1710,38 @@ pub fn resolve_credential_variables( mod tests { use super::*; + #[test] + fn folder_config_display_name_is_optional_in_storage() { + // A config written before display names existed must still load, and + // a folder without one must be stored exactly as before, so existing + // Vault .config documents are untouched by the feature. + let legacy: FolderConfig = + serde_json::from_str(r#"{"allowed_groups":["ops"],"description":"Acme"}"#).unwrap(); + assert_eq!(legacy.display_name, ""); + let stored = serde_json::to_value(&legacy).unwrap(); + assert!( + stored.get("display_name").is_none(), + "an empty display name is omitted from the stored config" + ); + + let mut named = legacy.clone(); + named.display_name = "Dani & Fiori's Gourmet Pizzeria".to_string(); + let stored = serde_json::to_value(&named).unwrap(); + assert_eq!( + stored["display_name"], + serde_json::json!("Dani & Fiori's Gourmet Pizzeria") + ); + let back: FolderConfig = serde_json::from_value(stored).unwrap(); + assert_eq!(back.display_name, named.display_name); + assert_eq!(back.description, "Acme"); + } + fn cfg(groups: &[&str], users: &[&str]) -> FolderConfig { FolderConfig { allowed_groups: groups.iter().map(|s| s.to_string()).collect(), allowed_users: users.iter().map(|s| s.to_string()).collect(), description: String::new(), + display_name: String::new(), inherit_from_parent: false, } } diff --git a/static/admin.html b/static/admin.html index 5dea3b79..c67657e8 100644 --- a/static/admin.html +++ b/static/admin.html @@ -16,12 +16,13 @@

rustguac

@@ -155,6 +156,7 @@

Connections Audit Log

return res.json(); }) .then(function(data) { + if (data && data.role) document.documentElement.setAttribute('data-role-level', String({admin:4,poweruser:3,operator:2,viewer:1}[data.role] || 0)); if (!data || data.role !== 'admin') { window.location.href = '/connections.html'; return; diff --git a/static/connections.html b/static/connections.html index 4b0f0b24..52371b42 100644 --- a/static/connections.html +++ b/static/connections.html @@ -169,6 +169,10 @@ font-size: var(--fz-xl); color: var(--text); font-weight: bold; letter-spacing: 0.02em; line-height: 1.3; } + .entries-heading { display: inline-flex; flex-direction: column; line-height: 1.15; } + .entries-key { color: var(--text-dim); font-size: var(--fz-sm); letter-spacing: 0.04em; } + .entries-key:empty { display: none; } + .folder-desc.folder-key { letter-spacing: 0.04em; } .folder-actions .desc { color: var(--text-muted); font-size: var(--fz-md); font-style: italic; } @@ -216,8 +220,6 @@ display: flex; gap: var(--s-1); align-items: center; } .session-card-info .card-meta .type-badge { font-size: 10px; min-width: 36px; } - /* Proxmox VE brand orange badge */ - .type-badge.type-proxmox { background: #e57000; color: #000; padding: 1px 6px; border-radius: 3px; font-weight: 600; } .session-card.dormant { opacity: 0.75; } .session-card.dormant .session-card-thumb { filter: brightness(0.7); } .card-actions { @@ -535,6 +537,78 @@ .folder-list li.filtered-out { display: none; } .folder-row:focus { outline: none; } .folder-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + + /* UX (#254) app shell: the header stays put, the sidebar and the + entries area are two independent scroll panes that always fit the + viewport. The earlier sticky sidebar was sized from the viewport, + not from where it starts below the header, so at the top of the + page its bottom hung off screen and the page had to be scrolled + before the list could be scrolled to its end. */ + html, body { height: 100%; } + /* overflow: clip, not hidden: hidden still lets scripts scroll the + body (scrollIntoView on anything inside), which moved the header + out of view; clip makes the body unscrollable for good. The border + box is the full height so the page padding is inside the viewport. */ + body { display: flex; flex-direction: column; overflow: clip; min-height: 0; box-sizing: border-box; } + #main-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } + #main-content > :not(.layout) { flex: none; } + .layout { flex: 1 1 auto; min-height: 0; align-items: stretch; } + .sidebar { + position: static; top: auto; align-self: auto; + max-height: none; height: auto; min-height: 0; + display: flex; flex-direction: column; + overflow-y: auto; overflow-x: hidden; + padding: 0; margin: 0; + border: 1px solid var(--border); border-radius: var(--radius); + background: rgba(255,255,255,0.015); + } + .main { min-height: 0; overflow-y: auto; overflow-x: hidden; padding-right: var(--s-2); } + /* The folder header (name, description, search, actions, filters) + pins to the top of the entries pane, so it stays under the nav and + the active-sessions strip while a long table scrolls beneath it. */ + #entries-header { + position: sticky; top: 0; z-index: 30; + background: var(--bg); + padding-bottom: var(--s-2); + margin-bottom: var(--s-2); + box-shadow: 0 6px 10px -8px rgba(0, 0, 0, 0.6); + } + #entries-header .folder-actions { margin-bottom: 0; } + #entries-header #entries-filters { margin: var(--s-2) 0 0 0; } + + /* Sidebar panel: one pinned header band (label, new-folder button, + filter) on a surface tone, then the list with hairline dividers. + Every band shares the same 12px horizontal padding so edges line up. */ + .sidebar-pin { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: var(--s-2) var(--s-3); + gap: var(--s-2); + } + .sidebar-pin .sidebar-header { + margin: 0; padding: 0; border: 0; + min-height: 30px; + justify-content: space-between; + } + .sidebar-pin .sidebar-header strong { font-size: var(--fz-xs); letter-spacing: 0.14em; } + #btn-new-folder { height: 30px; padding: 0 var(--s-2); font-size: var(--fz-xs); } + #sidebar-tools { margin: 0; } + #sidebar-tools:not(:empty) { padding-bottom: 0; } + #folder-filter { + height: 34px; font-size: var(--fz-sm); + background: var(--bg); border: 1px solid var(--border); + } + .folder-list { padding: var(--s-1) 0 var(--s-2); } + .folder-list > li { margin: 0; } + .folder-list > li + li > .folder-row { border-top: 1px solid rgba(255,255,255,0.045); } + .folder-row { + padding: var(--s-2) var(--s-3) var(--s-2) calc(var(--s-3) - 3px); + min-height: 46px; + } + .folder-list li.selected > .folder-row { border-top-color: transparent; } + .folder-row-main { gap: 2px; } + .folder-desc { font-size: 12px; } + .tree-children { margin-left: var(--s-3) !important; } @@ -545,13 +619,13 @@

rustguac