Fixed admin display.

This commit is contained in:
Scott Duensing 2026-06-19 19:09:22 -05:00
parent 352bdc9fb6
commit f8c1f164f6

View file

@ -85,37 +85,48 @@ const csrfField = (req) => {
}; };
const layout = (title, body) => { // Bootstrap nav-pills with the current page marked active. The active link is the
return `<!doctype html> // one whose href is the LONGEST boundary-safe prefix of the request path, so a
<html lang="en"><head> // sub-page (e.g. /clients/<id>/secret) still highlights its parent tab.
<meta charset="utf-8"> const navPills = (req, links) => {
<title>${escapeHtml(title)}</title> const cur = String((req && (req.originalUrl || req.path)) || "").split("?")[0].replace(/\/+$/, "");
<style> let activeHref = "";
body { font-family: system-ui, -apple-system, sans-serif; margin: 1.5rem; max-width: 900px; } for (const [href] of links) {
h1 { font-size: 1.4rem; } const h = href.replace(/\/+$/, "");
h2 { font-size: 1.1rem; margin-top: 1.5rem; } if ((cur === h || cur.startsWith(h + "/")) && h.length > activeHref.length) {
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; } activeHref = h;
th, td { border: 1px solid #ddd; padding: 0.35rem 0.6rem; text-align: left; vertical-align: top; } }
th { background: #f5f5f5; } }
code { font-family: ui-monospace, Menlo, Consolas, monospace; } const items = links.map(([href, label]) => {
const on = href.replace(/\/+$/, "") === activeHref;
return ` <li class="nav-item"><a class="nav-link${on ? " active" : ""}"${on ? ' aria-current="page"' : ""} href="${escapeHtml(href)}">${escapeHtml(label)}</a></li>`;
}).join("\n");
return `<ul class="nav nav-pills mb-3">\n${items}\n</ul>`;
};
// Returns the INNER page content only (Bootstrap sub-nav + a small residual
// style for the non-table classes the body markup uses + body). The page is
// wrapped in the active Saltcorn theme by res.sendWrap at the call sites, so
// there is no <html>/<head>/<body> here.
const layout = (req, body) => {
const base = constants.ADMIN_BASE_PATH;
const links = [
[base, "Dashboard"],
[`${base}/clients`, "Clients"],
[`${base}/groups`, "Groups"],
[`${base}/saml-sps`, "SAML SPs"],
[`${base}/ldap`, "LDAP"],
];
return `<style>
.muted { color: #888; } .muted { color: #888; }
nav { margin-bottom: 1rem; }
nav a { margin-right: 1rem; }
form.inline { display: inline; } form.inline { display: inline; }
input[type=text] { padding: 0.2rem; } input[type=text] { padding: 0.2rem; }
button { padding: 0.2rem 0.5rem; cursor: pointer; } button:not(.btn) { padding: 0.2rem 0.5rem; cursor: pointer; }
</style> </style>
</head><body> ${navPills(req, links)}
<nav>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}">Dashboard</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Clients</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups">Groups</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps">SAML SPs</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap">LDAP</a>
</nav>
${body} ${body}
<p class="muted">${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}</p> <p class="text-muted small mt-3">${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}</p>`;
</body></html>`;
}; };
@ -133,7 +144,7 @@ const dashboard = async (req, res) => {
const active = await keys.getActiveKeyMeta(); const active = await keys.getActiveKeyMeta();
const issuer = issuerForReq(req); const issuer = issuerForReq(req);
const body = ` const body = `
<table> <table class="table table-sm table-bordered">
<tr><th>Env ID</th><td><code>${escapeHtml(env ? env.env_id : "?")}</code></td></tr> <tr><th>Env ID</th><td><code>${escapeHtml(env ? env.env_id : "?")}</code></td></tr>
<tr><th>Bootstrapped at</th><td>${escapeHtml(env ? env.bootstrapped_at : "")}</td></tr> <tr><th>Bootstrapped at</th><td>${escapeHtml(env ? env.bootstrapped_at : "")}</td></tr>
<tr><th>Issuer</th><td><code>${escapeHtml(issuer)}</code></td></tr> <tr><th>Issuer</th><td><code>${escapeHtml(issuer)}</code></td></tr>
@ -148,7 +159,7 @@ const dashboard = async (req, res) => {
<h2>Signing key rotation</h2> <h2>Signing key rotation</h2>
<p class="muted">Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as <code>retiring</code>) until its grace window elapses, then drops out.</p> <p class="muted">Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as <code>retiring</code>) until its grace window elapses, then drops out.</p>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/rotate-key">${csrfField(req)}<button>rotate signing key</button></form>`; <form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/rotate-key">${csrfField(req)}<button>rotate signing key</button></form>`;
res.type("text/html").send(layout("saltcorn-idp dashboard", body)); res.sendWrap("saltcorn-idp dashboard", layout(req, body));
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e); console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e);
@ -198,12 +209,12 @@ const groupsPage = async (req, res) => {
const body = ` const body = `
<h1>Groups</h1> <h1>Groups</h1>
<p class="muted">The OIDC <code>groups</code> claim = each user's Saltcorn role (as <code>role:&lt;name&gt;</code>) plus these custom groups (as <code>group:&lt;name&gt;</code>).</p> <p class="muted">The OIDC <code>groups</code> claim = each user's Saltcorn role (as <code>role:&lt;name&gt;</code>) plus these custom groups (as <code>group:&lt;name&gt;</code>).</p>
<table><tr><th>Group</th><th>Members</th><th></th></tr>${rows || '<tr><td colspan="3" class="muted">no groups yet</td></tr>'}</table> <table class="table table-sm table-bordered"><tr><th>Group</th><th>Members</th><th></th></tr>${rows || '<tr><td colspan="3" class="muted">no groups yet</td></tr>'}</table>
<h2>Create group</h2> <h2>Create group</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/create">${csrfField(req)} <form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/create">${csrfField(req)}
<input type="text" name="name" placeholder="group name" required> <button>create</button> <input type="text" name="name" placeholder="group name" required> <button>create</button>
</form>`; </form>`;
res.type("text/html").send(layout("saltcorn-idp groups", body)); res.sendWrap("saltcorn-idp groups", layout(req, body));
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e); console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e);
@ -306,7 +317,7 @@ const clientsPage = async (req, res) => {
} }
const body = ` const body = `
<h1>Clients (relying parties)</h1> <h1>Clients (relying parties)</h1>
<table><tr><th>client_id</th><th>label</th><th>auth</th><th>redirect URIs</th><th>scope</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no clients yet</td></tr>'}</table> <table class="table table-sm table-bordered"><tr><th>client_id</th><th>label</th><th>auth</th><th>redirect URIs</th><th>scope</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no clients yet</td></tr>'}</table>
<h2>Register client</h2> <h2>Register client</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/create">${csrfField(req)} <form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/create">${csrfField(req)}
<p>client_id <input type="text" name="client_id" required></p> <p>client_id <input type="text" name="client_id" required></p>
@ -322,7 +333,7 @@ const clientsPage = async (req, res) => {
<p>scope <input type="text" name="scope" value="openid email profile groups"></p> <p>scope <input type="text" name="scope" value="openid email profile groups"></p>
<button>register</button> <button>register</button>
</form>`; </form>`;
res.type("text/html").send(layout("saltcorn-idp clients", body)); res.sendWrap("saltcorn-idp clients", layout(req, body));
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] clients page failed:`, e); console.error(`[${constants.PLUGIN_NAME}] clients page failed:`, e);
@ -368,7 +379,7 @@ const createClientHandler = async (req, res) => {
<p>Client secret (shown once - copy it now):</p> <p>Client secret (shown once - copy it now):</p>
<p><code>${escapeHtml(created.secret)}</code></p> <p><code>${escapeHtml(created.secret)}</code></p>
<p><a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Back to clients</a></p>`; <p><a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Back to clients</a></p>`;
res.type("text/html").send(layout("client secret", body)); res.sendWrap("client secret", layout(req, body));
} else { } else {
res.redirect(constants.ADMIN_BASE_PATH + "/clients"); res.redirect(constants.ADMIN_BASE_PATH + "/clients");
} }
@ -409,7 +420,7 @@ const samlSpsPage = async (req, res) => {
const body = ` const body = `
<h1>SAML service providers</h1> <h1>SAML service providers</h1>
<p class="muted">Only registered SPs receive assertions, and only at an allow-listed ACS URL. A signing cert enables (and "require signed" enforces) AuthnRequest signature verification.</p> <p class="muted">Only registered SPs receive assertions, and only at an allow-listed ACS URL. A signing cert enables (and "require signed" enforces) AuthnRequest signature verification.</p>
<table><tr><th>entityID</th><th>label</th><th>ACS URLs</th><th>req signed</th><th>cert</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no SPs yet</td></tr>'}</table> <table class="table table-sm table-bordered"><tr><th>entityID</th><th>label</th><th>ACS URLs</th><th>req signed</th><th>cert</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no SPs yet</td></tr>'}</table>
<h2>Register SP</h2> <h2>Register SP</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/create">${csrfField(req)} <form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/create">${csrfField(req)}
<p>entityID <input type="text" name="entity_id" required></p> <p>entityID <input type="text" name="entity_id" required></p>
@ -419,7 +430,7 @@ const samlSpsPage = async (req, res) => {
<p><label><input type="checkbox" name="want_signed" value="1"> require signed AuthnRequests</label></p> <p><label><input type="checkbox" name="want_signed" value="1"> require signed AuthnRequests</label></p>
<button>register</button> <button>register</button>
</form>`; </form>`;
res.type("text/html").send(layout("saltcorn-idp saml sps", body)); res.sendWrap("saltcorn-idp saml sps", layout(req, body));
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e); console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e);
@ -512,7 +523,7 @@ const ldapListenerSection = async (req) => {
const applied = await ldapSettings.getApplied(); const applied = await ldapSettings.getApplied();
const effective = runtime.enabled ? `${escapeHtml(runtime.host)}:${escapeHtml(String(runtime.port))}` : "disabled"; const effective = runtime.enabled ? `${escapeHtml(runtime.host)}:${escapeHtml(String(runtime.port))}` : "disabled";
const running = (applied && applied.enabled) ? `${escapeHtml(applied.host)}:${escapeHtml(String(applied.port))}` : "disabled"; const running = (applied && applied.enabled) ? `${escapeHtml(applied.host)}:${escapeHtml(String(applied.port))}` : "disabled";
const statusTable = `<table> const statusTable = `<table class="table table-sm table-bordered">
<tr><th>Currently running</th><td>${running}</td></tr> <tr><th>Currently running</th><td>${running}</td></tr>
<tr><th>Effective after restart</th><td>${effective}</td></tr> <tr><th>Effective after restart</th><td>${effective}</td></tr>
</table>`; </table>`;
@ -557,7 +568,7 @@ const ldapServicePage = async (req, res) => {
${listener} ${listener}
<h1>LDAP service account</h1> <h1>LDAP service account</h1>
<p class="muted">A service DN + password for the search-then-bind flow (an application binds as this DN, searches for a user, then re-binds as that user to validate the password). The password is sealed at rest and never displayed.</p> <p class="muted">A service DN + password for the search-then-bind flow (an application binds as this DN, searches for a user, then re-binds as that user to validate the password). The password is sealed at rest and never displayed.</p>
<table><tr><th>Configured service DN</th><td>${dn ? `<code>${escapeHtml(dn)}</code>` : '<span class="muted">(none)</span>'}</td></tr></table> <table class="table table-sm table-bordered"><tr><th>Configured service DN</th><td>${dn ? `<code>${escapeHtml(dn)}</code>` : '<span class="muted">(none)</span>'}</td></tr></table>
<h2>Set service account</h2> <h2>Set service account</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service">${csrfField(req)} <form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service">${csrfField(req)}
<p>service DN <input type="text" name="dn" placeholder="cn=svc,ou=people,dc=saltcorn,dc=local" size="55"></p> <p>service DN <input type="text" name="dn" placeholder="cn=svc,ou=people,dc=saltcorn,dc=local" size="55"></p>
@ -566,7 +577,7 @@ const ldapServicePage = async (req, res) => {
</form> </form>
<h2>Clear</h2> <h2>Clear</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service/clear">${csrfField(req)}<button>clear service account</button></form>`; <form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service/clear">${csrfField(req)}<button>clear service account</button></form>`;
res.type("text/html").send(layout("saltcorn-idp ldap", body)); res.sendWrap("saltcorn-idp ldap", layout(req, body));
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e); console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e);