diff --git a/bun.lock b/bun.lock
index 7e661e5..ea57514 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,7 +6,7 @@
"name": "gitvault-suite",
"dependencies": {
"@aws-sdk/client-s3": "^3.1119.0",
- "@getgitops/gitdb": "^0.5.0",
+ "@getgitops/gitdb": "^0.8.0",
"@google-cloud/storage": "^8.0.1",
"@lucide/svelte": "^1.34.0",
"chart.js": "^4.5.1",
@@ -152,7 +152,7 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
- "@getgitops/gitdb": ["@getgitops/gitdb@0.5.0", "", {}, "sha512-5tgn+XWtrGboPLv9dShZD6TeEvRXFf0uDXA//kH7A6LBOVkP3vuU0f3YxqcAYCYoGKo0V0xkgXk1CHSnJ2DQPg=="],
+ "@getgitops/gitdb": ["@getgitops/gitdb@0.8.0", "", {}, "sha512-OYeCchlR1n91UBuDA789+4zgl9i+27DzzLr6djahQlLd7haYrO+LQqaRoJQHVKBmn0SOYPsCY7R+Kc6YeGQOgA=="],
"@google-cloud/paginator": ["@google-cloud/paginator@7.0.1", "", { "dependencies": { "extend": "^3.0.2" } }, "sha512-k32cWlHAF8yTgg8rciLI8mPMI6UzuJdKp53YRxISRwMFxUl2FYplvs+Mr2UHxKn0W7rXsqZnUZy73AOJFDP8iA=="],
diff --git a/package.json b/package.json
index b59203c..d0d765d 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1119.0",
- "@getgitops/gitdb": "^0.5.0",
+ "@getgitops/gitdb": "^0.8.0",
"@google-cloud/storage": "^8.0.1",
"@lucide/svelte": "^1.34.0",
"chart.js": "^4.5.1"
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 1e33c18..983b494 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -4,6 +4,7 @@ import { organizationService } from '$modules/organization';
import { isBootstrapCompleted, refreshBootstrapState } from '$lib/server/bootstrap';
import { startGitDb } from '$lib/server/gitdb';
import { isServerReady, markServerFailed, markServerReady } from '$lib/server/server-ready';
+import { runWithActor } from '$lib/server/request-context';
// clone, manifest, sync poll and bootstrap detection run once per process
const serverReady = (async () => {
@@ -73,7 +74,7 @@ export const handle: Handle = async ({ event, resolve }) => {
if (!isAuthenticated) {
return new Response(null, { status: 401 });
}
- return resolve(event);
+ return runWithActor({ name: 'apikey', email: 'apikey@gitops.local' }, () => resolve(event));
}
const sessionCookie = event.cookies.get('pos_session');
@@ -114,5 +115,9 @@ export const handle: Handle = async ({ event, resolve }) => {
}
}
- return resolve(event);
+ const actor = {
+ name: currentUser.username,
+ email: currentUser.email || `${currentUser.username}@gitops.local`,
+ };
+ return runWithActor(actor, () => resolve(event));
};
diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte
index 18d5c20..3cc9278 100644
--- a/src/lib/components/AppSidebar.svelte
+++ b/src/lib/components/AppSidebar.svelte
@@ -224,6 +224,11 @@
href: `/org/${organizationSlug}/settings/server-access-keys`,
icon: KeyRound,
},
+ {
+ label: 'Audit',
+ href: `/org/${organizationSlug}/settings/audit`,
+ icon: ScrollText,
+ },
],
},
]
@@ -242,6 +247,7 @@
},
{ label: 'Users', href: '/cluster-settings/users', icon: Users },
{ label: 'Database', href: '/cluster-settings/database', icon: Database },
+ { label: 'Audit Log', href: '/cluster-settings/audit', icon: ScrollText },
],
},
]
diff --git a/src/lib/components/AuditFieldValue.svelte b/src/lib/components/AuditFieldValue.svelte
new file mode 100644
index 0000000..b10fc1b
--- /dev/null
+++ b/src/lib/components/AuditFieldValue.svelte
@@ -0,0 +1,50 @@
+
+
+{#if !value}
+ —
+{:else if value.kind === 'text'}
+ {value.text}
+{:else if value.kind === 'flags'}
+
+ {#each value.flags as flag}
+
+ {flag.label}
+
+ {/each}
+
+{:else if value.kind === 'list'}
+
+ {#each value.items as item}
+ {@const [scope, action] = item.split(':')}
+
+ {#if action}
+ {scope}
+ {action}
+ {:else}
+ {item}
+ {/if}
+
+ {/each}
+
+{:else}
+ {JSON.stringify(value.json, null, 2)}
+{/if}
diff --git a/src/lib/components/AuditLog.svelte b/src/lib/components/AuditLog.svelte
new file mode 100644
index 0000000..a2f0831
--- /dev/null
+++ b/src/lib/components/AuditLog.svelte
@@ -0,0 +1,523 @@
+
+
+
+ {title}
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+ {#if events.length === 0}
+
+ {hasActiveFilters ? 'No audit events match your filters.' : 'No audit events found.'}
+
+ {:else}
+
+
+
+
+
+ | Date |
+ Action |
+ Entity |
+ Author |
+ Commit |
+ Actions |
+
+
+
+ {#each events as event (event.commitHash + event.reason)}
+
+ | {formatDate(event.timestamp)} |
+
+
+ {event.action}
+
+ |
+ {event.entity ?? '-'} |
+ {event.author} |
+ {event.commitHash.slice(0, 7)} |
+
+
+ {#if commitBaseUrl}
+
+
+ Commit
+
+ {/if}
+ {#if event.entity}
+
+ {/if}
+
+ |
+
+ {/each}
+
+
+
+
+
+
+
+ {(pagination.page - 1) * pagination.perPage + 1}–{Math.min(
+ pagination.page * pagination.perPage,
+ pagination.total,
+ )} of {pagination.total}
+
+
+
+
+
+ {#if pagination.page > 1}
+
+
+ Previous
+
+ {:else}
+
+
+ Previous
+
+ {/if}
+
+
Page {pagination.page} of {pagination.totalPages}
+
+ {#if pagination.page < pagination.totalPages}
+
+ Next
+
+
+ {:else}
+
+ Next
+
+
+ {/if}
+
+
+
+ {/if}
+
+
+{#if diffModalEvent}
+
+
+
+
+
+
Detalle del cambio
+
+ {formatDate(diffModalEvent.timestamp)} por {diffModalEvent.author} · {diffModalEvent.commitHash.slice(0, 7)}
+
+
+
+
+
+
+ {#if diffLoading}
+
Loading changes...
+ {:else if diffError}
+
+ {diffError}
+
+ {:else if diffChanges.length === 0}
+
No se detectaron cambios.
+ {:else}
+
+ {#each diffChanges as change}
+ {@const summary = summarizeChange(diffModalEvent.entity ?? '', change)}
+
+
{summary.title}
+
+ {#if summary.fields.length}
+
+ {#each summary.fields as field}
+
+
- {field.label}
+
-
+ {#if change.type === 'modified'}
+ {#if field.before?.kind === 'text' && field.after?.kind === 'text'}
+
+ →
+
+ {:else if field.before?.kind === 'list' && field.after?.kind === 'list'}
+
+ {#each diffListItems(field.before.items, field.after.items) as entry}
+ {@const [scope, action] = entry.item.split(':')}
+
+ {#if entry.status !== 'unchanged'}
+ {entry.status === 'added' ? '+' : '−'}
+ {/if}
+ {#if action}
+ {scope}
+ {action}
+ {:else}
+ {entry.item}
+ {/if}
+
+ {/each}
+
+ {:else}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+
+{/if}
+
diff --git a/src/lib/components/audit-summary.ts b/src/lib/components/audit-summary.ts
new file mode 100644
index 0000000..2960d4d
--- /dev/null
+++ b/src/lib/components/audit-summary.ts
@@ -0,0 +1,191 @@
+/** Human-readable summaries for gitdb entity row changes, used by the audit log UI. */
+
+export type EntityRowChange =
+ | { type: 'added'; row: Record }
+ | { type: 'removed'; row: Record }
+ | { type: 'modified'; before: Record; after: Record; changedFields: string[] };
+
+export type FieldValue =
+ | { kind: 'text'; text: string }
+ | { kind: 'flags'; flags: { label: string; value: boolean }[] }
+ | { kind: 'list'; items: string[] }
+ | { kind: 'json'; json: unknown };
+
+export type ListDiffEntry = { item: string; status: 'added' | 'removed' | 'unchanged' };
+
+export type FieldSummary = {
+ label: string;
+ before?: FieldValue;
+ after?: FieldValue;
+};
+
+export type ChangeSummary = {
+ title: string;
+ fields: FieldSummary[];
+};
+
+type EntityLabel = { label: string; feminine: boolean };
+
+const ENTITY_LABELS: Record = {
+ organizations: { label: 'organización', feminine: true },
+ roles: { label: 'rol', feminine: false },
+ users: { label: 'usuario', feminine: false },
+ projects: { label: 'proyecto', feminine: false },
+ user_access: { label: 'acceso de usuario', feminine: false },
+};
+
+// fields that are noise in a human summary (always touched, rarely meaningful on their own)
+const IGNORED_FIELDS = new Set(['id', 'createdAt', 'updatedAt']);
+
+const FIELD_LABELS: Record = {
+ id: 'ID',
+ name: 'Nombre',
+ slug: 'Slug',
+ description: 'Descripción',
+ email: 'Email',
+ permissions: 'Permisos',
+ scope: 'Alcance',
+ organizationId: 'Organización',
+ projectId: 'Proyecto',
+ roleId: 'Rol',
+ userId: 'Usuario',
+ isActive: 'Activo',
+ modules: 'Módulos',
+ settings: 'Configuración',
+ status: 'Estado',
+};
+
+function entityLabel(entity: string): EntityLabel {
+ return ENTITY_LABELS[entity] ?? { label: entity, feminine: false };
+}
+
+function humanizeField(field: string): string {
+ if (FIELD_LABELS[field]) {
+ return FIELD_LABELS[field];
+ }
+
+ return field
+ .replace(/([A-Z])/g, ' $1')
+ .replace(/^./, (char) => char.toUpperCase())
+ .trim();
+}
+
+function formatValue(value: unknown): string {
+ if (value === null || value === undefined || value === '') {
+ return '—';
+ }
+ if (typeof value === 'boolean') {
+ return value ? 'Sí' : 'No';
+ }
+ if (Array.isArray(value)) {
+ return value.length ? value.map((item) => formatValue(item)).join(', ') : '—';
+ }
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
+ }
+ return String(value);
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+// { key: boolean, ... } shapes (e.g. project `modules`) render as toggle chips instead of raw JSON
+function isFlagsObject(value: Record): boolean {
+ const values = Object.values(value);
+ return values.length > 0 && values.every((entry) => typeof entry === 'boolean');
+}
+
+/** Classifies a raw field value into how it should be rendered: plain text, flag chips, a chip list, or a JSON block. */
+export function classifyValue(value: unknown): FieldValue {
+ if (isPlainObject(value)) {
+ if (isFlagsObject(value)) {
+ return {
+ kind: 'flags',
+ flags: Object.entries(value).map(([key, entry]) => ({ label: humanizeField(key), value: Boolean(entry) })),
+ };
+ }
+ return { kind: 'json', json: value };
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return { kind: 'text', text: '—' };
+ }
+ if (value.every((item) => typeof item === 'string' || typeof item === 'number')) {
+ return { kind: 'list', items: value.map((item) => String(item)) };
+ }
+ return { kind: 'json', json: value };
+ }
+
+ return { kind: 'text', text: formatValue(value) };
+}
+
+/** Diffs two string lists (e.g. role permissions) so additions/removals can be highlighted together. */
+export function diffListItems(before: string[], after: string[]): ListDiffEntry[] {
+ const beforeSet = new Set(before);
+ const afterSet = new Set(after);
+ const entries: ListDiffEntry[] = after.map((item) => ({
+ item,
+ status: beforeSet.has(item) ? 'unchanged' : 'added',
+ }));
+
+ for (const item of before) {
+ if (!afterSet.has(item)) {
+ entries.push({ item, status: 'removed' });
+ }
+ }
+
+ return entries;
+}
+
+function displayName(row: Record | undefined): string {
+ if (!row) return '';
+ const candidate = row.name ?? row.slug ?? row.title ?? row.email ?? row.id;
+ return candidate !== undefined && candidate !== null ? String(candidate) : '';
+}
+
+export function summarizeChange(entity: string, change: EntityRowChange): ChangeSummary {
+ const { label, feminine } = entityLabel(entity);
+ const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1);
+
+ if (change.type === 'added') {
+ const name = displayName(change.row);
+ const newWord = feminine ? 'Nueva' : 'Nuevo';
+ const fields = Object.entries(change.row)
+ .filter(([field]) => !IGNORED_FIELDS.has(field))
+ .map(([field, value]) => ({ label: humanizeField(field), after: classifyValue(value) }));
+
+ return {
+ title: `${newWord} ${label} cread${feminine ? 'a' : 'o'}${name ? `: ${name}` : ''}`,
+ fields,
+ };
+ }
+
+ if (change.type === 'removed') {
+ const name = displayName(change.row);
+ const fields = Object.entries(change.row)
+ .filter(([field]) => !IGNORED_FIELDS.has(field))
+ .map(([field, value]) => ({ label: humanizeField(field), before: classifyValue(value) }));
+
+ return {
+ title: `${capitalizedLabel} eliminad${feminine ? 'a' : 'o'}${name ? `: ${name}` : ''}`,
+ fields,
+ };
+ }
+
+ const name = displayName(change.after) || displayName(change.before);
+ const fields = change.changedFields
+ .filter((field) => !IGNORED_FIELDS.has(field))
+ .map((field) => ({
+ label: humanizeField(field),
+ before: classifyValue(change.before[field]),
+ after: classifyValue(change.after[field]),
+ }));
+
+ return {
+ title: `${capitalizedLabel} actualizad${feminine ? 'a' : 'o'}${name ? `: ${name}` : ''}`,
+ fields,
+ };
+}
diff --git a/src/lib/server/gitdb/config.ts b/src/lib/server/gitdb/config.ts
index bb2ecfc..53a1b2e 100644
--- a/src/lib/server/gitdb/config.ts
+++ b/src/lib/server/gitdb/config.ts
@@ -142,3 +142,35 @@ export function buildAuthenticatedUrl(config: GitDbRepositoryConfig): string {
export function redactUrl(value: string): string {
return value.replace(/\/\/[^/@\s]+@/g, '//***@');
}
+
+/** Best-effort browsable repo URL (github/gitlab/bitbucket-style), or null when it can't be derived. */
+export function resolveRepositoryWebUrl(repositoryUrl: string): string | null {
+ const withoutGitSuffix = repositoryUrl.replace(/\.git$/, '');
+
+ // git@host:owner/repo -> https://host/owner/repo
+ const scpMatch = withoutGitSuffix.match(/^[\w-]+@([^:]+):(.+)$/);
+ if (scpMatch) {
+ return `https://${scpMatch[1]}/${scpMatch[2]}`;
+ }
+
+ try {
+ const url = new URL(withoutGitSuffix);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ return null;
+ }
+ url.username = '';
+ url.password = '';
+ return url.toString().replace(/\/$/, '');
+ } catch {
+ return null;
+ }
+}
+
+/** Web URL for a specific commit in the configured repository, or null when unavailable. */
+export function resolveCommitUrl(commitHash: string): string | null {
+ const config = readRepositoryConfig();
+ if (!config) return null;
+
+ const webUrl = resolveRepositoryWebUrl(config.repositoryUrl);
+ return webUrl ? `${webUrl}/commit/${commitHash}` : null;
+}
diff --git a/src/lib/server/gitdb/index.ts b/src/lib/server/gitdb/index.ts
index 5619008..70b773c 100644
--- a/src/lib/server/gitdb/index.ts
+++ b/src/lib/server/gitdb/index.ts
@@ -1,12 +1,29 @@
import { gitDb, type GitDB } from '@getgitops/gitdb';
-import { buildAuthenticatedUrl, isRepositoryConfigured, redactUrl, requireRepositoryConfig } from './config';
+import {
+ buildAuthenticatedUrl,
+ isRepositoryConfigured,
+ redactUrl,
+ requireRepositoryConfig,
+ resolveRepositoryWebUrl,
+} from './config';
import { gitDbSyncService } from './sync';
+import { getCurrentActor } from '../request-context';
let instance: GitDB | null = null;
let startup: Promise | null = null;
export { isRepositoryConfigured };
+/** Browsable repo URL for building commit links, or null when it can't be derived. */
+export function getRepositoryWebUrl(): string | null {
+ try {
+ const config = requireRepositoryConfig();
+ return resolveRepositoryWebUrl(config.repositoryUrl);
+ } catch {
+ return null;
+ }
+}
+
/**
* Initializes GitDB instance and sync state.
* Runs once per process, not per request.
@@ -48,11 +65,11 @@ function createClient(authorName: string, authorEmail: string): GitDB {
}
export function getGitDb(): GitDB {
- if (instance) {
- return instance;
+ if (!instance) {
+ const config = requireRepositoryConfig();
+ instance = createClient(config.authorName, config.authorEmail);
}
- const config = requireRepositoryConfig();
- instance = createClient(config.authorName, config.authorEmail);
- return instance;
+ const actor = getCurrentActor();
+ return actor ? instance.as(actor) : instance;
}
\ No newline at end of file
diff --git a/src/lib/server/request-context.ts b/src/lib/server/request-context.ts
new file mode 100644
index 0000000..3938892
--- /dev/null
+++ b/src/lib/server/request-context.ts
@@ -0,0 +1,18 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+export type RequestActor = {
+ name: string;
+ email: string;
+};
+
+const storage = new AsyncLocalStorage();
+
+/** Runs `fn` with `actor` available to any `getGitDb()` call made during its execution. */
+export function runWithActor(actor: RequestActor, fn: () => T): T {
+ return storage.run(actor, fn);
+}
+
+/** The actor for the request currently being handled, if one was set. */
+export function getCurrentActor(): RequestActor | null {
+ return storage.getStore() ?? null;
+}
diff --git a/src/modules/audit/application/audit.service.ts b/src/modules/audit/application/audit.service.ts
new file mode 100644
index 0000000..e213254
--- /dev/null
+++ b/src/modules/audit/application/audit.service.ts
@@ -0,0 +1,53 @@
+import { getGitDb, getRepositoryWebUrl } from '$lib/server/gitdb';
+import type { AuditEvent, EntityRowChange } from '@getgitops/gitdb';
+
+export type AuditListOptions = {
+ search?: string;
+ organizationId?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ page?: number;
+ perPage?: number;
+};
+
+export type AuditListResult = {
+ events: AuditEvent[];
+ total: number;
+ page: number;
+ perPage: number;
+ totalPages: number;
+};
+
+const DEFAULT_PER_PAGE = 20;
+const MAX_PER_PAGE = 100;
+
+export class AuditService {
+ /** Only real insert/update/delete events with a resolved entity are audit-worthy; drop the rest. */
+ async listEvents(options: AuditListOptions = {}): Promise {
+ const gitdb = getGitDb();
+ const { search, organizationId, dateFrom, dateTo } = options;
+ const { events } = await gitdb.auditLog({ search, organizationId, dateFrom, dateTo });
+ const filtered = events.filter((event) => event.action !== 'other' && event.entity !== null);
+
+ const perPage = Math.min(Math.max(Math.trunc(options.perPage ?? DEFAULT_PER_PAGE), 1), MAX_PER_PAGE);
+ const total = filtered.length;
+ const totalPages = Math.max(Math.ceil(total / perPage), 1);
+ const page = Math.min(Math.max(Math.trunc(options.page ?? 1), 1), totalPages);
+
+ const start = (page - 1) * perPage;
+ const pageEvents = filtered.slice(start, start + perPage);
+
+ return { events: pageEvents, total, page, perPage, totalPages };
+ }
+
+ async getEntityDiff(commitHash: string, entity: string): Promise {
+ const gitdb = getGitDb();
+ return gitdb.entityDiff(commitHash, entity);
+ }
+
+ getRepositoryWebUrl(): string | null {
+ return getRepositoryWebUrl();
+ }
+}
+
+export type { AuditEvent, EntityRowChange };
diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts
new file mode 100644
index 0000000..3cc8047
--- /dev/null
+++ b/src/modules/audit/index.ts
@@ -0,0 +1,4 @@
+import { AuditService } from './application/audit.service';
+
+export const auditService = new AuditService();
+export type { AuditEvent, AuditListOptions, AuditListResult, EntityRowChange } from './application/audit.service';
diff --git a/src/routes/cluster-settings/audit/+page.server.ts b/src/routes/cluster-settings/audit/+page.server.ts
new file mode 100644
index 0000000..cb5b6ba
--- /dev/null
+++ b/src/routes/cluster-settings/audit/+page.server.ts
@@ -0,0 +1,43 @@
+import { fail } from '@sveltejs/kit';
+import { auditService } from '$modules/audit';
+import { organizationService } from '$modules/organization';
+
+export async function load({ url }) {
+ const [{ events, page, perPage, total, totalPages }, organizations] = await Promise.all([
+ auditService.listEvents({
+ search: url.searchParams.get('search') || undefined,
+ organizationId: url.searchParams.get('organizationId') || undefined,
+ dateFrom: url.searchParams.get('from') || undefined,
+ dateTo: url.searchParams.get('to') || undefined,
+ page: Number(url.searchParams.get('page')) || undefined,
+ perPage: Number(url.searchParams.get('perPage')) || undefined,
+ }),
+ organizationService.listOrganizations(),
+ ]);
+
+ return {
+ events,
+ pagination: { page, perPage, total, totalPages },
+ organizations,
+ commitBaseUrl: auditService.getRepositoryWebUrl(),
+ };
+}
+
+export const actions = {
+ async viewDiff({ request }) {
+ const form = await request.formData();
+ const commit = String(form.get('commit') ?? '');
+ const entity = String(form.get('entity') ?? '');
+
+ if (!commit || !entity) {
+ return fail(400, { error: 'commit and entity are required.' });
+ }
+
+ try {
+ const changes = await auditService.getEntityDiff(commit, entity);
+ return { success: true, changes };
+ } catch (error: unknown) {
+ return fail(500, { error: error instanceof Error ? error.message : 'Failed to load changes.' });
+ }
+ },
+};
diff --git a/src/routes/cluster-settings/audit/+page.svelte b/src/routes/cluster-settings/audit/+page.svelte
new file mode 100644
index 0000000..91003dd
--- /dev/null
+++ b/src/routes/cluster-settings/audit/+page.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/routes/org/[org]/settings/audit/+page.server.ts b/src/routes/org/[org]/settings/audit/+page.server.ts
new file mode 100644
index 0000000..7038645
--- /dev/null
+++ b/src/routes/org/[org]/settings/audit/+page.server.ts
@@ -0,0 +1,39 @@
+import { fail } from '@sveltejs/kit';
+import { auditService } from '$modules/audit';
+
+export async function load({ parent, url }) {
+ const { organization } = await parent();
+ const { events, page, perPage, total, totalPages } = await auditService.listEvents({
+ organizationId: organization.id,
+ search: url.searchParams.get('search') || undefined,
+ dateFrom: url.searchParams.get('from') || undefined,
+ dateTo: url.searchParams.get('to') || undefined,
+ page: Number(url.searchParams.get('page')) || undefined,
+ perPage: Number(url.searchParams.get('perPage')) || undefined,
+ });
+
+ return {
+ events,
+ pagination: { page, perPage, total, totalPages },
+ commitBaseUrl: auditService.getRepositoryWebUrl(),
+ };
+}
+
+export const actions = {
+ async viewDiff({ request }) {
+ const form = await request.formData();
+ const commit = String(form.get('commit') ?? '');
+ const entity = String(form.get('entity') ?? '');
+
+ if (!commit || !entity) {
+ return fail(400, { error: 'commit and entity are required.' });
+ }
+
+ try {
+ const changes = await auditService.getEntityDiff(commit, entity);
+ return { success: true, changes };
+ } catch (error: unknown) {
+ return fail(500, { error: error instanceof Error ? error.message : 'Failed to load changes.' });
+ }
+ },
+};
diff --git a/src/routes/org/[org]/settings/audit/+page.svelte b/src/routes/org/[org]/settings/audit/+page.svelte
new file mode 100644
index 0000000..6215c94
--- /dev/null
+++ b/src/routes/org/[org]/settings/audit/+page.svelte
@@ -0,0 +1,16 @@
+
+
+