v0.4.5: VMware UEFI fix, static musl binary, Forms auth + SSO config

VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
  canonical Casper `iso-url=` option and `ds=nocloud`, matching the
  fix Bootimus shipped in v0.1.67. The previous
  `boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
  bare-metal UEFI but hung at "cloud-init running" on VMware guests
  because subiquity / cloud-init can't reach a metadata datasource
  through PXE.

Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
  x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
  dependency at all; the runtime stage still ships Debian slim for the
  samba/wimtools/nfs-common shellouts, but a future scratch/distroless
  variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
  found" surprises on older RHEL/Rocky hosts.

Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
  <work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
  current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
  with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
  /api/logout, GET /api/me, PUT /api/me/credentials (rotates and
  revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
  passes through entirely until then (tests + fresh installs ride this
  path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
  /api/queue/join, /api/queue/poll/*) so iPXE clients still work
  without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
  footer, Account card in Settings for rotating creds. Auth screen is
  fully styled (centered narrow card, matches Sonarr layout).

SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
  metadata_url } persisted to <work_dir>/sso.json with size caps and
  URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
  without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
  inert "Sign in with X" button on the login screen while runtime
  flow is pending. Per the brief: no Entity ID field (defaults to the
  advertised public_base_url internally when SAML wiring lands).

Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
  unit tests, +7 auth integration tests, +1 SSO integration test, +1
  regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-25 22:37:20 -04:00
co-authored by Claude Opus 4.7
parent 7b972dc049
commit a1518110ed
18 changed files with 2149 additions and 61 deletions
+80
View File
@@ -504,6 +504,86 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
}
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
/* ── Auth screen (first-run setup + login) ───────────────────────
Used when /api/me reports setup_required or !authenticated. The
regular .shell is hidden; this overlay takes the full viewport so
the operator never sees half-loaded dashboard chrome while the auth
state is unknown. Same palette as the rest of the UI — borrows the
Sonarr/Radarr layout (centered narrow card on the page background).
*/
.auth-screen {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg);
padding: 24px;
z-index: 100;
}
.auth-card {
width: 100%; max-width: 380px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 28px 28px 22px;
}
.auth-card .brand-row {
display: flex; align-items: center; gap: 12px;
margin-bottom: 18px;
}
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
.auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
}
.auth-card .lede {
color: var(--fg-dim); font-size: 13px; margin: 0 0 18px;
line-height: 1.5;
}
.auth-card .field { margin-bottom: 12px; }
.auth-card input[type="text"],
.auth-card input[type="password"] {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 11px; font: inherit; font-size: 13.5px;
}
.auth-card input:focus { outline: none; border-color: var(--accent); }
.auth-card .submit { width: 100%; padding: 9px 12px; margin-top: 6px; }
.auth-card .auth-err {
margin-top: 12px; color: var(--err); font-size: 12.5px;
}
.auth-card .auth-foot {
margin-top: 14px; padding-top: 12px;
border-top: 1px solid var(--border-soft);
color: var(--fg-dimmer); font-size: 11.5px; text-align: center;
}
.auth-card .sso-btn {
width: 100%; margin-top: 10px;
background: transparent; color: var(--fg);
border: 1px solid var(--border);
padding: 9px 12px;
}
.auth-card .sso-btn:hover {
background: var(--bg-panel-2); border-color: var(--accent); color: var(--fg);
}
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
/* ── Logout chip (sidebar footer) ────────────────────────────── */
.sidebar .footer .logout-row {
margin-top: 8px; display: flex; align-items: center; justify-content: space-between;
gap: 8px;
}
.sidebar .footer .logout-row .who {
color: var(--fg); font-weight: 600; font-size: 11.5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sidebar .footer .logout-btn {
background: transparent; color: var(--fg-dim);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 2px 8px; font: inherit; font-size: 11px; font-weight: 500;
cursor: pointer;
}
.sidebar .footer .logout-btn:hover { color: var(--fg); background: var(--bg-panel-2); border-color: var(--accent); }
/* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; }
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
+444 -28
View File
@@ -63,16 +63,37 @@
};
// ── network helpers ──────────────────────────────────────────────
// All three helpers funnel through a 401 detector. When the server
// says "auth required" mid-session — most commonly because the
// operator's session expired while the tab was idle — we transparently
// swap the SPA out for the login screen rather than letting the UI
// throw a generic error.
function maybeAuthBounce(r) {
if (r && r.status === 401) {
// Render the login screen without reloading; any in-flight
// promises still return their values to the original caller.
showAuthScreen('login');
}
return r;
}
async function getJSON(url) {
const r = await fetch(url);
const r = maybeAuthBounce(await fetch(url));
if (!r.ok) throw new Error(url + ': ' + r.status);
return r.json();
}
async function putJSON(url, body) {
return fetch(url, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
return maybeAuthBounce(await fetch(url, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body),
}));
}
async function postJSON(url, body) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
return maybeAuthBounce(await fetch(url, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body),
}));
}
// Animated brand-mark + progress bar widget. Built once here and inlined
@@ -981,40 +1002,197 @@
},
settings: async () => {
const [status, docs] = await Promise.all([
const [status, docs, me, sso] = await Promise.all([
getJSON('/api/status'),
getJSON('/api/docs').catch(() => ({ groups: [] })),
getJSON('/api/me').catch(() => ({})),
getJSON('/api/sso').catch(() => ({
enabled:false, idp_name:'', metadata:'', metadata_url:'',
})),
]);
const hasLogo = !!status.custom_logo;
// ── Identity & access placeholder.
// We haven't shipped auth yet — but operators looking at this tab
// need to see *where* it'll live so they're not surprised when an
// upgrade lights up real config controls under the same heading.
const accessCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Identity & access')),
// ── Account card (Forms admin credentials, v0.4.5).
// Sonarr/Radarr-style: the admin enters their current password
// before changing username or password. On success the server
// revokes every other session, so a forgotten browser tab can't
// keep operating with stale credentials.
const currentPw = el('input', {type:'password', autocomplete:'current-password'});
const newUser = el('input', {type:'text', autocomplete:'username',
placeholder: (me.user && me.user.username) || 'admin'});
const newPw = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'leave blank to keep current'});
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'confirm new password'});
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
const accountSave = el('button', {onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.';
accountMsg.className = 'msg err';
return;
}
if (newPw.value && newPw.value !== newPwConfirm.value) {
accountMsg.textContent = 'New password and confirmation do not match.';
accountMsg.className = 'msg err';
return;
}
if (!newUser.value && !newPw.value) {
accountMsg.textContent = 'Nothing to change. Fill in a new username or password.';
accountMsg.className = 'msg err';
return;
}
const body = { current_password: currentPw.value };
if (newUser.value) body.new_username = newUser.value;
if (newPw.value) body.new_password = newPw.value;
const r = await putJSON('/api/me/credentials', body);
// Clear the typed plaintext immediately — minimises DOM dwell time.
currentPw.value = ''; newPw.value = ''; newPwConfirm.value = '';
if (r.ok) {
accountMsg.textContent = 'Credentials updated. Other sessions were signed out.';
accountMsg.className = 'msg ok';
// Refresh the settings view to pick up the new "logged in as" display.
setTimeout(() => render('settings'), 600);
} else {
const j = await r.json().catch(() => ({}));
accountMsg.textContent = j.error || ('Update failed: HTTP ' + r.status);
accountMsg.className = 'msg err';
}
}}, 'Update credentials');
const accountCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Administrator account'),
el('span', {class:'sub'},
(me && me.user && me.user.username)
? ('signed in as ' + me.user.username)
: 'signed in'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg'},
'LDAP, SSO (OIDC), and operator user management are planned ' +
'for a future release. Today, OpenPXE assumes a single trusted ' +
'operator on a flat L2 network and provides no built-in ' +
'authentication on the WebUI or HTTP API. Run behind your ' +
'identity-aware reverse proxy (Authentik, Authelia, oauth2-proxy) ' +
'for a hardened deployment until first-class support lands here.'),
el('div', {class:'form-row', style:'opacity:.55;pointer-events:none'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Rotate the administrator login. Your current password is required ' +
'to make any change; on success every other browser session is ' +
'signed out so a stale cookie can\'t keep operating.'),
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'LDAP server URL'),
el('input', {type:'text', placeholder:'ldaps://dc.example.com:636', disabled:'disabled'}),
el('span', {class:'name'}, 'Current password'),
currentPw,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'OIDC issuer'),
el('input', {type:'text', placeholder:'https://idp.example.com/realms/openpxe', disabled:'disabled'}),
el('span', {class:'name'}, 'New username (optional)'),
newUser,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'User management'),
el('input', {type:'text', placeholder:'configurable in a future release', disabled:'disabled'}),
el('span', {class:'name'}, 'New password (optional)'),
newPw,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Confirm new password'),
newPwConfirm,
]),
]),
accountSave, accountMsg,
]),
]);
// ── SSO card (FleetDM-shaped, storage-only for v0.4.5).
// Operators paste either a metadata URL or the raw XML; tabs
// switch the visible field. Saving validates server-side. The
// actual SAML login flow ships in a later release — we surface
// a yellow "config saved, runtime pending" line when usable.
const ssoEnabled = el('input', {type:'checkbox'});
ssoEnabled.checked = !!sso.enabled;
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
value: sso.idp_name || ''});
const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
value: sso.metadata_url || ''});
const ssoXml = el('textarea', {rows:'6',
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
style:'width:100%;font-family:var(--mono);font-size:12px;background:var(--bg);' +
'color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);' +
'padding:8px 10px;resize:vertical'},
sso.metadata || '');
const ssoMode = el('select', {style:'min-width:160px;background:var(--bg);color:var(--fg);' +
'border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:inherit'}, [
el('option', {value:'url'}, 'Metadata URL'),
el('option', {value:'xml'}, 'Metadata XML'),
]);
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
const urlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata URL'),
ssoUrl,
el('span', {class:'hint'},
'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.5 just stores it.'),
]);
const xmlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata XML'),
ssoXml,
el('span', {class:'hint'},
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
]);
const refreshSsoFields = () => {
if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
} else {
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
}
};
ssoMode.onchange = refreshSsoFields;
refreshSsoFields();
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = {
enabled: ssoEnabled.checked,
idp_name: ssoName.value,
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
};
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 configuration saved (disabled).';
ssoMsg.className = 'msg ok';
} else {
const t = await r.text();
ssoMsg.textContent = 'Save failed: ' + t;
ssoMsg.className = 'msg err';
}
}}, 'Save SSO settings');
const ssoCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Single sign-on (SAML)'),
el('span', {class:'sub'},
sso.enabled
? (sso.metadata_url || sso.metadata
? 'configured · runtime pending'
: '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.'),
el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
]),
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP display name'),
ssoName,
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Metadata source'),
ssoMode,
]),
]),
urlWrap,
xmlWrap,
ssoSave, ssoMsg,
]),
]);
@@ -1113,7 +1291,7 @@
'No API documentation returned by /api/docs.')),
]);
return el('div', {class:'grid'}, [accessCard, logoCard, apiCard]);
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]);
},
about: async () => {
@@ -1252,7 +1430,245 @@
if (a) { e.preventDefault(); render(a.dataset.view); }
});
render('dashboard');
refreshChips();
setInterval(refreshChips, 3000);
// ── Auth bootstrap (v0.4.5) ──────────────────────────────────────
// Before painting the dashboard, ask /api/me whether the operator
// needs to bootstrap an admin (`setup_required`), sign in
// (`!authenticated`), or just load the dashboard. The auth screen
// takes over the viewport completely — no half-rendered chrome
// bleeding through. Sonarr/Radarr-style.
let chipsInterval = null;
let authScreenEl = null;
let ssoConfig = null;
function teardownAuthScreen() {
if (authScreenEl && authScreenEl.parentNode) {
authScreenEl.parentNode.removeChild(authScreenEl);
}
authScreenEl = null;
document.querySelector('.shell').style.display = '';
}
function buildLoginCard() {
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'current-password'});
const err = el('div', {class:'auth-err', style:'display:none'});
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
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 = '';
}}, [
el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
el('div', {class:'meta'}, 'configured · runtime flow pending'),
])
: null;
const form = el('form', {class:'auth-form', onsubmit: async (e) => {
e.preventDefault();
err.style.display = 'none';
submit.disabled = true;
submit.textContent = 'Signing in…';
try {
const r = await fetch('/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
});
if (r.ok) {
passwordInput.value = '';
teardownAuthScreen();
await startDashboard();
return;
}
const j = await r.json().catch(() => ({}));
err.textContent = j.error || ('Sign-in failed: HTTP ' + r.status);
err.style.display = '';
} catch (ex) {
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
err.style.display = '';
} finally {
submit.disabled = false;
submit.textContent = 'Sign in';
}
}}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
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,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password'),
passwordInput,
]),
submit,
ssoButton,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
return form;
}
function buildSetupCard() {
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'new-password'});
const confirmInput = el('input', {type:'password', name:'confirm', autocomplete:'new-password'});
const err = el('div', {class:'auth-err', style:'display:none'});
const submit = el('button', {class:'submit', type:'submit'}, 'Create administrator');
const form = el('form', {class:'auth-form', onsubmit: async (e) => {
e.preventDefault();
err.style.display = 'none';
if (passwordInput.value !== confirmInput.value) {
err.textContent = 'Passwords do not match.';
err.style.display = '';
return;
}
if (passwordInput.value.length < 8) {
err.textContent = 'Password must be at least 8 characters.';
err.style.display = '';
return;
}
submit.disabled = true;
submit.textContent = 'Creating…';
try {
const r = await fetch('/api/setup', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
});
if (r.ok) {
passwordInput.value = '';
confirmInput.value = '';
teardownAuthScreen();
await startDashboard();
return;
}
const j = await r.json().catch(() => ({}));
err.textContent = j.error || ('Setup failed: HTTP ' + r.status);
err.style.display = '';
} catch (ex) {
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
err.style.display = '';
} finally {
submit.disabled = false;
submit.textContent = 'Create administrator';
}
}}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'First-run setup'),
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
usernameInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password (≥8 chars)'),
passwordInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Confirm password'),
confirmInput,
]),
submit,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
return form;
}
function showAuthScreen(mode) {
// Tear down any previous screen + the dashboard chrome.
if (authScreenEl && authScreenEl.parentNode) {
authScreenEl.parentNode.removeChild(authScreenEl);
}
const shell = document.querySelector('.shell');
if (shell) shell.style.display = 'none';
if (chipsInterval) { clearInterval(chipsInterval); chipsInterval = null; }
const card = (mode === 'setup' ? buildSetupCard() : buildLoginCard());
authScreenEl = el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, card));
document.body.appendChild(authScreenEl);
// Focus the first visible input (autofocus on dynamically created
// inputs doesn't fire in all browsers).
setTimeout(() => {
const inp = authScreenEl.querySelector('input[type="text"], input[type="password"]');
if (inp) inp.focus();
}, 30);
}
async function startDashboard() {
// Light up the sidebar's "signed in as X / Sign out" row. It was
// hidden in index.html because we don't know the identity until
// /api/me resolves.
try {
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
const row = $('[data-bind=logout_row]');
const who = $('[data-bind=signed_in_as]');
const btn = $('[data-bind=logout_btn]');
if (row && me && me.authenticated && me.user) {
who.textContent = me.user.username;
who.title = 'Signed in as ' + me.user.username;
row.style.display = '';
if (btn && !btn._wired) {
btn._wired = true;
btn.addEventListener('click', async () => {
await fetch('/api/logout', {method:'POST'}).catch(() => {});
showAuthScreen('login');
});
}
}
} catch (e) { /* surfaces elsewhere */ }
render('dashboard');
await refreshChips();
if (!chipsInterval) chipsInterval = setInterval(refreshChips, 3000);
}
async function bootstrap() {
let me;
try {
me = await fetch('/api/me').then(r => r.json());
} catch (e) {
// /api/me is unauthenticated in every state — if we can't reach
// it the server is genuinely down, not an auth problem.
document.body.appendChild(el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'Connection error'),
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
])));
return;
}
// Preload the SSO config so the login card can offer the operator
// an "Sign in with X" button when configured. Failure is harmless.
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
catch { ssoConfig = null; }
if (me.setup_required) {
showAuthScreen('setup');
return;
}
if (!me.authenticated) {
showAuthScreen('login');
return;
}
await startDashboard();
}
bootstrap();
})();
+9 -1
View File
@@ -59,7 +59,15 @@
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.4</span></div>
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.5</span></div>
<!-- v0.4.5: signed-in identity + one-click sign-out. The button
is populated by app.js after /api/me reports an authenticated
session — pre-auth states swap the whole shell for the
login/setup card so this row never gets shown there. -->
<div class="logout-row" data-bind="logout_row" style="display:none">
<span class="who" data-bind="signed_in_as" title=""></span>
<button type="button" class="logout-btn" data-bind="logout_btn">Sign out</button>
</div>
</div>
</aside>