dorfteich/apps/api/src/admin/user-admin.service.ts
Claude Fable 5 c8aac13dfb
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
Add Site-Admin system panel with persistent audit trail (#86)
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
2026-07-11 20:03:05 +02:00

162 lines
5.5 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
AdminUserListQuery,
AdminUserListView,
AdminUserStatus,
AdminUserView,
} from '@dorfteich/shared';
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';
/**
* Site-Admin user administration (issue #59): the searchable list plus the
* lifecycle actions (disable/enable, resend verification, delete, grant/revoke
* Site Admin). Guards protect the operator from locking the instance out —
* you cannot act on your own account, and the last Site Admin cannot be
* dropped. Every action is audit-logged with the actor; Site-Admin gating is
* the controller's job.
*/
@Injectable()
export class UserAdminService {
constructor(
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);
}
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
const q = query.q?.trim();
const where: Prisma.UserWhereInput = q
? {
OR: [
{ username: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ displayName: { contains: q, mode: 'insensitive' } },
],
}
: {};
const [total, rows] = await Promise.all([
this.prisma.user.count({ where }),
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
const owned = await this.prisma.pond.groupBy({
by: ['ownerId'],
where: { ownerId: { in: rows.map((u) => u.id) }, deletedAt: null },
_count: { _all: true },
});
const pondCount = new Map(owned.map((o) => [o.ownerId, o._count._all]));
return {
users: rows.map((u) => this.viewOf(u, pondCount.get(u.id) ?? 0)),
total,
page: query.page,
pageSize: query.pageSize,
};
}
async setDisabled(actor: User, id: string, disabled: boolean): Promise<AdminUserView> {
await this.requireOther(actor, id);
const updated = await this.prisma.user.update({
where: { id },
data: { status: disabled ? 'DISABLED' : 'ACTIVE' },
});
// 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 } });
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));
}
async resendVerification(actor: User, id: string): Promise<void> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException();
await this.auth.resendVerification(user.email); // no-op unless PENDING
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);
await this.audit.record({
action: 'user.deleted',
actorId: actor.id,
targetType: 'user',
targetId: id,
});
}
async setSiteAdmin(actor: User, id: string, value: boolean): Promise<AdminUserView> {
const user = await this.requireOther(actor, id); // not on self
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
const updated = await this.prisma.user.update({
where: { id },
data: { isSiteAdmin: value },
});
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));
}
private async requireOther(actor: User, id: string): Promise<User> {
if (id === actor.id) throw new BadRequestException({ code: 'cannot_modify_self' });
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException();
return user;
}
private async assertNotLastSiteAdmin(): Promise<void> {
const admins = await this.prisma.user.count({
where: { isSiteAdmin: true, status: { not: 'DISABLED' } },
});
if (admins <= 1) throw new BadRequestException({ code: 'last_site_admin' });
}
private async pondCountOf(id: string): Promise<number> {
return this.prisma.pond.count({ where: { ownerId: id, deletedAt: null } });
}
private viewOf(user: User, pondCount: number): AdminUserView {
return {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
status: user.status as AdminUserStatus,
isSiteAdmin: user.isSiteAdmin,
createdAt: user.createdAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
pondCount,
};
}
}