v0.4.6: iVentoy-style PXE menu, top-right user menu, Settings touchups
PXE boot menu polish (iVentoy-inspired):
- render_menu now opens with a best-effort `console --picture
<base>/branding/pxe-logo || console` line so iPXE builds with PNG
support paint the operator's uploaded raster logo as the background.
- ASCII OpenPXE wordmark banner sits at the top of the menu in
`item --gap` lines — always visible on every iPXE build, including
the snponly/undionly variants without graphics console.
- New footer line above `choose`: "OpenPXE v0.4.6 - <arch label>",
where <arch label> is mapped from iPXE's ${buildarch}/${platform}
to "x86 BIOS", "x86_64 UEFI", or "arm64 UEFI". No URL, per brief.
- New GET /branding/pxe-logo route serves the operator's PNG / JPEG /
WebP / GIF as-is for iPXE to consume. SVG uploads 404 here (iPXE
can't rasterize SVG) — the always-visible ASCII wordmark stands in.
Route stays public after admin setup so iPXE clients (no cookies)
can fetch it.
UI:
- Removed the bottom-left "signed in as / Sign out" row.
- Added a person-icon button next to the theme toggle in the topbar.
Click opens a small popover with: Name (display only), Edit account
(jumps to Settings), Sign out. Esc + click-outside close it.
- Settings → Account card form chrome made consistent. The previous
`label.field` selector only styled type=text/number, leaving
password inputs with default browser chrome. Switched to a
negation-list selector that covers every typed input we use, plus
-webkit-appearance:none + a 1px focus ring. Light + dark mode both
show the same border/padding/focus state across all four account
fields.
- Settings → SSO card now renders display name, IdP logo URL (new),
and metadata source on one 3-column row. The metadata <select>
inherits the same chrome as the text inputs so it baseline-aligns
with them. SsoConfig grew an idp_logo_url field, persisted to
sso.json, length-capped and validated to http(s) only.
Quality:
- 138 tests passing (was 132 in v0.4.5). +1 IdP-logo-URL validation,
+1 PXE menu polish regression guard, +4 /branding/pxe-logo
integration tests covering missing-config / SVG-fallback / raster-
serve / post-auth public-allowlist cases.
- cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a1518110ed
commit
55f4765a20
Generated
+8
-8
@@ -1015,7 +1015,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1037,7 +1037,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1056,7 +1056,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1070,7 +1070,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1100,7 +1100,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -1110,7 +1110,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1133,7 +1133,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1147,7 +1147,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ pub struct SsoConfig {
|
|||||||
/// with X" button label. Empty/whitespace falls back to "SSO".
|
/// with X" button label. Empty/whitespace falls back to "SSO".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub idp_name: String,
|
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
|
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
|
||||||
/// with `metadata_url`; if both are set, the URL wins at apply time
|
/// with `metadata_url`; if both are set, the URL wins at apply time
|
||||||
/// (operators typically forget about a stale XML paste).
|
/// (operators typically forget about a stale XML paste).
|
||||||
@@ -100,6 +105,7 @@ impl SsoStore {
|
|||||||
/// but the server enforces a hard ceiling regardless.
|
/// but the server enforces a hard ceiling regardless.
|
||||||
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
|
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
|
||||||
cfg.idp_name = cfg.idp_name.trim().to_string();
|
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 = cfg.metadata.trim().to_string();
|
||||||
cfg.metadata_url = cfg.metadata_url.trim().to_string();
|
cfg.metadata_url = cfg.metadata_url.trim().to_string();
|
||||||
if cfg.metadata.len() > MAX_METADATA_BYTES {
|
if cfg.metadata.len() > MAX_METADATA_BYTES {
|
||||||
@@ -112,6 +118,11 @@ impl SsoStore {
|
|||||||
"metadata_url exceeds {MAX_URL_LEN}-char cap"
|
"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()
|
if !cfg.metadata_url.is_empty()
|
||||||
&& !cfg.metadata_url.starts_with("http://")
|
&& !cfg.metadata_url.starts_with("http://")
|
||||||
&& !cfg.metadata_url.starts_with("https://")
|
&& !cfg.metadata_url.starts_with("https://")
|
||||||
@@ -120,6 +131,14 @@ impl SsoStore {
|
|||||||
"metadata_url must start with http:// or https://".into(),
|
"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
|
// If they're trying to *enable* the integration but haven't
|
||||||
// supplied either source, reject — saves a "configured but
|
// supplied either source, reject — saves a "configured but
|
||||||
// unusable" surprise later.
|
// unusable" surprise later.
|
||||||
@@ -197,6 +216,7 @@ mod tests {
|
|||||||
idp_name: "Okta".into(),
|
idp_name: "Okta".into(),
|
||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: "https://idp.example.com/metadata".into(),
|
metadata_url: "https://idp.example.com/metadata".into(),
|
||||||
|
idp_logo_url: String::new(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(s);
|
drop(s);
|
||||||
@@ -218,6 +238,7 @@ mod tests {
|
|||||||
idp_name: "Test IdP".into(),
|
idp_name: "Test IdP".into(),
|
||||||
metadata: xml.into(),
|
metadata: xml.into(),
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
|
idp_logo_url: String::new(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(s.snapshot().is_usable());
|
assert!(s.snapshot().is_usable());
|
||||||
@@ -232,6 +253,7 @@ mod tests {
|
|||||||
idp_name: "Okta".into(),
|
idp_name: "Okta".into(),
|
||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
|
idp_logo_url: String::new(),
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
// …and a disabled blank config is fine.
|
// …and a disabled blank config is fine.
|
||||||
@@ -247,10 +269,38 @@ mod tests {
|
|||||||
idp_name: String::new(),
|
idp_name: String::new(),
|
||||||
metadata: String::new(),
|
metadata: String::new(),
|
||||||
metadata_url: "ftp://idp.example.com/metadata".into(),
|
metadata_url: "ftp://idp.example.com/metadata".into(),
|
||||||
|
idp_logo_url: String::new(),
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
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 <IdP-logo>"
|
||||||
|
// 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]
|
#[test]
|
||||||
fn metadata_size_cap_enforced() {
|
fn metadata_size_cap_enforced() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -261,6 +311,7 @@ mod tests {
|
|||||||
idp_name: String::new(),
|
idp_name: String::new(),
|
||||||
metadata: oversize,
|
metadata: oversize,
|
||||||
metadata_url: String::new(),
|
metadata_url: String::new(),
|
||||||
|
idp_logo_url: String::new(),
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/assets/app.css", get(ui_css))
|
.route("/assets/app.css", get(ui_css))
|
||||||
.route("/assets/logo.svg", get(ui_logo))
|
.route("/assets/logo.svg", get(ui_logo))
|
||||||
.route("/assets/loader.svg", get(ui_loader))
|
.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.
|
// iPXE script endpoints.
|
||||||
.route("/boot.ipxe", get(boot_top_menu))
|
.route("/boot.ipxe", get(boot_top_menu))
|
||||||
.route("/boot/:filename", get(boot_sub))
|
.route("/boot/:filename", get(boot_sub))
|
||||||
@@ -263,6 +270,49 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
|
|||||||
.into_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<AppState>) -> 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 {
|
async fn ui_loader() -> Response {
|
||||||
(
|
(
|
||||||
[(
|
[(
|
||||||
@@ -945,6 +995,8 @@ async fn api_docs() -> Json<serde_json::Value> {
|
|||||||
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
|
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
|
||||||
{"method": "DELETE", "path": "/api/branding/logo",
|
{"method": "DELETE", "path": "/api/branding/logo",
|
||||||
"summary": "Remove the custom logo and revert to the bundled mark."},
|
"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",
|
{"method": "GET", "path": "/api/sso",
|
||||||
"summary": "Current SAML SSO configuration."},
|
"summary": "Current SAML SSO configuration."},
|
||||||
{"method": "PUT", "path": "/api/sso",
|
{"method": "PUT", "path": "/api/sso",
|
||||||
|
|||||||
@@ -440,6 +440,9 @@ mod tests {
|
|||||||
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
|
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
|
||||||
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
|
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
|
||||||
"/metrics",
|
"/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");
|
assert!(is_public_path(p), "expected {p} to be public");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ use std::fmt::Write as _;
|
|||||||
|
|
||||||
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
|
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
|
||||||
/// clients because iPXE normalises the menu primitives across firmwares.
|
/// 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]
|
#[must_use]
|
||||||
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
|
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
|
||||||
let mut s = String::new();
|
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 base-url {base}");
|
||||||
let _ = writeln!(s, "set esc:hex 1b");
|
let _ = writeln!(s, "set esc:hex 1b");
|
||||||
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
|
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
|
||||||
|
// `<buildarch> <platform>` 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");
|
||||||
let _ = writeln!(s, "menu OpenPXE - network boot 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 -- <text>` 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!(
|
let _ = writeln!(
|
||||||
s,
|
s,
|
||||||
"item --gap -- ------------------------- Default -------------------------"
|
"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 queue Queued Deployment (join queue)");
|
||||||
let _ = writeln!(s, "item --gap");
|
let _ = writeln!(s, "item --gap");
|
||||||
let _ = writeln!(s, "item --key x exit Exit iPXE");
|
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) {
|
if matches!(settings.timeout_action, TimeoutAction::Stay) {
|
||||||
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
|
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"));
|
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]
|
#[test]
|
||||||
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
|
|||||||
@@ -1742,3 +1742,92 @@ async fn docs_lists_new_v0_4_5_endpoints() {
|
|||||||
assert!(paths.iter().any(|p| p == needle), "{needle} missing");
|
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#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
|
||||||
|
)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
+72
-16
@@ -287,16 +287,26 @@ label.field {
|
|||||||
}
|
}
|
||||||
label.field .name { color: var(--fg-dim); font-size: 12px; }
|
label.field .name { color: var(--fg-dim); font-size: 12px; }
|
||||||
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
|
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
|
||||||
label.field input[type="text"],
|
/* All single-line inputs share one chrome rule. Pre-v0.4.6 we only
|
||||||
label.field input[type="number"],
|
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 select,
|
||||||
label.field textarea {
|
label.field textarea {
|
||||||
width: 100%; background: var(--bg); color: var(--fg);
|
width: 100%; background: var(--bg); color: var(--fg);
|
||||||
border: 1px solid var(--border); border-radius: var(--radius);
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
padding: 7px 10px; font: inherit;
|
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 {
|
label.field input:focus, label.field select:focus, label.field textarea:focus {
|
||||||
outline: none; border-color: var(--accent);
|
outline: none; border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
}
|
}
|
||||||
label.check {
|
label.check {
|
||||||
display: flex; gap: 10px; align-items: center;
|
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.err { background: var(--err); }
|
||||||
.dot.warn { background: var(--warn); }
|
.dot.warn { background: var(--warn); }
|
||||||
|
|
||||||
/* Inline form rows. */
|
/* Inline form rows. The default is a 4-column grid sized for the
|
||||||
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
|
Account card's "Current / New username / New password / Confirm"
|
||||||
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
|
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 ────────────────────────────────────── */
|
/* ── Queued deployment visual ────────────────────────────────────── */
|
||||||
.queue-track {
|
.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; }
|
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
|
||||||
|
|
||||||
/* ── Logout chip (sidebar footer) ────────────────────────────── */
|
/* ── Top-right user menu (v0.4.6) ────────────────────────────
|
||||||
.sidebar .footer .logout-row {
|
The "signed in as X" identity + sign-out moved out of the sidebar
|
||||||
margin-top: 8px; display: flex; align-items: center; justify-content: space-between;
|
footer in v0.4.6 — the sidebar footer is now reserved for the
|
||||||
gap: 8px;
|
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 {
|
.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); }
|
||||||
color: var(--fg); font-weight: 600; font-size: 11.5px;
|
.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;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sidebar .footer .logout-btn {
|
.user-pop .user-pop-item {
|
||||||
background: transparent; color: var(--fg-dim);
|
text-align: left; width: 100%;
|
||||||
border: 1px solid var(--border); border-radius: var(--radius);
|
background: transparent; color: var(--fg);
|
||||||
padding: 2px 8px; font: inherit; font-size: 11px; font-weight: 500;
|
border: 0; border-radius: var(--radius);
|
||||||
|
padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500;
|
||||||
cursor: pointer;
|
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 card ─────────────────────────────────────────────────── */
|
||||||
.about-hero { padding: 20px 24px; }
|
.about-hero { padding: 20px 24px; }
|
||||||
|
|||||||
+85
-19
@@ -1103,16 +1103,29 @@
|
|||||||
ssoEnabled.checked = !!sso.enabled;
|
ssoEnabled.checked = !!sso.enabled;
|
||||||
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
|
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
|
||||||
value: sso.idp_name || ''});
|
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',
|
const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
|
||||||
value: sso.metadata_url || ''});
|
value: sso.metadata_url || ''});
|
||||||
const ssoXml = el('textarea', {rows:'6',
|
// The textarea inherits the same chrome via the global
|
||||||
|
// `label.field textarea` rule, plus the monospace family for
|
||||||
|
// pasting raw XML. Children come after the attrs object — the
|
||||||
|
// initial value is the only "child".
|
||||||
|
const ssoXml = el('textarea',
|
||||||
|
{rows:'6',
|
||||||
|
spellcheck:'false', autocapitalize:'off',
|
||||||
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
|
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
|
||||||
style:'width:100%;font-family:var(--mono);font-size:12px;background:var(--bg);' +
|
style:'font-family:var(--mono);font-size:12px;resize:vertical'},
|
||||||
'color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);' +
|
|
||||||
'padding:8px 10px;resize:vertical'},
|
|
||||||
sso.metadata || '');
|
sso.metadata || '');
|
||||||
const ssoMode = el('select', {style:'min-width:160px;background:var(--bg);color:var(--fg);' +
|
// The mode picker is a styled <select> so it aligns with text
|
||||||
'border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:inherit'}, [
|
// 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:'url'}, 'Metadata URL'),
|
||||||
el('option', {value:'xml'}, 'Metadata XML'),
|
el('option', {value:'xml'}, 'Metadata XML'),
|
||||||
]);
|
]);
|
||||||
@@ -1121,7 +1134,7 @@
|
|||||||
el('span', {class:'name'}, 'IdP metadata URL'),
|
el('span', {class:'name'}, 'IdP metadata URL'),
|
||||||
ssoUrl,
|
ssoUrl,
|
||||||
el('span', {class:'hint'},
|
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'}, [
|
const xmlWrap = el('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'IdP metadata XML'),
|
el('span', {class:'name'}, 'IdP metadata XML'),
|
||||||
@@ -1144,6 +1157,7 @@
|
|||||||
const payload = {
|
const payload = {
|
||||||
enabled: ssoEnabled.checked,
|
enabled: ssoEnabled.checked,
|
||||||
idp_name: ssoName.value,
|
idp_name: ssoName.value,
|
||||||
|
idp_logo_url: ssoLogo.value,
|
||||||
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
|
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
|
||||||
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
|
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
|
||||||
};
|
};
|
||||||
@@ -1179,12 +1193,22 @@
|
|||||||
ssoEnabled,
|
ssoEnabled,
|
||||||
el('span', {}, 'Enable single sign-on'),
|
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('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'IdP display name'),
|
el('span', {class:'name'}, 'IdP display name'),
|
||||||
ssoName,
|
ssoName,
|
||||||
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
|
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('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'Metadata source'),
|
el('span', {class:'name'}, 'Metadata source'),
|
||||||
ssoMode,
|
ssoMode,
|
||||||
@@ -1610,25 +1634,67 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startDashboard() {
|
async function startDashboard() {
|
||||||
// Light up the sidebar's "signed in as X / Sign out" row. It was
|
// v0.4.6: light up the top-right user-menu chip. The button is
|
||||||
// hidden in index.html because we don't know the identity until
|
// hidden in index.html until /api/me confirms a signed-in session,
|
||||||
// /api/me resolves.
|
// 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 {
|
try {
|
||||||
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
|
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
|
||||||
const row = $('[data-bind=logout_row]');
|
const wrap = $('[data-bind=user_menu_wrap]');
|
||||||
const who = $('[data-bind=signed_in_as]');
|
const pop = $('[data-bind=user_menu_pop]');
|
||||||
const btn = $('[data-bind=logout_btn]');
|
const name = $('[data-bind=user_pop_name]');
|
||||||
if (row && me && me.authenticated && me.user) {
|
const edit = $('[data-bind=user_pop_edit]');
|
||||||
who.textContent = me.user.username;
|
const out = $('[data-bind=user_pop_logout]');
|
||||||
who.title = 'Signed in as ' + me.user.username;
|
const btn = $('#user-menu-btn');
|
||||||
row.style.display = '';
|
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) {
|
if (btn && !btn._wired) {
|
||||||
btn._wired = true;
|
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(() => {});
|
await fetch('/api/logout', {method:'POST'}).catch(() => {});
|
||||||
|
wrap.style.display = 'none';
|
||||||
showAuthScreen('login');
|
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 */ }
|
} catch (e) { /* surfaces elsewhere */ }
|
||||||
render('dashboard');
|
render('dashboard');
|
||||||
|
|||||||
@@ -59,15 +59,7 @@
|
|||||||
<!-- The brand badge at the top can be overridden by operator-uploaded
|
<!-- The brand badge at the top can be overridden by operator-uploaded
|
||||||
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
||||||
identity is always visible regardless of branding. -->
|
identity is always visible regardless of branding. -->
|
||||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.5</span></div>
|
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.6</span></div>
|
||||||
<!-- v0.4.5: signed-in identity + one-click sign-out. The button
|
|
||||||
is populated by app.js after /api/me reports an authenticated
|
|
||||||
session — pre-auth states swap the whole shell for the
|
|
||||||
login/setup card so this row never gets shown there. -->
|
|
||||||
<div class="logout-row" data-bind="logout_row" style="display:none">
|
|
||||||
<span class="who" data-bind="signed_in_as" title=""></span>
|
|
||||||
<button type="button" class="logout-btn" data-bind="logout_btn">Sign out</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -97,6 +89,27 @@
|
|||||||
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
|
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
|
||||||
|
in the top-right corner so the sidebar footer stays clean for
|
||||||
|
the "Service status / Advertised URL / Backend version" trio.
|
||||||
|
The whole block is hidden until /api/me confirms a session. -->
|
||||||
|
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
|
||||||
|
<button id="user-menu-btn" class="user-btn" type="button"
|
||||||
|
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
|
||||||
|
title="Account">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="8" r="3.6"/>
|
||||||
|
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
|
||||||
|
<div class="user-pop-name" data-bind="user_pop_name">—</div>
|
||||||
|
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
|
||||||
|
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main" id="view-root"></main>
|
<main class="main" id="view-root"></main>
|
||||||
|
|||||||
Reference in New Issue
Block a user