diff --git a/Cargo.lock b/Cargo.lock index 88d7989..66b968d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,7 +1015,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "axum", @@ -1037,7 +1037,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "bcrypt", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "bytes", @@ -1070,7 +1070,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "axum", @@ -1100,7 +1100,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.5" +version = "0.4.6" dependencies = [ "openpxe-core", "rust-embed", @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "bcrypt", @@ -1133,7 +1133,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "bytes", @@ -1147,7 +1147,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.5" +version = "0.4.6" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index 0013d78..11b2340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.5" +version = "0.4.6" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/core/src/sso.rs b/crates/core/src/sso.rs index bf8afcf..a32fa1d 100644 --- a/crates/core/src/sso.rs +++ b/crates/core/src/sso.rs @@ -33,6 +33,11 @@ pub struct SsoConfig { /// with X" button label. Empty/whitespace falls back to "SSO". #[serde(default)] pub idp_name: String, + /// Optional HTTPS URL pointing at the IdP's brand logo. Rendered + /// next to `idp_name` on the WebUI's login screen (FleetDM-style). + /// Length-capped at [`MAX_URL_LEN`]; empty is fine. + #[serde(default)] + pub idp_logo_url: String, /// Raw SAML metadata XML pasted by the operator. Mutually exclusive /// with `metadata_url`; if both are set, the URL wins at apply time /// (operators typically forget about a stale XML paste). @@ -100,6 +105,7 @@ impl SsoStore { /// but the server enforces a hard ceiling regardless. pub fn replace(&self, mut cfg: SsoConfig) -> Result { cfg.idp_name = cfg.idp_name.trim().to_string(); + cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string(); cfg.metadata = cfg.metadata.trim().to_string(); cfg.metadata_url = cfg.metadata_url.trim().to_string(); if cfg.metadata.len() > MAX_METADATA_BYTES { @@ -112,6 +118,11 @@ impl SsoStore { "metadata_url exceeds {MAX_URL_LEN}-char cap" ))); } + if cfg.idp_logo_url.len() > MAX_URL_LEN { + return Err(Error::Invalid(format!( + "idp_logo_url exceeds {MAX_URL_LEN}-char cap" + ))); + } if !cfg.metadata_url.is_empty() && !cfg.metadata_url.starts_with("http://") && !cfg.metadata_url.starts_with("https://") @@ -120,6 +131,14 @@ impl SsoStore { "metadata_url must start with http:// or https://".into(), )); } + if !cfg.idp_logo_url.is_empty() + && !cfg.idp_logo_url.starts_with("http://") + && !cfg.idp_logo_url.starts_with("https://") + { + return Err(Error::Invalid( + "idp_logo_url must start with http:// or https://".into(), + )); + } // If they're trying to *enable* the integration but haven't // supplied either source, reject — saves a "configured but // unusable" surprise later. @@ -197,6 +216,7 @@ mod tests { idp_name: "Okta".into(), metadata: String::new(), metadata_url: "https://idp.example.com/metadata".into(), + idp_logo_url: String::new(), }) .unwrap(); drop(s); @@ -218,6 +238,7 @@ mod tests { idp_name: "Test IdP".into(), metadata: xml.into(), metadata_url: String::new(), + idp_logo_url: String::new(), }) .unwrap(); assert!(s.snapshot().is_usable()); @@ -232,6 +253,7 @@ mod tests { idp_name: "Okta".into(), metadata: String::new(), metadata_url: String::new(), + idp_logo_url: String::new(), }); assert!(matches!(r, Err(Error::Invalid(_)))); // …and a disabled blank config is fine. @@ -247,10 +269,38 @@ mod tests { idp_name: String::new(), metadata: String::new(), metadata_url: "ftp://idp.example.com/metadata".into(), + idp_logo_url: String::new(), }); assert!(matches!(r, Err(Error::Invalid(_)))); } + #[test] + fn idp_logo_url_must_be_http_scheme() { + // v0.4.6: SSO settings learned an idp_logo_url so the login + // screen can render the FleetDM-style "Sign in with " + // affordance. Same scheme rule as metadata_url. + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + let r = s.replace(SsoConfig { + enabled: false, + idp_name: "Okta".into(), + metadata: String::new(), + metadata_url: String::new(), + idp_logo_url: "data:image/png;base64,...".into(), + }); + assert!(matches!(r, Err(Error::Invalid(_)))); + // Real HTTPS URL is fine. + s.replace(SsoConfig { + enabled: false, + idp_name: "Okta".into(), + metadata: String::new(), + metadata_url: String::new(), + idp_logo_url: "https://idp.example.com/logo.png".into(), + }) + .unwrap(); + assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png"); + } + #[test] fn metadata_size_cap_enforced() { let dir = tempdir().unwrap(); @@ -261,6 +311,7 @@ mod tests { idp_name: String::new(), metadata: oversize, metadata_url: String::new(), + idp_logo_url: String::new(), }); assert!(matches!(r, Err(Error::Invalid(_)))); } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index ca493aa..63a5a73 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -51,6 +51,13 @@ pub fn build_router(state: AppState) -> Router { .route("/assets/app.css", get(ui_css)) .route("/assets/logo.svg", get(ui_logo)) .route("/assets/loader.svg", get(ui_loader)) + // v0.4.6: PXE menu logo — the raster form of the operator's + // uploaded mark, served so iPXE's `console --picture` can + // overlay it on the boot menu. SVG uploads 404 here (iPXE + // can't rasterize SVG); we deliberately don't bundle a + // pre-rendered PNG fallback because iPXE's ASCII wordmark + // banner already provides the always-visible branding. + .route("/branding/pxe-logo", get(ui_pxe_logo)) // iPXE script endpoints. .route("/boot.ipxe", get(boot_top_menu)) .route("/boot/:filename", get(boot_sub)) @@ -263,6 +270,49 @@ async fn ui_logo(State(state): State) -> Response { .into_response() } +/// v0.4.6: raster-only logo endpoint for the iPXE menu's +/// `console --picture`. iPXE can't rasterize SVG, so SVG uploads 404 +/// here — the ASCII OpenPXE wordmark in `render_menu` already gives +/// the operator a polished default. No bundled PNG fallback by design: +/// either the operator's raster logo paints, or the text stands in. +async fn ui_pxe_logo(State(state): State) -> Response { + let Some(path) = state.branding.logo_path() else { + return (StatusCode::NOT_FOUND, "no custom logo configured").into_response(); + }; + let Some(mime) = state.branding.logo_mime() else { + return (StatusCode::NOT_FOUND, "no mime recorded").into_response(); + }; + if mime == "image/svg+xml" { + return ( + StatusCode::NOT_FOUND, + "operator-uploaded logo is SVG; iPXE menu falls back to the bundled ASCII wordmark", + ) + .into_response(); + } + match tokio::fs::read(&path).await { + Ok(bytes) => { + let ct = HeaderValue::from_str(&mime) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); + ( + [ + (header::CONTENT_TYPE, ct), + ( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, max-age=0"), + ), + ], + bytes, + ) + .into_response() + } + Err(e) => ( + StatusCode::NOT_FOUND, + format!("custom logo unreadable: {e}"), + ) + .into_response(), + } +} + async fn ui_loader() -> Response { ( [( @@ -945,6 +995,8 @@ async fn api_docs() -> Json { "summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."}, {"method": "DELETE", "path": "/api/branding/logo", "summary": "Remove the custom logo and revert to the bundled mark."}, + {"method": "GET", "path": "/branding/pxe-logo", + "summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."}, {"method": "GET", "path": "/api/sso", "summary": "Current SAML SSO configuration."}, {"method": "PUT", "path": "/api/sso", diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index c7040b2..8d0ec93 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -440,6 +440,9 @@ mod tests { "/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe", "/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz", "/metrics", + // v0.4.6: iPXE fetches this for `console --picture` before + // it can possibly have a session cookie. + "/branding/pxe-logo", ] { assert!(is_public_path(p), "expected {p} to be public"); } diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index b5c0c26..a0846f7 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -29,6 +29,15 @@ use std::fmt::Write as _; /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI /// clients because iPXE normalises the menu primitives across firmwares. +/// +/// v0.4.6: rendered with an iVentoy-style polished frame — centered +/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can +/// paint it), a footer carrying version + arch + firmware kind, and an +/// optional `console --picture` directive that paints the operator's +/// uploaded raster logo on top when the iPXE binary on the wire was +/// built with PNG support. The ASCII banner is always rendered so +/// even when the picture call no-ops the screen still reads as +/// "OpenPXE — here is the menu" rather than a featureless box. #[must_use] pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { let mut s = String::new(); @@ -47,8 +56,38 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set cls ${{esc:string}}[2J"); + // v0.4.6: best-effort graphics console with the operator-uploaded + // raster logo. Falls back to plain text console on iPXE builds + // without PNG support — the `||` chain keeps a parse-clean + // single-statement form so even the strictest iPXE parsers accept + // it. The `console` reset at the end re-syncs the menu output. + let _ = writeln!( + s, + "console --picture {base}/branding/pxe-logo || console" + ); + // Map iPXE's ${{buildarch}} + ${{platform}} into the human form the + // user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI"). + // iPXE evaluates `iseq` lazily, so we only set whichever line + // matches. Anything not on the allowlist falls through to a generic + // ` ` display. + let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}"); + let _ = writeln!( + s, + "iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true" + ); let _ = writeln!(s, ":menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu"); + // Centered ASCII wordmark. iPXE menus are ~76 columns wide on the + // default VGA text console; the lines below are padded to sit + // approximately centered. `item --gap -- ` emits text without + // a selectable hotkey. + let _ = writeln!(s, "item --gap"); + let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___"); + let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|"); + let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|"); + let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|"); + let _ = writeln!(s, "item --gap -- |_|"); + let _ = writeln!(s, "item --gap"); let _ = writeln!( s, "item --gap -- ------------------------- Default -------------------------" @@ -82,6 +121,18 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str let _ = writeln!(s, "item queue Queued Deployment (join queue)"); let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --key x exit Exit iPXE"); + // v0.4.6 footer line. Sits just above the `choose` line so it's + // always visible regardless of how the menu paginates. iPXE + // interpolates `${arch-label}` (set near the top of this script) + // and `${version}` is the binary-baked iPXE version — *not* the + // OpenPXE version — so we hard-code the OpenPXE version string + // here. + let openpxe_version = env!("CARGO_PKG_VERSION"); + let _ = writeln!(s, "item --gap"); + let _ = writeln!( + s, + "item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}" + ); if matches!(settings.timeout_action, TimeoutAction::Stay) { let _ = writeln!(s, "choose --default {default_item} target || goto menu"); @@ -575,6 +626,50 @@ mod password_tests { assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe")); } + #[test] + fn top_menu_has_polished_branding_and_arch_footer() { + // v0.4.6 polish: a `console --picture` line for operator + // logos, an ASCII OpenPXE wordmark visible across iPXE + // builds (graphics or not), and a single-line footer carrying + // the current OpenPXE version + the resolved arch label. + let settings = Settings::default(); + let s = render_menu(&[], &settings, "http://10.0.0.5"); + assert!( + s.contains("console --picture http://10.0.0.5/branding/pxe-logo"), + "missing console --picture line:\n{s}" + ); + // Picture-or-text-console must be a single statement so older + // iPXE parsers don't choke on the chain. + assert!(s.contains("|| console"), "missing graceful fallback:\n{s}"); + // ASCII wordmark — at least one of the banner lines must + // contain the trailing pipe segment, plus the leading "_"s. + assert!( + s.contains("___ ___ __ __ ___"), + "ascii banner missing first row:\n{s}" + ); + // Footer with version + arch interpolation. The version comes + // from CARGO_PKG_VERSION at compile time. + let version = env!("CARGO_PKG_VERSION"); + assert!( + s.contains(&format!("OpenPXE v{version}")), + "footer missing OpenPXE version:\n{s}" + ); + assert!( + s.contains("${arch-label}"), + "footer missing arch-label interpolation:\n{s}" + ); + // No website URL — the design brief calls that out as tacky. + assert!( + !s.to_ascii_lowercase().contains("openpxe.com"), + "footer should not advertise the website:\n{s}" + ); + // Arch-label mapping covers the three labels from the brief: + // "x86 BIOS", "x86_64 UEFI", "arm64 UEFI". + assert!(s.contains("x86 BIOS"), "{s}"); + assert!(s.contains("x86_64 UEFI"), "{s}"); + assert!(s.contains("arm64 UEFI"), "{s}"); + } + #[test] fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() { let settings = Settings::default(); diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 94097e4..5e321b5 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -1742,3 +1742,92 @@ async fn docs_lists_new_v0_4_5_endpoints() { assert!(paths.iter().any(|p| p == needle), "{needle} missing"); } } + +// ─── v0.4.6: PXE logo endpoint ──────────────────────────────────────────── + +#[tokio::test] +async fn pxe_logo_404_when_no_custom_logo_configured() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, body) = get(&app, "/branding/pxe-logo").await; + assert_eq!(s, StatusCode::NOT_FOUND); + let text = std::str::from_utf8(&body).unwrap(); + assert!(text.contains("no custom logo"), "got: {text}"); +} + +#[tokio::test] +async fn pxe_logo_404_when_uploaded_logo_is_svg() { + // iPXE can't rasterize SVG, so an SVG upload deliberately doesn't + // light up the PXE menu's `console --picture` overlay — the ASCII + // wordmark in render_menu stands in instead. + let (state, _dir) = build_state().await; + state + .branding + .set_logo( + "image/svg+xml", + "svg", + br#""#, + ) + .unwrap(); + let app = build_router(state); + let (s, body) = get(&app, "/branding/pxe-logo").await; + assert_eq!(s, StatusCode::NOT_FOUND); + let text = std::str::from_utf8(&body).unwrap(); + assert!(text.contains("SVG"), "got: {text}"); +} + +#[tokio::test] +async fn pxe_logo_serves_raster_with_correct_mime() { + let (state, _dir) = build_state().await; + state + .branding + .set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake-png-bytes") + .unwrap(); + let app = build_router(state); + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/branding/pxe-logo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let ct = res + .headers() + .get(axum::http::header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(); + assert_eq!(ct, "image/png"); + let body = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + assert!(body.starts_with(b"\x89PNG"), "PNG header missing"); +} + +#[tokio::test] +async fn pxe_logo_endpoint_is_public_after_admin_setup() { + // iPXE clients can't send a session cookie, so /branding/pxe-logo + // must stay reachable once the admin has been bootstrapped. The + // auth allowlist gates `/api/*` only. + let (state, _dir) = build_state().await; + state + .branding + .set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake") + .unwrap(); + let app = build_router(state); + // Configure an admin so the middleware kicks in. + let (s, _, _) = post_collect( + &app, + "/api/setup", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + assert_eq!(s, StatusCode::CREATED); + // Still public without a cookie. + let (s, _) = get(&app, "/branding/pxe-logo").await; + assert_eq!(s, StatusCode::OK); +} diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index eb25e27..0ee06ab 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -287,16 +287,26 @@ label.field { } label.field .name { color: var(--fg-dim); font-size: 12px; } label.field .hint { color: var(--fg-dimmer); font-size: 11px; } -label.field input[type="text"], -label.field input[type="number"], +/* All single-line inputs share one chrome rule. Pre-v0.4.6 we only + styled type=text/number, which left type=password fields rendering + with the default browser look — visibly off vs adjacent text fields + in the Account card. The negation list keeps `type=checkbox`, + `type=file`, and `type=range` (none of which we use inside + `label.field`) from picking up the padded-box look. */ +label.field input:not([type="checkbox"]):not([type="file"]):not([type="range"]), label.field select, label.field textarea { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 10px; font: inherit; + /* iOS/Safari shrinks password-field text by default; clamp it so + the password input matches the username input's metrics. */ + font-size: 14px; line-height: 1.4; + box-shadow: none; -webkit-appearance: none; appearance: none; } label.field input:focus, label.field select:focus, label.field textarea:focus { outline: none; border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent); } label.check { display: flex; gap: 10px; align-items: center; @@ -421,9 +431,21 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } .dot.err { background: var(--err); } .dot.warn { background: var(--warn); } -/* Inline form rows. */ -.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; } -@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } } +/* Inline form rows. The default is a 4-column grid sized for the + Account card's "Current / New username / New password / Confirm" + quartet; the `.cols-3` modifier swaps to a 3-column layout for the + SSO header strip (display name / logo URL / metadata source). All + `.form-row > label.field` children share the same baseline because + their inner inputs share metrics via the global rule above. */ +.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; } +.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); } +.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); } +.form-row label.field { margin-bottom: 0; } +@media (max-width: 900px) { + .form-row, + .form-row.cols-3, + .form-row.cols-2 { grid-template-columns: 1fr; } +} /* ── Queued deployment visual ────────────────────────────────────── */ .queue-track { @@ -567,22 +589,56 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } } .auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; } -/* ── Logout chip (sidebar footer) ────────────────────────────── */ -.sidebar .footer .logout-row { - margin-top: 8px; display: flex; align-items: center; justify-content: space-between; - gap: 8px; +/* ── Top-right user menu (v0.4.6) ──────────────────────────── + The "signed in as X" identity + sign-out moved out of the sidebar + footer in v0.4.6 — the sidebar footer is now reserved for the + service-state trio (Service status / Advertised URL / Backend + version). The button matches the theme toggle's size + chrome so + the top-right reads as a tidy two-icon strip. */ +.user-menu { position: relative; } +.user-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 36px; height: 32px; + background: transparent; color: var(--fg); + border: 1px solid var(--border); border-radius: 8px; + cursor: pointer; padding: 0; + transition: background 0.15s ease, border-color 0.15s ease; } -.sidebar .footer .logout-row .who { - color: var(--fg); font-weight: 600; font-size: 11.5px; +.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); } +.user-pop { + position: absolute; right: 0; top: 38px; + min-width: 200px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 6px; + z-index: 60; + display: flex; flex-direction: column; gap: 2px; +} +.user-pop[hidden] { display: none; } +.user-pop .user-pop-name { + padding: 8px 10px 6px; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 4px; + color: var(--fg); font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.sidebar .footer .logout-btn { - background: transparent; color: var(--fg-dim); - border: 1px solid var(--border); border-radius: var(--radius); - padding: 2px 8px; font: inherit; font-size: 11px; font-weight: 500; +.user-pop .user-pop-item { + text-align: left; width: 100%; + background: transparent; color: var(--fg); + border: 0; border-radius: var(--radius); + padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; } -.sidebar .footer .logout-btn:hover { color: var(--fg); background: var(--bg-panel-2); border-color: var(--accent); } +.user-pop .user-pop-item:hover { + background: var(--bg-panel-2); color: var(--fg); +} +.user-pop .user-pop-danger { color: var(--err); } +.user-pop .user-pop-danger:hover { + background: color-mix(in srgb, var(--err) 12%, transparent); + color: var(--err); +} /* ── About card ─────────────────────────────────────────────────── */ .about-hero { padding: 20px 24px; } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index c6f5099..8786a0a 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -1103,16 +1103,29 @@ ssoEnabled.checked = !!sso.enabled; const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD', value: sso.idp_name || ''}); + // v0.4.6: optional FleetDM-style IdP logo URL. The login screen + // will render this as the brand mark on the "Sign in with X" + // button once the runtime SSO flow ships; for v0.4.6 we just + // persist it. + const ssoLogo = el('input', {type:'text', + placeholder:'https://idp.example.com/logo.svg', + value: sso.idp_logo_url || ''}); const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata', value: sso.metadata_url || ''}); - const ssoXml = el('textarea', {rows:'6', - placeholder:' so it aligns with text + // inputs in the same `.form-row` — the global `label.field + // select` rule takes care of the chrome. + const ssoMode = el('select', {}, [ el('option', {value:'url'}, 'Metadata URL'), el('option', {value:'xml'}, 'Metadata XML'), ]); @@ -1121,7 +1134,7 @@ el('span', {class:'name'}, 'IdP metadata URL'), ssoUrl, el('span', {class:'hint'}, - 'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.5 just stores it.'), + 'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.6 just stores it.'), ]); const xmlWrap = el('label', {class:'field'}, [ el('span', {class:'name'}, 'IdP metadata XML'), @@ -1144,6 +1157,7 @@ const payload = { enabled: ssoEnabled.checked, idp_name: ssoName.value, + idp_logo_url: ssoLogo.value, metadata: ssoMode.value === 'xml' ? ssoXml.value : '', metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '', }; @@ -1179,12 +1193,22 @@ ssoEnabled, el('span', {}, 'Enable single sign-on'), ]), - el('div', {class:'form-row'}, [ + // 3-column header strip: display name, logo URL, metadata + // source. All three controls inherit the same border/padding/ + // focus chrome from the global `label.field input/select` + // rule, so they line up cleanly. Below: the active source + // field (URL or XML) spans the full width. + el('div', {class:'form-row cols-3'}, [ el('label', {class:'field'}, [ el('span', {class:'name'}, 'IdP display name'), ssoName, el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'), ]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'IdP logo URL'), + ssoLogo, + el('span', {class:'hint'}, 'Optional. Shown next to the IdP name on the login button.'), + ]), el('label', {class:'field'}, [ el('span', {class:'name'}, 'Metadata source'), ssoMode, @@ -1610,25 +1634,67 @@ } async function startDashboard() { - // Light up the sidebar's "signed in as X / Sign out" row. It was - // hidden in index.html because we don't know the identity until - // /api/me resolves. + // v0.4.6: light up the top-right user-menu chip. The button is + // hidden in index.html until /api/me confirms a signed-in session, + // so we don't show the icon (then hide it) when the user lands + // on /login. Clicking the icon opens a small popover with + // Name / Edit account / Sign out. try { const me = await fetch('/api/me').then(r => r.ok ? r.json() : null); - const row = $('[data-bind=logout_row]'); - const who = $('[data-bind=signed_in_as]'); - const btn = $('[data-bind=logout_btn]'); - if (row && me && me.authenticated && me.user) { - who.textContent = me.user.username; - who.title = 'Signed in as ' + me.user.username; - row.style.display = ''; + const wrap = $('[data-bind=user_menu_wrap]'); + const pop = $('[data-bind=user_menu_pop]'); + const name = $('[data-bind=user_pop_name]'); + const edit = $('[data-bind=user_pop_edit]'); + const out = $('[data-bind=user_pop_logout]'); + const btn = $('#user-menu-btn'); + if (wrap && me && me.authenticated && me.user) { + wrap.style.display = ''; + if (name) name.textContent = me.user.username; + if (btn) btn.title = 'Signed in as ' + me.user.username; if (btn && !btn._wired) { btn._wired = true; - btn.addEventListener('click', async () => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const open = !pop.hidden; + pop.hidden = open; + btn.setAttribute('aria-expanded', String(!open)); + }); + } + if (edit && !edit._wired) { + edit._wired = true; + edit.addEventListener('click', () => { + pop.hidden = true; + btn.setAttribute('aria-expanded', 'false'); + render('settings'); + }); + } + if (out && !out._wired) { + out._wired = true; + out.addEventListener('click', async () => { + pop.hidden = true; + btn.setAttribute('aria-expanded', 'false'); await fetch('/api/logout', {method:'POST'}).catch(() => {}); + wrap.style.display = 'none'; showAuthScreen('login'); }); } + // Click-outside-to-close, wired once. Stored on document so we + // don't re-attach every render. + if (!document._userPopWired) { + document._userPopWired = true; + document.addEventListener('click', (e) => { + if (pop.hidden) return; + if (e.target.closest('.user-menu')) return; + pop.hidden = true; + btn.setAttribute('aria-expanded', 'false'); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !pop.hidden) { + pop.hidden = true; + btn.setAttribute('aria-expanded', 'false'); + } + }); + } } } catch (e) { /* surfaces elsewhere */ } render('dashboard'); diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index 92919ab..32f8532 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -59,15 +59,7 @@ - - - + @@ -97,6 +89,27 @@ + + +