diff --git a/README.md b/README.md index 69518f4..1dba2ad 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/platform-backend/src/audit/audit.service.ts b/apps/platform-backend/src/audit/audit.service.ts index e21b822..bdfe99e 100644 --- a/apps/platform-backend/src/audit/audit.service.ts +++ b/apps/platform-backend/src/audit/audit.service.ts @@ -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]; diff --git a/apps/platform-backend/src/auth/session.service.ts b/apps/platform-backend/src/auth/session.service.ts index 3e62092..243bf97 100644 --- a/apps/platform-backend/src/auth/session.service.ts +++ b/apps/platform-backend/src/auth/session.service.ts @@ -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 { + 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 { await this.database.query('DELETE FROM sessions WHERE expires_at <= now()'); diff --git a/apps/platform-backend/src/common/zod-validation.pipe.ts b/apps/platform-backend/src/common/zod-validation.pipe.ts index 4a65517..f78945e 100644 --- a/apps/platform-backend/src/common/zod-validation.pipe.ts +++ b/apps/platform-backend/src/common/zod-validation.pipe.ts @@ -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 = {}; + 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; diff --git a/apps/platform-backend/src/users/profile.controller.ts b/apps/platform-backend/src/users/profile.controller.ts new file mode 100644 index 0000000..1da323a --- /dev/null +++ b/apps/platform-backend/src/users/profile.controller.ts @@ -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 }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/users/profile.service.spec.ts b/apps/platform-backend/src/users/profile.service.spec.ts new file mode 100644 index 0000000..fd16b8f --- /dev/null +++ b/apps/platform-backend/src/users/profile.service.spec.ts @@ -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 { + 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 { + return this.user && this.user.id === id ? this.user : null; + } + + async updatePassword(id: string, hash: string): Promise { + this.updatedPasswords.push({ id, hash }); + } +} + +/** Mock des SessionService. */ +class MockSessionService { + public deletedForUser: Array<{ userId: string; exceptSessionId?: string }> = []; + + async deleteAllForUser(userId: string, exceptSessionId?: string): Promise { + this.deletedForUser.push({ userId, exceptSessionId }); + } +} + +/** Mock des AuditService. */ +class MockAuditService { + public records: Array<{ action: string }> = []; + + async record(entry: { action: string }): Promise { + 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); + }); +}); \ No newline at end of file diff --git a/apps/platform-backend/src/users/profile.service.ts b/apps/platform-backend/src/users/profile.service.ts new file mode 100644 index 0000000..df5c683 --- /dev/null +++ b/apps/platform-backend/src/users/profile.service.ts @@ -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 { + 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 { + 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, + }); + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/users/user.repository.ts b/apps/platform-backend/src/users/user.repository.ts index 42ba7be..d63e51a 100644 --- a/apps/platform-backend/src/users/user.repository.ts +++ b/apps/platform-backend/src/users/user.repository.ts @@ -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 { + const result = await this.database.query( + `SELECT ${USER_COLUMNS} ${USER_FROM} ORDER BY u.created_at DESC`, + ); + return result.rows.map((row) => this.mapRow(row)); + } + async findByUsername(username: string): Promise { const result = await this.database.query( - `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 { const result = await this.database.query( - `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 { + const result = await this.database.query( + `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 { + const passwordHash = await this.passwordHasher.hash(input.password); + const result = await this.database.query( + `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 { + 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( + `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 { + 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 { + 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 { + await this.database.query('DELETE FROM users WHERE id = $1', [id]); + } + + /** Anzahl aktiver Administratoren (Schutz vor Verlust des letzten Admins). */ + async countActiveAdmins(): Promise { + 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 { 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 { - const passwordHash = await this.passwordHasher.hash(input.password); - const result = await this.database.query( - `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, diff --git a/apps/platform-backend/src/users/user.types.ts b/apps/platform-backend/src/users/user.types.ts index f17560c..ac39cbc 100644 --- a/apps/platform-backend/src/users/user.types.ts +++ b/apps/platform-backend/src/users/user.types.ts @@ -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; \ No newline at end of file +export type LoginDto = z.infer; + +/** 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; + +/** 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; + +/** 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; + +/** 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; \ No newline at end of file diff --git a/apps/platform-backend/src/users/users.controller.ts b/apps/platform-backend/src/users/users.controller.ts new file mode 100644 index 0000000..86800f1 --- /dev/null +++ b/apps/platform-backend/src/users/users.controller.ts @@ -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 }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/users/users.module.ts b/apps/platform-backend/src/users/users.module.ts index 39f7dc7..63a056a 100644 --- a/apps/platform-backend/src/users/users.module.ts +++ b/apps/platform-backend/src/users/users.module.ts @@ -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 {} \ No newline at end of file diff --git a/apps/platform-backend/src/users/users.service.spec.ts b/apps/platform-backend/src/users/users.service.spec.ts new file mode 100644 index 0000000..24dd72a --- /dev/null +++ b/apps/platform-backend/src/users/users.service.spec.ts @@ -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 { + 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 { + return this.users; + } + + async findById(id: string): Promise { + return this.users.find((user) => user.id === id) ?? null; + } + + async findByUsername(username: string): Promise { + return this.users.find((user) => user.username === username) ?? null; + } + + async findByEmail(email: string): Promise { + return this.users.find((user) => user.email === email) ?? null; + } + + async create(input: CreateUserDto): Promise { + 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): Promise { + 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 { + this.updatedPasswords.push({ id, hash }); + } + + async resetLoginLockout(id: string): Promise { + this.lockoutResets.push(id); + } + + async delete(id: string): Promise { + this.deletedIds.push(id); + this.users = this.users.filter((user) => user.id !== id); + } + + async countActiveAdmins(): Promise { + return this.users.filter((user) => user.role === 'ADMIN' && user.isActive).length; + } +} + +/** Mock des SessionService. */ +class MockSessionService { + public deletedSessionsForUser: string[] = []; + + async deleteAllForUser(userId: string): Promise { + 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 { + 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); + }); + }); +}); \ No newline at end of file diff --git a/apps/platform-backend/src/users/users.service.ts b/apps/platform-backend/src/users/users.service.ts new file mode 100644 index 0000000..43d34c2 --- /dev/null +++ b/apps/platform-backend/src/users/users.service.ts @@ -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 { + return this.userRepository.list(); + } + + async findById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + if (user.role !== 'ADMIN' || !user.isActive) { + return false; + } + const activeAdminCount = await this.userRepository.countActiveAdmins(); + return activeAdminCount <= 1; + } +} \ No newline at end of file diff --git a/apps/platform-frontend/src/components/layout/app-layout.tsx b/apps/platform-frontend/src/components/layout/app-layout.tsx index d94b50d..a224cf5 100644 --- a/apps/platform-frontend/src/components/layout/app-layout.tsx +++ b/apps/platform-frontend/src/components/layout/app-layout.tsx @@ -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 }, ]; diff --git a/apps/platform-frontend/src/components/ui/modal.tsx b/apps/platform-frontend/src/components/ui/modal.tsx new file mode 100644 index 0000000..543362b --- /dev/null +++ b/apps/platform-frontend/src/components/ui/modal.tsx @@ -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 ( +
+
event.stopPropagation()} + > +
+

