v0.5.2: FleetDM login split, 3-slot branding, unattended installs

Authentication / login:
- Separate the local username/password form from the SSO "Sign in with …"
  button (FleetDM-style divider + optional IdP logo); credential fields no
  longer double as the SSO trigger. Settings → SSO copy now says SAML is live.

Branding — three slots (light / dark / client) on one row:
- Light/Dark feed the top-left mark + sign-in page by active theme (with
  cross-theme fallback; theme toggle swaps the logo live). Client feeds the
  PXE boot-menu background. Favicon pinned to the bundled mark via a new
  /assets/favicon.svg endpoint. Legacy single logo migrates to dark + client.
- BrandingStore refactored to per-slot storage; /api/branding/logo/:slot.

Unattended installs (Storage → Advanced):
- New UnattendedStore (iso-store) + /api/unattended upload/list/delete and a
  public templated serve at /unattended/:id (+ NoCloud seed dir for
  autoinstall). Accepts .ks/.cfg/.seed/.yaml/.yml/.xml/user-data; classified
  on upload; stored in its own unattended/ dir, never the ISO listing/menu.
- {{HOSTNAME}}/{{IP}}/{{MAC}} substituted per host at serve time.

Host pins + Queue profiles:
- HostBinding + QueueEntry carry an optional DeployProfile (auto_hostname /
  auto_ip / unattended_file). Hosts pin form + a per-device Queue "Profile"
  button collect them. On boot, a matched MAC has the right kernel arg
  injected (inst.ks= / preseed url= / autoinstall ds=nocloud-net) and the
  hostname/IP templated into the served answer file. DHCP stays proxy-only.

Storage:
- Remote shares default protocol is now NFS; updated descriptive copy.

