// Admin UI for saltcorn-idp: a read-only dashboard plus group management. // All pages are admin-gated (role_id 1) and live under /admin/idp (CSRF-protected // browser forms — note these are NOT under the CSRF-exempt /idp namespace). const crypto = require("crypto"); const constants = require("./constants"); const keys = require("./keys"); const groups = require("./groups"); const clients = require("./clients"); const samlSps = require("./saml/sps"); const serviceAccount = require("./ldap/serviceAccount"); const ldapSettings = require("./ldap/settings"); const db = require("@saltcorn/data/db"); const User = require("@saltcorn/data/models/user"); const { escapeHtml } = require("./web"); const { getEnv } = require("./env"); const { issuerForReq } = require("./oidc/discovery"); // Saltcorn native markup primitives -- the same ones core admin pages use, so // these pages inherit the active theme by construction. const { mkTable, post_btn, renderForm, link, alert, tags } = require("@saltcorn/markup"); const { p, code, pre, div, span, strong } = tags; const Form = require("@saltcorn/data/models/form"); const ADMIN_ROLE_ID = 1; const isAdmin = (req) => { return !!(req && req.user && req.user.role_id === ADMIN_ROLE_ID); }; // URL-scheme guards for admin-supplied URLs. A SAML ACS is always an http(s) // web endpoint, so require that (a "javascript:" ACS would otherwise become a // form action). OIDC redirect_uris may use native/custom schemes, so for those // only reject the dangerous executable schemes. const DANGEROUS_SCHEME = /^\s*(javascript|data|vbscript|file)\s*:/i; const isHttpUrl = (u) => { try { const p = new URL(String(u)).protocol; return p === "http:" || p === "https:"; } catch (e) { return false; } }; // True if a PEM X.509 cert parses and is currently within its validity window. // A null/empty cert is "ok" here (means "no cert supplied"); callers decide. const certCurrentlyValid = (pem) => { if (!pem) { return true; } try { const x = new crypto.X509Certificate(pem); const now = Date.now(); const nb = Date.parse(x.validFrom); const na = Date.parse(x.validTo); return Number.isFinite(nb) && Number.isFinite(na) && now >= nb && now <= na; } catch (e) { return false; } }; // Lightweight per-session rate limit on admin MUTATIONS (defence-in-depth on top // of the role gate). In-memory sliding window keyed by session id (falls back to // IP). Generous enough never to trip normal admin use or the gates. const adminHits = new Map(); const adminRateOk = (req) => { const key = (req.sessionID || (req.session && req.session.id) || (req.connection && req.connection.remoteAddress) || "anon"); const now = Date.now(); const window = constants.ADMIN_RATE_WINDOW_MS; const hits = (adminHits.get(key) || []).filter((t) => now - t < window); if (hits.length >= constants.ADMIN_RATE_MAX) { adminHits.set(key, hits); return false; } hits.push(now); adminHits.set(key, hits); return true; }; const csrfField = (req) => { const token = req.csrfToken ? req.csrfToken() : ""; return ``; }; // 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//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 ` `; }).join("\n"); return ``; }; const IDP_NAV = [ [constants.ADMIN_BASE_PATH, "Dashboard"], [constants.ADMIN_BASE_PATH + "/clients", "Clients"], [constants.ADMIN_BASE_PATH + "/groups", "Groups"], [constants.ADMIN_BASE_PATH + "/saml-sps", "SAML SPs"], [constants.ADMIN_BASE_PATH + "/ldap", "LDAP"], ]; // Render an admin page in the ACTIVE Saltcorn theme: breadcrumbs + sub-nav + // content segments (cards or strings), via res.sendWrap. The one rendering path; // pages build content with mkTable / renderForm / post_btn (no raw page HTML). const adminPage = (req, res, title, ...segments) => { const above = [ { type: "breadcrumbs", crumbs: [{ text: "Settings", href: "/settings" }, { text: "saltcorn-idp" }, { text: title }] }, navPills(req, IDP_NAV), ]; res.sendWrap(`saltcorn-idp ${title}`, { above: above.concat(segments) }); }; const dashboard = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { // Opportunistically sweep RETIRING keys whose grace window has elapsed to // RETIRED (drops them from JWKS) so admins don't need a separate cron. await keys.retireExpiredKeys(); const env = await getEnv(); const jwks = await keys.getJwks(); const active = await keys.getActiveKeyMeta(); const issuer = issuerForReq(req); const kvRows = [ { k: "Env ID", v: code(escapeHtml(env ? env.env_id : "?")) }, { k: "Bootstrapped at", v: escapeHtml(env ? env.bootstrapped_at : "") }, { k: "Issuer", v: code(escapeHtml(issuer)) }, { k: "Active signing key (kid)", v: code(escapeHtml(active ? active.kid : "(none)")) }, { k: "Active key status", v: escapeHtml(active ? constants.KEY_STATUS.ACTIVE : "(none)") }, { k: "Active key created at", v: escapeHtml(active ? active.created_at : "") }, { k: "Signing alg", v: escapeHtml(active ? active.alg : "") }, { k: "Published JWKS keys", v: escapeHtml(jwks.keys.length) }, { k: "Discovery", v: link(constants.WELL_KNOWN_OPENID, constants.WELL_KNOWN_OPENID) }, { k: "JWKS", v: link(constants.JWKS_PATH, constants.JWKS_PATH) }, ]; const kv = mkTable([{ label: "Setting", key: "k" }, { label: "Value", key: (r) => r.v }], kvRows); const rotate = p({ class: "text-muted" }, "Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as ", code("retiring"), ") until its grace window elapses, then drops out.") + post_btn(constants.ADMIN_BASE_PATH + "/rotate-key", "Rotate signing key", req.csrfToken(), { req, btnClass: "btn-warning", confirm: true }); adminPage(req, res, "dashboard", { type: "card", title: "Identity provider", contents: kv }, { type: "card", title: "Signing key rotation", contents: rotate } ); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e); res.status(500).type("text/plain").send("dashboard unavailable"); } }; const rotateKeyHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } try { await keys.rotateActiveKey(); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] key rotation failed:`, e); } res.redirect(constants.ADMIN_BASE_PATH); }; const groupsPage = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const all = await groups.listGroups(); const groupRows = []; for (const g of all) { const members = await groups.membersOf(g.id); const memberList = members.map((member) => span({ class: "d-inline-block me-2 mb-1" }, code(escapeHtml(member.user_email)) + " " + post_btn(constants.ADMIN_BASE_PATH + "/groups/removemember", "x", req.csrfToken(), { req, btnClass: "btn-outline-danger", small: true, formClass: "d-inline", body: { group_id: g.id, user_email: member.user_email } })) ).join(""); const addMember = `
${csrfField(req)}
`; groupRows.push({ name: code(escapeHtml(g.name)), members: (memberList || span({ class: "text-muted" }, "(none)")) + "
" + addMember, actions: post_btn(constants.ADMIN_BASE_PATH + "/groups/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { id: g.id }, confirm: true }), }); } const groupsTable = groupRows.length === 0 ? p({ class: "text-muted" }, "no groups yet") : mkTable([{ label: "Group", key: (r) => r.name }, { label: "Members", key: (r) => r.members }, { label: "", key: (r) => r.actions }], groupRows); const createForm = new Form({ action: constants.ADMIN_BASE_PATH + "/groups/create", submitLabel: "Create", fields: [{ name: "name", label: "Group name", type: "String", required: true }] }); adminPage(req, res, "groups", { type: "card", title: "Groups", contents: p({ class: "text-muted" }, "The OIDC ", code("groups"), " claim = each user's Saltcorn role (as ", code("role:<name>"), ") plus these custom groups (as ", code("group:<name>"), ").") + groupsTable }, { type: "card", title: "Create group", contents: renderForm(createForm, req.csrfToken()) } ); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e); res.status(500).type("text/plain").send("groups unavailable"); } }; const requireAdmin = (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return false; } // requireAdmin gates the mutating handlers; throttle POSTs per session. if (req.method === "POST" && !adminRateOk(req)) { res.status(429).type("text/plain").send("too many requests"); return false; } return true; }; const createGroupHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const name = String((req.body && req.body.name) || "").trim(); if (name) { try { await groups.createGroup(name); } catch (e) { // unique-name violation or similar; ignore and re-render } } res.redirect(constants.ADMIN_BASE_PATH + "/groups"); }; const deleteGroupHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const id = parseInt(req.body && req.body.id, 10); if (Number.isFinite(id)) { await groups.deleteGroup(id); } res.redirect(constants.ADMIN_BASE_PATH + "/groups"); }; const addMemberHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const groupId = parseInt(req.body && req.body.group_id, 10); const email = String((req.body && req.body.email) || "").trim(); if (Number.isFinite(groupId) && email) { // Validate the user exists and store its CANONICAL email (matches how // users.email is stored), so the membership key stays consistent. const u = await User.findOne({ email: email }); if (u) { await groups.addMember(groupId, u.email); } } res.redirect(constants.ADMIN_BASE_PATH + "/groups"); }; const removeMemberHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const groupId = parseInt(req.body && req.body.group_id, 10); const email = String((req.body && req.body.user_email) || "").trim(); if (Number.isFinite(groupId) && email) { await groups.removeMember(groupId, email); } res.redirect(constants.ADMIN_BASE_PATH + "/groups"); }; const clientsPage = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const all = await clients.listClients(); const clientsTable = all.length === 0 ? p({ class: "text-muted" }, "no clients yet") : mkTable([ { label: "client_id", key: (c) => code(escapeHtml(c.client_id)) }, { label: "label", key: (c) => escapeHtml(c.label || "") }, { label: "auth", key: (c) => escapeHtml(c.token_auth_method) }, { label: "redirect URIs", key: (c) => JSON.parse(c.redirect_uris).map((u) => code(escapeHtml(u))).join("
") }, { label: "scope", key: (c) => escapeHtml(c.scope || "") }, { label: "", key: (c) => post_btn(constants.ADMIN_BASE_PATH + "/clients/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { client_id: c.client_id }, confirm: true }) }, ], all); const regForm = new Form({ action: constants.ADMIN_BASE_PATH + "/clients/create", submitLabel: "Register", values: { auth_method: "none", scope: "openid email profile groups" }, fields: [ { name: "client_id", label: "client_id", type: "String", required: true }, { name: "label", label: "label", type: "String" }, { name: "redirect_uris", label: "redirect URIs (one per line)", type: "String", fieldview: "textarea" }, { name: "auth_method", label: "auth method", input_type: "select", options: [ { label: "none (public + PKCE)", value: "none" }, { label: "client_secret_basic (confidential)", value: "client_secret_basic" }, { label: "client_secret_post (confidential)", value: "client_secret_post" }, ] }, { name: "scope", label: "scope", type: "String" }, ], }); adminPage(req, res, "clients", { type: "card", title: "Clients (relying parties)", contents: clientsTable }, { type: "card", title: "Register client", contents: renderForm(regForm, req.csrfToken()) } ); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] clients page failed:`, e); res.status(500).type("text/plain").send("clients unavailable"); } }; const parseUris = (text) => { return String(text || "").split(/[\r\n]+/).map((s) => s.trim()).filter(Boolean); }; const createClientHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const clientId = String((req.body && req.body.client_id) || "").trim(); const redirectUris = parseUris(req.body && req.body.redirect_uris); // Reject executable-scheme redirect URIs (javascript:/data:/...). Custom // native-app schemes + loopback are allowed (oidc-provider validates the rest). if (!clientId || redirectUris.some((u) => DANGEROUS_SCHEME.test(u))) { res.redirect(constants.ADMIN_BASE_PATH + "/clients"); return; } let created = null; try { created = await clients.createClient({ clientId: clientId, label: String((req.body && req.body.label) || "").trim(), redirectUris: redirectUris, authMethod: String((req.body && req.body.auth_method) || "none"), scope: String((req.body && req.body.scope) || "").trim() }); } catch (e) { res.redirect(constants.ADMIN_BASE_PATH + "/clients"); return; } if (created && created.secret) { adminPage(req, res, "client secret", { type: "card", title: "Client registered", contents: p("client_id: ", code(escapeHtml(created.client_id))) + p("Client secret (shown once - copy it now):") + pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escapeHtml(created.secret)) + p(link(constants.ADMIN_BASE_PATH + "/clients", "Back to clients")) } ); } else { res.redirect(constants.ADMIN_BASE_PATH + "/clients"); } }; const deleteClientHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const clientId = String((req.body && req.body.client_id) || "").trim(); if (clientId) { await clients.deleteClient(clientId); } res.redirect(constants.ADMIN_BASE_PATH + "/clients"); }; const samlSpsPage = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const all = await samlSps.listSps(); const spsTable = all.length === 0 ? p({ class: "text-muted" }, "no SPs yet") : mkTable([ { label: "entityID", key: (s) => code(escapeHtml(s.entity_id)) }, { label: "label", key: (s) => escapeHtml(s.label || "") }, { label: "ACS URLs", key: (s) => samlSps.acsUrls(s).map((u) => code(escapeHtml(u))).join("
") }, { label: "req signed", key: (s) => s.want_authn_requests_signed ? "yes" : "no" }, { label: "cert", key: (s) => s.signing_cert ? "yes" : "no" }, { label: "", key: (s) => post_btn(constants.ADMIN_BASE_PATH + "/saml-sps/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { entity_id: s.entity_id }, confirm: true }) }, ], all); const regForm = new Form({ action: constants.ADMIN_BASE_PATH + "/saml-sps/create", submitLabel: "Register", fields: [ { name: "entity_id", label: "entityID", type: "String", required: true }, { name: "label", label: "label", type: "String" }, { name: "acs_urls", label: "ACS URLs (one per line)", type: "String", fieldview: "textarea" }, { name: "signing_cert", label: "signing cert (PEM, optional)", type: "String", fieldview: "textarea" }, { name: "want_signed", label: "require signed AuthnRequests", type: "Bool" }, ], }); adminPage(req, res, "saml sps", { type: "card", title: "SAML service providers", contents: p({ class: "text-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.") + spsTable }, { type: "card", title: "Register SP", contents: renderForm(regForm, req.csrfToken()) } ); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e); res.status(500).type("text/plain").send("saml sps unavailable"); } }; const createSamlSpHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const entityId = String((req.body && req.body.entity_id) || "").trim(); const acsUrls = parseUris(req.body && req.body.acs_urls); // An SP with no ACS URL is unusable and a hazard: the SSO/SLO handlers would // have nothing to validate a request-supplied ACS against. Reject it here so // a no-ACS SP never reaches the registry (defence-in-depth over the request // handlers, which also reject an empty allow-list). const signingCert = String((req.body && req.body.signing_cert) || "").trim() || null; // Reject: no entityId, no/empty ACS list, any ACS that is not an http(s) URL // (a SAML ACS is always a web endpoint; this blocks a "javascript:" ACS from // becoming the auto-POST form action), or an expired/unparseable signing cert. if (!entityId || acsUrls.length === 0 || !acsUrls.every(isHttpUrl) || !certCurrentlyValid(signingCert)) { res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps"); return; } try { await samlSps.createSp({ entityId: entityId, label: String((req.body && req.body.label) || "").trim(), acsUrls: acsUrls, signingCert: signingCert, wantSigned: !!(req.body && req.body.want_signed) }); } catch (e) { // duplicate entity_id or similar; fall through to re-render } res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps"); }; const deleteSamlSpHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const entityId = String((req.body && req.body.entity_id) || "").trim(); if (entityId) { await samlSps.deleteSp(entityId); } res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps"); }; // The LDAP listener is process-global (one per instance), so its host/port/enable // settings are editable only on the root/public site; tenant admins see them // read-only. isRootTenant() is true on a single-tenant (SQLite) deployment. const isRootTenant = () => { if (!db.is_it_multi_tenant || !db.is_it_multi_tenant()) { return true; } const def = String((db.connectObj && db.connectObj.default_schema) || "public").toLowerCase(); const cur = String((db.getTenantSchema && db.getTenantSchema()) || "").toLowerCase(); return cur === def; }; // Whether the running listener (applied) differs from the resolved desired // settings (runtime) -- i.e. a restart is needed for an edit to take effect. const ldapAppliedDiffers = (applied, runtime) => { const a = applied || { enabled: false }; if (!a.enabled && !runtime.enabled) { return false; } if (!!a.enabled !== !!runtime.enabled) { return true; } return a.host !== runtime.host || a.port !== runtime.port; }; const LDAP_SETTINGS_ERRORS = { port: "Port must be an integer between 1 and 65535.", host: "Bind host contains invalid characters.", noport: "Enabling the LDAP listener requires a valid port." }; const ldapListenerSection = async (req) => { const runtime = await ldapSettings.resolveRuntime(); 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 = `
Currently running${running}
Effective after restart${effective}
`; if (!isRootTenant()) { return `

