feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)

This commit is contained in:
MPM Dev
2026-10-06 14:58:36 +02:00
parent a7e1c421f2
commit e87dc1f836
25 changed files with 2056 additions and 75 deletions

View File

@@ -2,7 +2,7 @@
Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können.
**Status: Phase 1 – Grundgerüst (abgeschlossen)**
**Status: Phase 2 – Benutzerverwaltung (abgeschlossen)**
## Architektur-Überblick
@@ -87,7 +87,15 @@ MPM/
| Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger |
| Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) |
| Betrieb | Docker, Nginx, Supervisor, unprivilegierter Benutzer |
| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, Rate Limiting, Account Lockout, Audit-Log, Helmet |
| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, Rate Limiting, Account Lockout, Audit-Log, Helmet, RBAC |
## Funktionen
### Phase 1 – Grundgerüst
Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), Health-Monitoring, Audit-Log, Migrationen mit Advisory-Lock, responsive Management-UI mit Design-System.
### Phase 2 – Benutzerverwaltung
Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letzten Admins, sofortiger Session-Sperrung bei Deaktivierung, Passwort-Reset, eigenes Passwort ändern, Benutzerverwaltungs-UI (Tabelle, Modals, Toasts) und Profil-Seite.
## Annahme

View File

@@ -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];

View File

@@ -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()');

View File

@@ -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;

View 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 };
}
}

View 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);
});
});

View 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,
});
}
}

View File

@@ -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,

View File

@@ -30,3 +30,47 @@ export const loginSchema = z.object({
password: z.string().min(1).max(200),
});
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>;

View 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 };
}
}

View File

@@ -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 {}

View 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);
});
});
});

View 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;
}
}

View File

@@ -13,6 +13,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: '⌂' },
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
];

View File

@@ -0,0 +1,60 @@
import { type ReactNode, useEffect } from 'react';
export interface ModalProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
export function Modal({
open,
title,
description,
onClose,
children,
footer,
}: ModalProps): ReactNode {
useEffect(() => {
if (!open) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onClick={onClose}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-md rounded-xl bg-white shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="border-b border-slate-200 px-6 py-4">
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
</div>
<div className="px-6 py-4">{children}</div>
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { type ReactNode, type SelectHTMLAttributes } from 'react';
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label: string;
error?: string;
options: ReadonlyArray<{ value: string; label: string }>;
}
/** Select-Dropdown mit Label (Design-System). */
export function Select({
label,
error,
options,
className = '',
id,
...rest
}: SelectProps): ReactNode {
const selectId = id ?? `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={selectId} className="text-sm font-medium text-slate-700">
{label}
</label>
<select
id={selectId}
className={`h-10 rounded-lg border bg-white px-3 text-sm transition-colors
${
error
? 'border-red-400 focus:border-red-500'
: 'border-slate-300 focus:border-brand-500'
}
${className}`}
aria-invalid={error ? true : undefined}
{...rest}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<p role="alert" className="text-xs text-red-600">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import {
type ReactNode,
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
/** Toast-Varianten. */
export type ToastVariant = 'success' | 'error';
interface ToastEntry {
readonly id: number;
readonly variant: ToastVariant;
readonly message: string;
}
interface ToastContextValue {
showToast: (variant: ToastVariant, message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const VARIANT_CLASSES: Record<ToastVariant, string> = {
success: 'bg-emerald-600 text-white',
error: 'bg-red-600 text-white',
};
const VARIANT_ICONS: Record<ToastVariant, string> = {
success: '✓',
error: '✕',
};
/**
* Toast-Benachrichtigungen (Design-System).
* Meldungen verschwinden automatisch nach 4 Sekunden.
*/
export function ToastProvider({ children }: { children: ReactNode }): ReactNode {
const [toasts, setToasts] = useState<ToastEntry[]>([]);
const nextId = useRef(1);
const showToast = useCallback((variant: ToastVariant, message: string) => {
const id = nextId.current;
nextId.current += 1;
setToasts((current) => [...current, { id, variant, message }]);
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 4_000);
}, []);
const value = useMemo<ToastContextValue>(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg
${VARIANT_CLASSES[toast.variant]}`}
>
<span aria-hidden="true">{VARIANT_ICONS[toast.variant]}</span>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
/** Zeigt eine Toast-Meldung an (wirft außerhalb des Providers). */
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
}
return context;
}

View File

