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) => {
return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<title>${escapeHtml(title)}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 1.5rem; max-width: 900px; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.1rem; margin-top: 1.5rem; }
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
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; }
// Bootstrap nav-pills with the current page marked active. The active link is the
// one whose href is the LONGEST boundary-safe prefix of the request path, so a
// sub-page (e.g. /clients/<id>/secret) still highlights its parent tab.
const navPills = (req, links) => {
const cur = String((req && (req.originalUrl || req.path)) || "").split("?")[0].replace(/\/+$/, "");
let activeHref = "";
for (const [href] of links) {
const h = href.replace(/\/+$/, "");
if ((cur === h || cur.startsWith(h + "/")) && h.length > activeHref.length) {
activeHref = h;
}
}
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; }
nav { margin-bottom: 1rem; }
nav a { margin-right: 1rem; }
form.inline { display: inline; }
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>
</head><body>
<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>
${navPills(req, links)}
${body}
<p class="muted">${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}</p>
</body></html>`;
<p class="text-muted small mt-3">${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}</p>`;
};
@ -133,7 +144,7 @@ const dashboard = async (req, res) => {
const active = await keys.getActiveKeyMeta();
const issuer = issuerForReq(req);
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>Bootstrapped at</th><td>${escapeHtml(env ? env.bootstrapped_at : "")}</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>
<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>`;
res.type("text/html").send(layout("saltcorn-idp dashboard", body));
res.sendWrap("saltcorn-idp dashboard", layout(req, body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e);
@ -198,12 +209,12 @@ const groupsPage = async (req, res) => {
const body = `
<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>
<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>
<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>
</form>`;
res.type("text/html").send(layout("saltcorn-idp groups", body));
res.sendWrap("saltcorn-idp groups", layout(req, body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e);
@ -306,7 +317,7 @@ const clientsPage = async (req, res) => {
}
const body = `
<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>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/create">${csrfField(req)}
<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>
<button>register</button>
</form>`;
res.type("text/html").send(layout("saltcorn-idp clients", body));
res.sendWrap("saltcorn-idp clients", layout(req, body));
} catch (e) {
// eslint-disable-next-line no-console
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><code>${escapeHtml(created.secret)}</code></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 {
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
}
@ -409,7 +420,7 @@ const samlSpsPage = async (req, res) => {
const body = `
<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>
<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>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/create">${csrfField(req)}
<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>
<button>register</button>
</form>`;
res.type("text/html").send(layout("saltcorn-idp saml sps", body));
res.sendWrap("saltcorn-idp saml sps", layout(req, body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e);
@ -512,7 +523,7 @@ const ldapListenerSection = async (req) => {
const applied = await ldapSettings.getApplied();
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 statusTable = `<table>
const statusTable = `<table class="table table-sm table-bordered">
<tr><th>Currently running</th><td>${running}</td></tr>
<tr><th>Effective after restart</th><td>${effective}</td></tr>
</table>`;
@ -557,7 +568,7 @@ const ldapServicePage = async (req, res) => {
${listener}
<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>
<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>
<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>
@ -566,7 +577,7 @@ const ldapServicePage = async (req, res) => {
</form>
<h2>Clear</h2>
<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) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e);