Add Site-Admin system panel with persistent audit trail (#86)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s

New /admin/system panel (operations.md §Maintenance jobs): the maintenance
job list shows every registered job with truthful last-run data (new
Job.lastDurationMs recorded by the scheduler) and a manual trigger that
respects the run-mutex and is itself audit-logged; a backup card mirrors
the sidecar's status.json including the freshness verdict; an audit-log
viewer filters by actor, action, and time range with pagination; and a
storage overview lists the largest ponds. Auth events and admin actions
(grants, members, user/quota admin, plugins, settings, setup) now land in
a new audit_log table through a central AuditService — which keeps
emitting the established stdout log line — while content activity stays
log-only by design. All endpoints are Site-Admin-only; covered by API DB
tests and a Playwright pack in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 20:03:05 +02:00
parent 0ef96147e0
commit c8aac13dfb
33 changed files with 1780 additions and 61 deletions

View File

@ -212,6 +212,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/legal.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/legal.spec.ts
- name: Reset login rate limit before system pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run system pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/system.spec.ts
- name: Reset login rate limit before admin-quotas pack - name: Reset login rate limit before admin-quotas pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -0,0 +1,23 @@
-- Persistent audit trail for the Site-Admin system panel (issue #86) plus
-- the duration of the last completed maintenance-job run.
ALTER TABLE "jobs" ADD COLUMN "last_duration_ms" INTEGER;
CREATE TABLE "audit_log" (
"id" TEXT NOT NULL,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"action" TEXT NOT NULL,
"actor_id" TEXT,
"target_type" TEXT,
"target_id" TEXT,
"details" JSONB,
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "audit_log_at_idx" ON "audit_log"("at");
CREATE INDEX "audit_log_actor_id_at_idx" ON "audit_log"("actor_id", "at");
CREATE INDEX "audit_log_action_at_idx" ON "audit_log"("action", "at");
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_fkey"
FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -49,10 +49,36 @@ model User {
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
conversionJobs ConversionJob[] conversionJobs ConversionJob[]
auditEntries AuditEntry[]
@@map("users") @@map("users")
} }
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
/// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which
/// also keeps emitting the established `audit: …` stdout log line. Content
/// activity (pages, files, exports) stays log-only by design.
model AuditEntry {
id String @id @default(uuid())
at DateTime @default(now())
/// Stable dot-namespaced action id, e.g. `grant.created`, `auth.login_failed`.
action String
/// Null for anonymous events (failed login for an unknown user) and after a
/// hard account deletion; pseudonymized accounts keep their id.
actorId String? @map("actor_id")
targetType String? @map("target_type")
targetId String? @map("target_id")
details Json?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
@@index([at])
@@index([actorId, at])
@@index([action, at])
@@map("audit_log")
}
enum PondType { enum PondType {
PERSONAL PERSONAL
SHARED SHARED
@ -515,6 +541,8 @@ model Job {
lastRunAt DateTime? @map("last_run_at") lastRunAt DateTime? @map("last_run_at")
lockedAt DateTime? @map("locked_at") lockedAt DateTime? @map("locked_at")
lastError String? @map("last_error") lastError String? @map("last_error")
/// Wall-clock time of the last completed run (issue #86 admin panel).
lastDurationMs Int? @map("last_duration_ms")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@map("jobs") @@map("jobs")

View File

@ -2,18 +2,26 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { QuotasModule } from '../quotas/quotas.module'; import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { AdminSettingsController } from './admin.controller'; import { AdminSettingsController } from './admin.controller';
import { PseudonymizationService } from './pseudonymization.service'; import { PseudonymizationService } from './pseudonymization.service';
import { QuotaAdminController } from './quota-admin.controller'; import { QuotaAdminController } from './quota-admin.controller';
import { SystemAdminController } from './system-admin.controller';
import { SystemAdminService } from './system-admin.service';
import { QuotaAdminService } from './quota-admin.service'; import { QuotaAdminService } from './quota-admin.service';
import { UserAdminController } from './user-admin.controller'; import { UserAdminController } from './user-admin.controller';
import { UserAdminService } from './user-admin.service'; import { UserAdminService } from './user-admin.service';
@Module({ @Module({
imports: [QuotasModule, UsersModule, AuthModule], imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule],
controllers: [AdminSettingsController, QuotaAdminController, UserAdminController], controllers: [
providers: [QuotaAdminService, UserAdminService, PseudonymizationService], AdminSettingsController,
QuotaAdminController,
SystemAdminController,
UserAdminController,
],
providers: [QuotaAdminService, SystemAdminService, UserAdminService, PseudonymizationService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DELETED_USER_DISPLAY_NAME } from '@dorfteich/shared'; import { DELETED_USER_DISPLAY_NAME } from '@dorfteich/shared';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
@ -15,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service';
export class PseudonymizationService { export class PseudonymizationService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(PseudonymizationService.name); this.logger.setContext(PseudonymizationService.name);
@ -43,6 +45,10 @@ export class PseudonymizationService {
data: { deletedAt: new Date(), deletedBy: userId }, data: { deletedAt: new Date(), deletedBy: userId },
}); });
}); });
this.logger.info({ userId }, 'audit: user pseudonymized (account deleted)'); await this.audit.record({
action: 'user.pseudonymized',
targetType: 'user',
targetId: userId,
});
} }
} }

View File

@ -9,6 +9,7 @@ import {
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
@ -27,6 +28,7 @@ export class QuotaAdminService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly users: UsersService, private readonly users: UsersService,
private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(QuotaAdminService.name); this.logger.setContext(QuotaAdminService.name);
@ -79,7 +81,13 @@ export class QuotaAdminService {
create: { subjectType, subjectId: id, quotaKey, value }, create: { subjectType, subjectId: id, quotaKey, value },
update: { value }, update: { value },
}); });
this.logger.info({ actor: actor.id, type, id, quotaKey, value }, 'audit: quota override set'); await this.audit.record({
action: 'quota.override_set',
actorId: actor.id,
targetType: type,
targetId: id,
details: { quotaKey, value },
});
return this.subject(type, id); return this.subject(type, id);
} }
@ -94,7 +102,13 @@ export class QuotaAdminService {
await this.prisma.quotaOverride.deleteMany({ await this.prisma.quotaOverride.deleteMany({
where: { subjectType: type === 'user' ? 'USER' : 'POND', subjectId: id, quotaKey }, where: { subjectType: type === 'user' ? 'USER' : 'POND', subjectId: id, quotaKey },
}); });
this.logger.info({ actor: actor.id, type, id, quotaKey }, 'audit: quota override cleared'); await this.audit.record({
action: 'quota.override_cleared',
actorId: actor.id,
targetType: type,
targetId: id,
details: { quotaKey },
});
return this.subject(type, id); return this.subject(type, id);
} }

View File

