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:
Miles Ward
2026-05-26 00:02:20 -04:00
co-authored by Claude Opus 4.7
parent a1518110ed
commit 55f4765a20
10 changed files with 479 additions and 54 deletions
+86 -20
View File
@@ -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:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
style:'width:100%;font-family:var(--mono);font-size:12px;background:var(--bg);' +
'color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);' +
'padding:8px 10px;resize:vertical'},
// 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"…',
style:'font-family:var(--mono);font-size:12px;resize:vertical'},
sso.metadata || '');
const ssoMode = el('select', {style:'min-width:160px;background:var(--bg);color:var(--fg);' +
'border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:inherit'}, [
// The mode picker is a styled <select> 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');