allow admins to delete approvals and activity entries
This commit is contained in:
@@ -2594,6 +2594,19 @@ function renderActivityLog() {
|
||||
details.textContent = JSON.stringify(entry.details, null, 2);
|
||||
block.appendChild(details);
|
||||
}
|
||||
if (isAdmin()) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.className = "danger";
|
||||
deleteBtn.textContent = "Eintrag loeschen";
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
await deleteActivityEntry(entry.id);
|
||||
});
|
||||
actions.appendChild(deleteBtn);
|
||||
block.appendChild(actions);
|
||||
}
|
||||
els.activityLogList.appendChild(block);
|
||||
}
|
||||
}
|
||||
@@ -2813,6 +2826,17 @@ function renderApprovals() {
|
||||
await decideApproval(entry.id, false);
|
||||
});
|
||||
actions.appendChild(rejectBtn);
|
||||
|
||||
if (isAdmin()) {
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.className = "danger";
|
||||
deleteBtn.textContent = "Eintrag loeschen";
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
await deleteApprovalEntry(entry.id);
|
||||
});
|
||||
actions.appendChild(deleteBtn);
|
||||
}
|
||||
block.appendChild(actions);
|
||||
|
||||
els.approvalsList.appendChild(block);
|
||||
@@ -2863,6 +2887,32 @@ async function decideApproval(id, approve) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApprovalEntry(id) {
|
||||
clearMessages("approvals");
|
||||
try {
|
||||
await api(`/v1/approvals/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
await refreshApprovals();
|
||||
renderMessage(els.approvalsMessage, "Freigabe-Eintrag geloescht", false, true);
|
||||
} catch (error) {
|
||||
renderMessage(els.approvalsMessage, error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteActivityEntry(id) {
|
||||
clearMessages("activity");
|
||||
try {
|
||||
await api(`/v1/activity-log/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
await refreshActivityLog();
|
||||
renderMessage(els.activityMessage, "Aktivitaets-Eintrag geloescht", false, true);
|
||||
} catch (error) {
|
||||
renderMessage(els.activityMessage, error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const status = state.status;
|
||||
if (!status) {
|
||||
|
||||
104
server/index.js
104
server/index.js
@@ -823,6 +823,15 @@ async function routeRequest(req, res) {
|
||||
return sendJson(res, 200, { approvals: listApprovalsView() });
|
||||
}
|
||||
|
||||
if (method === "DELETE" && url.pathname.match(/^\/v1\/approvals\/[^/]+$/)) {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth) return;
|
||||
if (!hasRole(auth.user, ["admin"])) {
|
||||
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
||||
}
|
||||
return handleApprovalDelete(res, auth.user, url.pathname);
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/v1/activity-log") {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth) return;
|
||||
@@ -835,6 +844,15 @@ async function routeRequest(req, res) {
|
||||
return sendJson(res, 200, { entries });
|
||||
}
|
||||
|
||||
if (method === "DELETE" && url.pathname.match(/^\/v1\/activity-log\/[^/]+$/)) {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth) return;
|
||||
if (!hasRole(auth.user, ["admin"])) {
|
||||
return sendError(res, 403, "auth.forbidden", "Nur Admin");
|
||||
}
|
||||
return handleActivityLogDelete(res, auth.user, url.pathname);
|
||||
}
|
||||
|
||||
if (method === "POST" && url.pathname.match(/^\/v1\/approvals\/[^/]+\/(approve|reject)$/)) {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth) return;
|
||||
@@ -1567,6 +1585,27 @@ async function handleApprovalDecision(res, actor, pathname) {
|
||||
return sendJson(res, 200, { ok: true, approval: approvalView, user: sanitizeUser(user) });
|
||||
}
|
||||
|
||||
async function handleApprovalDelete(res, actor, pathname) {
|
||||
const match = pathname.match(/^\/v1\/approvals\/([^/]+)$/);
|
||||
if (!match) {
|
||||
return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden");
|
||||
}
|
||||
const approvalId = decodeURIComponent(match[1]);
|
||||
const index = runtime.approvalRequests.findIndex((entry) => entry.id === approvalId);
|
||||
if (index < 0) {
|
||||
return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden");
|
||||
}
|
||||
const removed = runtime.approvalRequests[index];
|
||||
runtime.approvalRequests.splice(index, 1);
|
||||
await saveApprovalRequests();
|
||||
await appendAudit("approval.delete", actor, {
|
||||
approvalId,
|
||||
email: removed && removed.email ? removed.email : null,
|
||||
status: removed && removed.status ? removed.status : null
|
||||
});
|
||||
return sendJson(res, 200, { ok: true, deletedId: approvalId });
|
||||
}
|
||||
|
||||
function listApprovalsView() {
|
||||
return runtime.approvalRequests.map((approval) => {
|
||||
const user = runtime.users.find((entry) => entry.id === approval.userId);
|
||||
@@ -7465,6 +7504,7 @@ async function applyAdminRoles() {
|
||||
|
||||
async function appendAudit(action, user, details) {
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
at: new Date().toISOString(),
|
||||
action,
|
||||
userId: user ? user.id : null,
|
||||
@@ -7491,17 +7531,20 @@ async function listStationActivityLog(limit = 200) {
|
||||
]);
|
||||
|
||||
const entries = [];
|
||||
for (const line of lines) {
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const entry = parseAuditLine(line);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (!entry || !interesting.has(entry.action)) {
|
||||
continue;
|
||||
}
|
||||
const id = typeof entry.id === "string" && entry.id.trim()
|
||||
? entry.id.trim()
|
||||
: `legacy-${index}`;
|
||||
entries.push({
|
||||
id,
|
||||
at: entry.at,
|
||||
action: entry.action,
|
||||
email: entry.email || null,
|
||||
@@ -7514,6 +7557,57 @@ async function listStationActivityLog(limit = 200) {
|
||||
return entries.slice(0, limit);
|
||||
}
|
||||
|
||||
async function handleActivityLogDelete(res, actor, pathname) {
|
||||
const match = pathname.match(/^\/v1\/activity-log\/([^/]+)$/);
|
||||
if (!match) {
|
||||
return sendError(res, 404, "activity.not_found", "Aktivitaetseintrag nicht gefunden");
|
||||
}
|
||||
const targetId = decodeURIComponent(match[1]);
|
||||
const raw = await storage.readText(files.audit, "");
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const kept = [];
|
||||
let removed = null;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const entry = parseAuditLine(line);
|
||||
const id = entry && typeof entry.id === "string" && entry.id.trim()
|
||||
? entry.id.trim()
|
||||
: `legacy-${index}`;
|
||||
if (!removed && id === targetId) {
|
||||
removed = entry || { action: "unknown", at: null };
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
|
||||
if (!removed) {
|
||||
return sendError(res, 404, "activity.not_found", "Aktivitaetseintrag nicht gefunden");
|
||||
}
|
||||
|
||||
const nextRaw = kept.length ? `${kept.join("\n")}\n` : "";
|
||||
await storage.writeText(files.audit, nextRaw);
|
||||
await appendAudit("activity.delete", actor, {
|
||||
removedId: targetId,
|
||||
removedAction: removed.action || null,
|
||||
removedAt: removed.at || null,
|
||||
removedEmail: removed.email || null
|
||||
});
|
||||
return sendJson(res, 200, { ok: true, deletedId: targetId });
|
||||
}
|
||||
|
||||
function parseAuditLine(line) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function activityMessageForEntry(entry) {
|
||||
const email = entry.email || "unbekannt";
|
||||
if (entry.action === "auth.request_access") {
|
||||
|
||||
@@ -1137,6 +1137,80 @@ test("approvals list exposes account status and supports repeated decisions", as
|
||||
}
|
||||
});
|
||||
|
||||
test("admin can delete individual approval and activity entries", async (t) => {
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-delete-entries-test-"));
|
||||
const port = randomPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
const server = spawn(process.execPath, ["server/index.js"], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATA_DIR: dataDir,
|
||||
ADMIN_EMAILS: "admin@arcg.at",
|
||||
PRIMARY_EMAIL_DOMAIN: "arcg.at",
|
||||
PUBLIC_BASE_URL: baseUrl
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
let serverStdErr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
serverStdErr += String(chunk);
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
if (!server.killed) {
|
||||
server.kill("SIGTERM");
|
||||
}
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl);
|
||||
|
||||
const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at");
|
||||
const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` };
|
||||
|
||||
const externalEmail = "deleteme@oevsv.at";
|
||||
const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail);
|
||||
assert.equal(verify.approved, false);
|
||||
|
||||
const approvalsBefore = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders });
|
||||
const pending = (approvalsBefore.approvals || []).find((entry) => entry.email === externalEmail);
|
||||
assert.ok(pending, "approval entry should exist before delete");
|
||||
|
||||
const deleteApproval = await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}`, {
|
||||
method: "DELETE",
|
||||
headers: adminHeaders
|
||||
});
|
||||
assert.equal(deleteApproval.ok, true);
|
||||
assert.equal(deleteApproval.deletedId, pending.id);
|
||||
|
||||
const approvalsAfter = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders });
|
||||
assert.equal((approvalsAfter.approvals || []).some((entry) => entry.id === pending.id), false);
|
||||
|
||||
const activityBefore = await requestJson(baseUrl, "/v1/activity-log?limit=300", { headers: adminHeaders });
|
||||
assert.ok(Array.isArray(activityBefore.entries));
|
||||
const targetActivity = activityBefore.entries.find((entry) => entry && entry.id && entry.action === "auth.request_access");
|
||||
assert.ok(targetActivity, "activity entry with id should exist before delete");
|
||||
|
||||
const deleteActivity = await requestJson(baseUrl, `/v1/activity-log/${encodeURIComponent(targetActivity.id)}`, {
|
||||
method: "DELETE",
|
||||
headers: adminHeaders
|
||||
});
|
||||
assert.equal(deleteActivity.ok, true);
|
||||
assert.equal(deleteActivity.deletedId, targetActivity.id);
|
||||
|
||||
const activityAfter = await requestJson(baseUrl, "/v1/activity-log?limit=300", { headers: adminHeaders });
|
||||
assert.equal((activityAfter.entries || []).some((entry) => entry.id === targetActivity.id), false);
|
||||
|
||||
if (server.exitCode !== null && server.exitCode !== 0) {
|
||||
throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("station usage is auto-ended after configured max usage time", async (t) => {
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-station-timeout-test-"));
|
||||
|
||||
Reference in New Issue
Block a user