LDAP listener

One LDAPS listener serves all tenants on this Saltcorn instance; it is configured on the public site (read-only here).

${statusTable}`; } const stored = await ldapSettings.getStoredConfig(); const errCode = String((req.query && req.query.err) || ""); const errBanner = (errCode && LDAP_SETTINGS_ERRORS[errCode]) ? `

${escapeHtml(LDAP_SETTINGS_ERRORS[errCode])}

` : ""; const restartBanner = ldapAppliedDiffers(applied, runtime) ? `

⚠ LDAP settings changed — restart Saltcorn for them to take effect.

` : ""; const envNote = (runtime.portFromEnv || runtime.hostFromEnv) ? `

An LDAP environment variable (${escapeHtml(constants.LDAP_PORT_ENV)} / ${escapeHtml(constants.LDAP_HOST_ENV)}) is set and currently OVERRIDES the values below until it is removed.

` : ""; const exposureWarn = (stored.host && !ldapSettings.isLoopbackHost(stored.host)) ? `

Warning: bind host ${escapeHtml(stored.host)} is beyond loopback — the listener will be reachable from the network. Ensure this is intended and firewalled.

` : ""; return `

LDAP listener (this server)

One LDAPS listener serves all tenants on this Saltcorn instance. Changes apply on the next Saltcorn restart.

