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:
co-authored by
Claude Opus 4.8
parent
cbcd63bb14
commit
7adf5e2918
+357
-119
@@ -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 host’s 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 Samba’s ' +
|
||||
'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 host’s IP in the server’s 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 IdP’s 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() {
|
||||
|
||||
Reference in New Issue
Block a user