@ -0,0 +1,52 @@
import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
import {
auditListQuerySchema,
type AuditListQuery,
type AuditListView,
type JobTriggerResult,
type StorageOverviewView,
type SystemBackupView,
type SystemJobView,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { SiteAdminGuard } from './site-admin.guard';
import { SystemAdminService } from './system-admin.service';
/** Site-Admin system panel (issue #86): jobs, backup card, audit, storage. */
@Controller('admin/system')
@UseGuards(SiteAdminGuard)
export class SystemAdminController {
constructor(private readonly system: SystemAdminService) {}
@Get('jobs')
async jobs(): Promise<SystemJobView[]> {
return this.system.jobs();
}
@Post('jobs/:name/run')
async triggerJob(
@Param('name') name: string,
@Req() request: AuthedRequest,
): Promise<JobTriggerResult> {
return this.system.triggerJob(request.user!, name);
}
@Get('backup')
backup(): SystemBackupView {
return this.system.backup();
}
@Get('audit')
async audit(
@Query(new ZodValidationPipe(auditListQuerySchema)) query: AuditListQuery,
): Promise<AuditListView> {
return this.system.auditLog(query);
}
@Get('storage')
async storage(): Promise<StorageOverviewView> {
return this.system.storage();
}
}

View File

@ -0,0 +1,258 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import {
BACKUP_STATUS_FILE,
type AuditListView,
type BackupStatus,
type JobTriggerResult,
type StorageOverviewView,
type SystemBackupView,
type SystemJobView,
} from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { GrantsService } from '../grants/grants.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const HOUR = 3_600_000;
function statusFixture(finishedAt: string): BackupStatus {
return {
schemaVersion: 1,
updatedAt: finishedAt,
retentionDays: 7,
lastRun: {
backupId: '20260711-030000',
startedAt: finishedAt,
finishedAt,
durationMs: 1200,
outcome: 'succeeded',
sizes: { dumpBytes: 100, archiveBytes: 200 },
},
lastSuccess: {
backupId: '20260711-030000',
finishedAt,
sizes: { dumpBytes: 100, archiveBytes: 200 },
},
};
}
/**
* Site-Admin system panel end to end (issue #86): registered jobs with
* truthful last-run data, an audit-logged manual trigger, the backup card
* mirroring status.json, the audit viewer finding a grant change by actor,
* and the storage top list all Site-Admin-only.
*/
describe.skipIf(!hasTestDb)('system admin panel (e2e, issue #86)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let backupsDir: string;
const suffix = uniqueSuffix();
const password = 'systempanel ist wachsam 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
const pondIds: string[] = [];
const api = () => request(app.getHttpServer());
async function makeUser(handle: string, siteAdmin: boolean): Promise<void> {
const users = app.get(UsersService);
const username = `sys-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Sys ${handle}`,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
if (siteAdmin)
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
ids[handle] = user.id;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
beforeAll(async () => {
backupsDir = mkdtempSync(join(tmpdir(), 'dorfteich-system-backups-'));
process.env.BACKUPS_DIR = backupsDir;
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('admin', true);
await makeUser('user', false);
});
afterAll(async () => {
delete process.env.BACKUPS_DIR;
const all = Object.values(ids);
await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } });
await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('lists every registered maintenance job', async () => {
const res = await api().get('/api/v1/admin/system/jobs').set('Cookie', cookies.admin!);
expect(res.status).toBe(200);
const jobs = res.body as SystemJobView[];
const names = jobs.map((job) => job.name);
for (const expected of [
'trash-purge',
'version-thinning',
'page-compaction',
'data-export-purge',
]) {
expect(names).toContain(expected);
}
for (const job of jobs) {
expect(job.cadenceSeconds).toBeGreaterThan(0);
}
});
it('manually triggers a job, records truthful run data, and audit-logs itself', async () => {
const res = await api()
.post('/api/v1/admin/system/jobs/trash-purge/run')
.set('Cookie', cookies.admin!)
.expect(201);
const result = res.body as JobTriggerResult;
expect(result.outcome).toBe('succeeded');
expect(result.job.status).toBe('IDLE');
expect(result.job.lastRunAt).not.toBeNull();
expect(result.job.lastDurationMs).not.toBeNull();
const audit = await api()
.get(`/api/v1/admin/system/audit?actor=sys-admin-${suffix}&action=job.triggered`)
.set('Cookie', cookies.admin!)
.expect(200);
const list = audit.body as AuditListView;
expect(list.total).toBeGreaterThan(0);
expect(list.entries[0]).toMatchObject({
action: 'job.triggered',
targetType: 'job',
targetId: 'trash-purge',
details: { outcome: 'succeeded' },
});
expect(list.entries[0]?.actor?.username).toBe(`sys-admin-${suffix}`);
});
it('rejects triggering an unknown job', async () => {
await api()
.post('/api/v1/admin/system/jobs/no-such-job/run')
.set('Cookie', cookies.admin!)
.expect(404);
});
it('finds a grant change through the audit viewer, filtered by actor', async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { id: ids.admin! } });
const target = await prisma.user.findUniqueOrThrow({ where: { id: ids.user! } });
const pond = await prisma.pond.create({
data: { slug: `sys-pond-${suffix}`, name: 'Sys Pond', type: 'SHARED', ownerId: admin.id },
});
pondIds.push(pond.id);
await app.get(GrantsService).createGrant(admin, pond.id, {
subjectType: 'user',
subjectId: target.id,
role: 'editor',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
});
const res = await api()
.get(`/api/v1/admin/system/audit?actor=sys-admin-${suffix}&action=grant.created`)
.set('Cookie', cookies.admin!)
.expect(200);
const list = res.body as AuditListView;
expect(list.total).toBe(1);
expect(list.entries[0]).toMatchObject({
action: 'grant.created',
targetType: 'pond',
targetId: pond.id,
details: expect.objectContaining({ role: 'editor', subjectId: target.id }),
});
// Filtering by a different actor does not surface it.
const other = await api()
.get(`/api/v1/admin/system/audit?actor=sys-user-${suffix}&action=grant.created`)
.set('Cookie', cookies.admin!)
.expect(200);
expect((other.body as AuditListView).total).toBe(0);
});
it('mirrors status.json in the backup card, including staleness', async () => {
const stale = new Date(Date.now() - 40 * HOUR).toISOString();
writeFileSync(join(backupsDir, BACKUP_STATUS_FILE), JSON.stringify(statusFixture(stale)));
const staleRes = await api()
.get('/api/v1/admin/system/backup')
.set('Cookie', cookies.admin!)
.expect(200);
const staleView = staleRes.body as SystemBackupView;
expect(staleView).toMatchObject({ available: true, fresh: false, maxAgeHours: 26 });
expect(staleView.status?.lastSuccess?.backupId).toBe('20260711-030000');
const freshAt = new Date(Date.now() - 2 * HOUR).toISOString();
writeFileSync(join(backupsDir, BACKUP_STATUS_FILE), JSON.stringify(statusFixture(freshAt)));
const freshRes = await api()
.get('/api/v1/admin/system/backup')
.set('Cookie', cookies.admin!)
.expect(200);
expect((freshRes.body as SystemBackupView).fresh).toBe(true);
});
it('lists the largest ponds in the storage overview', async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { id: ids.admin! } });
const big = await prisma.pond.create({
data: { slug: `sys-big-${suffix}`, name: 'Big Pond', type: 'SHARED', ownerId: admin.id },
});
const small = await prisma.pond.create({
data: { slug: `sys-small-${suffix}`, name: 'Small Pond', type: 'SHARED', ownerId: admin.id },
});
pondIds.push(big.id, small.id);
await prisma.pondUsage.create({ data: { pondId: big.id, storageBytesUsed: 5_000_000n } });
await prisma.pondUsage.create({ data: { pondId: small.id, storageBytesUsed: 1_000n } });
const res = await api()
.get('/api/v1/admin/system/storage')
.set('Cookie', cookies.admin!)
.expect(200);
const view = res.body as StorageOverviewView;
const bigIndex = view.ponds.findIndex((p) => p.pondId === big.id);
const smallIndex = view.ponds.findIndex((p) => p.pondId === small.id);
expect(bigIndex).toBeGreaterThanOrEqual(0);
expect(view.ponds[bigIndex]?.storageBytesUsed).toBe(5_000_000);
if (smallIndex >= 0) expect(bigIndex).toBeLessThan(smallIndex);
expect(view.totalBytes).toBeGreaterThanOrEqual(5_000_000);
});
it('is Site-Admin-only', async () => {
for (const path of [
'/api/v1/admin/system/jobs',
'/api/v1/admin/system/backup',
'/api/v1/admin/system/audit',
'/api/v1/admin/system/storage',
]) {
await api().get(path).set('Cookie', cookies.user!).expect(403);
}
await api()
.post('/api/v1/admin/system/jobs/trash-purge/run')
.set('Cookie', cookies.user!)
.expect(403);
});
});

View File

@ -0,0 +1,175 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, User } from '@prisma/client';
import {
AUDIT_PAGE_SIZE,
BACKUP_FRESH_MAX_AGE_HOURS,
BACKUP_STATUS_FILE,
type AuditListQuery,
type AuditListView,
type BackupStatus,
type JobTriggerResult,
type StorageOverviewView,
type SystemBackupView,
type SystemJobView,
} from '@dorfteich/shared';
import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { PrismaService } from '../prisma/prisma.service';
import { SchedulerService } from '../scheduler/scheduler.service';
/**
* Data behind the Site-Admin "System" panel (issue #86): maintenance jobs
* with truthful last-run data, the backup card mirroring the sidecar's
* status.json, the persistent audit trail, and the per-pond storage top
* list. Reads only the single write path is the manual job trigger,
* which is itself audit-logged.
*/
@Injectable()
export class SystemAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly scheduler: SchedulerService,
private readonly audit: AuditService,
private readonly config: AppConfig,
) {}
/**
* Registered jobs merged with their `jobs` rows: a job that never ran yet
* appears with null last-run data, and a leftover row whose job no longer
* registers in this build is flagged instead of hidden.
*/
async jobs(): Promise<SystemJobView[]> {
const definitions = this.scheduler.definitions();
const rows = await this.prisma.job.findMany();
const rowsByName = new Map(rows.map((row) => [row.name, row]));
const views: SystemJobView[] = definitions.map((definition) => {
const row = rowsByName.get(definition.name);
rowsByName.delete(definition.name);
return {
name: definition.name,
cadenceSeconds: definition.cadenceSeconds,
status: row?.status ?? 'IDLE',
lastRunAt: row?.lastRunAt?.toISOString() ?? null,
lastDurationMs: row?.lastDurationMs ?? null,
lastError: row?.lastError ?? null,
registered: true,
} satisfies SystemJobView;
});
for (const row of rowsByName.values()) {
views.push({
name: row.name,
cadenceSeconds: row.cadenceSeconds,
status: row.status,
lastRunAt: row.lastRunAt?.toISOString() ?? null,
lastDurationMs: row.lastDurationMs ?? null,
lastError: row.lastError ?? null,
registered: false,
});
}
return views.sort((a, b) => a.name.localeCompare(b.name));
}
async triggerJob(actor: User, name: string): Promise<JobTriggerResult> {
if (!this.scheduler.definitions().some((job) => job.name === name)) {
throw new NotFoundException();
}
const outcome = await this.scheduler.runNow(name);
await this.audit.record({
action: 'job.triggered',
actorId: actor.id,
targetType: 'job',
targetId: name,
details: { outcome },
});
const job = (await this.jobs()).find((view) => view.name === name)!;
return { outcome, job };
}
/** The backup card mirrors status.json including the freshness verdict. */
backup(): SystemBackupView {
const path = join(this.config.env.BACKUPS_DIR, BACKUP_STATUS_FILE);
let status: BackupStatus | null = null;
if (existsSync(path)) {
try {
status = JSON.parse(readFileSync(path, 'utf8')) as BackupStatus;
} catch {
status = null;
}
}
const finishedAt = status?.lastSuccess?.finishedAt;
const ageHours = finishedAt
? (Date.now() - new Date(finishedAt).getTime()) / 3_600_000
: Number.POSITIVE_INFINITY;
return {
available: status !== null,
fresh: Number.isFinite(ageHours) && ageHours <= BACKUP_FRESH_MAX_AGE_HOURS,
status,
maxAgeHours: BACKUP_FRESH_MAX_AGE_HOURS,
};
}
async auditLog(query: AuditListQuery): Promise<AuditListView> {
const where: Prisma.AuditEntryWhereInput = {};
if (query.actor) {
const actor = await this.prisma.user.findUnique({ where: { username: query.actor } });
// An unknown username matches nothing rather than everything.
where.actorId = actor?.id ?? '00000000-0000-0000-0000-000000000000';
}
if (query.action) where.action = { startsWith: query.action };
if (query.from || query.to) {
where.at = {
...(query.from ? { gte: query.from } : {}),
...(query.to ? { lte: query.to } : {}),
};
}
const total = await this.prisma.auditEntry.count({ where });
const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE));
const page = Math.min(query.page, pageCount);
const entries = await this.prisma.auditEntry.findMany({
where,
orderBy: { at: 'desc' },
skip: (page - 1) * AUDIT_PAGE_SIZE,
take: AUDIT_PAGE_SIZE,
include: { actor: { select: { id: true, username: true, displayName: true } } },
});
return {
entries: entries.map((entry) => ({
id: entry.id,
at: entry.at.toISOString(),
action: entry.action,
actor: entry.actor,
targetType: entry.targetType,
targetId: entry.targetId,
details: (entry.details as Record<string, unknown> | null) ?? null,
})),
page,
pageCount,
total,
};
}
async storage(): Promise<StorageOverviewView> {
const usages = await this.prisma.pondUsage.findMany({
where: { pond: { deletedAt: null } },
orderBy: { storageBytesUsed: 'desc' },
take: 20,
include: { pond: { select: { name: true, slug: true, type: true } } },
});
const totals = await this.prisma.pondUsage.aggregate({ _sum: { storageBytesUsed: true } });
return {
totalBytes: Number(totals._sum.storageBytesUsed ?? 0n),
ponds: usages.map((usage) => ({
pondId: usage.pondId,
name: usage.pond.name,
slug: usage.pond.slug,
type: usage.pond.type === 'PERSONAL' ? 'personal' : 'shared',
storageBytesUsed: Number(usage.storageBytesUsed),
})),
};
}
}

