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
+72
-16
@@ -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; }
|
||||
|
||||
+86
-20
@@ -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');
|
||||
|
||||
@@ -59,15 +59,7 @@
|
||||
<!-- The brand badge at the top can be overridden by operator-uploaded
|
||||
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
||||
identity is always visible regardless of branding. -->
|
||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.5</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 class="footer-version">OpenPXE v<span data-bind="version">0.4.6</span></div>
|
||||
</div>
|
||||
</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"/>
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<main class="main" id="view-root"></main>
|
||||
|
||||
Reference in New Issue
Block a user