feat(saml): wire SAML 2.0 SSO end-to-end (pure-Rust) + Settings/Storage UI consolidation (v0.5.1)
SAML SSO (the config was storage-only since v0.4.5; now it logs you in):
- New openpxe-core::saml — pure-Rust SP built on bergshamra (XML-DSig +
exclusive c14n via RustCrypto, no OpenSSL/xmlsec/libxml2). The static
musl binary stays C-free; samael was rejected for hard-requiring OpenSSL.
* metadata.rs — parse IdP EntityDescriptor (SSO URLs + signing certs),
build our SP metadata.
* authn_request.rs — build + HTTP-Redirect-encode AuthnRequests.
* response.rs — verify the signature against the pinned IdP cert
(trusted_keys_only + strict_verification for XSW),
then enforce Status/Destination/Audience/time-bounds/
signature-scope. Stateless; returns the IDs the HTTP
layer needs.
- http-api saml_routes: GET /api/sso/login (302 to IdP), POST /api/sso/acs
(verify -> InResponseTo correlation / IdP-initiated gating / assertion
replay guard -> mint operator session -> 302), GET /api/sso/metadata.
Added to the pre-auth allowlist; /api/sso config stays gated.
- SsoConfig gains entity_id (SP Entity ID, defaults to public base URL)
and allow_idp_initiated (default off), mirroring FleetDM.
- Access model: any IdP-authenticated, cryptographically-verified user gets
an operator session (single-tier; local admin remains the fallback owner).
- Login page: the "Sign in with <IdP>" button now drives the real flow and
surfaces sso_error redirects.
UI consolidation:
- Removed the Advanced sidebar tab; folded its webhook-notifications +
API-reference cards into a collapsible "Advanced" disclosure at the
bottom of Settings.
- Merged the Storage tab's separate SMB and NFS cards into one "Remote
shares" card with a protocol dropdown and a unified, protocol-badged
table. No backend changes — same /api/smb-shares + /api/nfs-shares.
Tests: 17 SAML core tests (accept + reject tampered/unsigned/wrong-key/
wrong-audience/expired/future/wrong-issuer/non-success) and 6 ACS
integration tests (happy path, IdP-initiated gating, SP correlation,
replay, garbage). Full workspace: 206 tests green, clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
252b557b9c
commit
cbcd63bb14
@@ -797,3 +797,46 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
.logo-preview .info { flex: 1; min-width: 0; }
|
||||
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
|
||||
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings
|
||||
(the former Advanced sidebar tab). A quiet, full-width toggle that
|
||||
expands to reveal the notification + API-reference cards. */
|
||||
.advanced-disclosure { width: 100%; }
|
||||
.advanced-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.advanced-summary:hover { color: var(--fg); }
|
||||
.advanced-summary::-webkit-details-marker { display: none; }
|
||||
.advanced-summary::before {
|
||||
content: "▸";
|
||||
font-size: 11px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); }
|
||||
|
||||
/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */
|
||||
.proto-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
margin-right: 8px;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
background: var(--bg-panel-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
+179
-183
@@ -684,19 +684,46 @@
|
||||
])
|
||||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
|
||||
|
||||
// ── SMB shares section (v0.4.65) ──
|
||||
// Replaces the kernel-mount NFS card. SMB shares are consumed
|
||||
// in userspace via Samba's `smbclient` CLI — no kernel modules,
|
||||
// no CAP_SYS_ADMIN, works in any container. This is the same
|
||||
// approach Bootimus uses.
|
||||
const smbMsg = el('div', {class:'msg'});
|
||||
// ── Remote shares section (v0.5.1) ──
|
||||
// SMB + NFS unified into one "Remote shares" card with a protocol
|
||||
// dropdown. The two protocols keep their own backend endpoints
|
||||
// (/api/smb-shares, /api/nfs-shares) and the same add/scan/remove
|
||||
// UX; the form just swaps the relevant fields. This declutters the
|
||||
// Storage tab and leaves room for a future "Config files" card.
|
||||
const shareMsg = el('div', {class:'msg'});
|
||||
|
||||
// Shared structured-error renderer ({error, stderr, hint}) for both
|
||||
// protocols' add calls.
|
||||
const showShareError = async (r) => {
|
||||
let bodyJson = null;
|
||||
let raw = null;
|
||||
try { bodyJson = await r.clone().json(); }
|
||||
catch (_) { raw = await r.text().catch(() => 'connect failed'); }
|
||||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
||||
const hint = bodyJson && bodyJson.hint;
|
||||
const parts = [el('div', {}, [
|
||||
el('strong', {}, 'Connect failed: '),
|
||||
document.createTextNode(msg),
|
||||
])];
|
||||
if (hint) {
|
||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||
}
|
||||
shareMsg.replaceChildren(...parts);
|
||||
shareMsg.className = 'msg err';
|
||||
};
|
||||
|
||||
// Protocol picker — swaps which field block is visible.
|
||||
const protoSelect = el('select', {}, [
|
||||
el('option', {value:'smb'}, 'SMB / CIFS'),
|
||||
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
|
||||
]);
|
||||
|
||||
// SMB inputs.
|
||||
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
|
||||
const smbShare = el('input', {type:'text', placeholder:'isos'});
|
||||
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
|
||||
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
|
||||
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
|
||||
// Toggle username/password fields based on the Guest checkbox so
|
||||
// operators don't get confused about which fields matter.
|
||||
const syncAuthDisabled = () => {
|
||||
smbUser.disabled = smbGuest.checked;
|
||||
smbPass.disabled = smbGuest.checked;
|
||||
@@ -705,58 +732,23 @@
|
||||
};
|
||||
smbGuest.addEventListener('change', syncAuthDisabled);
|
||||
syncAuthDisabled();
|
||||
const smbFields = el('div', {}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMB server'), smbServer]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Share name'), smbShare]),
|
||||
]),
|
||||
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||||
el('label', {class:'check'}, [smbGuest, el('span', {}, 'Guest (anonymous read)')]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), smbUser]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), smbPass]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const addSmb = el('button', {onclick: async () => {
|
||||
if (!smbServer.value || !smbShare.value) {
|
||||
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
||||
smbMsg.className='msg err'; return;
|
||||
}
|
||||
if (!smbGuest.checked && !smbUser.value) {
|
||||
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
|
||||
smbMsg.className='msg err'; return;
|
||||
}
|
||||
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
smbMsg.className = 'msg';
|
||||
const body = {
|
||||
server: smbServer.value,
|
||||
share: smbShare.value,
|
||||
guest: smbGuest.checked,
|
||||
};
|
||||
if (!smbGuest.checked) {
|
||||
body.username = smbUser.value;
|
||||
body.password = smbPass.value;
|
||||
}
|
||||
const r = await postJSON('/api/smb-shares', body);
|
||||
if (r.ok) {
|
||||
smbMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
smbMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
// The API returns a structured {error, stderr, hint} JSON
|
||||
// body on failure so the raw smbclient error and the
|
||||
// actionable hint render as two distinct lines.
|
||||
let bodyJson = null;
|
||||
let raw = null;
|
||||
try { bodyJson = await r.clone().json(); }
|
||||
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
|
||||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
||||
const hint = bodyJson && bodyJson.hint;
|
||||
const parts = [el('div', {}, [
|
||||
el('strong', {}, 'Connect failed: '),
|
||||
document.createTextNode(msg),
|
||||
])];
|
||||
if (hint) {
|
||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||
}
|
||||
smbMsg.replaceChildren(...parts);
|
||||
smbMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Add share');
|
||||
|
||||
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
const smbRowEls = shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
|
||||
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SMB'),
|
||||
document.createTextNode('//' + m.server + '/' + m.share)]),
|
||||
el('div', {class:'meta'},
|
||||
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
|
||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
@@ -773,59 +765,75 @@
|
||||
render('storage');
|
||||
}}, 'Remove'),
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
|
||||
]));
|
||||
|
||||
// ── NFS shares section (v0.4.67) ──
|
||||
// Parallel to SMB shares above. The NFSv3 client is in-process
|
||||
// (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
|
||||
// requests — SMB-sourced ones don't (smbclient CLI can't seek
|
||||
// mid-stream). Otherwise the UX is identical: server + export,
|
||||
// submit, scan, remove.
|
||||
const nfsMsg = el('div', {class:'msg'});
|
||||
// NFS inputs. The NFSv3 client is in-process (nfs3_client crate) so
|
||||
// NFS-sourced ISOs support HTTP Range — SMB-sourced ones can't seek
|
||||
// mid-stream. No auth fields: NFSv3 access is gated by client IP on
|
||||
// the server's export list, not client-supplied credentials.
|
||||
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||
const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
|
||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
nfsMsg.className = 'msg err'; return;
|
||||
}
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
nfsMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs-shares', {
|
||||
server: nfsServerIn.value,
|
||||
export: nfsExportIn.value,
|
||||
});
|
||||
if (r.ok) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
nfsMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
// Structured {error, stderr, hint} same as SMB.
|
||||
let bodyJson = null;
|
||||
let raw = null;
|
||||
try { bodyJson = await r.clone().json(); }
|
||||
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
|
||||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
||||
const hint = bodyJson && bodyJson.hint;
|
||||
const parts = [el('div', {}, [
|
||||
el('strong', {}, 'Connect failed: '),
|
||||
document.createTextNode(msg),
|
||||
])];
|
||||
if (hint) {
|
||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||
const nfsFields = el('div', {}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'NFS server'), nfsServerIn]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), nfsExportIn]),
|
||||
]),
|
||||
]);
|
||||
|
||||
// Swap the visible field block + clear any stale message.
|
||||
const syncProto = () => {
|
||||
const nfs = protoSelect.value === 'nfs';
|
||||
smbFields.style.display = nfs ? 'none' : '';
|
||||
nfsFields.style.display = nfs ? '' : 'none';
|
||||
shareMsg.replaceChildren();
|
||||
shareMsg.className = 'msg';
|
||||
};
|
||||
protoSelect.addEventListener('change', syncProto);
|
||||
|
||||
// One add button; dispatches to the selected protocol's endpoint.
|
||||
const addShare = el('button', {style:'margin-top:14px', onclick: async () => {
|
||||
if (protoSelect.value === 'smb') {
|
||||
if (!smbServer.value || !smbShare.value) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
||||
shareMsg.className = 'msg err'; return;
|
||||
}
|
||||
nfsMsg.replaceChildren(...parts);
|
||||
nfsMsg.className = 'msg err';
|
||||
if (!smbGuest.checked && !smbUser.value) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
|
||||
shareMsg.className = 'msg err'; return;
|
||||
}
|
||||
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
shareMsg.className = 'msg';
|
||||
const body = { server: smbServer.value, share: smbShare.value, guest: smbGuest.checked };
|
||||
if (!smbGuest.checked) { body.username = smbUser.value; body.password = smbPass.value; }
|
||||
const r = await postJSON('/api/smb-shares', body);
|
||||
if (r.ok) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
shareMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else { await showShareError(r); }
|
||||
} else {
|
||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
shareMsg.className = 'msg err'; return;
|
||||
}
|
||||
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
shareMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs-shares', { server: nfsServerIn.value, export: nfsExportIn.value });
|
||||
if (r.ok) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
shareMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else { await showShareError(r); }
|
||||
}
|
||||
}}, 'Add share');
|
||||
|
||||
const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
const nfsRowEls = nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, m.server + ':' + m.export),
|
||||
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'NFS'),
|
||||
document.createTextNode(m.server + ':' + m.export)]),
|
||||
el('div', {class:'meta'},
|
||||
'NFSv3 · ' +
|
||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
'NFSv3 · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||
]),
|
||||
@@ -839,7 +847,13 @@
|
||||
render('storage');
|
||||
}}, 'Remove'),
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
|
||||
]));
|
||||
|
||||
const totalShares = shares.length + nfsShares.length;
|
||||
const remoteRows = totalShares
|
||||
? [...smbRowEls, ...nfsRowEls]
|
||||
: [el('div', {class:'empty'}, 'No remote shares configured.')];
|
||||
syncProto();
|
||||
|
||||
const diskCard = diskSpaceCard(disk);
|
||||
|
||||
@@ -849,77 +863,36 @@
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
||||
]),
|
||||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||
// pure UI consolidation that declutters the Storage tab.
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'SMB shares'),
|
||||
el('span', {class:'sub'}, shares.length + ' configured'),
|
||||
el('h2', {}, 'Remote shares'),
|
||||
el('span', {class:'sub'}, totalShares + ' configured'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'SMB server'),
|
||||
smbServer,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Share name'),
|
||||
smbShare,
|
||||
el('span', {class:'name'}, 'Protocol'),
|
||||
protoSelect,
|
||||
]),
|
||||
el('span'),
|
||||
]),
|
||||
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||||
el('label', {class:'check'}, [
|
||||
smbGuest, el('span', {}, 'Guest (anonymous read)'),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Username'),
|
||||
smbUser,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Password'),
|
||||
smbPass,
|
||||
]),
|
||||
]),
|
||||
addSmb, smbMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
|
||||
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]),
|
||||
addShare, shareMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'SMB shares are read in userspace via Samba’s smbclient — ' +
|
||||
'no kernel modules, no CAP_SYS_ADMIN, works in any container ' +
|
||||
'(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' +
|
||||
'appliances expose ISO libraries as guest-readable; check the box ' +
|
||||
'above when that’s the case. ISOs are streamed on demand at PXE ' +
|
||||
'boot time — no local cache, no double disk usage.'),
|
||||
]),
|
||||
]),
|
||||
// v0.4.67: NFS shares card sits right below SMB so operators
|
||||
// can see both protocols at a glance. The form is simpler
|
||||
// (no auth) because NFSv3 access control is by client IP on
|
||||
// the server side, not by client-supplied credentials.
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'NFS shares'),
|
||||
el('span', {class:'sub'}, nfsShares.length + ' configured'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'NFS server'),
|
||||
nfsServerIn,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Export path'),
|
||||
nfsExportIn,
|
||||
]),
|
||||
]),
|
||||
addNfs, nfsMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'NFSv3 shares are read in-process via a pure-Rust client — ' +
|
||||
'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
|
||||
'every container the SMB path works in (Unraid included). ' +
|
||||
'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
|
||||
'by allowing this OpenPXE host’s IP in the export list. ' +
|
||||
'ISOs are streamed on demand and HTTP Range requests work — ' +
|
||||
'NFSv3 READ3 takes an explicit offset, so clients can seek ' +
|
||||
'into a 5 GB ISO without reading what comes before.'),
|
||||
'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.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
@@ -1204,12 +1177,16 @@
|
||||
},
|
||||
|
||||
settings: async () => {
|
||||
const [status, me, sso] = await Promise.all([
|
||||
const [status, me, sso, notify, docs] = await Promise.all([
|
||||
getJSON('/api/status'),
|
||||
getJSON('/api/me').catch(() => ({})),
|
||||
getJSON('/api/sso').catch(() => ({
|
||||
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
||||
})),
|
||||
// v0.5.1: the former Advanced tab folds in here, so Settings
|
||||
// fetches the notify config + API docs it needs too.
|
||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
]);
|
||||
const hasLogo = !!status.custom_logo;
|
||||
|
||||
@@ -1507,18 +1484,26 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
// v0.5.0: the API reference moved to the Advanced tab; Settings
|
||||
// now holds just account / SSO / branding.
|
||||
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]);
|
||||
// v0.5.1: the former "Advanced" sidebar tab now lives here, folded
|
||||
// into a collapsible disclosure beneath the core settings cards —
|
||||
// webhook/email notifications + the API reference. Keeps Settings
|
||||
// clean by default while leaving the knobs one click away.
|
||||
const [notifyCard, apiCard] = views._advancedCards(notify, docs);
|
||||
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||||
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
|
||||
]);
|
||||
return el('div', {}, [
|
||||
el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]),
|
||||
advanced,
|
||||
]);
|
||||
},
|
||||
|
||||
// v0.5.0: Advanced settings — webhook/email notifications, and the
|
||||
// API reference (relocated from the bottom of Settings).
|
||||
advanced: async () => {
|
||||
const [notify, docs] = await Promise.all([
|
||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
]);
|
||||
// v0.5.1: builds the two "Advanced" cards — webhook/email notifications
|
||||
// and the API reference. There is no longer an Advanced sidebar tab;
|
||||
// the Settings view folds these into a collapsible disclosure and
|
||||
// passes in the pre-fetched `notify` + `docs` payloads.
|
||||
_advancedCards: (notify, docs) => {
|
||||
|
||||
// ── Notification config ──
|
||||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||
@@ -1600,7 +1585,7 @@
|
||||
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
|
||||
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
||||
const r = await putJSON('/api/notify', collectNotify());
|
||||
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('advanced'); }
|
||||
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
|
||||
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||
}}, 'Save notification settings');
|
||||
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
|
||||
@@ -1656,7 +1641,7 @@
|
||||
'No API documentation returned by /api/docs.')),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [notifyCard, apiCard]);
|
||||
return [notifyCard, apiCard];
|
||||
},
|
||||
|
||||
about: async () => {
|
||||
@@ -1770,7 +1755,6 @@
|
||||
hosts: 'Hosts',
|
||||
terminal: 'Terminal',
|
||||
settings: 'Settings',
|
||||
advanced: 'Advanced',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
@@ -1910,16 +1894,28 @@
|
||||
const err = el('div', {class:'auth-err', style:'display:none'});
|
||||
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
|
||||
|
||||
// v0.5.1: surface a failed/blocked SSO round-trip. The ACS handler
|
||||
// redirects back to "/?sso_error=..." on any failure; we show a
|
||||
// generic, non-leaky message and scrub the query so a refresh is clean.
|
||||
const ssoErr = new URLSearchParams(window.location.search).get('sso_error');
|
||||
if (ssoErr) {
|
||||
err.textContent = ssoErr === 'idp_initiated'
|
||||
? 'IdP-initiated SSO is disabled. Use the “Sign in with …” button, or enable it under Settings → SSO.'
|
||||
: (ssoErr === 'unavailable' || ssoErr === 'metadata')
|
||||
? 'Single sign-on is unavailable right now. Sign in with the local admin, or check the SSO settings.'
|
||||
: 'SSO sign-in failed. Please try again, or sign in with the local admin.';
|
||||
err.style.display = '';
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
|
||||
const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
|
||||
? el('button', {type:'button', class:'sso-btn', onclick: () => {
|
||||
// SSO login flow lands in a later release — for now we
|
||||
// surface a friendly note so the operator knows the config
|
||||
// landed but the runtime hookup is pending.
|
||||
err.textContent = 'SSO sign-in is configured but the runtime flow ships in a future release. Sign in with the local admin for now.';
|
||||
err.style.display = '';
|
||||
// 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')),
|
||||
el('div', {class:'meta'}, 'configured · runtime flow pending'),
|
||||
])
|
||||
: null;
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
</a>
|
||||
<a data-view="terminal">Terminal</a>
|
||||
<a data-view="settings">Settings</a>
|
||||
<a data-view="advanced">Advanced</a>
|
||||
<a data-view="about">About</a>
|
||||
</nav>
|
||||
<div class="footer">
|
||||
|
||||
Reference in New Issue
Block a user