@@ -1,28 +1,516 @@
import { type ReactNode } from 'react';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { EmptyState } from '../../components/ui/states';
import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Modal } from '../../components/ui/modal';
import { Select } from '../../components/ui/select';
import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client';
import {
createUser,
deleteUser,
fetchUsers,
resetUserPassword,
updateUser,
} from '../../lib/users-api';
import {
createUserSchema,
resetPasswordSchema,
ROLE_NAMES,
type RoleName,
type User,
} from '../../lib/schemas';
/** Platzhalter für die Benutzerverwaltung (Phase 2). */
/** Formular-Fehler aus Zod-Issues (nur erste Meldung pro Feld). */
function fieldErrorsFromZod(
issues: Array<{ path: (string | number | symbol)[]; message: string }>,
): Record<string, string> {
const errors: Record<string, string> = {};
for (const issue of issues) {
const key = String(issue.path[0] ?? 'form');
if (!errors[key]) {
errors[key] = issue.message;
}
}
return errors;
}
/** API-Fehler-Details in einfache Feldmeldungen umwandeln. */
function fieldErrorsFromApi(details: Record<string, string | string[]> | undefined): Record<string, string> {
if (!details) {
return {};
}
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(details)) {
errors[key] = Array.isArray(value) ? value[0] : value;
}
return errors;
}
/** Datumsformat für Tabellenanzeigen. */
function formatDate(isoDate: string | null): string {
if (!isoDate) {
return '–';
}
return new Date(isoDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/** Formular: Neuen Benutzer anlegen. */
function CreateUserModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => void;
}): ReactNode {
const { showToast } = useToast();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: createUser,
onSuccess: (user) => {
showToast('success', `Benutzer "${user.username}" wurde angelegt`);
onCreated();
onClose();
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
setFieldErrors(fieldErrorsFromApi(error.details));
} else {
setFormError('Benutzer konnte nicht angelegt werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const parsed = createUserSchema.safeParse({
username: formData.get('username'),
email: formData.get('email'),
displayName: formData.get('displayName'),
password: formData.get('password'),
role: formData.get('role'),
});
if (!parsed.success) {
setFieldErrors(fieldErrorsFromZod(parsed.error.issues));
return;
}
createMutation.mutate(parsed.data);
}
return (
<Modal
open={open}
title="Benutzer anlegen"
description="Neuen Benutzer für die Plattform registrieren"
onClose={onClose}
>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<Input label="Benutzername" name="username" error={fieldErrors.username} autoFocus />
<Input label="Anzeigename" name="displayName" error={fieldErrors.displayName} />
<Input label="E-Mail-Adresse" name="email" type="email" error={fieldErrors.email} />
<Input
label="Passwort"
name="password"
type="password"
hint="Mindestens 10 Zeichen"
error={fieldErrors.password}
/>
<Select
label="Rolle"
name="role"
defaultValue="USER"
error={fieldErrors.role}
options={ROLE_NAMES.map((role) => ({
value: role,
label: role === 'ADMIN' ? 'Administrator' : 'Benutzer',
}))}
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" loading={createMutation.isPending}>
Anlegen
</Button>
</div>
</form>
</Modal>
);
}
/** Formular: Benutzer bearbeiten. */
function EditUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const updateMutation = useMutation({
mutationFn: (input: { email: string; displayName: string; role: RoleName }) =>
updateUser(user.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['users'] });
showToast('success', 'Benutzer wurde gespeichert');
onClose();
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
setFieldErrors(fieldErrorsFromApi(error.details));
} else {
setFormError('Benutzer konnte nicht gespeichert werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const role = String(formData.get('role'));
updateMutation.mutate({
email: String(formData.get('email')),
displayName: String(formData.get('displayName')),
role: role === 'ADMIN' ? 'ADMIN' : 'USER',
});
}
return (
<Modal open title="Benutzer bearbeiten" description={`@${user.username}`} onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<Input
label="Anzeigename"
name="displayName"
defaultValue={user.displayName}
error={fieldErrors.displayName}
autoFocus
/>
<Input
label="E-Mail-Adresse"
name="email"
type="email"
defaultValue={user.email}
error={fieldErrors.email}
/>
<Select
label="Rolle"
name="role"
defaultValue={user.role}
error={fieldErrors.role}
options={ROLE_NAMES.map((role) => ({
value: role,
label: role === 'ADMIN' ? 'Administrator' : 'Benutzer',
}))}
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" loading={updateMutation.isPending}>
Speichern
</Button>
</div>
</form>
</Modal>
);
}
/** Formular: Passwort zurücksetzen. */
function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const resetMutation = useMutation({
mutationFn: (input: { newPassword: string }) => resetUserPassword(user.id, input),
onSuccess: () => {
showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`);
onClose();
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
setFieldErrors(fieldErrorsFromApi(error.details));
} else {
setFormError('Passwort konnte nicht zurückgesetzt werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const parsed = resetPasswordSchema.safeParse({
newPassword: formData.get('newPassword'),
});
if (!parsed.success) {
setFieldErrors(fieldErrorsFromZod(parsed.error.issues));
return;
}
resetMutation.mutate(parsed.data);
}
return (
<Modal
open
title="Passwort zurücksetzen"
description={`Neues Passwort für @${user.username} festlegen. Der Benutzer wird von allen Sitzungen abgemeldet.`}
onClose={onClose}
>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<Input
label="Neues Passwort"
name="newPassword"
type="password"
hint="Mindestens 10 Zeichen"
error={fieldErrors.newPassword}
autoFocus
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" variant="danger" loading={resetMutation.isPending}>
Zurücksetzen
</Button>
</div>
</form>
</Modal>
);
}
/** Bestätigungsdialog: Benutzer löschen. */
function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null);
const deleteMutation = useMutation({
mutationFn: () => deleteUser(user.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['users'] });
showToast('success', `Benutzer "${user.username}" wurde gelöscht`);
onClose();
},
onError: (error) => {
setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen');
},
});
return (
<Modal
open
title="Benutzer löschen"
description={`Möchten Sie "${user.displayName}" (@${user.username}) wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`}
onClose={onClose}
>
{formError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="danger"
loading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
Endgültig lÃschen
</Button>
</div>
</Modal>
);
}
/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */
export function UsersPage(): ReactNode {
const { user: currentUser } = useAuth();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [createOpen, setCreateOpen] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const usersQuery = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
const toggleActiveMutation = useMutation({
mutationFn: (target: User) => updateUser(target.id, { isActive: !target.isActive }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['users'] });
showToast('success', 'Status wurde aktualisiert');
},
onError: (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktualisierung fehlgeschlagen');
},
});
return (
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
<p className="mt-1 text-sm text-slate-500">
Benutzer anlegen, bearbeiten und verwalten.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>+ Benutzer anlegen</Button>
</div>
<Card>
<CardHeader title="Benutzer" description="Alle Benutzer der Plattform" />
<CardBody>
<EmptyState
title="Benutzerverwaltung folgt in Phase 2"
description="Die vollständige Benutzerverwaltung (Liste, Anlegen, Bearbeiten, Deaktivieren) wird in Phase 2 implementiert."
icon={<span className="text-3xl" aria-hidden="true">👥</span>}
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-semibold">Benutzer</th>
<th className="px-4 py-3 font-semibold">Rolle</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">Letzter Login</th>
<th className="px-4 py-3 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{usersQuery.isLoading && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Benutzer werden geladen…
</td>
</tr>
)}
{usersQuery.isError && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-red-600">
Benutzer konnten nicht geladen werden.
</td>
</tr>
)}
{usersQuery.data?.map((user) => (
<tr key={user.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-slate-900">{user.displayName}</div>
<div className="text-xs text-slate-500">
@{user.username} · {user.email}
</div>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${
user.role === 'ADMIN'
? 'bg-brand-50 text-brand-700 ring-brand-600/20'
: 'bg-slate-100 text-slate-600 ring-slate-500/20'
}`}
>
{user.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${
user.isActive
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
: 'bg-red-50 text-red-700 ring-red-600/20'
}`}
>
{user.isActive ? 'Aktiv' : 'Deaktiviert'}
</span>
</td>
<td className="px-4 py-3 text-slate-500">{formatDate(user.lastLoginAt)}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
<Button size="sm" variant="ghost" onClick={() => setEditUser(user)}>
Bearbeiten
</Button>
<Button size="sm" variant="ghost" onClick={() => setResetPasswordUser(user)}>
Passwort
</Button>
{user.id !== currentUser?.id && (
<>
<Button
size="sm"
variant="ghost"
loading={
toggleActiveMutation.isPending &&
toggleActiveMutation.variables?.id === user.id
}
onClick={() => toggleActiveMutation.mutate(user)}
>
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setDeleteTarget(user)}>
LÃschen
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{usersQuery.data?.length === 0 && (
<p className="px-4 py-8 text-center text-slate-500">
Noch keine Benutzer vorhanden.
</p>
)}
</div>
{createOpen && (
<CreateUserModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => void queryClient.invalidateQueries({ queryKey: ['users'] })}
/>
</CardBody>
</Card>
)}
{editUser && <EditUserModal user={editUser} onClose={() => setEditUser(null)} />}
{resetPasswordUser && (
<ResetPasswordModal user={resetPasswordUser} onClose={() => setResetPasswordUser(null)} />
)}
{deleteTarget && (
<DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} />
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Button } from '../../components/ui/button';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Input } from '../../components/ui/input';
import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client';
import { changePassword, fetchProfile } from '../../lib/users-api';
import { changePasswordSchema } from '../../lib/schemas';
/** Datumsformat für Profilanzeigen. */
function formatDate(isoDate: string | null): string {
if (!isoDate) {
return '–';
}
return new Date(isoDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/** Profil des angemeldeten Benutzers mit Passwortänderung. */
export function ProfilePage(): ReactNode {
const { showToast } = useToast();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const profileQuery = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
const changePasswordMutation = useMutation({
mutationFn: changePassword,
onSuccess: () => {
showToast('success', 'Passwort wurde geändert');
setFieldErrors({});
setFormError(null);
},
onError: (error) => {
if (error instanceof ApiError) {
setFormError(error.message);
const details = error.details ?? {};
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(details)) {
errors[key] = Array.isArray(value) ? value[0] : value;
}
setFieldErrors(errors);
} else {
setFormError('Passwort konnte nicht geändert werden');
}
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldErrors({});
setFormError(null);
const formData = new FormData(event.currentTarget);
const parsed = changePasswordSchema.safeParse({
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
});
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? 'form');
if (!errors[key]) {
errors[key] = issue.message;
}
}
setFieldErrors(errors);
return;
}
changePasswordMutation.mutate(parsed.data);
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Mein Profil</h1>
<p className="mt-1 text-sm text-slate-500">
Ihre persönlichen Daten und Passwort-Einstellungen.
</p>
</div>
<Card>
<CardHeader title="Persönliche Daten" />
<CardBody>
{profileQuery.isLoading && <p className="text-sm text-slate-500">Wird geladen…</p>}
{profileQuery.isError && (
<p className="text-sm text-red-600">Profil konnte nicht geladen werden.</p>
)}
{profileQuery.data && (
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Anzeigename</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{profileQuery.data.displayName}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Benutzername</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
@{profileQuery.data.username}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">E-Mail</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{profileQuery.data.email}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Rolle</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{profileQuery.data.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Letzter Login</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{formatDate(profileQuery.data.lastLoginAt)}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">Mitglied seit</dt>
<dd className="mt-1 text-sm font-medium text-slate-900">
{formatDate(profileQuery.data.createdAt)}
</dd>
</div>
</dl>
)}
</CardBody>
</Card>
<Card>
<CardHeader
title="Passwort ändern"
description="Nach der Änderung werden alle anderen Sitzungen abgemeldet."
/>
<CardBody>
<form onSubmit={handleSubmit} className="max-w-sm space-y-4" noValidate>
<Input
label="Aktuelles Passwort"
name="currentPassword"
type="password"
autoComplete="current-password"
error={fieldErrors.currentPassword}
/>
<Input
label="Neues Passwort"
name="newPassword"
type="password"
autoComplete="new-password"
hint="Mindestens 10 Zeichen"
error={fieldErrors.newPassword}
/>
<Input
label="Neues Passwort bestätigen"
name="confirmPassword"
type="password"
autoComplete="new-password"
error={fieldErrors.confirmPassword}
/>
{formError && (
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<Button type="submit" loading={changePasswordMutation.isPending}>
Passwort ändern
</Button>
</form>
</CardBody>
</Card>
</div>
);
}

View File

@@ -10,7 +10,7 @@ export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: Record<string, string[]>,
public readonly details?: Record<string, string | string[]>,
) {
super(message);
this.name = 'ApiError';

View File

@@ -35,3 +35,75 @@ export const healthSchema = z.object({
}),
});
export type Health = z.infer<typeof healthSchema>;
/** Benutzer-Datensatz der Admin-API (/api/v1/users). */
export const userSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
displayName: z.string(),
role: z.enum(ROLE_NAMES),
isActive: z.boolean(),
lastLoginAt: z.string().nullable(),
createdAt: z.string(),
});
export type User = z.infer<typeof userSchema>;
/** Profil des angemeldeten Benutzers (/api/v1/profile). */
export const profileSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
displayName: z.string(),
role: z.enum(ROLE_NAMES),
lastLoginAt: z.string().nullable(),
createdAt: z.string(),
});
export type Profile = z.infer<typeof profileSchema>;
/** Benutzer anlegen (Client-Validierung ist UX; Server validiert erneut). */
export const createUserSchema = z.object({
username: z
.string()
.trim()
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
.max(100)
.regex(/^[A-Za-z0-9._-]+$/, 'Nur Buchstaben, Zahlen sowie . _ - erlaubt'),
email: z.string().trim().email('Ungültige E-Mail-Adresse'),
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich'),
password: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
role: z.enum(ROLE_NAMES),
});
export type CreateUserDto = z.infer<typeof createUserSchema>;
/** Benutzer bearbeiten. */
export const updateUserSchema = z
.object({
email: z.string().trim().email('Ungültige E-Mail-Adresse').optional(),
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').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 zurücksetzen (Admin). */
export const resetPasswordSchema = z.object({
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
});
export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>;
/** Eigenes Passwort ändern. */
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich'),
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
confirmPassword: z.string().min(1, 'Passwortbestätigung ist erforderlich'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwörter stimmen nicht überein',
path: ['confirmPassword'],
});
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;

View File

@@ -0,0 +1,58 @@
import { apiRequest } from './api-client';
import {
profileSchema,
userSchema,
type ChangePasswordDto,
type CreateUserDto,
type Profile,
type ResetPasswordDto,
type UpdateUserDto,
type User,
} from './schemas';
/** Typsichere API-Funktionen für die Benutzerverwaltung. */
export async function fetchUsers(): Promise<User[]> {
const response = await apiRequest<{ users: unknown[] }>('/api/v1/users');
return response.users.map((user) => userSchema.parse(user));
}
export async function createUser(input: CreateUserDto): Promise<User> {
const response = await apiRequest<{ user: unknown }>('/api/v1/users', {
method: 'POST',
body: input,
});
return userSchema.parse(response.user);
}
export async function updateUser(id: string, input: UpdateUserDto): Promise<User> {
const response = await apiRequest<{ user: unknown }>(`/api/v1/users/${id}`, {
method: 'PATCH',
body: input,
});
return userSchema.parse(response.user);
}
export async function resetUserPassword(id: string, input: ResetPasswordDto): Promise<void> {
await apiRequest(`/api/v1/users/${id}/password`, { method: 'PATCH', body: input });
}
export async function deleteUser(id: string): Promise<void> {
await apiRequest(`/api/v1/users/${id}`, { method: 'DELETE' });
}
export async function fetchProfile(): Promise<Profile> {
const response = await apiRequest<{ profile: unknown }>('/api/v1/profile');
return profileSchema.parse(response.profile);
}
export async function changePassword(input: ChangePasswordDto): Promise<void> {
// confirmPassword ist nur eine Client-Prüfung und wird nicht gesendet.
await apiRequest('/api/v1/profile/password', {
method: 'PATCH',
body: {
currentPassword: input.currentPassword,
newPassword: input.newPassword,
},
});
}

View File

@@ -4,11 +4,13 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppLayout } from './components/layout/app-layout';
import { RequireAdmin, RequireAuth } from './components/route-guards';
import { ToastProvider } from './components/ui/toast';
import { AuthProvider } from './features/auth/auth-context';
import { LoginPage } from './features/auth/login-page';
import { DashboardPage } from './features/dashboard/dashboard-page';
import { SystemStatusPage } from './features/admin/system-status-page';
import { UsersPage } from './features/admin/users-page';
import { ProfilePage } from './features/profile/profile-page';
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
import './index.css';
@@ -31,6 +33,7 @@ function AppRoutes(): ReactNode {
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<DashboardPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route
path="/admin/users"
element={<RequireAdmin><UsersPage /></RequireAdmin>}
@@ -58,7 +61,9 @@ createRoot(rootElement).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<AppRoutes />
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>

View File

@@ -134,8 +134,24 @@ Security Hardening (Penetrationstests, Dependency/Container-Scans, HSTS, Backup/
| POST | `/api/v1/auth/logout` | Session | Beendet die aktuelle Session |
| GET | `/api/v1/auth/me` | Session | Angemeldeter Benutzer (Id, Rolle, …) |
| GET | `/api/v1/health` | Public | Systemstatus (Backend, DB-Latenz, Version, Uptime) |
| GET | `/api/v1/users` | Admin | Alle Benutzer |
| POST | `/api/v1/users` | Admin | Benutzer anlegen (409 bei Duplikat) |
| GET | `/api/v1/users/:id` | Admin | Einzelner Benutzer |
| PATCH | `/api/v1/users/:id` | Admin | Bearbeiten / Aktivieren / Deaktivieren |
| PATCH | `/api/v1/users/:id/password` | Admin | Passwort zurücksetzen |
| DELETE | `/api/v1/users/:id` | Admin | Benutzer löschen |
| GET | `/api/v1/profile` | Session | Eigenes Profil |
| PATCH | `/api/v1/profile/password` | Session | Eigenes Passwort ändern |
OpenAPI/Swagger UI: `/api/docs` · Ab Phase 2: `/api/v1/users/*`, `/api/v1/modules/*`, `/api/v1/permissions/*`, `/api/v1/audit/*`.
OpenAPI/Swagger UI: `/api/docs` · Ab Phase 3: `/api/v1/modules/*`, `/api/v1/permissions/*`, `/api/v1/audit/*`.
### Schutzregeln der Benutzerverwaltung
- Keine Selbst-Deaktivierung und keine Selbst-Löschung (400)
- Der letzte aktive Administrator kann nicht herabgestuft, deaktiviert oder gelöscht werden (400)
- Deaktivierte Benutzer werden sofort von allen Sessions abgemeldet
- Passwort-Reset (Admin) meldet den Benutzer von allen Sessions ab
- Eigenes Passwort ändern meldet alle übrigen Sessions ab (aktuelle bleibt aktiv)
## 9. Modul-Vertrag (Ausblick Phase 3+)

View File

@@ -5,7 +5,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S
| Phase | Bereich | Status |
|---|---|---|
| 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen |
| 2 | Benutzerverwaltung | ⏳ Geplant |
| 2 | Benutzerverwaltung | ✅ Abgeschlossen |
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ⏳ Geplant |
| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant |
| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
@@ -33,9 +33,28 @@ Definition of Done:
- [x] Admin-Dashboard sichtbar (inkl. Systemstatus)
- [x] Backend-Unit-Tests (Auth, Session, Passwort, Validierung)
## Nächste Schritte (Phase 2 – Benutzerverwaltung)
## Phase 2 – Benutzerverwaltung (abgeschlossen)
- Benutzerliste, Benutzer erstellen/bearbeiten/deaktivieren
- Passwortänderung und Reset durch Admin
- `GET/POST/PATCH/DELETE /api/v1/users/*` mit `@Roles('ADMIN')`
- Frontend-Seite `/admin/users` mit Tabelle, Formular und Bestätigungsdialogen
Definition of Done: Admin kann Benutzer vollständig verwalten.
- [x] `GET/POST/PATCH/DELETE /api/v1/users/*` (nur `@Roles('ADMIN')`)
- [x] Benutzerliste, Benutzer anlegen (Zod-Validierung, Duplikat-Schutz 409)
- [x] Benutzer bearbeiten (Anzeigename, E-Mail, Rolle)
- [x] Benutzer deaktivieren/aktivieren (Sessions werden sofort ungültig)
- [x] Passwort-Reset durch Admin (alle Sessions des Benutzers werden gelöscht)
- [x] Schutzregeln: keine Selbst-Deaktivierung/-Löschung, letzter aktiver Admin geschützt
- [x] Profil-Endpunkte (`GET /api/v1/profile`, `PATCH /api/v1/profile/password`)
- [x] Eigenes Passwort ändern (Verifikation des aktuellen Passworts, andere Sessions werden abgemeldet)
- [x] Frontend: Benutzerverwaltungs-Seite (Tabelle, Create/Edit/Reset/Delete-Modals, Toasts)
- [x] Frontend: Profil-Seite mit Passwortänderung
- [x] UI-Komponenten: Modal, Select, Toast (Design-System erweitert)
- [x] Backend-Tests: 49 bestanden (inkl. UsersService, ProfileService)
- [x] E2E verifiziert: CRUD, RBAC (User → 403), Login-Sperre nach Deaktivierung, Passwort-Flows
## Nächste Schritte (Phase 3 – Modul-System)
- Modul-Datenmodell (`modules`-Tabelle) und Manifest-Vertrag (`module.json`)
- Modul-Installation als ZIP-Paket (Validierung, Dateien, Registrierung)
- Modul-Lifecycle (INSTALLED/STARTING/RUNNING/STOPPING/STOPPED/ERROR/DISABLED)
- Prozessverwaltung der Modul-Prozesse über Supervisor
- Healthchecks und Statusanzeige im Admin-Bereich