View File

@ -9,6 +9,7 @@ import { Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PseudonymizationService } from './pseudonymization.service'; import { PseudonymizationService } from './pseudonymization.service';
@ -26,6 +27,7 @@ export class UserAdminService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService, private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(UserAdminService.name); this.logger.setContext(UserAdminService.name);
@ -74,7 +76,13 @@ export class UserAdminService {
// A disabled user is logged out everywhere; login then blocks with a // A disabled user is logged out everywhere; login then blocks with a
// distinct message (auth.service: account_disabled). // distinct message (auth.service: account_disabled).
if (disabled) await this.prisma.session.deleteMany({ where: { userId: id } }); if (disabled) await this.prisma.session.deleteMany({ where: { userId: id } });
this.logger.info({ actor: actor.id, userId: id, disabled }, 'audit: user disabled toggled'); await this.audit.record({
action: 'user.disabled_set',
actorId: actor.id,
targetType: 'user',
targetId: id,
details: { disabled },
});
return this.viewOf(updated, await this.pondCountOf(id)); return this.viewOf(updated, await this.pondCountOf(id));
} }
@ -82,14 +90,24 @@ export class UserAdminService {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException(); if (!user) throw new NotFoundException();
await this.auth.resendVerification(user.email); // no-op unless PENDING await this.auth.resendVerification(user.email); // no-op unless PENDING
this.logger.info({ actor: actor.id, userId: id }, 'audit: verification resent'); await this.audit.record({
action: 'user.verification_resent',
actorId: actor.id,
targetType: 'user',
targetId: id,
});
} }
async deleteUser(actor: User, id: string): Promise<void> { async deleteUser(actor: User, id: string): Promise<void> {
const user = await this.requireOther(actor, id); const user = await this.requireOther(actor, id);
if (user.isSiteAdmin) await this.assertNotLastSiteAdmin(); if (user.isSiteAdmin) await this.assertNotLastSiteAdmin();
await this.pseudonymizer.pseudonymize(id); await this.pseudonymizer.pseudonymize(id);
this.logger.info({ actor: actor.id, userId: id }, 'audit: user deleted'); await this.audit.record({
action: 'user.deleted',
actorId: actor.id,
targetType: 'user',
targetId: id,
});
} }
async setSiteAdmin(actor: User, id: string, value: boolean): Promise<AdminUserView> { async setSiteAdmin(actor: User, id: string, value: boolean): Promise<AdminUserView> {
@ -99,7 +117,13 @@ export class UserAdminService {
where: { id }, where: { id },
data: { isSiteAdmin: value }, data: { isSiteAdmin: value },
}); });
this.logger.info({ actor: actor.id, userId: id, isSiteAdmin: value }, 'audit: site-admin set'); await this.audit.record({
action: 'user.site_admin_set',
actorId: actor.id,
targetType: 'user',
targetId: id,
details: { isSiteAdmin: value },
});
return this.viewOf(updated, await this.pondCountOf(id)); return this.viewOf(updated, await this.pondCountOf(id));
} }

View File

@ -3,6 +3,7 @@ import { APP_FILTER } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { ApiExceptionFilter } from './common/api-exception.filter'; import { ApiExceptionFilter } from './common/api-exception.filter';
import { CompactionModule } from './compaction/compaction.module'; import { CompactionModule } from './compaction/compaction.module';
@ -35,6 +36,7 @@ import { VersionsModule } from './versions/versions.module';
imports: [ imports: [
ConfigModule, ConfigModule,
PrismaModule, PrismaModule,
AuditModule,
RateLimitModule, RateLimitModule,
MailModule, MailModule,
SettingsModule, SettingsModule,

View File

@ -0,0 +1,15 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
/**
* Global because the audit trail cuts across nearly every feature module
* (auth, grants, members, admin, plugins, setup) like PrismaModule, one
* import list entry per consumer would only add noise.
*/
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service';
export interface AuditEvent {
/** Stable dot-namespaced id, e.g. `grant.created` — the UI translates it. */
action: string;
/** The acting user; null/undefined for anonymous events. */
actorId?: string | null;
targetType?: string;
targetId?: string;
/** Small, structured context — never secrets, tokens, or page content. */
details?: Record<string, unknown>;
}
/**
* The persistent audit trail (issue #86, security.md §Logging): auth events
* and admin actions land as queryable rows for the Site-Admin viewer AND as
* the established `audit: …` stdout line. Content activity (pages, files,
* exports, labels) intentionally stays log-only the trail answers "who
* changed access/configuration", not "who edited what".
*
* Recording must never break the audited operation: failures are logged and
* swallowed (the stdout line still fired, so nothing is silently lost).
*/
@Injectable()
export class AuditService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(AuditService.name);
}
async record(event: AuditEvent): Promise<void> {
const { action, actorId, targetType, targetId, details } = event;
this.logger.info(
{ actor: actorId ?? null, targetType, targetId, ...details },
`audit: ${action}`,
);
try {
await this.prisma.auditEntry.create({
data: {
action,
actorId: actorId ?? null,
targetType,
targetId,
details: details ? (details as Prisma.InputJsonObject) : undefined,
},
});
} catch (error) {
this.logger.error({ action, err: error }, 'audit entry could not be persisted');
}
}
}

View File

