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
162 lines
5.5 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|