Add confirmed audit reversals
This commit is contained in:
@@ -88,6 +88,12 @@ Follow these rules:
|
||||
10. Tests and handoff verification for an ISAC mutation must include confirming
|
||||
that its audit event contains the correct actor, category, action, target,
|
||||
and change evidence.
|
||||
11. Audit reversion is a separately authorized compensating action, never a
|
||||
history rewrite. Require `audit.revert`, an explicit confirmation
|
||||
interaction, current-state comparison against the original after-snapshot,
|
||||
relationship validation, and a new `audit.reverted` event referencing the
|
||||
original event. Block stale, already-reverted, unsupported, destructive
|
||||
identity, and incomplete-snapshot events rather than guessing.
|
||||
|
||||
## Database preservation
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-delete','users.delete','Soft-delete user accounts while retaining authored content');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-identities','users.identities','View and manage linked external identities');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-view','audit.view','View and search the immutable ISAC audit history');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access');
|
||||
|
||||
@@ -222,6 +222,14 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
|
||||
JSON snapshots. Search and category/action filters run before pagination.
|
||||
The Auditor role grants read-only `audit.view` access plus entry to ISAC;
|
||||
audit visibility never implies mutation, user-management, or game-data access.
|
||||
- Supported audit events may expose a permissioned compensating-action control.
|
||||
Reversion uses a danger-styled `ATTEMPT REVERT` entry action followed by a
|
||||
modal that explains the original action, operator, target, and conflict
|
||||
checks. The orange Confirm action is the only submission path. ISAC compares
|
||||
current state with the original event’s after-snapshot before restoring the
|
||||
before-snapshot; newer work blocks the operation. Successful reversions append
|
||||
a new `audit.reverted` event and never alter the original history. `audit.view`
|
||||
remains read-only; `audit.revert` is independently assigned.
|
||||
- Account deletion uses a collapsed danger zone, explicit identity confirmation,
|
||||
and plain-language retention guidance. Soft-deleted accounts retain authored
|
||||
content under a deleted-agent identity while releasing credentials, usernames,
|
||||
|
||||
@@ -83,3 +83,33 @@ The restore operation refuses to import into any database containing records.
|
||||
This guard prevents accidental merging or overwriting of contributor data.
|
||||
Stop the application before backup or restore so the embedded database file is
|
||||
not locked by another process.
|
||||
|
||||
# ISAC audit and compensating reversals
|
||||
|
||||
Every successful ISAC mutation appends an event to `audit_log` through the
|
||||
shared server audit utility. Events identify the operator, operational category,
|
||||
specific action, target entity, timestamp, and useful before/after evidence.
|
||||
The audit registry is read-only and requires `audit.view`; the seeded Auditor
|
||||
role grants that permission without granting mutation capabilities.
|
||||
|
||||
Audit history is immutable. “Revert” is implemented as a new compensating
|
||||
mutation rather than deletion or modification of the original event:
|
||||
|
||||
1. The operator must independently hold `audit.revert`.
|
||||
2. The original action must be explicitly supported and contain a usable
|
||||
before-snapshot.
|
||||
3. The UI requires a second, explicit confirmation.
|
||||
4. The server reloads the target and compares it with the original
|
||||
after-snapshot, ignoring bookkeeping timestamps. A mismatch means newer work
|
||||
exists and blocks automatic compensation.
|
||||
5. Referenced roles, permissions, and relationships are revalidated before
|
||||
restoration. Restoring administrator authority additionally requires full
|
||||
administrative access.
|
||||
6. The server restores the previous state and appends an `audit.reverted` event
|
||||
whose target is the original audit-event ID.
|
||||
7. A successfully reverted event cannot be reverted again.
|
||||
|
||||
Unsupported destructive identity operations, account deletion, incomplete
|
||||
snapshots, stale targets, and events whose dependencies no longer exist are
|
||||
marked non-reversible or rejected. Recovery for those cases requires a
|
||||
specialized workflow rather than inferred database writes.
|
||||
|
||||
@@ -106,4 +106,5 @@
|
||||
.kpi-filters{height:auto}.kpi-filters>a{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid;color:inherit;cursor:pointer}.kpi-filters>a:hover,.kpi-filters>a:focus-visible{border-color:#8a672b;border-top-color:var(--shd-orange);background:#171a1b}.kpi-filters>a.active{border-color:#8a672b;border-top-color:var(--shd-orange);background:#211a0e;box-shadow:inset 0 -2px #f6a821}.kpi-filters>a:active{transform:translateY(1px);filter:brightness(.88)}.kpi-filters>a span{font-size:7px;letter-spacing:1.3px;color:#737a7c}.kpi-filters>a strong{font-size:23px;line-height:1.1;margin:3px 0}.kpi-filters>a small{font-size:7px;color:#555d5f}.kpi-filters>a.active span,.kpi-filters>a.active strong{color:var(--shd-orange)}
|
||||
.role-kpis{grid-template-columns:repeat(5,minmax(105px,1fr))}.role-identity{grid-template-columns:minmax(220px,.7fr) minmax(320px,1.3fr)!important}.permission-catalogue{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:8px}.permission-catalogue label{min-height:54px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:8px 10px;border:1px solid #303536;background:#0d1011;cursor:pointer}.permission-catalogue label:hover,.permission-catalogue label:focus-within{border-color:#8a672b;background:#151819}.permission-catalogue label:active{transform:translateY(1px);filter:brightness(.88)}.permission-catalogue input{width:auto;margin:0;accent-color:var(--shd-orange)}.permission-catalogue span{display:grid;gap:4px}.permission-catalogue b{color:#d7d9d5;font-size:9px}.permission-catalogue small{color:#626a6c;font-size:7px;line-height:1.35}.permission-catalogue em{color:#596164;font-size:6px;font-style:normal;font-weight:900;letter-spacing:.7px}.permission-catalogue label:has(input:checked){border-color:#6e5226;background:#211a0e}.permission-catalogue label:has(input:checked) em{color:var(--shd-orange)}.role-row{min-width:960px;grid-template-columns:1.1fr .55fr 2fr .65fr .3fr}.role-permission-summary{display:flex!important;flex-wrap:wrap;gap:3px}.role-permission-summary b{padding:3px 5px;border:1px solid #3a4840;color:#75c08c;font-size:6px;letter-spacing:.5px}@media(max-width:1000px){.role-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:700px){.permission-catalogue,.role-identity{grid-template-columns:1fr!important}.role-kpis{grid-template-columns:1fr 1fr}}
|
||||
.audit-filter{height:auto;display:grid;grid-template-columns:minmax(220px,1fr) 210px 240px auto;gap:7px;margin:10px 0}.audit-filter input,.audit-filter select,.audit-filter button{height:40px;margin:0}.audit-row{min-width:980px;grid-template-columns:1fr 1fr 1.3fr .85fr .35fr}.audit-entry{border-top:1px solid #272c2d}.audit-entry:first-of-type{border-top:0}.audit-entry summary{border:0;cursor:pointer;list-style:none}.audit-entry summary::-webkit-details-marker{display:none}.audit-entry summary:hover{background:#15191a}.audit-entry summary:active{transform:translateY(1px);filter:brightness(.88)}.audit-entry summary>b{justify-self:end;color:var(--shd-orange);font-size:7px;letter-spacing:.7px}.audit-entry[open] summary{background:#171a1b;box-shadow:inset 2px 0 var(--shd-orange)}.audit-comparison{display:grid;grid-template-columns:1fr 1fr;gap:1px;padding:1px;background:#303536}.audit-comparison section{min-width:0;padding:12px;background:#0b0e0f}.audit-comparison span{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.audit-comparison pre{max-height:320px;overflow:auto;margin:9px 0 0;color:#aeb4b5;font:10px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}@media(max-width:900px){.audit-filter{grid-template-columns:1fr 1fr}.audit-comparison{grid-template-columns:1fr}}@media(max-width:560px){.audit-filter{grid-template-columns:1fr}}
|
||||
.audit-revert-bar{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-top:1px solid #4c302d;background:#1c1111}.audit-revert-bar>span{color:#a87870;font-size:7px;letter-spacing:.8px}.audit-revert-bar button{height:32px}.audit-revert-bar.completed{border-color:#36563f;background:#101713}.audit-revert-bar.completed>span{color:var(--shd-success)}.audit-revert-modal>p{color:#969d9f;font:13px/1.6 Georgia,serif}.audit-revert-modal code{color:#d7d9d5;font:11px Consolas,monospace}.audit-revert-summary{display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:7px;margin:14px 0}.audit-revert-summary>div{min-width:0;display:grid;gap:5px;padding:9px;border:1px solid #303536;background:#0d1011}.audit-revert-summary span{color:#626a6c;font-size:6px;letter-spacing:.8px}.audit-revert-summary b{overflow:hidden;text-overflow:ellipsis;color:#d7d9d5;font-size:8px;white-space:nowrap}@media(max-width:600px){.audit-revert-bar{align-items:stretch;flex-direction:column}.audit-revert-summary{grid-template-columns:1fr}}
|
||||
|
||||
|
||||
@@ -1,11 +1,48 @@
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const reversibleActions = new Set([
|
||||
'attribute.updated', 'attribute.archived',
|
||||
'brand_set.archived', 'gear.archived', 'weapon.archived',
|
||||
'role.updated', 'access.updated'
|
||||
]);
|
||||
|
||||
const parseSnapshot = (value: unknown): Record<string, any> | null => {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; }
|
||||
};
|
||||
|
||||
const normalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map(normalize);
|
||||
return normalized.every((item) => ['string', 'number', 'boolean'].includes(typeof item))
|
||||
? normalized.sort((left, right) => String(left).localeCompare(String(right)))
|
||||
: normalized;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => !['created_at', 'updated_at'].includes(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, normalize(item)]));
|
||||
};
|
||||
const comparable = (value: unknown): string => JSON.stringify(normalize(value));
|
||||
|
||||
const sameRecord = (current: Record<string, unknown> | null, expected: Record<string, unknown> | null) => {
|
||||
if (!current || !expected) return false;
|
||||
return Object.entries(expected).every(([key, value]) =>
|
||||
!['created_at', 'updated_at'].includes(key) && comparable(current[key]) === comparable(value)
|
||||
);
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'audit.view');
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'audit.view');
|
||||
const canRevert = (await getPermissions(operator)).includes('*') || (await getPermissions(operator)).includes('audit.revert');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
|
||||
const category = url.searchParams.get('category')?.trim() ?? '';
|
||||
@@ -43,5 +80,87 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
FROM audit_log JOIN users ON users.id=audit_log.actor_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
${where} ORDER BY audit_log.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
return { entries, categories, actions, summary, pagination, filters: { query, category, action } };
|
||||
const revertedIds = entries.length ? await (await database.prepare(`SELECT entity_id FROM audit_log
|
||||
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)).all(...entries.map((entry) => entry.id)) : [];
|
||||
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
|
||||
return {
|
||||
entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)) })),
|
||||
categories, actions, summary, pagination, filters: { query, category, action }, canRevert
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
revert: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'audit.revert');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const isFullAdmin = operatorPermissions.includes('*');
|
||||
const eventId = String((await request.formData()).get('eventId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const event = await (await database.prepare('SELECT * FROM audit_log WHERE id=?')).get(eventId);
|
||||
if (!event) return fail(404, { revertError: 'Audit event not found.' });
|
||||
if (!reversibleActions.has(String(event.action)) || !event.before_json) return fail(400, { revertError: 'This event does not contain a supported reversible change.' });
|
||||
if (await (await database.prepare("SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?")).get(eventId)) {
|
||||
return fail(409, { revertError: 'This audit event has already been reverted.' });
|
||||
}
|
||||
const before = parseSnapshot(event.before_json), after = parseSnapshot(event.after_json);
|
||||
if (!before || !after) return fail(400, { revertError: 'The stored snapshots cannot be safely interpreted.' });
|
||||
const action = String(event.action), targetId = String(event.entity_id);
|
||||
let current: Record<string, unknown> | null = null;
|
||||
let restored: Record<string, unknown> | null = null;
|
||||
try {
|
||||
if (action.startsWith('attribute.')) {
|
||||
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
||||
if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' });
|
||||
await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`))
|
||||
.run(before.name,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
|
||||
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
||||
} else if (action.endsWith('.archived')) {
|
||||
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
|
||||
if (!table) return fail(400, { revertError: 'Unsupported archive category.' });
|
||||
current = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
|
||||
if (!sameRecord(current, after)) return fail(409, { revertError: 'The record changed after it was archived. Reversion was blocked.' });
|
||||
await (await database.prepare(`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`)).run(before.active, new Date().toISOString(), targetId);
|
||||
restored = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
|
||||
} else if (action === 'role.updated') {
|
||||
const currentRole = await (await database.prepare('SELECT name,description FROM roles WHERE id=?')).get(targetId);
|
||||
const currentPermissions = await (await database.prepare(`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)).all(targetId);
|
||||
current = { ...currentRole, permissions: currentPermissions.map((row) => row.name) };
|
||||
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The role changed after this event. Reversion was blocked.' });
|
||||
const permissionNames = Array.isArray(before.permissions) ? before.permissions.map(String) : [];
|
||||
if (permissionNames.includes('system.admin') && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore unrestricted system access.' });
|
||||
const permissionRows = permissionNames.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`)).all(...permissionNames) : [];
|
||||
if (permissionRows.length !== permissionNames.length) return fail(409, { revertError: 'A previous permission no longer exists.' });
|
||||
await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(before.name, before.description ?? null, targetId);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(targetId);
|
||||
for (const permission of permissionRows) await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(targetId, permission.id);
|
||||
restored = { name: before.name, description: before.description ?? null, permissions: permissionNames };
|
||||
} else if (action === 'access.updated') {
|
||||
const currentRoles = await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(targetId);
|
||||
const currentPermissions = await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(targetId);
|
||||
current = { roleIds: currentRoles.map((row) => row.role_id), permissionIds: currentPermissions.map((row) => row.permission_id) };
|
||||
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The user’s access changed after this event. Reversion was blocked.' });
|
||||
const roleIds = Array.isArray(before.roleIds) ? before.roleIds.map(String) : [];
|
||||
const permissionIds = Array.isArray(before.permissionIds) ? before.permissionIds.map(String) : [];
|
||||
const restoresAdminRole = roleIds.includes('role-admin');
|
||||
const restoresSystemPermission = permissionIds.includes('permission-system-admin');
|
||||
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore administrator access.' });
|
||||
const roleCount = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
|
||||
const permissionCount = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
|
||||
if (roleCount !== roleIds.length || permissionCount !== permissionIds.length) return fail(409, { revertError: 'A previous role or permission no longer exists.' });
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(targetId);
|
||||
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(targetId);
|
||||
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(targetId, roleId);
|
||||
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(targetId, permissionId);
|
||||
restored = { roleIds, permissionIds };
|
||||
} else {
|
||||
return fail(400, { revertError: 'This change requires a specialized recovery workflow that is not available yet.' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||
return fail(409, { revertError: 'The previous state could not be restored because related data has changed.' });
|
||||
}
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'audit_control', action: 'audit.reverted', entityId: eventId, before: current, after: { originalEventId: eventId, originalAction: action, targetId, restored } });
|
||||
return { reverted: true, resultToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<script lang="ts">
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
let { data } = $props();
|
||||
let { data, form } = $props();
|
||||
let confirming = $state<any>(null);
|
||||
let handledResultToken = $state('');
|
||||
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(String(value)));
|
||||
const pretty = (value: unknown) => {
|
||||
if (!value) return 'NO SNAPSHOT';
|
||||
try { return JSON.stringify(JSON.parse(String(value)), null, 2); } catch { return String(value); }
|
||||
};
|
||||
$effect(() => {
|
||||
if (form?.reverted && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
confirming = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Audit history — ISAC</title></svelte:head>
|
||||
@@ -30,6 +39,20 @@
|
||||
<details class="audit-entry">
|
||||
<summary class="dashboard-row audit-row"><div><strong>{String(entry.action).toUpperCase()}</strong><small>{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}</small></div><div><strong>{entry.actor_name}</strong><small>{entry.actor_login ?? entry.actor_id}</small></div><div><strong>{entry.entity_id}</strong><small>EVENT {String(entry.id).slice(0,8).toUpperCase()}</small></div><span>{date(entry.created_at)}</span><b>DETAILS +</b></summary>
|
||||
<div class="audit-comparison"><section><span>BEFORE</span><pre>{pretty(entry.before_json)}</pre></section><section><span>AFTER</span><pre>{pretty(entry.after_json)}</pre></section></div>
|
||||
{#if entry.reversible}<div class="audit-revert-bar"><span>SUPPORTED COMPENSATING ACTION · CURRENT STATE WILL BE VERIFIED FIRST</span><button class="danger" type="button" onclick={() => confirming = entry}>ATTEMPT REVERT</button></div>
|
||||
{:else if entry.reverted}<div class="audit-revert-bar completed"><span>THIS EVENT HAS ALREADY BEEN REVERTED</span></div>{/if}
|
||||
</details>
|
||||
{:else}<div class="empty-dashboard"><strong>NO AUDIT EVENTS FOUND</strong><span>Adjust the category, action, or search value.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
{#if confirming}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal audit-revert-modal" role="dialog" aria-modal="true" aria-labelledby="revert-title">
|
||||
<div class="editor-title"><div><p class="eyebrow">CONFIRM COMPENSATING ACTION</p><h3 id="revert-title">REVERT {String(confirming.action).toUpperCase()}?</h3></div><button aria-label="Close confirmation" onclick={() => confirming = null}>×</button></div>
|
||||
<p>This does not erase history. ISAC will first verify that target <code>{confirming.entity_id}</code> still matches this event’s resulting state. If it has newer changes, the revert will be blocked.</p>
|
||||
<div class="audit-revert-summary"><div><span>ORIGINAL ACTION</span><b>{confirming.action}</b></div><div><span>ORIGINAL OPERATOR</span><b>{confirming.actor_name}</b></div><div><span>EVENT</span><b>{confirming.id}</b></div></div>
|
||||
{#if form?.revertError}<p class="error">{form.revertError}</p>{/if}
|
||||
<PendingForm action="?/revert">{#snippet children(pending)}<input type="hidden" name="eventId" value={confirming.id}/><div class="form-actions"><button class="secondary" type="button" onclick={() => confirming = null} disabled={pending}>CANCEL</button><button class="primary" aria-busy={pending} disabled={pending}>{pending ? 'VERIFYING STATE…' : 'CONFIRM REVERT →'}</button></div>{/snippet}</PendingForm>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user