diff --git a/public/app.js b/public/app.js index e32e262..20ae6f2 100644 --- a/public/app.js +++ b/public/app.js @@ -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) { diff --git a/server/index.js b/server/index.js index ca4e33b..49fb492 100644 --- a/server/index.js +++ b/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") { diff --git a/test/auth-methods.integration.test.js b/test/auth-methods.integration.test.js index 3c5d647..a6e5a2a 100644 --- a/test/auth-methods.integration.test.js +++ b/test/auth-methods.integration.test.js @@ -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-"));