@ -11,6 +11,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RateLimitService } from '../rate-limit/rate-limit.service'; import { RateLimitService } from '../rate-limit/rate-limit.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
@ -33,6 +34,7 @@ export class AuthService {
private readonly mail: MailService, private readonly mail: MailService,
private readonly ponds: PondsService, private readonly ponds: PondsService,
private readonly rateLimits: RateLimitService, private readonly rateLimits: RateLimitService,
private readonly audit: AuditService,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
@ -46,7 +48,7 @@ export class AuthService {
} }
const user = await this.users.createUser(input); const user = await this.users.createUser(input);
await this.sendVerificationMail(user); await this.sendVerificationMail(user);
this.logger.info({ userId: user.id }, 'audit: user signed up'); await this.audit.record({ action: 'auth.signup', actorId: user.id });
} }
async verifyEmail(token: string): Promise<void> { async verifyEmail(token: string): Promise<void> {
@ -56,7 +58,7 @@ export class AuthService {
if (!user) throw new BadRequestException({ code: 'token_invalid' }); if (!user) throw new BadRequestException({ code: 'token_invalid' });
if (user.status === 'PENDING_VERIFICATION') { if (user.status === 'PENDING_VERIFICATION') {
await this.users.markEmailVerified(userId); await this.users.markEmailVerified(userId);
this.logger.info({ userId }, 'audit: e-mail verified'); await this.audit.record({ action: 'auth.email_verified', actorId: userId });
} }
// Every verified account owns a personal pond (issue #21). Idempotent, // Every verified account owns a personal pond (issue #21). Idempotent,
// so re-verification attempts and races cannot create duplicates. // so re-verification attempts and races cannot create duplicates.
@ -95,7 +97,7 @@ export class AuthService {
const passwordOk = user ? await this.users.checkPassword(user.id, password) : false; const passwordOk = user ? await this.users.checkPassword(user.id, password) : false;
if (!user || !passwordOk) { if (!user || !passwordOk) {
// Same generic error for unknown user and wrong password. // Same generic error for unknown user and wrong password.
this.logger.info({ userId: user?.id ?? null }, 'audit: login failed'); await this.audit.record({ action: 'auth.login_failed', actorId: user?.id ?? null });
throw new UnauthorizedException({ code: 'login_failed' }); throw new UnauthorizedException({ code: 'login_failed' });
} }
if (user.status === 'DISABLED') { if (user.status === 'DISABLED') {
@ -108,7 +110,7 @@ export class AuthService {
await this.rateLimits.reset('login-account', user.id); await this.rateLimits.reset('login-account', user.id);
const sessionToken = await this.sessions.create(user.id, userAgent); const sessionToken = await this.sessions.create(user.id, userAgent);
await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
this.logger.info({ userId: user.id }, 'audit: login succeeded'); await this.audit.record({ action: 'auth.login_succeeded', actorId: user.id });
return { sessionToken, user }; return { sessionToken, user };
} }
@ -134,7 +136,7 @@ export class AuthService {
await this.users.setPassword(userId, password); await this.users.setPassword(userId, password);
// Whoever held old sessions (possibly an attacker) is logged out. // Whoever held old sessions (possibly an attacker) is logged out.
await this.sessions.destroyAllForUser(userId); await this.sessions.destroyAllForUser(userId);
this.logger.info({ userId }, 'audit: password reset'); await this.audit.record({ action: 'auth.password_reset', actorId: userId });
} }
private async sendVerificationMail(user: User): Promise<void> { private async sendVerificationMail(user: User): Promise<void> {

View File

@ -8,6 +8,7 @@ import { AccessRuleView, Grant, GrantView, grantValidationError } from '@dorftei
import { Pond, RoleGrant, User } from '@prisma/client'; import { Pond, RoleGrant, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PondPermissionCache } from '../permissions/pond-permission-cache'; import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -28,6 +29,7 @@ export class GrantsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionCache: PondPermissionCache, private readonly permissionCache: PondPermissionCache,
private readonly accessNotifier: PondAccessNotifier, private readonly accessNotifier: PondAccessNotifier,
private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(GrantsService.name); this.logger.setContext(GrantsService.name);
@ -170,11 +172,13 @@ export class GrantsService {
data: { pondId, createdBy: user.id, ...columns }, data: { pondId, createdBy: user.id, ...columns },
}); });
await this.accessChanged(pondId); await this.accessChanged(pondId);
this.logger.info( await this.audit.record({
{ action: 'grant.created',
actorId: user.id,
targetType: 'pond',
targetId: pondId,
details: {
grantId: created.id, grantId: created.id,
pondId,
userId: user.id,
subject: grant.subjectType, subject: grant.subjectType,
subjectId: grant.subjectId, subjectId: grant.subjectId,
role: grant.role, role: grant.role,
@ -182,8 +186,7 @@ export class GrantsService {
scopeId: grant.scopeId, scopeId: grant.scopeId,
effect: grant.effect, effect: grant.effect,
}, },
'audit: grant created', });
);
return GrantsService.viewOf(created); return GrantsService.viewOf(created);
} }
@ -205,10 +208,13 @@ export class GrantsService {
await this.prisma.roleGrant.delete({ where: { id: grantId } }); await this.prisma.roleGrant.delete({ where: { id: grantId } });
await this.accessChanged(pondId); await this.accessChanged(pondId);
this.logger.info( await this.audit.record({
{ grantId, pondId, userId: user.id, subjectId: grant.subjectId, role: grant.role }, action: 'grant.deleted',
'audit: grant deleted', actorId: user.id,
); targetType: 'pond',
targetId: pondId,
details: { grantId, subjectId: grant.subjectId, role: grant.role },
});
} }
/** Revoked/added permission takes effect immediately: drop the cached pond /** Revoked/added permission takes effect immediately: drop the cached pond

View File

@ -20,6 +20,7 @@ import { toGrant, toGrantColumns } from '../grants/grant-mappers';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PondPermissionCache } from '../permissions/pond-permission-cache'; import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService, quotaExceeded } from '../quotas/quota.service'; import { QuotaService, quotaExceeded } from '../quotas/quota.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
@ -45,6 +46,7 @@ export class MembersService {
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly permissionCache: PondPermissionCache, private readonly permissionCache: PondPermissionCache,
private readonly accessNotifier: PondAccessNotifier, private readonly accessNotifier: PondAccessNotifier,
private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(MembersService.name); this.logger.setContext(MembersService.name);
@ -158,10 +160,13 @@ export class MembersService {
}); });
await this.accessChanged(pondId); await this.accessChanged(pondId);
this.logger.info( await this.audit.record({
{ pondId, actor: actor.id, member: target.id, role: input.role }, action: 'member.added',
'audit: member added', actorId: actor.id,
); targetType: 'pond',
targetId: pondId,
details: { member: target.id, role: input.role },
});
return this.viewOf(target, input.role, pond); return this.viewOf(target, input.role, pond);
} }
@ -208,10 +213,13 @@ export class MembersService {
if (changed) { if (changed) {
await this.accessChanged(pondId); await this.accessChanged(pondId);
this.logger.info( await this.audit.record({
{ pondId, actor: actor.id, member: memberUserId, role: input.role }, action: 'member.role_changed',
'audit: member role changed', actorId: actor.id,
); targetType: 'pond',
targetId: pondId,
details: { member: memberUserId, role: input.role },
});
} }
return this.viewOf(target, input.role, pond); return this.viewOf(target, input.role, pond);
} }
@ -234,7 +242,13 @@ export class MembersService {
}); });
await this.accessChanged(pondId); await this.accessChanged(pondId);
this.logger.info({ pondId, actor: actor.id, member: memberUserId }, 'audit: member removed'); await this.audit.record({
action: 'member.removed',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { member: memberUserId },
});
} }
private assertGrantValid(grant: Grant, pond: Pond): void { private assertGrantValid(grant: Grant, pond: Pond): void {

View File

@ -12,6 +12,7 @@ import {
Patch, Patch,
PayloadTooLargeException, PayloadTooLargeException,
Post, Post,
Req,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@ -20,6 +21,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { pluginModeInputSchema, type PluginModeInput, type PluginView } from '@dorfteich/shared'; import { pluginModeInputSchema, type PluginModeInput, type PluginView } from '@dorfteich/shared';
import { SiteAdminGuard } from '../admin/site-admin.guard'; import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { PluginsService } from './plugins.service'; import { PluginsService } from './plugins.service';
@ -57,10 +59,13 @@ export class PluginAdminController {
/** Upload and install (or update) a plugin ZIP. */ /** Upload and install (or update) a plugin ZIP. */
@Post() @Post()
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_ZIP_BYTES } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_ZIP_BYTES } }))
async install(@UploadedFile() file: Express.Multer.File | undefined): Promise<PluginView> { async install(
@UploadedFile() file: Express.Multer.File | undefined,
@Req() request: AuthedRequest,
): Promise<PluginView> {
if (!file) throw new BadRequestException({ code: 'bad_request', message: 'No file uploaded' }); if (!file) throw new BadRequestException({ code: 'bad_request', message: 'No file uploaded' });
try { try {
return await this.plugins.install(file.buffer); return await this.plugins.install(file.buffer, request.user!);
} catch (error) { } catch (error) {
if (error instanceof PluginPackageError) throw toHttpException(error); if (error instanceof PluginPackageError) throw toHttpException(error);
throw error; throw error;
@ -78,9 +83,10 @@ export class PluginAdminController {
async setMode( async setMode(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(pluginModeInputSchema)) body: PluginModeInput, @Body(new ZodValidationPipe(pluginModeInputSchema)) body: PluginModeInput,
@Req() request: AuthedRequest,
): Promise<PluginView> { ): Promise<PluginView> {
try { try {
return await this.plugins.setMode(id, body.mode); return await this.plugins.setMode(id, body.mode, request.user!);
} catch (error) { } catch (error) {
if (error instanceof PluginPackageError) throw toHttpException(error); if (error instanceof PluginPackageError) throw toHttpException(error);
throw error; throw error;
@ -90,9 +96,9 @@ export class PluginAdminController {
/** Uninstall a plugin (refused while `required`). */ /** Uninstall a plugin (refused while `required`). */
@Delete(':id') @Delete(':id')
@HttpCode(204) @HttpCode(204)
async uninstall(@Param('id') id: string): Promise<void> { async uninstall(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
try { try {
await this.plugins.uninstall(id); await this.plugins.uninstall(id, request.user!);
} catch (error) { } catch (error) {
if (error instanceof PluginPackageError) throw toHttpException(error); if (error instanceof PluginPackageError) throw toHttpException(error);
throw error; throw error;

View File

@ -8,6 +8,7 @@ import {
Param, Param,
Put, Put,
Body, Body,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
pondPluginToggleInputSchema, pondPluginToggleInputSchema,
@ -16,6 +17,7 @@ import {
type PondPluginToggleInput, type PondPluginToggleInput,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequiresPondRole } from '../permissions/permission.decorators'; import { RequiresPondRole } from '../permissions/permission.decorators';
@ -54,9 +56,10 @@ export class PluginPondController {
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@Param('pluginId') pluginId: string, @Param('pluginId') pluginId: string,
@Body(new ZodValidationPipe(pondPluginToggleInputSchema)) body: PondPluginToggleInput, @Body(new ZodValidationPipe(pondPluginToggleInputSchema)) body: PondPluginToggleInput,
@Req() request: AuthedRequest,
): Promise<void> { ): Promise<void> {
try { try {
await this.plugins.setPondActivation(pondId, pluginId, body.enabled); await this.plugins.setPondActivation(pondId, pluginId, body.enabled, request.user!);
} catch (error) { } catch (error) {
if (error instanceof PluginPackageError) throw toHttpException(error); if (error instanceof PluginPackageError) throw toHttpException(error);
throw error; throw error;

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk'; import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk';
import type { import type {
@ -10,6 +10,7 @@ import type {
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { ClockService } from '../common/clock.service'; import { ClockService } from '../common/clock.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PluginPackageService } from './plugin-package.service'; import { PluginPackageService } from './plugin-package.service';
@ -42,6 +43,7 @@ export class PluginsService {
private readonly packages: PluginPackageService, private readonly packages: PluginPackageService,
private readonly storage: PluginStorageService, private readonly storage: PluginStorageService,
private readonly clock: ClockService, private readonly clock: ClockService,
private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(PluginsService.name); this.logger.setContext(PluginsService.name);
@ -53,7 +55,8 @@ export class PluginsService {
* version is strictly higher; the admin's chosen instance `mode` is preserved * version is strictly higher; the admin's chosen instance `mode` is preserved
* across updates. Files land atomically before the metadata pointer flips. * across updates. Files land atomically before the metadata pointer flips.
*/ */
async install(zip: Buffer): Promise<PluginView> { /** `actor` is absent for dropzone installs (watcher, no session). */
async install(zip: Buffer, actor?: User): Promise<PluginView> {
const { manifest, files } = this.packages.parse(zip); const { manifest, files } = this.packages.parse(zip);
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } }); const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
@ -95,10 +98,13 @@ export class PluginsService {
await this.storage.removeVersion(manifest.id, existing.version); await this.storage.removeVersion(manifest.id, existing.version);
} }
this.logger.info( await this.audit.record({
{ plugin: manifest.id, version: manifest.version, update: isActiveUpdate }, action: 'plugin.installed',
'audit: plugin installed', actorId: actor?.id,
); targetType: 'plugin',
targetId: manifest.id,
details: { version: manifest.version, update: isActiveUpdate },
});
return this.toView(record); return this.toView(record);
} }
@ -109,7 +115,7 @@ export class PluginsService {
* kept across mode changes (they are simply ignored while non-optional), so * kept across mode changes (they are simply ignored while non-optional), so
* flipping optionalrequiredoptional restores the previous per-pond choices. * flipping optionalrequiredoptional restores the previous per-pond choices.
*/ */
async setMode(id: string, mode: PluginInstanceMode): Promise<PluginView> { async setMode(id: string, mode: PluginInstanceMode, actor?: User): Promise<PluginView> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } }); const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) { if (!plugin || plugin.removedAt !== null) {
throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`);
@ -118,7 +124,13 @@ export class PluginsService {
where: { id }, where: { id },
data: { mode: VIEW_MODE_TO_DB[mode] }, data: { mode: VIEW_MODE_TO_DB[mode] },
}); });
this.logger.info({ plugin: id, mode }, 'audit: plugin mode set'); await this.audit.record({
action: 'plugin.mode_set',
actorId: actor?.id,
targetType: 'plugin',
targetId: id,
details: { mode },
});
return this.toView(updated); return this.toView(updated);
} }
@ -127,7 +139,7 @@ export class PluginsService {
* tombstoned (`removedAt` set, per-pond activations dropped) and every file is * tombstoned (`removedAt` set, per-pond activations dropped) and every file is
* removed from disk. * removed from disk.
*/ */
async uninstall(id: string): Promise<void> { async uninstall(id: string, actor?: User): Promise<void> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } }); const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) { if (!plugin || plugin.removedAt !== null) {
throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`);
@ -147,7 +159,12 @@ export class PluginsService {
}), }),
]); ]);
await this.storage.removePlugin(id); await this.storage.removePlugin(id);
this.logger.info({ plugin: id }, 'audit: plugin uninstalled'); await this.audit.record({
action: 'plugin.uninstalled',
actorId: actor?.id,
targetType: 'plugin',
targetId: id,
});
} }
/** /**
@ -210,7 +227,12 @@ export class PluginsService {
* plugins are per-pond choices; toggling a required/disabled/absent plugin is * plugins are per-pond choices; toggling a required/disabled/absent plugin is
* rejected so the UI cannot desync the model. * rejected so the UI cannot desync the model.
*/ */
async setPondActivation(pondId: string, pluginId: string, enabled: boolean): Promise<void> { async setPondActivation(
pondId: string,
pluginId: string,
enabled: boolean,
actor?: User,
): Promise<void> {
const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } });
if (!plugin || plugin.removedAt !== null) { if (!plugin || plugin.removedAt !== null) {
throw new PluginPackageError('plugin_not_found', `Plugin ${pluginId} is not installed`); throw new PluginPackageError('plugin_not_found', `Plugin ${pluginId} is not installed`);
@ -226,7 +248,13 @@ export class PluginsService {
create: { pondId, pluginId, enabled }, create: { pondId, pluginId, enabled },
update: { enabled }, update: { enabled },
}); });
this.logger.info({ plugin: pluginId, pond: pondId, enabled }, 'audit: pond plugin toggled'); await this.audit.record({
action: 'plugin.pond_toggled',
actorId: actor?.id,
targetType: 'pond',
targetId: pondId,
details: { plugin: pluginId, enabled },
});
} }
/** /**

View File

@ -50,6 +50,23 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
this.jobs.set(job.name, job); this.jobs.set(job.name, job);
} }
/** Every job registered in this process (issue #86 admin panel). */
definitions(): JobDefinition[] {
return [...this.jobs.values()];
}
/**
* Manual trigger from the admin panel (issue #86): runs `name` now,
* regardless of cadence only the run-mutex still applies, so a job
* already running elsewhere reports `already_running` instead of
* doubling up.
*/
async runNow(name: string): Promise<'succeeded' | 'failed' | 'already_running'> {
const job = this.jobs.get(name);
if (!job) throw new Error(`unknown job: ${name}`);
return this.claimAndRun(job, { force: true });
}
onModuleInit(): void { onModuleInit(): void {
if (this.config.env.NODE_ENV === 'test') return; // tests drive jobs directly if (this.config.env.NODE_ENV === 'test') return; // tests drive jobs directly
this.timer = setInterval(() => void this.tick(), TICK_MS); this.timer = setInterval(() => void this.tick(), TICK_MS);
@ -69,6 +86,13 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
/** Runs `job` now if due and not already running elsewhere; a no-op otherwise. */ /** Runs `job` now if due and not already running elsewhere; a no-op otherwise. */
async runIfDue(job: JobDefinition): Promise<void> { async runIfDue(job: JobDefinition): Promise<void> {
await this.claimAndRun(job, { force: false });
}
private async claimAndRun(
job: JobDefinition,
{ force }: { force: boolean },
): Promise<'succeeded' | 'failed' | 'already_running'> {
try { try {
await this.prisma.job.upsert({ await this.prisma.job.upsert({
where: { name: job.name }, where: { name: job.name },
@ -92,27 +116,34 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
where: { where: {
name: job.name, name: job.name,
AND: [ AND: [
{ OR: [{ lastRunAt: null }, { lastRunAt: { lte: dueBefore } }] }, // A manual trigger skips the due check, never the run-mutex.
...(force ? [] : [{ OR: [{ lastRunAt: null }, { lastRunAt: { lte: dueBefore } }] }]),
{ OR: [{ status: { not: 'RUNNING' } }, { lockedAt: { lte: staleLockBefore } }] }, { OR: [{ status: { not: 'RUNNING' } }, { lockedAt: { lte: staleLockBefore } }] },
], ],
}, },
data: { status: 'RUNNING', lockedAt: now, lastRunAt: now }, data: { status: 'RUNNING', lockedAt: now, lastRunAt: now },
}); });
if (claim.count === 0) return; // not due, or another run already holds it if (claim.count === 0) return 'already_running'; // or, unforced, simply not due
try { try {
await job.run(); await job.run();
await this.prisma.job.update({ await this.prisma.job.update({
where: { name: job.name }, where: { name: job.name },
data: { status: 'IDLE', lastError: null }, data: { status: 'IDLE', lastError: null, lastDurationMs: this.sinceMs(now) },
}); });
return 'succeeded';
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : String(error); const message = error instanceof Error ? error.message.slice(0, 500) : String(error);
this.logger.error({ job: job.name, err: error }, 'maintenance job failed'); this.logger.error({ job: job.name, err: error }, 'maintenance job failed');
await this.prisma.job.update({ await this.prisma.job.update({
where: { name: job.name }, where: { name: job.name },
data: { status: 'FAILED', lastError: message }, data: { status: 'FAILED', lastError: message, lastDurationMs: this.sinceMs(now) },
}); });
return 'failed';
} }
} }
private sinceMs(start: Date): number {
return Math.max(0, this.clock.now().getTime() - start.getTime());
}
} }

View File

@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod'; import { z } from 'zod';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
@ -79,6 +80,7 @@ export class InstanceSettingsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(InstanceSettingsService.name); this.logger.setContext(InstanceSettingsService.name);
@ -125,7 +127,14 @@ export class InstanceSettingsService {
update: { value: stored }, update: { value: stored },
}); });
this.cache.set(key, parsed.data); this.cache.set(key, parsed.data);
this.logger.info({ key, actorUserId }, 'audit: instance setting changed'); // Values stay out of the trail: legal texts are long, and future keys
// could be sensitive — the key names what changed, the log has the actor.
await this.audit.record({
action: 'settings.changed',
actorId: actorUserId,
targetType: 'setting',
targetId: key,
});
return parsed.data as InstanceSettingValue<K>; return parsed.data as InstanceSettingValue<K>;
} }
} }

