Files
shd.build/src/routes/admin/audit/+page.svelte
T

69 lines
5.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import KpiFilters from '$lib/components/KpiFilters.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
let { data, form } = $props();
let confirming = $state<any>(null);
let handledResultToken = $state('');
const categoryOptions = $derived([
{id:'user',name:'USER OPERATIONS · ALL'},
{id:'access_policy',name:'ACCESS POLICY · ALL'},
...data.categories
.filter((item) => !['user', 'access_policy'].includes(String(item.entity_type)))
.map((item) => ({id:String(item.entity_type),name:`${String(item.entity_type).replaceAll('_',' ').toUpperCase()} · ${item.count}`}))
]);
const actionOptions = $derived(data.actions.map((item) => ({id:String(item.action),name:`${String(item.action).toUpperCase()} · ${item.count}`})));
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>
<div class="module-toolbar audit-toolbar"><div><p class="eyebrow">SYSTEM OVERSIGHT</p><h2>AUDIT HISTORY</h2><p>Immutable operator activity, target records, and before/after change evidence.</p></div></div>
<KpiFilters className="audit-kpis" parameter="category" active={data.filters.category} items={[
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
]}/>
<form class="audit-filter">
<input name="q" value={data.filters.query} placeholder="ACTOR, TARGET ID, OR CHANGED VALUE"/>
<SearchableAssignment items={categoryOptions} mode="single" compact fieldName="category" value={data.filters.category} subjectLabel="AUDIT CATEGORY" searchPlaceholder="Search categories…" emptySelectionLabel="ALL CATEGORIES"/>
<SearchableAssignment items={actionOptions} mode="single" compact fieldName="action" value={data.filters.action} subjectLabel="AUDIT ACTION" searchPlaceholder="Search actions…" emptySelectionLabel="ALL ACTIONS"/>
<button class="secondary">SEARCH AUDIT</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row audit-row table-heading"><span>EVENT</span><span>OPERATOR</span><span>TARGET</span><span>TIME</span><span></span></div>
{#each data.entries as entry}
<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="moderation-action danger audit-revert-button" type="button" onclick={() => confirming = entry}><b>↶</b> 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}