235 tests green, clippy clean. Still a single static musl binary, pure Rust.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-31 16:11:04 -04:00
co-authored by Claude Opus 4.8
parent cbcd63bb14
commit 7adf5e2918
19 changed files with 2295 additions and 330 deletions
+64
View File
@@ -840,3 +840,67 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
border: 1px solid var(--border);
color: var(--fg-dim);
}
/* ── v0.5.2: three-slot branding (light / dark / client) ─────────── */
.logo-slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 720px) { .logo-slots { grid-template-columns: 1fr; } }
.logo-slot {
display: flex; flex-direction: column; gap: 8px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.logo-slot-head .name { color: var(--fg); font-weight: 600; font-size: 13px; }
.logo-slot .swatch {
height: 64px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot .swatch img { max-width: 90%; max-height: 52px; object-fit: contain; }
.logo-slot-hint { color: var(--fg-dim); font-size: 11.5px; }
/* ── v0.5.2: login local/SSO separation ─────────────────────────── */
.auth-card .auth-divider {
display: flex; align-items: center; text-align: center;
color: var(--fg-dimmer); font-size: 11px; text-transform: uppercase;
letter-spacing: 0.08em;
margin: 16px 0 12px;
}
.auth-card .auth-divider::before,
.auth-card .auth-divider::after {
content: ""; flex: 1; height: 1px; background: var(--border-soft);
}
.auth-card .auth-divider span { padding: 0 10px; }
.auth-card .sso-block .sso-btn { margin-top: 0; }
.auth-card .sso-btn {
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.auth-card .sso-btn .sso-logo { width: 16px; height: 16px; object-fit: contain; flex: none; }
/* ── v0.5.2: modal (queue Profile editor) ───────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.55);
padding: 24px;
}
.modal-box {
width: 100%; max-width: 520px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 22px;
}
.modal-box h2 { margin: 0 0 14px; font-size: 16px; font-weight: 600; color: var(--fg); }
.modal-actions {
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
}
.modal-actions .submit { width: auto; padding: 8px 18px; }
+357 -119
View File
@@ -175,6 +175,83 @@
return { ok: true };
}
// v0.5.2: pretty label for an unattended file's detected kind.
function unattendedKindLabel(k) {
return ({
kickstart: 'Kickstart', preseed: 'Preseed', autoinstall: 'Autoinstall',
answer_file: 'Answer file', unknown: 'Unknown',
})[k] || (k || 'Unknown');
}
// v0.5.2: build the shared "deployment profile" field group — auto
// hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the
// /api/unattended list; `profile` seeds the current values. Returns the
// wrapper element plus a `read()` that yields the API body shape.
function buildProfileFields(profile, files, layoutClass) {
profile = profile || {};
files = files || [];
const hostnameInput = el('input', {type:'text', spellcheck:'false',
placeholder:'e.g. node-7', value: profile.auto_hostname || ''});
const ipInput = el('input', {type:'text', spellcheck:'false',
placeholder:'e.g. 10.0.0.7', value: profile.auto_ip || ''});
const sel = el('select', {},
[el('option', {value:''}, '— none —')].concat(
files.map(f => el('option', {value: f.id},
f.filename + ' · ' + unattendedKindLabel(f.kind)))));
sel.value = profile.unattended_file || '';
const wrap = el('div', {class: layoutClass || 'form-row cols-3'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto hostname (optional)'), hostnameInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Unattended file'), sel]),
]);
return {
wrap,
read() {
return {
auto_hostname: hostnameInput.value.trim() || null,
auto_ip: ipInput.value.trim() || null,
unattended_file: sel.value || null,
};
},
};
}
// v0.5.2: minimal modal overlay. `onSave(msgEl)` runs on Save and may
// return a falsy value to keep the modal open (e.g. on validation
// error) or anything truthy to close it.
function openModal(titleText, contentEls, onSave) {
const overlay = el('div', {class:'modal-overlay'});
const close = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); };
const msg = el('div', {class:'msg', style:'margin-top:10px'});
const cancelBtn = el('button', {class:'ghost', type:'button', onclick: close}, 'Cancel');
const saveBtn = el('button', {class:'submit', type:'button'}, 'Save');
saveBtn.onclick = async () => {
saveBtn.disabled = true;
try {
const ok = await onSave(msg);
if (ok) close();
} finally {
saveBtn.disabled = false;
}
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
overlay.appendChild(el('div', {class:'modal-box'}, [
el('h2', {}, titleText),
...(Array.isArray(contentEls) ? contentEls : [contentEls]),
msg,
el('div', {class:'modal-actions'}, [cancelBtn, saveBtn]),
]));
document.body.appendChild(overlay);
return { close };
}
// ── views ────────────────────────────────────────────────────────
const views = {
dashboard: async () => {
@@ -319,9 +396,11 @@
},
queue: async () => {
const [{ entries = [] }, isos] = await Promise.all([
const [{ entries = [] }, isos, unattRes] = await Promise.all([
getJSON('/api/queue'), getJSON('/api/isos'),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]);
const unattendedFiles = unattRes.files || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
})));
@@ -344,21 +423,51 @@
const track = entries.length
? el('div', {class:'queue-track'},
entries.map(g => el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
el('div', {class:'pos'}, '#' + g.position),
el('div', {}, [
el('div', {class:'mac'}, g.mac),
el('div', {class:'meta'},
(g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
]),
el('div', {}, g.assigned_target
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
: el('span', {class:'tag accent'}, 'waiting')),
el('button', {class:'ghost', onclick: async () => {
await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
render('queue');
}}, 'Release'),
]))
entries.map(g => {
const prof = g.profile || {};
const hasProfile = prof.auto_hostname || prof.auto_ip || prof.unattended_file;
const profSummary = hasProfile
? el('div', {class:'meta', style:'margin-top:2px'},
' ' + [
prof.auto_hostname ? 'host ' + prof.auto_hostname : null,
prof.auto_ip ? 'ip ' + prof.auto_ip : null,
prof.unattended_file ? 'unattended: ' + prof.unattended_file : null,
].filter(Boolean).join(' · '))
: null;
return el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
el('div', {class:'pos'}, '#' + g.position),
el('div', {}, [
el('div', {class:'mac'}, g.mac),
el('div', {class:'meta'},
(g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
profSummary,
]),
el('div', {}, g.assigned_target
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
: el('span', {class:'tag accent'}, 'waiting')),
// v0.5.2: per-device deployment profile (auto hostname/IP +
// unattended file), same fields as a Hosts pin.
el('button', {class: hasProfile ? 'accent' : 'ghost', onclick: () => {
const fields = buildProfileFields(prof, unattendedFiles, 'form-row');
openModal('Deployment profile · ' + g.mac, [
el('p', {class:'msg', style:'margin-bottom:12px'},
'On assignment this device boots with the chosen unattended ' +
'file; {{HOSTNAME}}/{{IP}}/{{MAC}} are filled into the answer file.'),
fields.wrap,
], async (msg) => {
const r = await putJSON('/api/queue/' + encodeURIComponent(g.id) + '/profile', fields.read());
if (r.ok) { render('queue'); return true; }
msg.textContent = 'Save failed: ' + (await r.text());
msg.className = 'msg err';
return false;
});
}}, 'Profile'),
el('button', {class:'ghost', onclick: async () => {
await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
render('queue');
}}, 'Release'),
]);
})
)
: el('div', {class:'empty'},
'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.');
@@ -399,16 +508,18 @@
// v0.4.67: NFSv3 added back as an in-process Rust client
// (nfs3_client crate). Both protocols available side-by-side;
// operators pick whichever their NAS prefers.
const [isos, settings, smbRes, nfsRes, disk] = await Promise.all([
const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]);
const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || [];
const unattendedFiles = unattRes.files || [];
// ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [
@@ -712,10 +823,12 @@
shareMsg.className = 'msg err';
};
// Protocol picker — swaps which field block is visible.
// Protocol picker — swaps which field block is visible. v0.5.2:
// NFS is the default (listed first) — it has no credential fields,
// so the form lands cleaner than the SMB guest/user/password row.
const protoSelect = el('select', {}, [
el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
el('option', {value:'smb'}, 'SMB / CIFS'),
]);
// SMB inputs.
@@ -857,7 +970,86 @@
const diskCard = diskSpaceCard(disk);
return el('div', {class:'grid'}, [
// ── Advanced: unattended answer-file upload (v0.5.2) ──
// Mirrors the Settings "Advanced" disclosure. Kickstart / Preseed /
// Autoinstall / Windows answer files land in their own directory
// (never the ISO listing or PXE menu) and are referenced by host
// pins + queue profiles.
const unattMsg = el('div', {class:'msg', style:'margin-top:10px'});
const unattFile = el('input', {
type:'file',
accept:'.ks,.cfg,.seed,.yaml,.yml,.xml',
style:'display:none', id:'unatt-file',
});
async function uploadUnattended(f) {
const fd = new FormData(); fd.append('file', f, f.name);
unattMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
unattMsg.className = 'msg';
const r = await fetch('/api/unattended', {method:'POST', body: fd});
if (r.ok) {
unattMsg.textContent = 'Stored ' + f.name + '.';
unattMsg.className = 'msg ok';
render('storage');
} else {
unattMsg.textContent = 'Upload failed: ' + (await r.text());
unattMsg.className = 'msg err';
}
}
const unattDrop = el('div', {class:'drop', id:'unatt-drop'}, [
el('div', {}, 'Drop a Kickstart, Preseed, Autoinstall, or Answer File here.'),
el('div', {style:'font-size:12px;margin-top:6px'},
'Accepted: .ks · .cfg · .seed · .yaml · .yml · .xml (or user-data). ' +
'Use {{HOSTNAME}}, {{IP}}, {{MAC}} as placeholders — they are filled in per host at boot.'),
]);
unattDrop.onclick = () => unattFile.click();
unattDrop.addEventListener('dragover', e => { e.preventDefault(); unattDrop.classList.add('hover'); });
unattDrop.addEventListener('dragleave', () => unattDrop.classList.remove('hover'));
unattDrop.addEventListener('drop', e => {
e.preventDefault(); unattDrop.classList.remove('hover');
if (e.dataTransfer.files[0]) uploadUnattended(e.dataTransfer.files[0]);
});
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
const unattRows = unattendedFiles.length
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
el('span', {class:'dot ok'}),
el('div', {}, [
el('div', {class:'id'}, [
el('span', {class:'proto-badge'}, unattendedKindLabel(f.kind)),
document.createTextNode(f.filename),
]),
el('div', {class:'meta'}, fmtBytes(f.size_bytes) + ' · id ' + f.id),
]),
el('span'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Delete unattended file ' + f.filename + '?')) return;
await fetch('/api/unattended/' + encodeURIComponent(f.id), {method:'DELETE'});
render('storage');
}}, 'Delete'),
el('span'),
]))
: [el('div', {class:'empty'}, 'No unattended files yet.')];
const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
el('summary', {class:'advanced-summary'}, 'Advanced'),
el('div', {class:'card', style:'margin-top:14px'}, [
el('header', {}, [
el('h2', {}, 'Unattended file upload'),
el('span', {class:'sub'}, unattendedFiles.length + ' file' + (unattendedFiles.length === 1 ? '' : 's')),
]),
el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' +
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
'boot OpenPXE injects the matching kernel argument and serves the ' +
'file with the hosts name/IP filled in. Stored separately from ISOs.'),
]),
]),
]);
return el('div', {}, [el('div', {class:'grid'}, [
diskCard,
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Upload ISO')),
@@ -883,16 +1075,9 @@
addShare, shareMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Remote ISO libraries are read on demand — no local cache, no ' +
'double disk usage. SMB/CIFS is read in userspace via Sambas ' +
'smbclient; NFSv3 via a pure-Rust in-process client. Both work in ' +
'any container (Unraid, OpenShift restricted SCC, plain Docker) with ' +
'no kernel modules and no CAP_SYS_ADMIN. SMB supports guest or ' +
'user/password; most NAS appliances expose ISO libraries as ' +
'guest-readable. NFSv3 auth is AUTH_SYS only — gate access by ' +
'allowing this OpenPXE hosts IP in the servers export list. ' +
'NFS-sourced ISOs also support HTTP Range (seek into a 5 GB ISO ' +
'without reading what precedes the offset); SMB streams sequentially.'),
'Remote .iso libraries are read on demand — no local cache to ' +
'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' +
'the hosts IP address is provisioned.'),
]),
]),
el('div', {class:'card'}, [
@@ -902,15 +1087,17 @@
]),
isoTable,
]),
]);
]), unattendedAdvanced]);
},
hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes] = await Promise.all([
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]);
const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
})));
@@ -929,14 +1116,20 @@
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
.concat(targets.map(t => el('option', {value: t.id}, t.title))));
const msg = el('div', {class:'msg'});
// v0.5.2: optional unattended-install profile — auto hostname, auto
// IP, and an answer-file picker. On boot, a bound MAC with an
// unattended file selected has the right kernel arg injected
// (inst.ks / preseed url / autoinstall ds=nocloud) and the
// hostname/IP templated into the served answer file.
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
}
const r = await postJSON('/api/hosts', {
const r = await postJSON('/api/hosts', Object.assign({
mac: macInput.value, target: targetSel.value, label: labelInput.value,
});
}, profileFields.read()));
if (r.ok) {
msg.textContent = 'Saved.'; msg.className = 'msg ok';
render('hosts');
@@ -962,10 +1155,18 @@
}
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
}}, 'Wake');
const autoDeploy = (h.auto_hostname || h.auto_ip || h.unattended_file)
? el('div', {style:'font-size:12px;line-height:1.5'}, [
h.unattended_file ? el('div', {}, [el('span', {class:'tag accent'}, 'unattended'), document.createTextNode(' ' + h.unattended_file)]) : null,
h.auto_hostname ? el('div', {class:'mono'}, 'host: ' + h.auto_hostname) : null,
h.auto_ip ? el('div', {class:'mono'}, 'ip: ' + h.auto_ip) : null,
])
: el('span', {class:'tag'}, '—');
return el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, autoDeploy),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right;white-space:nowrap'}, [
wakeBtn,
@@ -982,7 +1183,8 @@
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'Label'),
el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''),
el('th',{},'Target'), el('th',{},'Auto-deploy'),
el('th',{},'Updated'), el('th',{},''),
])),
el('tbody', {}, rows),
])
@@ -1002,10 +1204,13 @@
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
]),
el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly.'),
'short-circuits past the interactive menu and chains directly. ' +
'If an unattended file is selected, the matching kernel argument ' +
'is injected and the hostname/IP are templated into the answer file.'),
]),
]),
el('div', {class:'card'}, [
@@ -1188,7 +1393,6 @@
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
getJSON('/api/docs').catch(() => ({ groups: [] })),
]);
const hasLogo = !!status.custom_logo;
// ── Account card (Forms admin credentials, v0.4.5).
// Sonarr/Radarr-style: the admin enters their current password
@@ -1332,7 +1536,8 @@
// Hint that used to live under the URL field; surfaced once below
// the whole row so it doesn't compete with the in-grid layout.
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
'OpenPXE fetches this metadata URL at sign-in to verify the IdPs signature. ' +
'Any IdP-authenticated user gets an operator session.');
const refreshSsoFields = () => {
if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
@@ -1356,7 +1561,7 @@
const r = await putJSON('/api/sso', payload);
if (r.ok) {
ssoMsg.textContent = ssoEnabled.checked
? 'SSO configuration saved. Runtime sign-in flow ships in a future release.'
? 'SSO saved and live. The login page now shows a “Sign in with …” button.'
: 'SSO configuration saved (disabled).';
ssoMsg.className = 'msg ok';
} else {
@@ -1371,16 +1576,17 @@
el('span', {class:'sub'},
sso.enabled
? (sso.metadata_url || sso.metadata
? 'configured · runtime pending'
? 'live · active'
: 'enabled but missing source')
: 'disabled'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Configure your SAML IdP today; OpenPXE persists the metadata so ' +
'when SSO sign-in lights up in a future release, no operator ' +
're-entry is needed. The local administrator account above is ' +
'always available as a fallback owner regardless of SSO state.'),
'SAML single sign-on is live. With it enabled, the login page shows ' +
'a “Sign in with …” button that hands off to your IdP; OpenPXE ' +
'verifies the signed assertion against the IdP metadata and mints an ' +
'operator session for any authenticated user. The local administrator ' +
'account above always remains available as a fallback.'),
el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
@@ -1415,72 +1621,79 @@
// referenced by `refreshSsoFields` are attached.
refreshSsoFields();
// ── Custom logo upload.
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
// Persisted as <work_dir>/branding/logo.<ext> and served from
// /assets/logo.svg in preference to the bundled mark.
const logoFile = el('input', {
type:'file',
accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif',
style:'display:none', id:'logo-file',
});
// ── Custom logos (v0.5.2). Three independent slots on one row,
// FleetDM-style: Light + Dark feed the WebUI top-left and the form
// login page (whichever theme is active picks its variant); Client
// is the raster painted above the PXE boot menu. Each slot has a
// preview, an upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB; the Client
// slot is raster-only), and a clear.
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
const logoBust = '?v=' + Date.now(); // bust the browser cache after upload
logoFile.onchange = async () => {
if (!logoFile.files[0]) return;
const f = logoFile.files[0];
const fd = new FormData(); fd.append('file', f, f.name);
logoMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
logoMsg.className = 'msg';
const r = await fetch('/api/branding/logo', {method:'POST', body: fd});
if (r.ok) {
logoMsg.textContent = 'Custom logo installed. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 600);
} else {
const t = await r.text();
logoMsg.textContent = 'Upload failed: ' + t;
logoMsg.className = 'msg err';
}
const bust = '?v=' + Date.now(); // bust the preview cache after a change
const brandingPresence = status.branding || { light:false, dark:false, client:false };
const slotDefs = [
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
];
const slotCol = (def) => {
const set = !!brandingPresence[def.slot];
const input = el('input', {type:'file', accept:def.accept, style:'display:none'});
input.onchange = async () => {
if (!input.files[0]) return;
const f = input.files[0];
const fd = new FormData(); fd.append('file', f, f.name);
logoMsg.textContent = 'Uploading ' + def.title + ' logo (' + fmtBytes(f.size) + ')…';
logoMsg.className = 'msg';
const r = await fetch('/api/branding/logo/' + def.slot, {method:'POST', body: fd});
if (r.ok) {
logoMsg.textContent = def.title + ' logo installed. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 600);
} else {
logoMsg.textContent = 'Upload failed: ' + (await r.text());
logoMsg.className = 'msg err';
}
};
return el('div', {class:'logo-slot'}, [
el('div', {class:'logo-slot-head'}, [
el('span', {class:'name'}, def.title),
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
]),
el('div', {class:'swatch', style: def.slot === 'light' ? 'background:#f4f5f7' : ''},
el('img', {src: def.preview, alt: def.title + ' logo'})),
el('div', {class:'logo-slot-hint'}, def.hint),
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
el('button', {class:'ghost', onclick: () => input.click()}, set ? 'Replace' : 'Upload'),
set ? el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove the ' + def.title + ' logo?')) return;
const r = await fetch('/api/branding/logo/' + def.slot, {method:'DELETE'});
if (r.ok || r.status === 204) {
logoMsg.textContent = def.title + ' logo cleared. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 500);
} else {
logoMsg.textContent = 'Clear failed: ' + (await r.text());
logoMsg.className = 'msg err';
}
}}, 'Remove') : null,
]),
input,
]);
};
const logoCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Branding')),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Override the top-left brand mark with your own logo. Up to 2 MB; ' +
'PNG, SVG, JPEG, WebP, or GIF. The original OpenPXE version is ' +
'always shown in the bottom-left for support purposes.'),
el('div', {class:'logo-preview'}, [
el('div', {class:'swatch'},
el('img', {src: '/assets/logo.svg' + logoBust, alt:'current logo'})),
el('div', {class:'info'}, [
el('div', {class:'name'}, hasLogo ? 'Custom logo (uploaded)' : 'Default OpenPXE mark'),
el('div', {class:'meta'},
hasLogo
? 'Operator-uploaded; served from <work_dir>/branding/.'
: 'Bundled rainbow-horizon mark. Upload an image to override.'),
]),
el('div', {style:'display:flex;gap:8px;flex-wrap:wrap'}, [
el('button', {onclick: () => logoFile.click()},
hasLogo ? 'Replace logo' : 'Upload logo'),
hasLogo
? el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove custom logo and revert to the OpenPXE mark?')) return;
const r = await fetch('/api/branding/logo', {method:'DELETE'});
if (r.ok || r.status === 204) {
logoMsg.textContent = 'Reverted to default mark. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 500);
} else {
const t = await r.text();
logoMsg.textContent = 'Clear failed: ' + t;
logoMsg.className = 'msg err';
}
}}, 'Remove')
: null,
]),
]),
logoFile, logoMsg,
'Upload your own brand marks. Up to 2 MB each; PNG, SVG, JPEG, ' +
'WebP, or GIF (the Client logo must be a raster). The Light and Dark ' +
'marks appear in the top-left and on the sign-in page depending on ' +
'theme; the Client mark sits above the PXE boot menu. The favicon ' +
'and the version string in the bottom-left always stay OpenPXE.'),
el('div', {class:'logo-slots'}, slotDefs.map(slotCol)),
logoMsg,
]),
]);
@@ -1763,11 +1976,23 @@
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('openpxe-theme', theme); } catch {}
applyBrandLogos(theme);
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
// v0.5.2: point the sidebar + login brand marks at the theme's logo
// slot so a light/dark toggle swaps the logo too (FleetDM-style).
function applyBrandLogos(theme) {
theme = theme || currentTheme();
const rev = (brandInfo && brandInfo.logo_rev) || 0;
const url = '/assets/logo.svg?theme=' + theme + '&r=' + rev;
document.querySelectorAll('.sidebar .brand img, .brand-row img').forEach(img => {
img.src = url;
});
}
document.addEventListener('DOMContentLoaded', () => {
applyBrandLogos();
const btn = $('#theme-toggle');
if (btn) {
btn.addEventListener('click', () => {
@@ -1868,7 +2093,7 @@
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
// Default: bundled mark + "OpenPXE".
function authBrandRow() {
const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0);
const src = '/assets/logo.svg?theme=' + currentTheme() + '&r=' + (brandInfo.logo_rev || 0);
if (brandInfo.has_custom_logo) {
return el('div', {class:'brand-row has-custom-logo'}, [
el('img', {src, alt:'logo'}),
@@ -1908,18 +2133,29 @@
window.history.replaceState({}, '', window.location.pathname);
}
const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
? el('button', {type:'button', class:'sso-btn', onclick: () => {
// SP-initiated SAML login (v0.5.1): hand off to the IdP. The
// /api/sso/acs endpoint verifies the response, mints the
// operator session, and redirects back to the dashboard.
window.location.assign('/api/sso/login');
}}, [
el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
// v0.5.2: FleetDM-style separation. The local credential form is its
// own self-contained <form>; when SSO is enabled, a distinct
// "Sign in with …" button sits below a divider — the credential
// fields no longer double as the SSO trigger.
const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata);
const ssoBlock = ssoLive
? el('div', {class:'sso-block'}, [
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
el('button', {type:'button', class:'sso-btn', onclick: () => {
// SP-initiated SAML login: hand off to the IdP. /api/sso/acs
// verifies the response, mints the operator session, and
// redirects back to the dashboard.
window.location.assign('/api/sso/login');
}}, [
ssoConfig.idp_logo_url
? el('img', {class:'sso-logo', src: ssoConfig.idp_logo_url, alt:'', onerror: function(){ this.style.display='none'; }})
: null,
el('span', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
]),
])
: null;
const form = el('form', {class:'auth-form', onsubmit: async (e) => {
const form = el('form', {class:'auth-form local-login', onsubmit: async (e) => {
e.preventDefault();
err.style.display = 'none';
submit.disabled = true;
@@ -1947,9 +2183,6 @@
submit.textContent = 'Sign in';
}
}}, [
authBrandRow(),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
usernameInput,
@@ -1959,11 +2192,16 @@
passwordInput,
]),
submit,
ssoButton,
]);
return el('div', {class:'login-stack'}, [
authBrandRow(),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
form,
ssoBlock,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
return form;
}
function buildSetupCard() {
+4 -1
View File
@@ -13,7 +13,10 @@
on the asset handlers, the practical caching window is one
version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" />
<!-- v0.5.2: favicon is pinned to the bundled OpenPXE mark (its own
endpoint, decoupled from operator branding) for tab-icon
continuity regardless of any uploaded light/dark/client logo. -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v={{ASSET_VERSION}}" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->