View File

@ -22,6 +22,7 @@ import { SecretStoreService } from '../config/secret-store.service';
import { renderMail } from '../mail/mail-templates'; import { renderMail } from '../mail/mail-templates';
import { SmtpConfigService, SmtpSettings } from '../mail/smtp-config.service'; import { SmtpConfigService, SmtpSettings } from '../mail/smtp-config.service';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
@ -45,6 +46,7 @@ export class SetupService implements OnModuleInit {
private readonly sessions: SessionsService, private readonly sessions: SessionsService,
private readonly secretStore: SecretStoreService, private readonly secretStore: SecretStoreService,
private readonly smtpConfig: SmtpConfigService, private readonly smtpConfig: SmtpConfigService,
private readonly audit: AuditService,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
@ -87,7 +89,7 @@ export class SetupService implements OnModuleInit {
await this.settings.set('auth.registrationMode', env.SETUP_REGISTRATION_MODE, admin.id); await this.settings.set('auth.registrationMode', env.SETUP_REGISTRATION_MODE, admin.id);
} }
await this.complete(admin); await this.complete(admin);
this.logger.info({ userId: admin.id }, 'audit: setup pre-seeded from environment'); await this.audit.record({ action: 'setup.preseeded', actorId: admin.id });
} }
async status(): Promise<SetupStatusView> { async status(): Promise<SetupStatusView> {
@ -117,7 +119,12 @@ export class SetupService implements OnModuleInit {
data: { isSiteAdmin: true, status: 'ACTIVE', emailVerifiedAt: new Date() }, data: { isSiteAdmin: true, status: 'ACTIVE', emailVerifiedAt: new Date() },
}); });
await this.ponds.ensurePersonalPond(admin); await this.ponds.ensurePersonalPond(admin);
this.logger.info({ userId: admin.id }, 'audit: setup created site admin'); await this.audit.record({
action: 'setup.admin_created',
actorId: admin.id,
targetType: 'user',
targetId: admin.id,
});
return admin; return admin;
} }
@ -160,7 +167,7 @@ export class SetupService implements OnModuleInit {
SMTP_FROM: candidate.from, SMTP_FROM: candidate.from,
}); });
this.smtpConfig.refresh(); this.smtpConfig.refresh();
this.logger.info({ userId: actor.id }, 'audit: setup stored SMTP configuration'); await this.audit.record({ action: 'setup.smtp_stored', actorId: actor.id });
} }
/** Step 4 — registration mode (ADR 0007). */ /** Step 4 — registration mode (ADR 0007). */
@ -176,7 +183,7 @@ export class SetupService implements OnModuleInit {
throw new BadRequestException({ code: 'setup_admin_missing' }); throw new BadRequestException({ code: 'setup_admin_missing' });
} }
await this.settings.set('setup.completedAt', new Date().toISOString(), actor.id); await this.settings.set('setup.completedAt', new Date().toISOString(), actor.id);
this.logger.info({ userId: actor.id }, 'audit: setup completed and locked'); await this.audit.record({ action: 'setup.completed', actorId: actor.id });
} }
private async sendTestMail(candidate: SmtpSettings, actor: User): Promise<void> { private async sendTestMail(candidate: SmtpSettings, actor: User): Promise<void> {

103
apps/web/e2e/system.spec.ts Normal file
View File

@ -0,0 +1,103 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Site-Admin system panel (issue #86): the maintenance-job table with a
* working, audit-logged manual trigger, the audit viewer finding entries by
* actor, the backup card, and Site-Admin-only access.
*/
test('lists maintenance jobs and triggers one manually', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/admin/system');
const jobsTable = page.locator('.system-jobs__table');
await expect(jobsTable).toBeVisible();
// All four registered jobs appear (language-neutral: row count + button).
await expect(jobsTable.locator('tbody tr')).toHaveCount(4);
const firstRow = jobsTable.locator('tbody tr').first();
await firstRow.getByRole('button').click();
await expect(page.locator('.system-jobs__notice')).toBeVisible();
// A completed run shows truthful last-run data: a date and a duration.
await expect(firstRow.locator('td').nth(2)).not.toHaveText('—');
await expect(firstRow.locator('td').nth(3)).not.toHaveText('—');
// The trigger itself lands in the audit log, attributed to the admin.
const audit = page.locator('.system-audit__table');
await page.locator('.system-audit__filters input[type="text"]').fill('fixture-admin');
await page.locator('.system-audit__filters select').selectOption('job.triggered');
await page.locator('.system-audit__filters button[type="submit"]').click();
await expect(audit.locator('tbody tr').first()).toContainText('job:');
await admin.close();
});
test('the audit viewer finds a grant change by actor', async ({ browser }) => {
// The fixture grant change happens as the shared pond's owner via the API
// (the UI flow itself is covered by #55's pack).
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ponds = await owner.request.get('/api/v1/ponds');
expect(ponds.ok()).toBe(true);
const pond = ((await ponds.json()) as { id: string; type: string }[]).find(
(p) => p.type === 'shared',
);
expect(pond).toBeTruthy();
const me = await owner.request.get('/api/v1/auth/me');
const ownerId = ((await me.json()) as { id: string }).id;
const created = await owner.request.post(`/api/v1/ponds/${pond!.id}/grants`, {
data: {
subjectType: 'user',
subjectId: ownerId,
role: 'reader',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
},
});
expect(created.ok()).toBe(true);
const grantId = ((await created.json()) as { id: string }).id;
await owner.close();
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/admin/system');
await page.locator('.system-audit__filters input[type="text"]').fill('fixture-user');
await page.locator('.system-audit__filters select').selectOption('grant.created');
await page.locator('.system-audit__filters button[type="submit"]').click();
const firstRow = page.locator('.system-audit__table tbody tr').first();
await expect(firstRow).toContainText(`pond:${pond!.id}`);
await expect(firstRow).toContainText('reader');
await admin.close();
// Cleanup so the pack is repeatable without stacking grants.
const cleanup = await contextForUser(browser, BASE_URL, 'fixture-user');
await cleanup.request.delete(`/api/v1/ponds/${pond!.id}/grants/${grantId}`);
await cleanup.close();
});
test('shows the backup card state', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/admin/system');
// CI stacks run without the sidecar → the unavailable notice; a stage with
// backups shows the freshness badge instead. Either way the card renders.
const card = page.locator('.system-backup');
await expect(card).toBeVisible();
await expect(card.locator('.system-backup__unavailable, .system-badge').first()).toBeVisible();
await admin.close();
});
test('regular users cannot open the system panel', async ({ browser }) => {
const user = await contextForUser(browser, BASE_URL, 'fixture-user');
const page = await user.newPage();
const response = await user.request.get('/api/v1/admin/system/jobs');
expect(response.status()).toBe(403);
await page.goto('/admin/system');
// The RequireSiteAdmin route guard keeps non-admins out of the panel.
await expect(page.locator('.system-jobs__table')).toHaveCount(0);
await user.close();
});

View File

@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards'; import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
import { AppLayout } from './layout/AppLayout'; import { AppLayout } from './layout/AppLayout';
import { AdminSettingsPage } from './pages/AdminSettingsPage'; import { AdminSettingsPage } from './pages/AdminSettingsPage';
import { AdminSystemPage } from './pages/AdminSystemPage';
import { FontCatalogPage } from './pages/FontCatalogPage'; import { FontCatalogPage } from './pages/FontCatalogPage';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { LegalPage } from './pages/LegalPage'; import { LegalPage } from './pages/LegalPage';
@ -78,6 +79,7 @@ export function App(): React.JSX.Element {
</Route> </Route>
<Route element={<RequireSiteAdmin />}> <Route element={<RequireSiteAdmin />}>
<Route path="admin" element={<AdminSettingsPage />} /> <Route path="admin" element={<AdminSettingsPage />} />
<Route path="admin/system" element={<AdminSystemPage />} />
{/* Sandbox preview of one installed plugin (issue #73). */} {/* Sandbox preview of one installed plugin (issue #73). */}
<Route path="admin/plugins/:pluginId/preview" element={<PluginPreviewPage />} /> <Route path="admin/plugins/:pluginId/preview" element={<PluginPreviewPage />} />
</Route> </Route>

View File

@ -16,6 +16,7 @@ import dePublic from '@dorfteich/shared/i18n/de/public.json';
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json';
import deSetup from '@dorfteich/shared/i18n/de/setup.json'; import deSetup from '@dorfteich/shared/i18n/de/setup.json';
import deSystem from '@dorfteich/shared/i18n/de/system.json';
import deUsers from '@dorfteich/shared/i18n/de/users.json'; import deUsers from '@dorfteich/shared/i18n/de/users.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json';
@ -36,6 +37,7 @@ import enPublic from '@dorfteich/shared/i18n/en/public.json';
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSearch from '@dorfteich/shared/i18n/en/search.json';
import enSetup from '@dorfteich/shared/i18n/en/setup.json'; import enSetup from '@dorfteich/shared/i18n/en/setup.json';
import enSystem from '@dorfteich/shared/i18n/en/system.json';
import enUsers from '@dorfteich/shared/i18n/en/users.json'; import enUsers from '@dorfteich/shared/i18n/en/users.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next'; import i18n from 'i18next';
@ -73,6 +75,7 @@ void i18n
quotas: enQuotas, quotas: enQuotas,
search: enSearch, search: enSearch,
setup: enSetup, setup: enSetup,
system: enSystem,
users: enUsers, users: enUsers,
}, },
de: { de: {
@ -95,6 +98,7 @@ void i18n
quotas: deQuotas, quotas: deQuotas,
search: deSearch, search: deSearch,
setup: deSetup, setup: deSetup,
system: deSystem,
users: deUsers, users: deUsers,
}, },
}, },