{title}

+ {description &&

{description}

} +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/components/ui/select.tsx b/apps/platform-frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..6810539 --- /dev/null +++ b/apps/platform-frontend/src/components/ui/select.tsx @@ -0,0 +1,50 @@ +import { type ReactNode, type SelectHTMLAttributes } from 'react'; + +export interface SelectProps extends SelectHTMLAttributes { + 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 ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/components/ui/toast.tsx b/apps/platform-frontend/src/components/ui/toast.tsx new file mode 100644 index 0000000..b0ea5b7 --- /dev/null +++ b/apps/platform-frontend/src/components/ui/toast.tsx @@ -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(null); + +const VARIANT_CLASSES: Record = { + success: 'bg-emerald-600 text-white', + error: 'bg-red-600 text-white', +}; + +const VARIANT_ICONS: Record = { + success: '✓', + error: '✕', +}; + +/** + * Toast-Benachrichtigungen (Design-System). + * Meldungen verschwinden automatisch nach 4 Sekunden. + */ +export function ToastProvider({ children }: { children: ReactNode }): ReactNode { + const [toasts, setToasts] = useState([]); + 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(() => ({ showToast }), [showToast]); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ + {toast.message} +
+ ))} +
+
+ ); +} + +/** 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; +} \ No newline at end of file diff --git a/apps/platform-frontend/src/features/admin/users-page.tsx b/apps/platform-frontend/src/features/admin/users-page.tsx index dfd8eb4..e5ad916 100644 --- a/apps/platform-frontend/src/features/admin/users-page.tsx +++ b/apps/platform-frontend/src/features/admin/users-page.tsx @@ -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 { + const errors: Record = {}; + 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 | undefined): Record { + if (!details) { + return {}; + } + const errors: Record = {}; + 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>({}); + const [formError, setFormError] = useState(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): 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 ( + +
+ + + + + + + + {formError && ( +

+ {formError} +

+ )} +
+ + +
+
+
+ ); +} + +/** Bestätigungsdialog: Benutzer löschen. */ +function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + const [formError, setFormError] = useState(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 ( + + {formError && ( +

+ {formError} +

+ )} +
+ + +
+
+ ); +} + +/** 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(null); + const [resetPasswordUser, setResetPasswordUser] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(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 (
-
-

Benutzerverwaltung

-

- Benutzer anlegen, bearbeiten und verwalten. -

+
+
+

Benutzerverwaltung

+

+ Benutzer anlegen, bearbeiten und verwalten. +

+
+
- - - - - +
+ + + + + + + + + + + + {usersQuery.isLoading && ( + + + + )} + {usersQuery.isError && ( + + + + )} + {usersQuery.data?.map((user) => ( + + + + + + + + ))} + +
BenutzerRolleStatusLetzter LoginAktionen
+ Benutzer werden geladen… +
+ Benutzer konnten nicht geladen werden. +
+
{user.displayName}
+
+ @{user.username} · {user.email} +
+
+ + {user.role === 'ADMIN' ? 'Administrator' : 'Benutzer'} + + + + {user.isActive ? 'Aktiv' : 'Deaktiviert'} + + {formatDate(user.lastLoginAt)} +
+ + + {user.id !== currentUser?.id && ( + <> + + + + )} +
+
+ {usersQuery.data?.length === 0 && ( +

+ Noch keine Benutzer vorhanden. +

+ )} +
+ + {createOpen && ( + setCreateOpen(false)} + onCreated={() => void queryClient.invalidateQueries({ queryKey: ['users'] })} + /> + )} + {editUser && setEditUser(null)} />} + {resetPasswordUser && ( + setResetPasswordUser(null)} /> + )} + {deleteTarget && ( + setDeleteTarget(null)} /> + )}
); -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/features/profile/profile-page.tsx b/apps/platform-frontend/src/features/profile/profile-page.tsx new file mode 100644 index 0000000..b5a8470 --- /dev/null +++ b/apps/platform-frontend/src/features/profile/profile-page.tsx @@ -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>({}); + const [formError, setFormError] = useState(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 = {}; + 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): 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 = {}; + 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 ( +
+
+

Mein Profil

+

+ Ihre persönlichen Daten und Passwort-Einstellungen. +

+
+ + + + + {profileQuery.isLoading &&

Wird geladen…

} + {profileQuery.isError && ( +

Profil konnte nicht geladen werden.

+ )} + {profileQuery.data && ( +
+
+
Anzeigename
+
+ {profileQuery.data.displayName} +
+
+
+
Benutzername
+
+ @{profileQuery.data.username} +
+
+
+
E-Mail
+
+ {profileQuery.data.email} +
+
+
+
Rolle
+
+ {profileQuery.data.role === 'ADMIN' ? 'Administrator' : 'Benutzer'} +
+
+
+
Letzter Login
+
+ {formatDate(profileQuery.data.lastLoginAt)} +
+
+
+
Mitglied seit
+
+ {formatDate(profileQuery.data.createdAt)} +
+
+
+ )} +
+
+ + + + +
+ + + + {formError && ( +

+ {formError} +

+ )} + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/lib/api-client.ts b/apps/platform-frontend/src/lib/api-client.ts index 83dcd18..2c6aac6 100644 --- a/apps/platform-frontend/src/lib/api-client.ts +++ b/apps/platform-frontend/src/lib/api-client.ts @@ -10,7 +10,7 @@ export class ApiError extends Error { constructor( public readonly status: number, message: string, - public readonly details?: Record, + public readonly details?: Record, ) { super(message); this.name = 'ApiError'; diff --git a/apps/platform-frontend/src/lib/schemas.ts b/apps/platform-frontend/src/lib/schemas.ts index 65bff05..3c2938e 100644 --- a/apps/platform-frontend/src/lib/schemas.ts +++ b/apps/platform-frontend/src/lib/schemas.ts @@ -34,4 +34,76 @@ export const healthSchema = z.object({ }), }), }); -export type Health = z.infer; \ No newline at end of file +export type Health = z.infer; + +/** 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; + +/** 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; + +/** 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; + +/** 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; + +/** 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; + +/** 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; \ No newline at end of file diff --git a/apps/platform-frontend/src/lib/users-api.ts b/apps/platform-frontend/src/lib/users-api.ts new file mode 100644 index 0000000..3b7d29f --- /dev/null +++ b/apps/platform-frontend/src/lib/users-api.ts @@ -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 { + const response = await apiRequest<{ users: unknown[] }>('/api/v1/users'); + return response.users.map((user) => userSchema.parse(user)); +} + +export async function createUser(input: CreateUserDto): Promise { + 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 { + 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 { + await apiRequest(`/api/v1/users/${id}/password`, { method: 'PATCH', body: input }); +} + +export async function deleteUser(id: string): Promise { + await apiRequest(`/api/v1/users/${id}`, { method: 'DELETE' }); +} + +export async function fetchProfile(): Promise { + const response = await apiRequest<{ profile: unknown }>('/api/v1/profile'); + return profileSchema.parse(response.profile); +} + +export async function changePassword(input: ChangePasswordDto): Promise { + // 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, + }, + }); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/main.tsx b/apps/platform-frontend/src/main.tsx index c0fc893..c1fc4be 100644 --- a/apps/platform-frontend/src/main.tsx +++ b/apps/platform-frontend/src/main.tsx @@ -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 { }> } /> + } /> } @@ -58,7 +61,9 @@ createRoot(rootElement).render( - + + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f086084..f674cb8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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+) diff --git a/docs/PHASES.md b/docs/PHASES.md index ee43612..0964910 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -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 \ No newline at end of file +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 \ No newline at end of file