feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)
This commit is contained in:
@@ -7,6 +7,13 @@ export const AUDIT_ACTIONS = {
|
||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||
LOGIN_LOCKED: 'LOGIN_FAILED_LOCKED',
|
||||
LOGOUT: 'LOGOUT',
|
||||
USER_CREATED: 'USER_CREATED',
|
||||
USER_UPDATED: 'USER_UPDATED',
|
||||
USER_ENABLED: 'USER_ENABLED',
|
||||
USER_DISABLED: 'USER_DISABLED',
|
||||
USER_DELETED: 'USER_DELETED',
|
||||
USER_PASSWORD_RESET: 'USER_PASSWORD_RESET',
|
||||
USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED',
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||
|
||||
@@ -73,6 +73,18 @@ export class SessionService {
|
||||
await this.database.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
|
||||
}
|
||||
|
||||
/** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */
|
||||
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
|
||||
if (exceptSessionId) {
|
||||
await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [
|
||||
userId,
|
||||
exceptSessionId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await this.database.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
|
||||
}
|
||||
|
||||
/** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */
|
||||
async deleteExpired(): Promise<void> {
|
||||
await this.database.query('DELETE FROM sessions WHERE expires_at <= now()');
|
||||
|
||||
@@ -17,11 +17,21 @@ export class ZodValidationPipe implements PipeTransform {
|
||||
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
const flattened = result.error.flatten();
|
||||
const details: Record<string, string[]> = {};
|
||||
for (const [key, messages] of Object.entries(flattened.fieldErrors)) {
|
||||
if (messages) {
|
||||
details[key] = messages;
|
||||
}
|
||||
}
|
||||
if (flattened.formErrors.length > 0) {
|
||||
details.form = flattened.formErrors;
|
||||
}
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
message: 'Validierung fehlgeschlagen',
|
||||
error: 'Bad Request',
|
||||
details: result.error.flatten().fieldErrors,
|
||||
details,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
|
||||
62
apps/platform-backend/src/users/profile.controller.ts
Normal file
62
apps/platform-backend/src/users/profile.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||
import { changePasswordSchema, type ChangePasswordDto } from './user.types';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
/** Profil-Daten in API-Antworten. */
|
||||
interface ProfileResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: RoleName;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function toProfileResponse(user: UserRecord): ProfileResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Profil des angemeldeten Benutzers (jede Rolle).
|
||||
* Passwort-Änderung erfordert das aktuelle Passwort.
|
||||
*/
|
||||
@ApiTags('Profile')
|
||||
@Controller({ path: 'api/v1/profile' })
|
||||
export class ProfileController {
|
||||
constructor(private readonly profileService: ProfileService) {}
|
||||
|
||||
@Get()
|
||||
async getProfile(@CurrentUser() user: AuthUser): Promise<{ profile: ProfileResponse }> {
|
||||
const profile = await this.profileService.getProfile(user.id);
|
||||
return { profile: toProfileResponse(profile) };
|
||||
}
|
||||
|
||||
@Patch('password')
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
@Body(new ZodValidationPipe(changePasswordSchema)) body: ChangePasswordDto,
|
||||
): Promise<{ success: true }> {
|
||||
const sessionId = request.session?.id;
|
||||
if (!sessionId) {
|
||||
throw new Error('Session-Kontext fehlt');
|
||||
}
|
||||
await this.profileService.changePassword(user.id, sessionId, body, request.ip ?? null);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
121
apps/platform-backend/src/users/profile.service.spec.ts
Normal file
121
apps/platform-backend/src/users/profile.service.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { UserRecord } from './user.types';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
/** Erzeugt einen Benutzer-Datensatz für Tests. */
|
||||
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||
return {
|
||||
id: 'user-1',
|
||||
username: 'max',
|
||||
email: 'max@example.com',
|
||||
passwordHash: 'not-a-real-hash',
|
||||
displayName: 'Max Mustermann',
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock des UserRepository. */
|
||||
class MockUserRepository {
|
||||
public user: UserRecord | null = createUserRecord();
|
||||
public updatedPasswords: Array<{ id: string; hash: string }> = [];
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
return this.user && this.user.id === id ? this.user : null;
|
||||
}
|
||||
|
||||
async updatePassword(id: string, hash: string): Promise<void> {
|
||||
this.updatedPasswords.push({ id, hash });
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des SessionService. */
|
||||
class MockSessionService {
|
||||
public deletedForUser: Array<{ userId: string; exceptSessionId?: string }> = [];
|
||||
|
||||
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
|
||||
this.deletedForUser.push({ userId, exceptSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des AuditService. */
|
||||
class MockAuditService {
|
||||
public records: Array<{ action: string }> = [];
|
||||
|
||||
async record(entry: { action: string }): Promise<void> {
|
||||
this.records.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProfileService', () => {
|
||||
let userRepository: MockUserRepository;
|
||||
let sessionService: MockSessionService;
|
||||
let auditService: MockAuditService;
|
||||
let profileService: ProfileService;
|
||||
let passwordHasher: PasswordHasher;
|
||||
|
||||
beforeEach(async () => {
|
||||
userRepository = new MockUserRepository();
|
||||
sessionService = new MockSessionService();
|
||||
auditService = new MockAuditService();
|
||||
passwordHasher = new PasswordHasher();
|
||||
profileService = new ProfileService(
|
||||
userRepository as unknown as UserRepository,
|
||||
passwordHasher,
|
||||
sessionService as unknown as SessionService,
|
||||
auditService as unknown as AuditService,
|
||||
);
|
||||
|
||||
userRepository.user = createUserRecord({
|
||||
passwordHash: await passwordHasher.hash('Altes-Passwort-1'),
|
||||
});
|
||||
});
|
||||
|
||||
it('gibt das Profil des angemeldeten Benutzers zurück', async () => {
|
||||
const profile = await profileService.getProfile('user-1');
|
||||
expect(profile.username).toBe('max');
|
||||
});
|
||||
|
||||
it('wirft UnauthorizedException bei unbekanntem Benutzer', async () => {
|
||||
await expect(profileService.getProfile('unbekannt')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('ändert das Passwort mit korrektem aktuellen Passwort', async () => {
|
||||
await profileService.changePassword(
|
||||
'user-1',
|
||||
'session-1',
|
||||
{ currentPassword: 'Altes-Passwort-1', newPassword: 'Neues-Passwort-123' },
|
||||
'127.0.0.1',
|
||||
);
|
||||
|
||||
expect(userRepository.updatedPasswords).toHaveLength(1);
|
||||
expect(sessionService.deletedForUser).toEqual([
|
||||
{ userId: 'user-1', exceptSessionId: 'session-1' },
|
||||
]);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED);
|
||||
});
|
||||
|
||||
it('lehnt falsches aktuelles Passwort ab', async () => {
|
||||
await expect(
|
||||
profileService.changePassword(
|
||||
'user-1',
|
||||
'session-1',
|
||||
{ currentPassword: 'falsch', newPassword: 'Neues-Passwort-123' },
|
||||
null,
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
expect(userRepository.updatedPasswords).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
61
apps/platform-backend/src/users/profile.service.ts
Normal file
61
apps/platform-backend/src/users/profile.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { ChangePasswordDto, UserRecord } from './user.types';
|
||||
|
||||
/**
|
||||
* Profil-Verwaltung des angemeldeten Benutzers:
|
||||
* Eigenes Passwort ändern (mit Verifikation des aktuellen Passworts).
|
||||
* Nach der Änderung werden alle übrigen Sessions des Benutzers
|
||||
* ungültig gemacht – die aktuelle Session bleibt aktiv.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProfileService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async getProfile(userId: string): Promise<UserRecord> {
|
||||
const user = await this.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Nicht authentifiziert');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentSessionId: string,
|
||||
input: ChangePasswordDto,
|
||||
ipAddress: string | null,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Nicht authentifiziert');
|
||||
}
|
||||
|
||||
const currentPasswordValid = await this.passwordHasher.verify(
|
||||
user.passwordHash,
|
||||
input.currentPassword,
|
||||
);
|
||||
if (!currentPasswordValid) {
|
||||
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
||||
}
|
||||
|
||||
const newPasswordHash = await this.passwordHasher.hash(input.newPassword);
|
||||
await this.userRepository.updatePassword(userId, newPasswordHash);
|
||||
await this.sessionService.deleteAllForUser(userId, currentSessionId);
|
||||
|
||||
await this.auditService.record({
|
||||
userId,
|
||||
username: user.username,
|
||||
action: AUDIT_ACTIONS.USER_PASSWORD_CHANGED,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
@@ -22,20 +22,11 @@ const USER_COLUMNS = `u.id, u.username, u.email, u.password_hash, u.display_name
|
||||
u.is_active, u.failed_login_attempts, u.locked_until,
|
||||
u.last_login_at, u.created_at, u.updated_at`;
|
||||
|
||||
/** Wandelt einen Datenbank-Datensatz in die öffentliche Benutzer-Repräsentation um. */
|
||||
function toAuthUser(record: UserRecord): AuthUser {
|
||||
return {
|
||||
id: record.id,
|
||||
username: record.username,
|
||||
email: record.email,
|
||||
displayName: record.displayName,
|
||||
role: record.role,
|
||||
};
|
||||
}
|
||||
const USER_FROM = `FROM users u JOIN roles r ON r.id = u.role_id`;
|
||||
|
||||
/**
|
||||
* Benutzer-Repository (Infrastructure): Alle Datenbankzugriffe für Benutzer.
|
||||
* Enthält keine Business-Logik – nur Datenzugriff.
|
||||
* Enthält keine Business-Logik – nur parametrisierten Datenzugriff.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserRepository {
|
||||
@@ -44,11 +35,17 @@ export class UserRepository {
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
) {}
|
||||
|
||||
/** Alle Benutzer, neueste zuerst. */
|
||||
async list(): Promise<UserRecord[]> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} ORDER BY u.created_at DESC`,
|
||||
);
|
||||
return result.rows.map((row) => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS}
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.username = $1`,
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.username = $1`,
|
||||
[username],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
@@ -56,14 +53,104 @@ export class UserRepository {
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS}
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.id = $1`,
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.id = $1`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<UserRecord | null> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.email = $1`,
|
||||
[email],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Legt einen Benutzer an (Passwort wird gehasht). */
|
||||
async create(input: CreateUserDto): Promise<UserRecord> {
|
||||
const passwordHash = await this.passwordHasher.hash(input.password);
|
||||
const result = await this.database.query<UserRow>(
|
||||
`INSERT INTO users (username, email, password_hash, display_name, role_id)
|
||||
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = $5))
|
||||
RETURNING id, username, email, password_hash, display_name,
|
||||
(SELECT name FROM roles WHERE id = role_id) AS role_name,
|
||||
true AS is_active, 0 AS failed_login_attempts,
|
||||
NULL::timestamptz AS locked_until, NULL::timestamptz AS last_login_at,
|
||||
now() AS created_at, now() AS updated_at`,
|
||||
[input.username, input.email, passwordHash, input.displayName, input.role],
|
||||
);
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/** Aktualisiert nur die übergebenen Felder (dynamisch, parametrisiert). */
|
||||
async update(id: string, changes: UpdateUserDto): Promise<UserRecord> {
|
||||
const setClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let parameterIndex = 1;
|
||||
|
||||
if (changes.email !== undefined) {
|
||||
setClauses.push(`email = $${parameterIndex++}`);
|
||||
params.push(changes.email);
|
||||
}
|
||||
if (changes.displayName !== undefined) {
|
||||
setClauses.push(`display_name = $${parameterIndex++}`);
|
||||
params.push(changes.displayName);
|
||||
}
|
||||
if (changes.role !== undefined) {
|
||||
setClauses.push(`role_id = (SELECT id FROM roles WHERE name = $${parameterIndex++})`);
|
||||
params.push(changes.role);
|
||||
}
|
||||
if (changes.isActive !== undefined) {
|
||||
setClauses.push(`is_active = $${parameterIndex++}`);
|
||||
params.push(changes.isActive);
|
||||
}
|
||||
setClauses.push('updated_at = now()');
|
||||
params.push(id);
|
||||
|
||||
const result = await this.database.query<UserRow>(
|
||||
`UPDATE users
|
||||
SET ${setClauses.join(', ')}
|
||||
WHERE id = $${parameterIndex}
|
||||
RETURNING id, username, email, password_hash, display_name,
|
||||
(SELECT name FROM roles WHERE id = role_id) AS role_name,
|
||||
is_active, failed_login_attempts, locked_until,
|
||||
last_login_at, created_at, updated_at`,
|
||||
params,
|
||||
);
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/** Setzt einen neuen Passwort-Hash. */
|
||||
async updatePassword(id: string, passwordHash: string): Promise<void> {
|
||||
await this.database.query(
|
||||
'UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1',
|
||||
[id, passwordHash],
|
||||
);
|
||||
}
|
||||
|
||||
/** Setzt Fehlversuchs-Zähler und Sperre zurück (bei Aktivierung). */
|
||||
async resetLoginLockout(id: string): Promise<void> {
|
||||
await this.database.query(
|
||||
'UPDATE users SET failed_login_attempts = 0, locked_until = NULL, updated_at = now() WHERE id = $1',
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.database.query('DELETE FROM users WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
/** Anzahl aktiver Administratoren (Schutz vor Verlust des letzten Admins). */
|
||||
async countActiveAdmins(): Promise<number> {
|
||||
const result = await this.database.query<{ count: number }>(
|
||||
`SELECT count(*)::int AS count
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE r.name = 'ADMIN' AND u.is_active`,
|
||||
);
|
||||
return result.rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async updateLoginSuccess(userId: string): Promise<void> {
|
||||
await this.database.query(
|
||||
`UPDATE users
|
||||
@@ -94,26 +181,6 @@ export class UserRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: RoleName;
|
||||
}): Promise<AuthUser> {
|
||||
const passwordHash = await this.passwordHasher.hash(input.password);
|
||||
const result = await this.database.query<UserRow>(
|
||||
`INSERT INTO users (username, email, password_hash, display_name, role_id)
|
||||
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = $5))
|
||||
RETURNING id, username, email, display_name,
|
||||
(SELECT name FROM roles WHERE id = role_id) AS role_name,
|
||||
true AS is_active, 0 AS failed_login_attempts, NULL::timestamptz AS locked_until,
|
||||
NULL::timestamptz AS last_login_at, now() AS created_at, now() AS updated_at`,
|
||||
[input.username, input.email, passwordHash, input.displayName, input.role],
|
||||
);
|
||||
return toAuthUser(this.mapRow(result.rows[0]));
|
||||
}
|
||||
|
||||
private mapRow(row: UserRow): UserRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -29,4 +29,48 @@ export const loginSchema = z.object({
|
||||
username: z.string().trim().min(1).max(100),
|
||||
password: z.string().min(1).max(200),
|
||||
});
|
||||
export type LoginDto = z.infer<typeof loginSchema>;
|
||||
export type LoginDto = z.infer<typeof loginSchema>;
|
||||
|
||||
/** Erlaubte Zeichen für Benutzernamen (kein Whitespace, keine Sonderzeichen). */
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/** Benutzer anlegen (Admin). */
|
||||
export const createUserSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
|
||||
.max(100)
|
||||
.regex(USERNAME_PATTERN, 'Benutzername darf nur Buchstaben, Zahlen sowie . _ - enthalten'),
|
||||
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255),
|
||||
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200),
|
||||
password: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||
role: z.enum(ROLE_NAMES),
|
||||
});
|
||||
export type CreateUserDto = z.infer<typeof createUserSchema>;
|
||||
|
||||
/** Benutzer bearbeiten (Admin) – mindestens ein Feld muss gesetzt sein. */
|
||||
export const updateUserSchema = z
|
||||
.object({
|
||||
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255).optional(),
|
||||
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200).optional(),
|
||||
role: z.enum(ROLE_NAMES).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => Object.values(data).some((value) => value !== undefined), {
|
||||
message: 'Mindestens ein Feld ist erforderlich',
|
||||
});
|
||||
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
|
||||
|
||||
/** Passwort durch einen Administrator zurücksetzen. */
|
||||
export const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||
});
|
||||
export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>;
|
||||
|
||||
/** Eigenes Passwort ändern (angemeldeter Benutzer). */
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich').max(200),
|
||||
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||
});
|
||||
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||
118
apps/platform-backend/src/users/users.controller.ts
Normal file
118
apps/platform-backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||
import {
|
||||
createUserSchema,
|
||||
resetPasswordSchema,
|
||||
updateUserSchema,
|
||||
type CreateUserDto,
|
||||
type ResetPasswordDto,
|
||||
type UpdateUserDto,
|
||||
} from './user.types';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
/** Benutzerdaten in API-Antworten (ohne interne Felder). */
|
||||
interface UserResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: RoleName;
|
||||
isActive: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function toUserResponse(user: UserRecord): UserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (nur Administratoren).
|
||||
* Guards (Session, CSRF, Rollen) sind global registriert;
|
||||
* @Roles('ADMIN') erzwingt die Administrator-Rolle auf Klassenebene.
|
||||
*/
|
||||
@ApiTags('Users')
|
||||
@Roles('ADMIN')
|
||||
@Controller({ path: 'api/v1/users' })
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
async list(): Promise<{ users: UserResponse[] }> {
|
||||
const users = await this.usersService.list();
|
||||
return { users: users.map(toUserResponse) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserDto,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ user: UserResponse }> {
|
||||
const user = await this.usersService.create(body, actor, request.ip ?? null);
|
||||
return { user: toUserResponse(user) };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getById(@Param('id', ParseUUIDPipe) id: string): Promise<{ user: UserResponse }> {
|
||||
const user = await this.usersService.findById(id);
|
||||
return { user: toUserResponse(user) };
|
||||
}
|
||||
|
||||
@Patch(':id/password')
|
||||
async resetPassword(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(resetPasswordSchema)) body: ResetPasswordDto,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ success: true }> {
|
||||
await this.usersService.resetPassword(id, body, actor, request.ip ?? null);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserDto,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ user: UserResponse }> {
|
||||
const user = await this.usersService.update(id, body, actor, request.ip ?? null);
|
||||
return { user: toUserResponse(user) };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ success: true }> {
|
||||
await this.usersService.remove(id, actor, request.ip ?? null);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { ProfileController } from './profile.controller';
|
||||
import { ProfileService } from './profile.service';
|
||||
import { SeedService } from './seed.service';
|
||||
import { UserRepository } from './user.repository';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
/** Benutzerverwaltung (Phase 1: Modell, Rollen, Seed). */
|
||||
/** Benutzerverwaltung: Admin-API, Profil, Rollen, Seed. */
|
||||
@Module({
|
||||
imports: [ConfigModule, DatabaseModule],
|
||||
providers: [UserRepository, PasswordHasher, SeedService],
|
||||
imports: [ConfigModule, DatabaseModule, AuditModule],
|
||||
controllers: [UsersController, ProfileController],
|
||||
providers: [UserRepository, PasswordHasher, SeedService, UsersService, ProfileService, SessionService],
|
||||
exports: [UserRepository, PasswordHasher],
|
||||
})
|
||||
export class UsersModule {}
|
||||
253
apps/platform-backend/src/users/users.service.spec.ts
Normal file
253
apps/platform-backend/src/users/users.service.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { CreateUserDto, UserRecord } from './user.types';
|
||||
import { UsersService, type ActingUser } from './users.service';
|
||||
|
||||
/** Erzeugt einen Benutzer-Datensatz für Tests. */
|
||||
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||
return {
|
||||
id: 'user-1',
|
||||
username: 'max',
|
||||
email: 'max@example.com',
|
||||
passwordHash: 'not-a-real-hash',
|
||||
displayName: 'Max Mustermann',
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' };
|
||||
|
||||
/** Mock des UserRepository. */
|
||||
class MockUserRepository {
|
||||
public users: UserRecord[] = [createUserRecord()];
|
||||
public deletedIds: string[] = [];
|
||||
public updatedPasswords: Array<{ id: string; hash: string }> = [];
|
||||
public lockoutResets: string[] = [];
|
||||
|
||||
async list(): Promise<UserRecord[]> {
|
||||
return this.users;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
return this.users.find((user) => user.id === id) ?? null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
return this.users.find((user) => user.username === username) ?? null;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<UserRecord | null> {
|
||||
return this.users.find((user) => user.email === email) ?? null;
|
||||
}
|
||||
|
||||
async create(input: CreateUserDto): Promise<UserRecord> {
|
||||
const user = createUserRecord({
|
||||
id: 'new-user',
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
role: input.role,
|
||||
});
|
||||
this.users.push(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: string, changes: Partial<UserRecord>): Promise<UserRecord> {
|
||||
const index = this.users.findIndex((user) => user.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('nicht gefunden');
|
||||
}
|
||||
this.users[index] = { ...this.users[index], ...changes };
|
||||
return this.users[index];
|
||||
}
|
||||
|
||||
async updatePassword(id: string, hash: string): Promise<void> {
|
||||
this.updatedPasswords.push({ id, hash });
|
||||
}
|
||||
|
||||
async resetLoginLockout(id: string): Promise<void> {
|
||||
this.lockoutResets.push(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.deletedIds.push(id);
|
||||
this.users = this.users.filter((user) => user.id !== id);
|
||||
}
|
||||
|
||||
async countActiveAdmins(): Promise<number> {
|
||||
return this.users.filter((user) => user.role === 'ADMIN' && user.isActive).length;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des SessionService. */
|
||||
class MockSessionService {
|
||||
public deletedSessionsForUser: string[] = [];
|
||||
|
||||
async deleteAllForUser(userId: string): Promise<void> {
|
||||
this.deletedSessionsForUser.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des AuditService. */
|
||||
class MockAuditService {
|
||||
public records: Array<{ action: string; userId: string | null }> = [];
|
||||
|
||||
async record(entry: { action: string; userId: string | null }): Promise<void> {
|
||||
this.records.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
describe('UsersService', () => {
|
||||
let userRepository: MockUserRepository;
|
||||
let sessionService: MockSessionService;
|
||||
let auditService: MockAuditService;
|
||||
let usersService: UsersService;
|
||||
let passwordHasher: PasswordHasher;
|
||||
|
||||
const createUserInput: CreateUserDto = {
|
||||
username: 'neu',
|
||||
email: 'neu@example.com',
|
||||
displayName: 'Neuer Benutzer',
|
||||
password: 'Sicheres-Passwort-1',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = new MockUserRepository();
|
||||
sessionService = new MockSessionService();
|
||||
auditService = new MockAuditService();
|
||||
passwordHasher = new PasswordHasher();
|
||||
usersService = new UsersService(
|
||||
userRepository as unknown as UserRepository,
|
||||
passwordHasher,
|
||||
sessionService as unknown as SessionService,
|
||||
auditService as unknown as AuditService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('gibt alle Benutzer zurück', async () => {
|
||||
const users = await usersService.list();
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].username).toBe('max');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('wirft NotFoundException bei unbekannter ID', async () => {
|
||||
await expect(usersService.findById('unbekannt')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('legt einen neuen Benutzer an und schreibt Audit', async () => {
|
||||
const user = await usersService.create(createUserInput, ACTOR, '127.0.0.1');
|
||||
expect(user.username).toBe('neu');
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_CREATED);
|
||||
});
|
||||
|
||||
it('lehnt doppelte Benutzernamen ab', async () => {
|
||||
await expect(
|
||||
usersService.create({ ...createUserInput, username: 'max' }, ACTOR, null),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('lehnt doppelte E-Mail-Adressen ab', async () => {
|
||||
await expect(
|
||||
usersService.create({ ...createUserInput, email: 'max@example.com' }, ACTOR, null),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('aktualisiert den Anzeigenamen und schreibt Audit', async () => {
|
||||
const updated = await usersService.update(
|
||||
'user-1',
|
||||
{ displayName: 'Max M.' },
|
||||
ACTOR,
|
||||
null,
|
||||
);
|
||||
expect(updated.displayName).toBe('Max M.');
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_UPDATED);
|
||||
});
|
||||
|
||||
it('verhindert Selbst-Deaktivierung', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.update('admin-1', { isActive: false }, ACTOR, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('meldet deaktivierte Benutzer von allen Sessions ab', async () => {
|
||||
await usersService.update('user-1', { isActive: false }, ACTOR, null);
|
||||
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DISABLED);
|
||||
});
|
||||
|
||||
it('setzt Login-Sperre bei Reaktivierung zurück', async () => {
|
||||
userRepository.users[0] = createUserRecord({ isActive: false });
|
||||
await usersService.update('user-1', { isActive: true }, ACTOR, null);
|
||||
expect(userRepository.lockoutResets).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_ENABLED);
|
||||
});
|
||||
|
||||
it('verhindert die Deaktivierung des letzten aktiven Admins', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.update('admin-1', { isActive: false }, { id: 'anderer', username: 'x' }, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('verhindert die Herabstufung des letzten aktiven Admins', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.update('admin-1', { role: 'USER' }, { id: 'anderer', username: 'x' }, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('setzt das Passwort, meldet Sessions ab und schreibt Audit', async () => {
|
||||
await usersService.resetPassword(
|
||||
'user-1',
|
||||
{ newPassword: 'Neues-Passwort-123' },
|
||||
ACTOR,
|
||||
null,
|
||||
);
|
||||
expect(userRepository.updatedPasswords).toHaveLength(1);
|
||||
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('löscht einen Benutzer und schreibt Audit', async () => {
|
||||
await usersService.remove('user-1', ACTOR, null);
|
||||
expect(userRepository.deletedIds).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DELETED);
|
||||
});
|
||||
|
||||
it('verhindert Selbst-Löschung', async () => {
|
||||
await expect(usersService.remove('admin-1', ACTOR, null)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('verhindert die Löschung des letzten aktiven Admins', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.remove('admin-1', { id: 'anderer', username: 'x' }, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
174
apps/platform-backend/src/users/users.service.ts
Normal file
174
apps/platform-backend/src/users/users.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService, type AuditAction } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { CreateUserDto, ResetPasswordDto, UpdateUserDto, UserRecord } from './user.types';
|
||||
|
||||
/** Der handelnde Benutzer (für Audit-Einträge). */
|
||||
export interface ActingUser {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (Application-Layer): Business-Regeln für Anlegen,
|
||||
* Bearbeiten, Aktivieren/Deaktivieren und Löschen von Benutzern.
|
||||
*
|
||||
* Schutzregeln:
|
||||
* - Keine Selbst-Deaktivierung und keine Selbst-Löschung
|
||||
* - Der letzte aktive Administrator kann nicht herabgestuft,
|
||||
* deaktiviert oder gelöscht werden
|
||||
* - Deaktivierte Benutzer werden sofort von allen Sessions abgemeldet
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async list(): Promise<UserRecord[]> {
|
||||
return this.userRepository.list();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserRecord> {
|
||||
const user = await this.userRepository.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Benutzer nicht gefunden');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateUserDto,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<UserRecord> {
|
||||
const [existingUsername, existingEmail] = await Promise.all([
|
||||
this.userRepository.findByUsername(input.username),
|
||||
this.userRepository.findByEmail(input.email),
|
||||
]);
|
||||
if (existingUsername) {
|
||||
throw new ConflictException('Benutzername ist bereits vergeben');
|
||||
}
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.create(input);
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.USER_CREATED,
|
||||
details: { targetUserId: user.id, targetUsername: user.username, role: user.role },
|
||||
ipAddress,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateUserDto,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<UserRecord> {
|
||||
const user = await this.findById(id);
|
||||
|
||||
if (input.email !== undefined && input.email !== user.email) {
|
||||
const existingEmail = await this.userRepository.findByEmail(input.email);
|
||||
if (existingEmail && existingEmail.id !== id) {
|
||||
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
|
||||
}
|
||||
}
|
||||
|
||||
const demotesFromAdmin = user.role === 'ADMIN' && input.role === 'USER';
|
||||
const deactivates = input.isActive === false && user.isActive;
|
||||
const activates = input.isActive === true && !user.isActive;
|
||||
|
||||
if (deactivates && id === actor.id) {
|
||||
throw new BadRequestException('Sie können Ihr eigenes Konto nicht deaktivieren');
|
||||
}
|
||||
if ((demotesFromAdmin || deactivates) && (await this.isLastActiveAdmin(user))) {
|
||||
throw new BadRequestException(
|
||||
'Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden',
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.userRepository.update(id, input);
|
||||
|
||||
if (deactivates) {
|
||||
await this.sessionService.deleteAllForUser(id);
|
||||
}
|
||||
if (activates) {
|
||||
await this.userRepository.resetLoginLockout(id);
|
||||
}
|
||||
|
||||
const action: AuditAction = deactivates
|
||||
? AUDIT_ACTIONS.USER_DISABLED
|
||||
: activates
|
||||
? AUDIT_ACTIONS.USER_ENABLED
|
||||
: AUDIT_ACTIONS.USER_UPDATED;
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action,
|
||||
details: { targetUserId: id, targetUsername: user.username },
|
||||
ipAddress,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
id: string,
|
||||
input: ResetPasswordDto,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<void> {
|
||||
const user = await this.findById(id);
|
||||
const passwordHash = await this.passwordHasher.hash(input.newPassword);
|
||||
await this.userRepository.updatePassword(id, passwordHash);
|
||||
await this.sessionService.deleteAllForUser(id);
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.USER_PASSWORD_RESET,
|
||||
details: { targetUserId: id, targetUsername: user.username },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> {
|
||||
if (id === actor.id) {
|
||||
throw new BadRequestException('Sie können Ihr eigenes Konto nicht löschen');
|
||||
}
|
||||
const user = await this.findById(id);
|
||||
if (await this.isLastActiveAdmin(user)) {
|
||||
throw new BadRequestException('Der letzte aktive Administrator kann nicht gelöscht werden');
|
||||
}
|
||||
await this.userRepository.delete(id);
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.USER_DELETED,
|
||||
details: { targetUserId: id, targetUsername: user.username },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/** Prüft, ob der gegebene Benutzer der einzige aktive Administrator ist. */
|
||||
private async isLastActiveAdmin(user: UserRecord): Promise<boolean> {
|
||||
if (user.role !== 'ADMIN' || !user.isActive) {
|
||||
return false;
|
||||
}
|
||||
const activeAdminCount = await this.userRepository.countActiveAdmins();
|
||||
return activeAdminCount <= 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user