View File

@ -3,6 +3,7 @@ import { docToHtml, markdownToDoc } from '@dorfteich/shared';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Field, FormError, FormSuccess } from '../components/forms'; import { Field, FormError, FormSuccess } from '../components/forms';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
@ -56,6 +57,9 @@ export function AdminSettingsPage(): React.JSX.Element {
return ( return (
<> <>
<h1>{t('settings:admin.title')}</h1> <h1>{t('settings:admin.title')}</h1>
<p>
<Link to="/admin/system">{t('system:settingsLink')} </Link>
</p>
<section className="settings-section"> <section className="settings-section">
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
<FormError error={error} /> <FormError error={error} />

View File

@ -0,0 +1,418 @@
import type {
AuditEntryView,
AuditListView,
JobTriggerOutcome,
JobTriggerResult,
StorageOverviewView,
SystemBackupView,
SystemJobView,
} from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { formatBytes } from '../files/file-format';
import { apiGet, apiPost } from '../lib/api';
/**
* Site-Admin "System" panel (issue #86): maintenance jobs with a manual
* trigger, the backup card fed by the sidecar's status.json, the audit-log
* viewer, and the per-pond storage top list the operator's single glance
* for instance health (operations.md).
*/
export function AdminSystemPage(): React.JSX.Element {
const { t } = useTranslation('system');
return (
<>
<h1>{t('title')}</h1>
<p>
<Link to="/admin"> {t('backLink')}</Link>
</p>
<JobsSection />
<BackupCard />
<AuditViewer />
<StorageSection />
</>
);
}
function cadenceParts(seconds: number): {
unit: 'days' | 'hours' | 'minutes' | 'seconds';
count: number;
} {
if (seconds % 86_400 === 0) return { unit: 'days', count: seconds / 86_400 };
if (seconds % 3_600 === 0) return { unit: 'hours', count: seconds / 3_600 };
if (seconds % 60 === 0) return { unit: 'minutes', count: seconds / 60 };
return { unit: 'seconds', count: seconds };
}
function durationLabel(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
}
function JobsSection(): React.JSX.Element {
const { t } = useTranslation('system');
const [notice, setNotice] = useState<{ job: string; outcome: JobTriggerOutcome } | null>(null);
const [pending, setPending] = useState<string | null>(null);
const query = useQuery({
queryKey: ['admin', 'system', 'jobs'],
queryFn: () => apiGet<SystemJobView[]>('/admin/system/jobs'),
});
const trigger = async (name: string): Promise<void> => {
setPending(name);
setNotice(null);
try {
const result = await apiPost<JobTriggerResult>(`/admin/system/jobs/${name}/run`, {});
setNotice({ job: name, outcome: result.outcome });
} finally {
setPending(null);
await query.refetch();
}
};
return (
<section className="settings-section">
<h2>{t('jobs.title')}</h2>
{notice && (
<p role="status" className="system-jobs__notice">
{t(`jobs.triggered.${notice.outcome}`)}
</p>
)}
<table className="table system-jobs__table">
<thead>
<tr>
<th>{t('jobs.columns.job')}</th>
<th>{t('jobs.columns.cadence')}</th>
<th>{t('jobs.columns.lastRun')}</th>
<th>{t('jobs.columns.duration')}</th>
<th>{t('jobs.columns.outcome')}</th>
<th>{t('jobs.columns.actions')}</th>
</tr>
</thead>
<tbody>
{(query.data ?? []).map((job) => (
<tr key={job.name}>
<td>
{t(`jobs.names.${job.name}`, { defaultValue: job.name })}
{!job.registered && (
<span className="system-badge system-badge--warn">{t('jobs.unregistered')}</span>
)}
</td>
<td>
{(() => {
const { unit, count } = cadenceParts(job.cadenceSeconds);
return t(`jobs.cadence.${unit}`, { count });
})()}
</td>
<td>{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '—'}</td>
<td>{job.lastDurationMs !== null ? durationLabel(job.lastDurationMs) : '—'}</td>
<td>
<JobOutcome job={job} />
</td>
<td>
{job.registered && (
<button
type="button"
className="button"
disabled={pending !== null || job.status === 'RUNNING'}
onClick={() => void trigger(job.name)}
>
{pending === job.name ? t('jobs.runPending') : t('jobs.run')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function JobOutcome({ job }: { job: SystemJobView }): React.JSX.Element {
const { t } = useTranslation('system');
if (job.status === 'RUNNING')
return <span className="system-badge">{t('jobs.outcome.running')}</span>;
if (job.status === 'FAILED')
return (
<span className="system-badge system-badge--error" title={job.lastError ?? undefined}>
{t('jobs.outcome.failed')}
</span>
);
if (!job.lastRunAt) return <span className="system-badge">{t('jobs.outcome.never')}</span>;
return <span className="system-badge system-badge--ok">{t('jobs.outcome.ok')}</span>;
}
function BackupCard(): React.JSX.Element {
const { t } = useTranslation('system');
const query = useQuery({
queryKey: ['admin', 'system', 'backup'],
queryFn: () => apiGet<SystemBackupView>('/admin/system/backup'),
});
const view = query.data;
if (!view) return <></>;
return (
<section className="settings-section system-backup">
<h2>{t('backup.title')}</h2>
{!view.available && <p className="system-backup__unavailable">{t('backup.unavailable')}</p>}
{view.available && view.status && (
<>
<p>
<span
className={`system-badge ${view.fresh ? 'system-badge--ok' : 'system-badge--error'}`}
>
{view.fresh ? t('backup.fresh') : t('backup.stale', { hours: view.maxAgeHours })}
</span>
</p>
<dl className="system-backup__facts">
<dt>{t('backup.lastSuccess')}</dt>
<dd>
{view.status.lastSuccess
? `${new Date(view.status.lastSuccess.finishedAt).toLocaleString()} (${view.status.lastSuccess.backupId}) — ${t(
'backup.sizes',
{
dump: formatBytes(view.status.lastSuccess.sizes.dumpBytes),
archive: formatBytes(view.status.lastSuccess.sizes.archiveBytes),
},
)}`
: t('backup.never')}
</dd>
<dt>{t('backup.lastRun')}</dt>
<dd>
{new Date(view.status.lastRun.finishedAt).toLocaleString()} {' '}
{t(`backup.outcome.${view.status.lastRun.outcome}`)}
</dd>
{view.status.lastRun.error && (
<>
<dt>{t('backup.error')}</dt>
<dd>
<code>{view.status.lastRun.error}</code>
</dd>
</>
)}
</dl>
<p className="system-backup__retention">
{t('backup.retention', { days: view.status.retentionDays })}
</p>
</>
)}
</section>
);
}
interface AuditFilter {
actor: string;
action: string;
from: string;
to: string;
}
const EMPTY_FILTER: AuditFilter = { actor: '', action: '', from: '', to: '' };
function AuditViewer(): React.JSX.Element {
const { t } = useTranslation('system');
const [draft, setDraft] = useState<AuditFilter>(EMPTY_FILTER);
const [filter, setFilter] = useState<AuditFilter>(EMPTY_FILTER);
const [page, setPage] = useState(1);
const query = useQuery({
queryKey: ['admin', 'system', 'audit', filter, page],
queryFn: () => {
const params = new URLSearchParams({ page: String(page) });
if (filter.actor) params.set('actor', filter.actor);
if (filter.action) params.set('action', filter.action);
if (filter.from) params.set('from', new Date(filter.from).toISOString());
if (filter.to) params.set('to', new Date(filter.to).toISOString());
return apiGet<AuditListView>(`/admin/system/audit?${params.toString()}`);
},
placeholderData: keepPreviousData,
});
const data = query.data;
return (
<section className="settings-section system-audit">
<h2>{t('audit.title')}</h2>
<form
className="system-audit__filters"
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setFilter(draft);
}}
>
<input
type="text"
value={draft.actor}
placeholder={t('audit.filters.actor')}
aria-label={t('audit.filters.actor')}
onChange={(e) => setDraft({ ...draft, actor: e.target.value })}
/>
<select
value={draft.action}
aria-label={t('audit.filters.action')}
onChange={(e) => setDraft({ ...draft, action: e.target.value })}
>
<option value="">{t('audit.filters.actionAny')}</option>
{KNOWN_ACTIONS.map((action) => (
<option key={action} value={action}>
{t(`audit.actions.${action}`, { defaultValue: action })}
</option>
))}
</select>
<input
type="datetime-local"
value={draft.from}
aria-label={t('audit.filters.from')}
onChange={(e) => setDraft({ ...draft, from: e.target.value })}
/>
<input
type="datetime-local"
value={draft.to}
aria-label={t('audit.filters.to')}
onChange={(e) => setDraft({ ...draft, to: e.target.value })}
/>
<button type="submit" className="button">
{t('audit.filters.apply')}
</button>
</form>
{data && data.entries.length === 0 && <p>{t('audit.empty')}</p>}
{data && data.entries.length > 0 && (
<table className="table system-audit__table">
<thead>
<tr>
<th>{t('audit.columns.time')}</th>
<th>{t('audit.columns.actor')}</th>
<th>{t('audit.columns.action')}</th>
<th>{t('audit.columns.target')}</th>
<th>{t('audit.columns.details')}</th>
</tr>
</thead>
<tbody>
{data.entries.map((entry) => (
<AuditRow key={entry.id} entry={entry} />
))}
</tbody>
</table>
)}
{data && (
<div className="system-audit__pager">
<button
type="button"
className="button"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
{t('audit.previous')}
</button>
<span>
{t('audit.pager', { page: data.page, pageCount: data.pageCount, total: data.total })}
</span>
<button
type="button"
className="button"
disabled={page >= data.pageCount}
onClick={() => setPage((p) => p + 1)}
>
{t('audit.next')}
</button>
</div>
)}
</section>
);
}
const KNOWN_ACTIONS = [
'grant.created',
'grant.deleted',
'member.added',
'member.role_changed',
'member.removed',
'user.disabled_set',
'user.verification_resent',
'user.deleted',
'user.site_admin_set',
'user.pseudonymized',
'quota.override_set',
'quota.override_cleared',
'plugin.installed',
'plugin.mode_set',
'plugin.uninstalled',
'plugin.pond_toggled',
'settings.changed',
'setup.preseeded',
'setup.admin_created',
'setup.smtp_stored',
'setup.completed',
'auth.signup',
'auth.email_verified',
'auth.login_failed',
'auth.login_succeeded',
'auth.password_reset',
'job.triggered',
];
function AuditRow({ entry }: { entry: AuditEntryView }): React.JSX.Element {
const { t } = useTranslation('system');
return (
<tr>
<td>{new Date(entry.at).toLocaleString()}</td>
<td>{entry.actor ? entry.actor.displayName : t('audit.systemActor')}</td>
<td>{t(`audit.actions.${entry.action}`, { defaultValue: entry.action })}</td>
<td>
{entry.targetType && (
<code>
{entry.targetType}:{entry.targetId}
</code>
)}
</td>
<td>{entry.details && <code>{JSON.stringify(entry.details)}</code>}</td>
</tr>
);
}
function StorageSection(): React.JSX.Element {
const { t } = useTranslation('system');
const query = useQuery({
queryKey: ['admin', 'system', 'storage'],
queryFn: () => apiGet<StorageOverviewView>('/admin/system/storage'),
});
const view = query.data;
if (!view) return <></>;
return (
<section className="settings-section system-storage">
<h2>{t('storage.title')}</h2>
<p>{t('storage.total', { total: formatBytes(view.totalBytes) })}</p>
{view.ponds.length === 0 && <p>{t('storage.empty')}</p>}
{view.ponds.length > 0 && (
<table className="table system-storage__table">
<thead>
<tr>
<th>{t('storage.columns.pond')}</th>
<th>{t('storage.columns.type')}</th>
<th>{t('storage.columns.used')}</th>
</tr>
</thead>
<tbody>
{view.ponds.map((pond) => (
<tr key={pond.pondId}>
<td>
<Link to={`/p/${pond.slug}`}>{pond.name}</Link>
</td>
<td>{t(`storage.types.${pond.type}`)}</td>
<td>{formatBytes(pond.storageBytesUsed)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}

View File

@ -2287,3 +2287,66 @@ button {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Site-Admin system panel (issue #86) */
.system-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.8125rem;
background: var(--color-surface-muted, #e2e8f0);
margin-left: var(--space-1);
}
.system-badge--ok {
background: #d6f2df;
color: #1d6f42;
}
.system-badge--error {
background: #fbe3e0;
color: #a02818;
}
.system-badge--warn {
background: #fdf0d4;
color: #8a5a00;
}
.system-jobs__notice {
color: var(--color-text-muted);
}
.system-backup__facts dt {
font-weight: 600;
margin-top: var(--space-2);
}
.system-backup__facts dd {
margin: 0;
}
.system-backup__retention,
.system-backup__unavailable {
color: var(--color-text-muted);
}
.system-audit__filters {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.system-audit__table code,
.system-storage__table code {
font-size: 0.8125rem;
word-break: break-all;
}
.system-audit__pager {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
}

View File

@ -34,6 +34,10 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack.
denials), admin actions (grants, plugin installs, quota changes) as an denials), admin actions (grants, plugin installs, quota changes) as an
**audit trail**, collab session open/close. Never log passwords, tokens, **audit trail**, collab session open/close. Never log passwords, tokens,
session ids, or page content. session ids, or page content.
- **Persistent audit trail** (issue #86): the auth events and admin actions
additionally land as rows in `audit_log` via `AuditService` — queryable in
the Site-Admin System panel (filter by actor/action/time, paginated).
Content activity (pages, files, exports, labels) stays log-only by design.
- Reading logs = `docker compose logs` / `docker logs` on the host; no - Reading logs = `docker compose logs` / `docker logs` on the host; no
central log stack at this scale (revisit if a second Prod host appears). central log stack at this scale (revisit if a second Prod host appears).

View File

@ -0,0 +1,127 @@
{
"title": "System",
"backLink": "Zurück zu den Einstellungen",
"settingsLink": "System-Panel",
"jobs": {
"title": "Wartungsjobs",
"columns": {
"job": "Job",
"cadence": "Rhythmus",
"lastRun": "Letzter Lauf",
"duration": "Dauer",
"outcome": "Ergebnis",
"actions": "Aktionen"
},
"names": {
"trash-purge": "Papierkorb-Bereinigung",
"version-thinning": "Versions-Ausdünnung",
"page-compaction": "Seiten-Kompaktierung",
"data-export-purge": "Datenexport-Bereinigung"
},
"outcome": {
"ok": "OK",
"running": "Läuft",
"failed": "Fehlgeschlagen",
"never": "Noch nie gelaufen"
},
"unregistered": "In diesem Build nicht mehr registriert",
"run": "Jetzt ausführen",
"runPending": "Läuft…",
"triggered": {
"succeeded": "Lauf erfolgreich abgeschlossen.",
"failed": "Lauf fehlgeschlagen — Fehler steht in der Zeile.",
"already_running": "Läuft bereits — gleich noch einmal versuchen."
},
"cadence": {
"days_one": "täglich",
"days_other": "alle {{count}} Tage",
"hours_one": "stündlich",
"hours_other": "alle {{count}} Stunden",
"minutes_one": "jede Minute",
"minutes_other": "alle {{count}} Minuten",
"seconds_other": "alle {{count}} Sekunden"
}
},
"backup": {
"title": "Backups",
"fresh": "Aktuell",
"stale": "Veraltet — älter als {{hours}} h",
"unavailable": "Kein Backup-Status gefunden. Der Backup-Sidecar ist noch nicht gelaufen (oder nicht eingerichtet) — siehe deploy/monitoring.md.",
"lastSuccess": "Letztes erfolgreiches Backup",
"lastRun": "Letzter Lauf",
"outcome": {
"succeeded": "erfolgreich",
"failed": "fehlgeschlagen"
},
"never": "noch keines",
"sizes": "Dump {{dump}}, Dateien {{archive}}",
"retention": "Aufbewahrung: {{days}} Tage",
"error": "Letzter Fehler"
},
"audit": {
"title": "Audit-Log",
"filters": {
"actor": "Akteur (Benutzername)",
"action": "Aktion",
"actionAny": "Alle Aktionen",
"from": "Von",
"to": "Bis",
"apply": "Filtern"
},
"columns": {
"time": "Zeit",
"actor": "Akteur",
"action": "Aktion",
"target": "Ziel",
"details": "Details"
},
"systemActor": "System",
"empty": "Keine Einträge für diesen Filter.",
"pager": "Seite {{page}} von {{pageCount}} ({{total}} Einträge)",
"previous": "Zurück",
"next": "Weiter",
"actions": {
"grant.created": "Zugriffsregel angelegt",
"grant.deleted": "Zugriffsregel gelöscht",
"member.added": "Mitglied hinzugefügt",
"member.role_changed": "Mitgliedsrolle geändert",
"member.removed": "Mitglied entfernt",
"user.disabled_set": "Nutzer gesperrt/entsperrt",
"user.verification_resent": "Bestätigungsmail erneut gesendet",
"user.deleted": "Nutzer gelöscht",
"user.site_admin_set": "Site-Admin-Rolle geändert",
"user.pseudonymized": "Nutzer pseudonymisiert",
"quota.override_set": "Quota-Ausnahme gesetzt",
"quota.override_cleared": "Quota-Ausnahme entfernt",
"plugin.installed": "Plugin installiert",
"plugin.mode_set": "Plugin-Modus geändert",
"plugin.uninstalled": "Plugin deinstalliert",
"plugin.pond_toggled": "Teich-Plugin umgeschaltet",
"settings.changed": "Instanz-Einstellung geändert",
"setup.preseeded": "Setup vorbefüllt",
"setup.admin_created": "Setup: Admin angelegt",
"setup.smtp_stored": "Setup: SMTP gespeichert",
"setup.completed": "Setup abgeschlossen",
"auth.signup": "Registrierung",
"auth.email_verified": "E-Mail bestätigt",
"auth.login_failed": "Anmeldung fehlgeschlagen",
"auth.login_succeeded": "Anmeldung",
"auth.password_reset": "Passwort zurückgesetzt",
"job.triggered": "Job manuell gestartet"
}
},
"storage": {
"title": "Speicher",
"total": "Gesamtbelegung: {{total}}",
"columns": {
"pond": "Teich",
"type": "Typ",
"used": "Belegt"
},
"types": {
"personal": "persönlich",
"shared": "geteilt"
},
"empty": "Noch keine Belegung erfasst."
}
}

View File

@ -0,0 +1,127 @@
{
"title": "System",
"backLink": "Back to settings",
"settingsLink": "System panel",
"jobs": {
"title": "Maintenance jobs",
"columns": {
"job": "Job",
"cadence": "Cadence",
"lastRun": "Last run",
"duration": "Duration",
"outcome": "Outcome",
"actions": "Actions"
},
"names": {
"trash-purge": "Trash purge",
"version-thinning": "Version thinning",
"page-compaction": "Page compaction",
"data-export-purge": "Data-export purge"
},
"outcome": {
"ok": "OK",
"running": "Running",
"failed": "Failed",
"never": "Never ran"
},
"unregistered": "No longer registered in this build",
"run": "Run now",
"runPending": "Running…",
"triggered": {
"succeeded": "Run finished successfully.",
"failed": "Run failed — see the error in the row.",
"already_running": "Already running elsewhere — try again in a moment."
},
"cadence": {
"days_one": "every day",
"days_other": "every {{count}} days",
"hours_one": "hourly",
"hours_other": "every {{count}} hours",
"minutes_one": "every minute",
"minutes_other": "every {{count}} minutes",
"seconds_other": "every {{count}} seconds"
}
},
"backup": {
"title": "Backups",
"fresh": "Fresh",
"stale": "Stale — older than {{hours}} h",
"unavailable": "No backup status found. The backup sidecar has not run yet (or is not deployed) — see deploy/monitoring.md.",
"lastSuccess": "Last successful backup",
"lastRun": "Last run",
"outcome": {
"succeeded": "succeeded",
"failed": "failed"
},
"never": "none yet",
"sizes": "Dump {{dump}}, files {{archive}}",
"retention": "Retention: {{days}} days",
"error": "Last error"
},
"audit": {
"title": "Audit log",
"filters": {
"actor": "Actor (username)",
"action": "Action",
"actionAny": "All actions",
"from": "From",
"to": "Until",
"apply": "Filter"
},
"columns": {
"time": "Time",
"actor": "Actor",
"action": "Action",
"target": "Target",
"details": "Details"
},
"systemActor": "System",
"empty": "No entries match the filter.",
"pager": "Page {{page}} of {{pageCount}} ({{total}} entries)",
"previous": "Previous",
"next": "Next",
"actions": {
"grant.created": "Access rule created",
"grant.deleted": "Access rule deleted",
"member.added": "Member added",
"member.role_changed": "Member role changed",
"member.removed": "Member removed",
"user.disabled_set": "User disabled/enabled",
"user.verification_resent": "Verification mail resent",
"user.deleted": "User deleted",
"user.site_admin_set": "Site-Admin role changed",
"user.pseudonymized": "User pseudonymized",
"quota.override_set": "Quota override set",
"quota.override_cleared": "Quota override cleared",
"plugin.installed": "Plugin installed",
"plugin.mode_set": "Plugin mode changed",
"plugin.uninstalled": "Plugin uninstalled",
"plugin.pond_toggled": "Pond plugin toggled",
"settings.changed": "Instance setting changed",
"setup.preseeded": "Setup pre-seeded",
"setup.admin_created": "Setup: admin created",
"setup.smtp_stored": "Setup: SMTP stored",
"setup.completed": "Setup completed",
"auth.signup": "Sign-up",
"auth.email_verified": "E-mail verified",
"auth.login_failed": "Login failed",
"auth.login_succeeded": "Login",
"auth.password_reset": "Password reset",
"job.triggered": "Job triggered manually"
}
},
"storage": {
"title": "Storage",
"total": "Total usage: {{total}}",
"columns": {
"pond": "Pond",
"type": "Type",
"used": "Used"
},
"types": {
"personal": "personal",
"shared": "shared"
},
"empty": "No usage recorded yet."
}
}

View File

@ -20,6 +20,7 @@ export * from './plugins';
export * from './search'; export * from './search';
export * from './secret-store'; export * from './secret-store';
export * from './setup'; export * from './setup';
export * from './system';
export * from './ponds'; export * from './ponds';
export * from './quotas'; export * from './quotas';
export * from './text-diff'; export * from './text-diff';

View File

@ -0,0 +1,88 @@
import { z } from 'zod';
import type { BackupStatus } from './backup-status';
/**
* Site-Admin system panel (issue #86): maintenance jobs, backup status,
* audit trail, and storage overview the operator's single glance for
* instance health (operations.md §Maintenance jobs).
*/
export interface SystemJobView {
name: string;
cadenceSeconds: number;
status: 'IDLE' | 'RUNNING' | 'FAILED';
lastRunAt: string | null;
lastDurationMs: number | null;
lastError: string | null;
/** False for a database row whose job no longer registers in this build. */
registered: boolean;
}
export type JobTriggerOutcome = 'succeeded' | 'failed' | 'already_running';
export interface JobTriggerResult {
outcome: JobTriggerOutcome;
job: SystemJobView;
}
export interface SystemBackupView {
/** False when no status.json exists (sidecar never ran / not deployed). */
available: boolean;
/** Freshness verdict mirroring the readyz `backup` check (issue #85). */
fresh: boolean;
status: BackupStatus | null;
maxAgeHours: number;
}
export interface AuditActorView {
id: string;
username: string;
displayName: string;
}
export interface AuditEntryView {
id: string;
at: string;
action: string;
actor: AuditActorView | null;
targetType: string | null;
targetId: string | null;
details: Record<string, unknown> | null;
}
export const AUDIT_PAGE_SIZE = 50;
export const auditListQuerySchema = z.object({
/** Exact username of the acting user. */
actor: z.string().trim().min(1).optional(),
/** Action id or prefix, e.g. `grant.` matches created and deleted. */
action: z.string().trim().min(1).optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
page: z.coerce.number().int().min(1).default(1),
});
export type AuditListQuery = z.infer<typeof auditListQuerySchema>;
export interface AuditListView {
entries: AuditEntryView[];
page: number;
pageCount: number;
total: number;
}
export interface StoragePondView {
pondId: string;
name: string;
slug: string;
/** Lowercase like PondView's `type` (the api's wire convention). */
type: 'personal' | 'shared';
storageBytesUsed: number;
}
export interface StorageOverviewView {
totalBytes: number;
/** Top ponds by storage use, largest first. */
ponds: StoragePondView[];
}