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
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:
parent
0ef96147e0
commit
c8aac13dfb
@ -212,6 +212,16 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
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
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
|
||||
@ -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;
|
||||
@ -49,10 +49,36 @@ model User {
|
||||
pages Page[]
|
||||
attachments Attachment[]
|
||||
conversionJobs ConversionJob[]
|
||||
auditEntries AuditEntry[]
|
||||
|
||||
@@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 {
|
||||
PERSONAL
|
||||
SHARED
|
||||
@ -515,6 +541,8 @@ model Job {
|
||||
lastRunAt DateTime? @map("last_run_at")
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
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")
|
||||
|
||||
@@map("jobs")
|
||||
|
||||
@ -2,18 +2,26 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { QuotasModule } from '../quotas/quotas.module';
|
||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
import { AdminSettingsController } from './admin.controller';
|
||||
import { PseudonymizationService } from './pseudonymization.service';
|
||||
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 { UserAdminController } from './user-admin.controller';
|
||||
import { UserAdminService } from './user-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [QuotasModule, UsersModule, AuthModule],
|
||||
controllers: [AdminSettingsController, QuotaAdminController, UserAdminController],
|
||||
providers: [QuotaAdminService, UserAdminService, PseudonymizationService],
|
||||
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule],
|
||||
controllers: [
|
||||
AdminSettingsController,
|
||||
QuotaAdminController,
|
||||
SystemAdminController,
|
||||
UserAdminController,
|
||||
],
|
||||
providers: [QuotaAdminService, SystemAdminService, UserAdminService, PseudonymizationService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DELETED_USER_DISPLAY_NAME } from '@dorfteich/shared';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
@ -15,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
export class PseudonymizationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(PseudonymizationService.name);
|
||||
@ -43,6 +45,10 @@ export class PseudonymizationService {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import { User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QuotaService } from '../quotas/quota.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
@ -27,6 +28,7 @@ export class QuotaAdminService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly quotas: QuotaService,
|
||||
private readonly users: UsersService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(QuotaAdminService.name);
|
||||
@ -79,7 +81,13 @@ export class QuotaAdminService {
|
||||
create: { subjectType, subjectId: id, quotaKey, 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);
|
||||
}
|
||||
|
||||
@ -94,7 +102,13 @@ export class QuotaAdminService {
|
||||
await this.prisma.quotaOverride.deleteMany({
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
52
apps/api/src/admin/system-admin.controller.ts
Normal file
52
apps/api/src/admin/system-admin.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
258
apps/api/src/admin/system-admin.e2e.db.test.ts
Normal file
258
apps/api/src/admin/system-admin.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
175
apps/api/src/admin/system-admin.service.ts
Normal file
175
apps/api/src/admin/system-admin.service.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { Prisma, User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PseudonymizationService } from './pseudonymization.service';
|
||||
|
||||
@ -26,6 +27,7 @@ export class UserAdminService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pseudonymizer: PseudonymizationService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(UserAdminService.name);
|
||||
@ -74,7 +76,13 @@ export class UserAdminService {
|
||||
// A disabled user is logged out everywhere; login then blocks with a
|
||||
// distinct message (auth.service: account_disabled).
|
||||
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));
|
||||
}
|
||||
|
||||
@ -82,14 +90,24 @@ export class UserAdminService {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException();
|
||||
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> {
|
||||
const user = await this.requireOther(actor, id);
|
||||
if (user.isSiteAdmin) await this.assertNotLastSiteAdmin();
|
||||
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> {
|
||||
@ -99,7 +117,13 @@ export class UserAdminService {
|
||||
where: { id },
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { APP_FILTER } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||
import { CompactionModule } from './compaction/compaction.module';
|
||||
@ -35,6 +36,7 @@ import { VersionsModule } from './versions/versions.module';
|
||||
imports: [
|
||||
ConfigModule,
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
RateLimitModule,
|
||||
MailModule,
|
||||
SettingsModule,
|
||||
|
||||
15
apps/api/src/audit/audit.module.ts
Normal file
15
apps/api/src/audit/audit.module.ts
Normal 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 {}
|
||||
57
apps/api/src/audit/audit.service.ts
Normal file
57
apps/api/src/audit/audit.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import { PinoLogger } from 'nestjs-pino';
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PondsService } from '../ponds/ponds.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||
@ -33,6 +34,7 @@ export class AuthService {
|
||||
private readonly mail: MailService,
|
||||
private readonly ponds: PondsService,
|
||||
private readonly rateLimits: RateLimitService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: AppConfig,
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly logger: PinoLogger,
|
||||
@ -46,7 +48,7 @@ export class AuthService {
|
||||
}
|
||||
const user = await this.users.createUser(input);
|
||||
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> {
|
||||
@ -56,7 +58,7 @@ export class AuthService {
|
||||
if (!user) throw new BadRequestException({ code: 'token_invalid' });
|
||||
if (user.status === 'PENDING_VERIFICATION') {
|
||||
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,
|
||||
// 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;
|
||||
if (!user || !passwordOk) {
|
||||
// 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' });
|
||||
}
|
||||
if (user.status === 'DISABLED') {
|
||||
@ -108,7 +110,7 @@ export class AuthService {
|
||||
await this.rateLimits.reset('login-account', user.id);
|
||||
const sessionToken = await this.sessions.create(user.id, userAgent);
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -134,7 +136,7 @@ export class AuthService {
|
||||
await this.users.setPassword(userId, password);
|
||||
// Whoever held old sessions (possibly an attacker) is logged out.
|
||||
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> {
|
||||
|
||||
@ -8,6 +8,7 @@ import { AccessRuleView, Grant, GrantView, grantValidationError } from '@dorftei
|
||||
import { Pond, RoleGrant, User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PondPermissionCache } from '../permissions/pond-permission-cache';
|
||||
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@ -28,6 +29,7 @@ export class GrantsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionCache: PondPermissionCache,
|
||||
private readonly accessNotifier: PondAccessNotifier,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(GrantsService.name);
|
||||
@ -170,11 +172,13 @@ export class GrantsService {
|
||||
data: { pondId, createdBy: user.id, ...columns },
|
||||
});
|
||||
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,
|
||||
pondId,
|
||||
userId: user.id,
|
||||
subject: grant.subjectType,
|
||||
subjectId: grant.subjectId,
|
||||
role: grant.role,
|
||||
@ -182,8 +186,7 @@ export class GrantsService {
|
||||
scopeId: grant.scopeId,
|
||||
effect: grant.effect,
|
||||
},
|
||||
'audit: grant created',
|
||||
);
|
||||
});
|
||||
return GrantsService.viewOf(created);
|
||||
}
|
||||
|
||||
@ -205,10 +208,13 @@ export class GrantsService {
|
||||
|
||||
await this.prisma.roleGrant.delete({ where: { id: grantId } });
|
||||
await this.accessChanged(pondId);
|
||||
this.logger.info(
|
||||
{ grantId, pondId, userId: user.id, subjectId: grant.subjectId, role: grant.role },
|
||||
'audit: grant deleted',
|
||||
);
|
||||
await this.audit.record({
|
||||
action: '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
|
||||
|
||||
@ -20,6 +20,7 @@ import { toGrant, toGrantColumns } from '../grants/grant-mappers';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PondPermissionCache } from '../permissions/pond-permission-cache';
|
||||
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QuotaService, quotaExceeded } from '../quotas/quota.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
@ -45,6 +46,7 @@ export class MembersService {
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly permissionCache: PondPermissionCache,
|
||||
private readonly accessNotifier: PondAccessNotifier,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(MembersService.name);
|
||||
@ -158,10 +160,13 @@ export class MembersService {
|
||||
});
|
||||
|
||||
await this.accessChanged(pondId);
|
||||
this.logger.info(
|
||||
{ pondId, actor: actor.id, member: target.id, role: input.role },
|
||||
'audit: member added',
|
||||
);
|
||||
await this.audit.record({
|
||||
action: 'member.added',
|
||||
actorId: actor.id,
|
||||
targetType: 'pond',
|
||||
targetId: pondId,
|
||||
details: { member: target.id, role: input.role },
|
||||
});
|
||||
return this.viewOf(target, input.role, pond);
|
||||
}
|
||||
|
||||
@ -208,10 +213,13 @@ export class MembersService {
|
||||
|
||||
if (changed) {
|
||||
await this.accessChanged(pondId);
|
||||
this.logger.info(
|
||||
{ pondId, actor: actor.id, member: memberUserId, role: input.role },
|
||||
'audit: member role changed',
|
||||
);
|
||||
await this.audit.record({
|
||||
action: 'member.role_changed',
|
||||
actorId: actor.id,
|
||||
targetType: 'pond',
|
||||
targetId: pondId,
|
||||
details: { member: memberUserId, role: input.role },
|
||||
});
|
||||
}
|
||||
return this.viewOf(target, input.role, pond);
|
||||
}
|
||||
@ -234,7 +242,13 @@ export class MembersService {
|
||||
});
|
||||
|
||||
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 {
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
Patch,
|
||||
PayloadTooLargeException,
|
||||
Post,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@ -20,6 +21,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { pluginModeInputSchema, type PluginModeInput, type PluginView } from '@dorfteich/shared';
|
||||
|
||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
|
||||
import { PluginsService } from './plugins.service';
|
||||
@ -57,10 +59,13 @@ export class PluginAdminController {
|
||||
/** Upload and install (or update) a plugin ZIP. */
|
||||
@Post()
|
||||
@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' });
|
||||
try {
|
||||
return await this.plugins.install(file.buffer);
|
||||
return await this.plugins.install(file.buffer, request.user!);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageError) throw toHttpException(error);
|
||||
throw error;
|
||||
@ -78,9 +83,10 @@ export class PluginAdminController {
|
||||
async setMode(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(pluginModeInputSchema)) body: PluginModeInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PluginView> {
|
||||
try {
|
||||
return await this.plugins.setMode(id, body.mode);
|
||||
return await this.plugins.setMode(id, body.mode, request.user!);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageError) throw toHttpException(error);
|
||||
throw error;
|
||||
@ -90,9 +96,9 @@ export class PluginAdminController {
|
||||
/** Uninstall a plugin (refused while `required`). */
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
async uninstall(@Param('id') id: string): Promise<void> {
|
||||
async uninstall(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||
try {
|
||||
await this.plugins.uninstall(id);
|
||||
await this.plugins.uninstall(id, request.user!);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageError) throw toHttpException(error);
|
||||
throw error;
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Param,
|
||||
Put,
|
||||
Body,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
pondPluginToggleInputSchema,
|
||||
@ -16,6 +17,7 @@ import {
|
||||
type PondPluginToggleInput,
|
||||
} from '@dorfteich/shared';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequiresPondRole } from '../permissions/permission.decorators';
|
||||
|
||||
@ -54,9 +56,10 @@ export class PluginPondController {
|
||||
@Param('pondId') pondId: string,
|
||||
@Param('pluginId') pluginId: string,
|
||||
@Body(new ZodValidationPipe(pondPluginToggleInputSchema)) body: PondPluginToggleInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.plugins.setPondActivation(pondId, pluginId, body.enabled);
|
||||
await this.plugins.setPondActivation(pondId, pluginId, body.enabled, request.user!);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageError) throw toHttpException(error);
|
||||
throw error;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk';
|
||||
import type {
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
} from '@dorfteich/shared';
|
||||
|
||||
import { ClockService } from '../common/clock.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { PluginPackageService } from './plugin-package.service';
|
||||
@ -42,6 +43,7 @@ export class PluginsService {
|
||||
private readonly packages: PluginPackageService,
|
||||
private readonly storage: PluginStorageService,
|
||||
private readonly clock: ClockService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(PluginsService.name);
|
||||
@ -53,7 +55,8 @@ export class PluginsService {
|
||||
* version is strictly higher; the admin's chosen instance `mode` is preserved
|
||||
* 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 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);
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
{ plugin: manifest.id, version: manifest.version, update: isActiveUpdate },
|
||||
'audit: plugin installed',
|
||||
);
|
||||
await this.audit.record({
|
||||
action: 'plugin.installed',
|
||||
actorId: actor?.id,
|
||||
targetType: 'plugin',
|
||||
targetId: manifest.id,
|
||||
details: { version: manifest.version, update: isActiveUpdate },
|
||||
});
|
||||
return this.toView(record);
|
||||
}
|
||||
|
||||
@ -109,7 +115,7 @@ export class PluginsService {
|
||||
* kept across mode changes (they are simply ignored while non-optional), so
|
||||
* flipping optional→required→optional 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 } });
|
||||
if (!plugin || plugin.removedAt !== null) {
|
||||
throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`);
|
||||
@ -118,7 +124,13 @@ export class PluginsService {
|
||||
where: { id },
|
||||
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);
|
||||
}
|
||||
|
||||
@ -127,7 +139,7 @@ export class PluginsService {
|
||||
* tombstoned (`removedAt` set, per-pond activations dropped) and every file is
|
||||
* 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 } });
|
||||
if (!plugin || plugin.removedAt !== null) {
|
||||
throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`);
|
||||
@ -147,7 +159,12 @@ export class PluginsService {
|
||||
}),
|
||||
]);
|
||||
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
|
||||
* 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 } });
|
||||
if (!plugin || plugin.removedAt !== null) {
|
||||
throw new PluginPackageError('plugin_not_found', `Plugin ${pluginId} is not installed`);
|
||||
@ -226,7 +248,13 @@ export class PluginsService {
|
||||
create: { pondId, pluginId, 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 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -50,6 +50,23 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
|
||||
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 {
|
||||
if (this.config.env.NODE_ENV === 'test') return; // tests drive jobs directly
|
||||
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. */
|
||||
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 {
|
||||
await this.prisma.job.upsert({
|
||||
where: { name: job.name },
|
||||
@ -92,27 +116,34 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy {
|
||||
where: {
|
||||
name: job.name,
|
||||
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 } }] },
|
||||
],
|
||||
},
|
||||
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 {
|
||||
await job.run();
|
||||
await this.prisma.job.update({
|
||||
where: { name: job.name },
|
||||
data: { status: 'IDLE', lastError: null },
|
||||
data: { status: 'IDLE', lastError: null, lastDurationMs: this.sinceMs(now) },
|
||||
});
|
||||
return 'succeeded';
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.slice(0, 500) : String(error);
|
||||
this.logger.error({ job: job.name, err: error }, 'maintenance job failed');
|
||||
await this.prisma.job.update({
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
@ -79,6 +80,7 @@ export class InstanceSettingsService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(InstanceSettingsService.name);
|
||||
@ -125,7 +127,14 @@ export class InstanceSettingsService {
|
||||
update: { value: stored },
|
||||
});
|
||||
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>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ import { SecretStoreService } from '../config/secret-store.service';
|
||||
import { renderMail } from '../mail/mail-templates';
|
||||
import { SmtpConfigService, SmtpSettings } from '../mail/smtp-config.service';
|
||||
import { PondsService } from '../ponds/ponds.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
@ -45,6 +46,7 @@ export class SetupService implements OnModuleInit {
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly secretStore: SecretStoreService,
|
||||
private readonly smtpConfig: SmtpConfigService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: AppConfig,
|
||||
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.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> {
|
||||
@ -117,7 +119,12 @@ export class SetupService implements OnModuleInit {
|
||||
data: { isSiteAdmin: true, status: 'ACTIVE', emailVerifiedAt: new Date() },
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@ -160,7 +167,7 @@ export class SetupService implements OnModuleInit {
|
||||
SMTP_FROM: candidate.from,
|
||||
});
|
||||
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). */
|
||||
@ -176,7 +183,7 @@ export class SetupService implements OnModuleInit {
|
||||
throw new BadRequestException({ code: 'setup_admin_missing' });
|
||||
}
|
||||
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> {
|
||||
|
||||
103
apps/web/e2e/system.spec.ts
Normal file
103
apps/web/e2e/system.spec.ts
Normal 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();
|
||||
});
|
||||
@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
|
||||
import { AppLayout } from './layout/AppLayout';
|
||||
import { AdminSettingsPage } from './pages/AdminSettingsPage';
|
||||
import { AdminSystemPage } from './pages/AdminSystemPage';
|
||||
import { FontCatalogPage } from './pages/FontCatalogPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { LegalPage } from './pages/LegalPage';
|
||||
@ -78,6 +79,7 @@ export function App(): React.JSX.Element {
|
||||
</Route>
|
||||
<Route element={<RequireSiteAdmin />}>
|
||||
<Route path="admin" element={<AdminSettingsPage />} />
|
||||
<Route path="admin/system" element={<AdminSystemPage />} />
|
||||
{/* Sandbox preview of one installed plugin (issue #73). */}
|
||||
<Route path="admin/plugins/:pluginId/preview" element={<PluginPreviewPage />} />
|
||||
</Route>
|
||||
|
||||
@ -16,6 +16,7 @@ import dePublic from '@dorfteich/shared/i18n/de/public.json';
|
||||
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
|
||||
import deSearch from '@dorfteich/shared/i18n/de/search.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 deSettings from '@dorfteich/shared/i18n/de/settings.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 enSearch from '@dorfteich/shared/i18n/en/search.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 enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
||||
import i18n from 'i18next';
|
||||
@ -73,6 +75,7 @@ void i18n
|
||||
quotas: enQuotas,
|
||||
search: enSearch,
|
||||
setup: enSetup,
|
||||
system: enSystem,
|
||||
users: enUsers,
|
||||
},
|
||||
de: {
|
||||
@ -95,6 +98,7 @@ void i18n
|
||||
quotas: deQuotas,
|
||||
search: deSearch,
|
||||
setup: deSetup,
|
||||
system: deSystem,
|
||||
users: deUsers,
|
||||
},
|
||||
},
|
||||
|
||||
@ -3,6 +3,7 @@ import { docToHtml, markdownToDoc } from '@dorfteich/shared';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Field, FormError, FormSuccess } from '../components/forms';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
@ -56,6 +57,9 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<h1>{t('settings:admin.title')}</h1>
|
||||
<p>
|
||||
<Link to="/admin/system">{t('system:settingsLink')} →</Link>
|
||||
</p>
|
||||
<section className="settings-section">
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<FormError error={error} />
|
||||
|
||||
418
apps/web/src/pages/AdminSystemPage.tsx
Normal file
418
apps/web/src/pages/AdminSystemPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -2287,3 +2287,66 @@ button {
|
||||
color: var(--color-text-muted);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -34,6 +34,10 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack.
|
||||
denials), admin actions (grants, plugin installs, quota changes) as an
|
||||
**audit trail**, collab session open/close. Never log passwords, tokens,
|
||||
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
|
||||
central log stack at this scale (revisit if a second Prod host appears).
|
||||
|
||||
|
||||
127
packages/shared/i18n/de/system.json
Normal file
127
packages/shared/i18n/de/system.json
Normal 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."
|
||||
}
|
||||
}
|
||||
127
packages/shared/i18n/en/system.json
Normal file
127
packages/shared/i18n/en/system.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ export * from './plugins';
|
||||
export * from './search';
|
||||
export * from './secret-store';
|
||||
export * from './setup';
|
||||
export * from './system';
|
||||
export * from './ponds';
|
||||
export * from './quotas';
|
||||
export * from './text-diff';
|
||||
|
||||
88
packages/shared/src/system.ts
Normal file
88
packages/shared/src/system.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user