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);
|
details.textContent = JSON.stringify(entry.details, null, 2);
|
||||||
block.appendChild(details);
|
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);
|
els.activityLogList.appendChild(block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2813,6 +2826,17 @@ function renderApprovals() {
|
|||||||
await decideApproval(entry.id, false);
|
await decideApproval(entry.id, false);
|
||||||
});
|
});
|
||||||
actions.appendChild(rejectBtn);
|
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);
|
block.appendChild(actions);
|
||||||
|
|
||||||
els.approvalsList.appendChild(block);
|
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() {
|
function renderStatus() {
|
||||||
const status = state.status;
|
const status = state.status;
|
||||||
if (!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() });
|
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") {
|
if (method === "GET" && url.pathname === "/v1/activity-log") {
|
||||||
const auth = requireAuth(req, res);
|
const auth = requireAuth(req, res);
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
@@ -835,6 +844,15 @@ async function routeRequest(req, res) {
|
|||||||
return sendJson(res, 200, { entries });
|
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)$/)) {
|
if (method === "POST" && url.pathname.match(/^\/v1\/approvals\/[^/]+\/(approve|reject)$/)) {
|
||||||
const auth = requireAuth(req, res);
|
const auth = requireAuth(req, res);
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
@@ -1567,6 +1585,27 @@ async function handleApprovalDecision(res, actor, pathname) {
|
|||||||
return sendJson(res, 200, { ok: true, approval: approvalView, user: sanitizeUser(user) });
|
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() {
|
function listApprovalsView() {
|
||||||
return runtime.approvalRequests.map((approval) => {
|
return runtime.approvalRequests.map((approval) => {
|
||||||
const user = runtime.users.find((entry) => entry.id === approval.userId);
|
const user = runtime.users.find((entry) => entry.id === approval.userId);
|
||||||
@@ -7465,6 +7504,7 @@ async function applyAdminRoles() {
|
|||||||
|
|
||||||
async function appendAudit(action, user, details) {
|
async function appendAudit(action, user, details) {
|
||||||
const entry = {
|
const entry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
action,
|
action,
|
||||||
userId: user ? user.id : null,
|
userId: user ? user.id : null,
|
||||||
@@ -7491,17 +7531,20 @@ async function listStationActivityLog(limit = 200) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
for (const line of lines) {
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
let entry;
|
const line = lines[index];
|
||||||
try {
|
const entry = parseAuditLine(line);
|
||||||
entry = JSON.parse(line);
|
if (!entry) {
|
||||||
} catch {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!entry || !interesting.has(entry.action)) {
|
if (!entry || !interesting.has(entry.action)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const id = typeof entry.id === "string" && entry.id.trim()
|
||||||
|
? entry.id.trim()
|
||||||
|
: `legacy-${index}`;
|
||||||
entries.push({
|
entries.push({
|
||||||
|
id,
|
||||||
at: entry.at,
|
at: entry.at,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
email: entry.email || null,
|
email: entry.email || null,
|
||||||
@@ -7514,6 +7557,57 @@ async function listStationActivityLog(limit = 200) {
|
|||||||
return entries.slice(0, limit);
|
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) {
|
function activityMessageForEntry(entry) {
|
||||||
const email = entry.email || "unbekannt";
|
const email = entry.email || "unbekannt";
|
||||||
if (entry.action === "auth.request_access") {
|
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) => {
|
test("station usage is auto-ended after configured max usage time", async (t) => {
|
||||||
const rootDir = path.resolve(__dirname, "..");
|
const rootDir = path.resolve(__dirname, "..");
|
||||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-station-timeout-test-"));
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-station-timeout-test-"));
|
||||||
|
|||||||
Reference in New Issue
Block a user