web/admin/access-requests.js
Rama main · réplica de lectura del repositorio original.
/* web/admin/access-requests.js — the "Request access" queue render
* (ADR-004 part 1, security only).
*
* A pure DOM-building module with no fetch/state of its own -- kept
* separate from web/admin/main.js (which owns fetching and wiring, same
* split main.js already keeps for renderKeys/renderVocabulary) so this
* file is testable in isolation under Node, the same way
* web/js/render/feedback.js is (see run_feedback_control.mjs): importing
* main.js directly would also run its top-level bootstrap
* (wireTopbar/refreshAll/etc.), which assumes a full page's worth of DOM
* elements exist.
*
* Every value rendered here is untrusted server data -- a requester id,
* a column name, the question joined from the audit log, a denial reason
* -- and every one of them is set through `.textContent` (or a plain
* DOM property, never a template string fed to `innerHTML`). The admin
* panel had a stored XSS fixed in 4.12.1 (a principal id round-tripping,
* quote intact, into a `data-*` attribute string -- see
* web/admin/main.js's own `escapeHtml` comment); this module follows the
* newer, structurally safer pattern `renderKeys`/`renderVocabulary`
* already established there instead of that fix's `escapeHtml`-in-a-
* string approach, so there is no string-built HTML here for a future
* value to break out of in the first place.
*/
"use strict";
import { formatWhen } from "./time.js";
const STATUS_LABELS = { open: "باز", approved: "تأیید شده", denied: "رد شده" };
const STATUS_CLASS = { open: "skip", approved: "pass", denied: "fail" };
/**
* Render the access-request queue into *host*, replacing its contents.
*
* @param {HTMLElement} host
* @param {Array<{
* request_id: number, requester_principal_id: string, column_name: string,
* status: "open"|"approved"|"denied", created_at: string,
* resolution_note: string|null,
* audit: {question: string|null}|null,
* }>} rows -- GET /admin/access-requests's `access_requests` list
* @param {{
* onApprove: (requestId: number, btn: HTMLButtonElement) => void,
* onDeny: (requestId: number, reason: string, btn: HTMLButtonElement) => void,
* }} handlers
*/
export function renderAccessRequestsList(host, rows, handlers) {
host.innerHTML = "";
if (!rows || !rows.length) {
const empty = document.createElement("p");
empty.className = "admin-loading";
empty.textContent = "درخواستی یافت نشد.";
host.appendChild(empty);
return;
}
for (const row of rows) {
host.appendChild(renderRow(row, handlers));
}
}
function renderRow(row, handlers) {
const cls = STATUS_CLASS[row.status] || "";
const wrap = document.createElement("div");
wrap.className = `admin-check ${cls}`;
wrap.dataset.requestId = String(row.request_id);
const mark = document.createElement("span");
mark.className = "admin-check-mark";
mark.setAttribute("aria-hidden", "true");
mark.textContent = row.status === "approved" ? "✓" : row.status === "denied" ? "✕" : "●";
wrap.appendChild(mark);
const body = document.createElement("span");
const nameLine = document.createElement("span");
nameLine.className = "admin-check-name";
// Built from separate text nodes/elements, never a single interpolated
// string -- requester_principal_id and column_name are both untrusted.
nameLine.appendChild(document.createTextNode(`#${row.request_id} · `));
nameLine.appendChild(document.createTextNode(row.requester_principal_id));
nameLine.appendChild(document.createTextNode(" · "));
const columnCode = document.createElement("code");
columnCode.textContent = row.column_name;
nameLine.appendChild(columnCode);
body.appendChild(nameLine);
body.appendChild(document.createTextNode(" "));
const statusPill = document.createElement("span");
statusPill.className = `admin-status-pill admin-status-${cls}`;
statusPill.style.marginInlineStart = "6px";
statusPill.textContent = STATUS_LABELS[row.status] || row.status;
body.appendChild(statusPill);
const question = row.audit && row.audit.question;
const questionLine = document.createElement("p");
questionLine.className = "admin-check-detail";
questionLine.textContent = question || "(دیگر قابل بازیابی از گزارش ممیزی نیست)";
body.appendChild(questionLine);
const createdLine = document.createElement("p");
createdLine.className = "admin-check-detail";
// A readable date (time.js); the original ISO string stays in the title.
const created = formatWhen(row.created_at);
createdLine.textContent = created.text;
if (created.title) createdLine.title = created.title;
body.appendChild(createdLine);
if (row.status === "denied" && row.resolution_note) {
const reasonLine = document.createElement("p");
reasonLine.className = "admin-check-detail";
reasonLine.appendChild(document.createTextNode("دلیل رد: "));
reasonLine.appendChild(document.createTextNode(row.resolution_note));
body.appendChild(reasonLine);
}
if (row.status === "open") {
body.appendChild(renderActions(row, handlers));
}
wrap.appendChild(body);
return wrap;
}
function renderActions(row, handlers) {
const actions = document.createElement("div");
actions.className = "admin-feedback-actions";
const approveBtn = document.createElement("button");
approveBtn.className = "admin-btn-refresh";
approveBtn.type = "button";
approveBtn.textContent = "تأیید";
approveBtn.addEventListener("click", () => handlers.onApprove(row.request_id, approveBtn));
actions.appendChild(approveBtn);
const reasonInput = document.createElement("input");
reasonInput.type = "text";
reasonInput.className = "fb-note";
reasonInput.placeholder = "دلیل رد (الزامی)";
actions.appendChild(reasonInput);
const denyBtn = document.createElement("button");
denyBtn.className = "admin-btn-refresh key-danger";
denyBtn.type = "button";
denyBtn.textContent = "رد کردن";
denyBtn.addEventListener("click", () => handlers.onDeny(row.request_id, reasonInput.value, denyBtn));
actions.appendChild(denyBtn);
return actions;
}