${errBanner}${restartBanner}${envNote} ${statusTable}
${csrfField(req)}

Bind host

Port

${exposureWarn}
`; }; const ldapServicePage = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const dn = await serviceAccount.getServiceDn(); const listener = await ldapListenerSection(req); const setForm = new Form({ action: constants.ADMIN_BASE_PATH + "/ldap/service", submitLabel: "Save", fields: [ { name: "dn", label: "service DN", type: "String", attributes: { placeholder: "cn=svc,ou=people,dc=saltcorn,dc=local" } }, { name: "password", label: "password", type: "String", input_type: "password" }, ], }); adminPage(req, res, "ldap", { type: "card", title: "LDAP listener", contents: listener }, { type: "card", title: "LDAP service account", contents: p({ class: "text-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("Configured service DN: ", dn ? code(escapeHtml(dn)) : span({ class: "text-muted" }, "(none)")) + renderForm(setForm, req.csrfToken()) }, { type: "card", title: "Clear", contents: post_btn(constants.ADMIN_BASE_PATH + "/ldap/service/clear", "Clear service account", req.csrfToken(), { req, btnClass: "btn-outline-danger", confirm: true }) } ); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e); res.status(500).type("text/plain").send("ldap admin unavailable"); } }; const setLdapServiceHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const dn = String((req.body && req.body.dn) || "").trim(); const password = String((req.body && req.body.password) || ""); if (dn && password) { await serviceAccount.setServiceAccount(dn, password); } res.redirect(constants.ADMIN_BASE_PATH + "/ldap"); }; const clearLdapServiceHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } await serviceAccount.setServiceAccount(null, null); res.redirect(constants.ADMIN_BASE_PATH + "/ldap"); }; const setLdapSettingsHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } // The listener is process-global; only the public/root site may change it. if (!isRootTenant()) { res.status(403).type("text/plain").send("LDAP listener settings are managed on the public site"); return; } const enabled = !!(req.body && (req.body.enabled === "on" || req.body.enabled === "true" || req.body.enabled === true)); const host = String((req.body && req.body.host) || "").trim(); const portRaw = String((req.body && req.body.port) || "").trim(); if (host && !/^[A-Za-z0-9_.:-]+$/.test(host)) { res.redirect(constants.ADMIN_BASE_PATH + "/ldap?err=host"); return; } let port = null; if (portRaw !== "") { const n = parseInt(portRaw, 10); if (!ldapSettings.validatePort(n)) { res.redirect(constants.ADMIN_BASE_PATH + "/ldap?err=port"); return; } port = n; } if (enabled && port === null) { res.redirect(constants.ADMIN_BASE_PATH + "/ldap?err=noport"); return; } await ldapSettings.setStoredConfig({ enabled: enabled, host: host, port: port }); res.redirect(constants.ADMIN_BASE_PATH + "/ldap"); }; const adminRoutes = [ { url: constants.ADMIN_BASE_PATH, method: "get", callback: dashboard }, { url: constants.ADMIN_BASE_PATH + "/", method: "get", callback: dashboard }, { url: constants.ADMIN_BASE_PATH + "/rotate-key", method: "post", callback: rotateKeyHandler }, { url: constants.ADMIN_BASE_PATH + "/clients", method: "get", callback: clientsPage }, { url: constants.ADMIN_BASE_PATH + "/clients/create", method: "post", callback: createClientHandler }, { url: constants.ADMIN_BASE_PATH + "/clients/delete", method: "post", callback: deleteClientHandler }, { url: constants.ADMIN_BASE_PATH + "/groups", method: "get", callback: groupsPage }, { url: constants.ADMIN_BASE_PATH + "/groups/create", method: "post", callback: createGroupHandler }, { url: constants.ADMIN_BASE_PATH + "/groups/delete", method: "post", callback: deleteGroupHandler }, { url: constants.ADMIN_BASE_PATH + "/groups/addmember", method: "post", callback: addMemberHandler }, { url: constants.ADMIN_BASE_PATH + "/groups/removemember", method: "post", callback: removeMemberHandler }, { url: constants.ADMIN_BASE_PATH + "/saml-sps", method: "get", callback: samlSpsPage }, { url: constants.ADMIN_BASE_PATH + "/saml-sps/create", method: "post", callback: createSamlSpHandler }, { url: constants.ADMIN_BASE_PATH + "/saml-sps/delete", method: "post", callback: deleteSamlSpHandler }, { url: constants.ADMIN_BASE_PATH + "/ldap", method: "get", callback: ldapServicePage }, { url: constants.ADMIN_BASE_PATH + "/ldap/service", method: "post", callback: setLdapServiceHandler }, { url: constants.ADMIN_BASE_PATH + "/ldap/service/clear", method: "post", callback: clearLdapServiceHandler }, { url: constants.ADMIN_BASE_PATH + "/ldap/settings", method: "post", callback: setLdapSettingsHandler } ]; module.exports = { adminRoutes, isAdmin, escapeHtml };