allow admins to delete approvals and activity entries

This commit is contained in:
2026-04-02 22:21:13 +02:00
parent 66b08693b9
commit bc2f972769
3 changed files with 223 additions and 5 deletions

View File

@@ -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) {

View File

@@ -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") {

View File

@@ -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-"));