feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)
This commit is contained in:
12
README.md
12
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.
|
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
|
## Architektur-Überblick
|
||||||
|
|
||||||
@@ -87,7 +87,15 @@ MPM/
|
|||||||
| Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger |
|
| Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger |
|
||||||
| Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) |
|
| Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) |
|
||||||
| Betrieb | Docker, Nginx, Supervisor, unprivilegierter Benutzer |
|
| 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
|
## Annahme
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export const AUDIT_ACTIONS = {
|
|||||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||||
LOGIN_LOCKED: 'LOGIN_FAILED_LOCKED',
|
LOGIN_LOCKED: 'LOGIN_FAILED_LOCKED',
|
||||||
LOGOUT: 'LOGOUT',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ export class SessionService {
|
|||||||
await this.database.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
|
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). */
|
/** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */
|
||||||
async deleteExpired(): Promise<void> {
|
async deleteExpired(): Promise<void> {
|
||||||
await this.database.query('DELETE FROM sessions WHERE expires_at <= now()');
|
await this.database.query('DELETE FROM sessions WHERE expires_at <= now()');
|
||||||
|
|||||||
@@ -17,11 +17,21 @@ export class ZodValidationPipe implements PipeTransform {
|
|||||||
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||||
const result = this.schema.safeParse(value);
|
const result = this.schema.safeParse(value);
|
||||||
if (!result.success) {
|
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({
|
throw new BadRequestException({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Validierung fehlgeschlagen',
|
message: 'Validierung fehlgeschlagen',
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
details: result.error.flatten().fieldErrors,
|
details,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
62
apps/platform-backend/src/users/profile.controller.ts
Normal file
62
apps/platform-backend/src/users/profile.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||||
|
import { changePasswordSchema, type ChangePasswordDto } from './user.types';
|
||||||
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
|
/** Profil-Daten in API-Antworten. */
|
||||||
|
interface ProfileResponse {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: RoleName;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfileResponse(user: UserRecord): ProfileResponse {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
role: user.role,
|
||||||
|
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil des angemeldeten Benutzers (jede Rolle).
|
||||||
|
* Passwort-Änderung erfordert das aktuelle Passwort.
|
||||||
|
*/
|
||||||
|
@ApiTags('Profile')
|
||||||
|
@Controller({ path: 'api/v1/profile' })
|
||||||
|
export class ProfileController {
|
||||||
|
constructor(private readonly profileService: ProfileService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getProfile(@CurrentUser() user: AuthUser): Promise<{ profile: ProfileResponse }> {
|
||||||
|
const profile = await this.profileService.getProfile(user.id);
|
||||||
|
return { profile: toProfileResponse(profile) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('password')
|
||||||
|
async changePassword(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Req() request: AuthenticatedRequest & Request,
|
||||||
|
@Body(new ZodValidationPipe(changePasswordSchema)) body: ChangePasswordDto,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
const sessionId = request.session?.id;
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new Error('Session-Kontext fehlt');
|
||||||
|
}
|
||||||
|
await this.profileService.changePassword(user.id, sessionId, body, request.ip ?? null);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/platform-backend/src/users/profile.service.spec.ts
Normal file
121
apps/platform-backend/src/users/profile.service.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import { PasswordHasher } from './password-hasher';
|
||||||
|
import { UserRepository } from './user.repository';
|
||||||
|
import type { UserRecord } from './user.types';
|
||||||
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
|
/** Erzeugt einen Benutzer-Datensatz für Tests. */
|
||||||
|
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'max',
|
||||||
|
email: 'max@example.com',
|
||||||
|
passwordHash: 'not-a-real-hash',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
role: 'USER',
|
||||||
|
isActive: true,
|
||||||
|
failedLoginAttempts: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
lastLoginAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock des UserRepository. */
|
||||||
|
class MockUserRepository {
|
||||||
|
public user: UserRecord | null = createUserRecord();
|
||||||
|
public updatedPasswords: Array<{ id: string; hash: string }> = [];
|
||||||
|
|
||||||
|
async findById(id: string): Promise<UserRecord | null> {
|
||||||
|
return this.user && this.user.id === id ? this.user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePassword(id: string, hash: string): Promise<void> {
|
||||||
|
this.updatedPasswords.push({ id, hash });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock des SessionService. */
|
||||||
|
class MockSessionService {
|
||||||
|
public deletedForUser: Array<{ userId: string; exceptSessionId?: string }> = [];
|
||||||
|
|
||||||
|
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
|
||||||
|
this.deletedForUser.push({ userId, exceptSessionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock des AuditService. */
|
||||||
|
class MockAuditService {
|
||||||
|
public records: Array<{ action: string }> = [];
|
||||||
|
|
||||||
|
async record(entry: { action: string }): Promise<void> {
|
||||||
|
this.records.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProfileService', () => {
|
||||||
|
let userRepository: MockUserRepository;
|
||||||
|
let sessionService: MockSessionService;
|
||||||
|
let auditService: MockAuditService;
|
||||||
|
let profileService: ProfileService;
|
||||||
|
let passwordHasher: PasswordHasher;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userRepository = new MockUserRepository();
|
||||||
|
sessionService = new MockSessionService();
|
||||||
|
auditService = new MockAuditService();
|
||||||
|
passwordHasher = new PasswordHasher();
|
||||||
|
profileService = new ProfileService(
|
||||||
|
userRepository as unknown as UserRepository,
|
||||||
|
passwordHasher,
|
||||||
|
sessionService as unknown as SessionService,
|
||||||
|
auditService as unknown as AuditService,
|
||||||
|
);
|
||||||
|
|
||||||
|
userRepository.user = createUserRecord({
|
||||||
|
passwordHash: await passwordHasher.hash('Altes-Passwort-1'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt das Profil des angemeldeten Benutzers zurück', async () => {
|
||||||
|
const profile = await profileService.getProfile('user-1');
|
||||||
|
expect(profile.username).toBe('max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft UnauthorizedException bei unbekanntem Benutzer', async () => {
|
||||||
|
await expect(profileService.getProfile('unbekannt')).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert das Passwort mit korrektem aktuellen Passwort', async () => {
|
||||||
|
await profileService.changePassword(
|
||||||
|
'user-1',
|
||||||
|
'session-1',
|
||||||
|
{ currentPassword: 'Altes-Passwort-1', newPassword: 'Neues-Passwort-123' },
|
||||||
|
'127.0.0.1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(userRepository.updatedPasswords).toHaveLength(1);
|
||||||
|
expect(sessionService.deletedForUser).toEqual([
|
||||||
|
{ userId: 'user-1', exceptSessionId: 'session-1' },
|
||||||
|
]);
|
||||||
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt falsches aktuelles Passwort ab', async () => {
|
||||||
|
await expect(
|
||||||
|
profileService.changePassword(
|
||||||
|
'user-1',
|
||||||
|
'session-1',
|
||||||
|
{ currentPassword: 'falsch', newPassword: 'Neues-Passwort-123' },
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
expect(userRepository.updatedPasswords).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
apps/platform-backend/src/users/profile.service.ts
Normal file
61
apps/platform-backend/src/users/profile.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import { PasswordHasher } from './password-hasher';
|
||||||
|
import { UserRepository } from './user.repository';
|
||||||
|
import type { ChangePasswordDto, UserRecord } from './user.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil-Verwaltung des angemeldeten Benutzers:
|
||||||
|
* Eigenes Passwort ändern (mit Verifikation des aktuellen Passworts).
|
||||||
|
* Nach der Änderung werden alle übrigen Sessions des Benutzers
|
||||||
|
* ungültig gemacht – die aktuelle Session bleibt aktiv.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ProfileService {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly passwordHasher: PasswordHasher,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getProfile(userId: string): Promise<UserRecord> {
|
||||||
|
const user = await this.userRepository.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Nicht authentifiziert');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(
|
||||||
|
userId: string,
|
||||||
|
currentSessionId: string,
|
||||||
|
input: ChangePasswordDto,
|
||||||
|
ipAddress: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await this.userRepository.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Nicht authentifiziert');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPasswordValid = await this.passwordHasher.verify(
|
||||||
|
user.passwordHash,
|
||||||
|
input.currentPassword,
|
||||||
|
);
|
||||||
|
if (!currentPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPasswordHash = await this.passwordHasher.hash(input.newPassword);
|
||||||
|
await this.userRepository.updatePassword(userId, newPasswordHash);
|
||||||
|
await this.sessionService.deleteAllForUser(userId, currentSessionId);
|
||||||
|
|
||||||
|
await this.auditService.record({
|
||||||
|
userId,
|
||||||
|
username: user.username,
|
||||||
|
action: AUDIT_ACTIONS.USER_PASSWORD_CHANGED,
|
||||||
|
ipAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { PasswordHasher } from './password-hasher';
|
import { PasswordHasher } from './password-hasher';
|
||||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
id: string;
|
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.is_active, u.failed_login_attempts, u.locked_until,
|
||||||
u.last_login_at, u.created_at, u.updated_at`;
|
u.last_login_at, u.created_at, u.updated_at`;
|
||||||
|
|
||||||
/** Wandelt einen Datenbank-Datensatz in die öffentliche Benutzer-Repräsentation um. */
|
const USER_FROM = `FROM users u JOIN roles r ON r.id = u.role_id`;
|
||||||
function toAuthUser(record: UserRecord): AuthUser {
|
|
||||||
return {
|
|
||||||
id: record.id,
|
|
||||||
username: record.username,
|
|
||||||
email: record.email,
|
|
||||||
displayName: record.displayName,
|
|
||||||
role: record.role,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Benutzer-Repository (Infrastructure): Alle Datenbankzugriffe für Benutzer.
|
* Benutzer-Repository (Infrastructure): Alle Datenbankzugriffe für Benutzer.
|
||||||
* Enthält keine Business-Logik – nur Datenzugriff.
|
* Enthält keine Business-Logik – nur parametrisierten Datenzugriff.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserRepository {
|
export class UserRepository {
|
||||||
@@ -44,11 +35,17 @@ export class UserRepository {
|
|||||||
private readonly passwordHasher: PasswordHasher,
|
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> {
|
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||||
const result = await this.database.query<UserRow>(
|
const result = await this.database.query<UserRow>(
|
||||||
`SELECT ${USER_COLUMNS}
|
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.username = $1`,
|
||||||
FROM users u JOIN roles r ON r.id = u.role_id
|
|
||||||
WHERE u.username = $1`,
|
|
||||||
[username],
|
[username],
|
||||||
);
|
);
|
||||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||||
@@ -56,14 +53,104 @@ export class UserRepository {
|
|||||||
|
|
||||||
async findById(id: string): Promise<UserRecord | null> {
|
async findById(id: string): Promise<UserRecord | null> {
|
||||||
const result = await this.database.query<UserRow>(
|
const result = await this.database.query<UserRow>(
|
||||||
`SELECT ${USER_COLUMNS}
|
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.id = $1`,
|
||||||
FROM users u JOIN roles r ON r.id = u.role_id
|
|
||||||
WHERE u.id = $1`,
|
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
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> {
|
async updateLoginSuccess(userId: string): Promise<void> {
|
||||||
await this.database.query(
|
await this.database.query(
|
||||||
`UPDATE users
|
`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 {
|
private mapRow(row: UserRow): UserRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -30,3 +30,47 @@ export const loginSchema = z.object({
|
|||||||
password: z.string().min(1).max(200),
|
password: z.string().min(1).max(200),
|
||||||
});
|
});
|
||||||
export type LoginDto = z.infer<typeof loginSchema>;
|
export type LoginDto = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
/** Erlaubte Zeichen für Benutzernamen (kein Whitespace, keine Sonderzeichen). */
|
||||||
|
const USERNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
/** Benutzer anlegen (Admin). */
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
|
||||||
|
.max(100)
|
||||||
|
.regex(USERNAME_PATTERN, 'Benutzername darf nur Buchstaben, Zahlen sowie . _ - enthalten'),
|
||||||
|
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255),
|
||||||
|
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200),
|
||||||
|
password: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||||
|
role: z.enum(ROLE_NAMES),
|
||||||
|
});
|
||||||
|
export type CreateUserDto = z.infer<typeof createUserSchema>;
|
||||||
|
|
||||||
|
/** Benutzer bearbeiten (Admin) – mindestens ein Feld muss gesetzt sein. */
|
||||||
|
export const updateUserSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255).optional(),
|
||||||
|
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200).optional(),
|
||||||
|
role: z.enum(ROLE_NAMES).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine((data) => Object.values(data).some((value) => value !== undefined), {
|
||||||
|
message: 'Mindestens ein Feld ist erforderlich',
|
||||||
|
});
|
||||||
|
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
|
||||||
|
|
||||||
|
/** Passwort durch einen Administrator zurücksetzen. */
|
||||||
|
export const resetPasswordSchema = z.object({
|
||||||
|
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||||
|
});
|
||||||
|
export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>;
|
||||||
|
|
||||||
|
/** Eigenes Passwort ändern (angemeldeter Benutzer). */
|
||||||
|
export const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich').max(200),
|
||||||
|
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||||
|
});
|
||||||
|
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||||
118
apps/platform-backend/src/users/users.controller.ts
Normal file
118
apps/platform-backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||||
|
import {
|
||||||
|
createUserSchema,
|
||||||
|
resetPasswordSchema,
|
||||||
|
updateUserSchema,
|
||||||
|
type CreateUserDto,
|
||||||
|
type ResetPasswordDto,
|
||||||
|
type UpdateUserDto,
|
||||||
|
} from './user.types';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
/** Benutzerdaten in API-Antworten (ohne interne Felder). */
|
||||||
|
interface UserResponse {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: RoleName;
|
||||||
|
isActive: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUserResponse(user: UserRecord): UserResponse {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung (nur Administratoren).
|
||||||
|
* Guards (Session, CSRF, Rollen) sind global registriert;
|
||||||
|
* @Roles('ADMIN') erzwingt die Administrator-Rolle auf Klassenebene.
|
||||||
|
*/
|
||||||
|
@ApiTags('Users')
|
||||||
|
@Roles('ADMIN')
|
||||||
|
@Controller({ path: 'api/v1/users' })
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(): Promise<{ users: UserResponse[] }> {
|
||||||
|
const users = await this.usersService.list();
|
||||||
|
return { users: users.map(toUserResponse) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserDto,
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Req() request: AuthenticatedRequest & Request,
|
||||||
|
): Promise<{ user: UserResponse }> {
|
||||||
|
const user = await this.usersService.create(body, actor, request.ip ?? null);
|
||||||
|
return { user: toUserResponse(user) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getById(@Param('id', ParseUUIDPipe) id: string): Promise<{ user: UserResponse }> {
|
||||||
|
const user = await this.usersService.findById(id);
|
||||||
|
return { user: toUserResponse(user) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/password')
|
||||||
|
async resetPassword(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(resetPasswordSchema)) body: ResetPasswordDto,
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Req() request: AuthenticatedRequest & Request,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
await this.usersService.resetPassword(id, body, actor, request.ip ?? null);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserDto,
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Req() request: AuthenticatedRequest & Request,
|
||||||
|
): Promise<{ user: UserResponse }> {
|
||||||
|
const user = await this.usersService.update(id, body, actor, request.ip ?? null);
|
||||||
|
return { user: toUserResponse(user) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Req() request: AuthenticatedRequest & Request,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
await this.usersService.remove(id, actor, request.ip ?? null);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '../config/config.module';
|
import { ConfigModule } from '../config/config.module';
|
||||||
import { DatabaseModule } from '../database/database.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 { PasswordHasher } from './password-hasher';
|
||||||
|
import { ProfileController } from './profile.controller';
|
||||||
|
import { ProfileService } from './profile.service';
|
||||||
import { SeedService } from './seed.service';
|
import { SeedService } from './seed.service';
|
||||||
import { UserRepository } from './user.repository';
|
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({
|
@Module({
|
||||||
imports: [ConfigModule, DatabaseModule],
|
imports: [ConfigModule, DatabaseModule, AuditModule],
|
||||||
providers: [UserRepository, PasswordHasher, SeedService],
|
controllers: [UsersController, ProfileController],
|
||||||
|
providers: [UserRepository, PasswordHasher, SeedService, UsersService, ProfileService, SessionService],
|
||||||
exports: [UserRepository, PasswordHasher],
|
exports: [UserRepository, PasswordHasher],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
253
apps/platform-backend/src/users/users.service.spec.ts
Normal file
253
apps/platform-backend/src/users/users.service.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import { PasswordHasher } from './password-hasher';
|
||||||
|
import { UserRepository } from './user.repository';
|
||||||
|
import type { CreateUserDto, UserRecord } from './user.types';
|
||||||
|
import { UsersService, type ActingUser } from './users.service';
|
||||||
|
|
||||||
|
/** Erzeugt einen Benutzer-Datensatz für Tests. */
|
||||||
|
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'max',
|
||||||
|
email: 'max@example.com',
|
||||||
|
passwordHash: 'not-a-real-hash',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
role: 'USER',
|
||||||
|
isActive: true,
|
||||||
|
failedLoginAttempts: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
lastLoginAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' };
|
||||||
|
|
||||||
|
/** Mock des UserRepository. */
|
||||||
|
class MockUserRepository {
|
||||||
|
public users: UserRecord[] = [createUserRecord()];
|
||||||
|
public deletedIds: string[] = [];
|
||||||
|
public updatedPasswords: Array<{ id: string; hash: string }> = [];
|
||||||
|
public lockoutResets: string[] = [];
|
||||||
|
|
||||||
|
async list(): Promise<UserRecord[]> {
|
||||||
|
return this.users;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<UserRecord | null> {
|
||||||
|
return this.users.find((user) => user.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||||
|
return this.users.find((user) => user.username === username) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<UserRecord | null> {
|
||||||
|
return this.users.find((user) => user.email === email) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateUserDto): Promise<UserRecord> {
|
||||||
|
const user = createUserRecord({
|
||||||
|
id: 'new-user',
|
||||||
|
username: input.username,
|
||||||
|
email: input.email,
|
||||||
|
displayName: input.displayName,
|
||||||
|
role: input.role,
|
||||||
|
});
|
||||||
|
this.users.push(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, changes: Partial<UserRecord>): Promise<UserRecord> {
|
||||||
|
const index = this.users.findIndex((user) => user.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('nicht gefunden');
|
||||||
|
}
|
||||||
|
this.users[index] = { ...this.users[index], ...changes };
|
||||||
|
return this.users[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePassword(id: string, hash: string): Promise<void> {
|
||||||
|
this.updatedPasswords.push({ id, hash });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetLoginLockout(id: string): Promise<void> {
|
||||||
|
this.lockoutResets.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
this.deletedIds.push(id);
|
||||||
|
this.users = this.users.filter((user) => user.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countActiveAdmins(): Promise<number> {
|
||||||
|
return this.users.filter((user) => user.role === 'ADMIN' && user.isActive).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock des SessionService. */
|
||||||
|
class MockSessionService {
|
||||||
|
public deletedSessionsForUser: string[] = [];
|
||||||
|
|
||||||
|
async deleteAllForUser(userId: string): Promise<void> {
|
||||||
|
this.deletedSessionsForUser.push(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock des AuditService. */
|
||||||
|
class MockAuditService {
|
||||||
|
public records: Array<{ action: string; userId: string | null }> = [];
|
||||||
|
|
||||||
|
async record(entry: { action: string; userId: string | null }): Promise<void> {
|
||||||
|
this.records.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UsersService', () => {
|
||||||
|
let userRepository: MockUserRepository;
|
||||||
|
let sessionService: MockSessionService;
|
||||||
|
let auditService: MockAuditService;
|
||||||
|
let usersService: UsersService;
|
||||||
|
let passwordHasher: PasswordHasher;
|
||||||
|
|
||||||
|
const createUserInput: CreateUserDto = {
|
||||||
|
username: 'neu',
|
||||||
|
email: 'neu@example.com',
|
||||||
|
displayName: 'Neuer Benutzer',
|
||||||
|
password: 'Sicheres-Passwort-1',
|
||||||
|
role: 'USER',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userRepository = new MockUserRepository();
|
||||||
|
sessionService = new MockSessionService();
|
||||||
|
auditService = new MockAuditService();
|
||||||
|
passwordHasher = new PasswordHasher();
|
||||||
|
usersService = new UsersService(
|
||||||
|
userRepository as unknown as UserRepository,
|
||||||
|
passwordHasher,
|
||||||
|
sessionService as unknown as SessionService,
|
||||||
|
auditService as unknown as AuditService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('gibt alle Benutzer zurück', async () => {
|
||||||
|
const users = await usersService.list();
|
||||||
|
expect(users).toHaveLength(1);
|
||||||
|
expect(users[0].username).toBe('max');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('wirft NotFoundException bei unbekannter ID', async () => {
|
||||||
|
await expect(usersService.findById('unbekannt')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('legt einen neuen Benutzer an und schreibt Audit', async () => {
|
||||||
|
const user = await usersService.create(createUserInput, ACTOR, '127.0.0.1');
|
||||||
|
expect(user.username).toBe('neu');
|
||||||
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_CREATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt doppelte Benutzernamen ab', async () => {
|
||||||
|
await expect(
|
||||||
|
usersService.create({ ...createUserInput, username: 'max' }, ACTOR, null),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt doppelte E-Mail-Adressen ab', async () => {
|
||||||
|
await expect(
|
||||||
|
usersService.create({ ...createUserInput, email: 'max@example.com' }, ACTOR, null),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('aktualisiert den Anzeigenamen und schreibt Audit', async () => {
|
||||||
|
const updated = await usersService.update(
|
||||||
|
'user-1',
|
||||||
|
{ displayName: 'Max M.' },
|
||||||
|
ACTOR,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(updated.displayName).toBe('Max M.');
|
||||||
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_UPDATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verhindert Selbst-Deaktivierung', async () => {
|
||||||
|
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||||
|
await expect(
|
||||||
|
usersService.update('admin-1', { isActive: false }, ACTOR, null),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet deaktivierte Benutzer von allen Sessions ab', async () => {
|
||||||
|
await usersService.update('user-1', { isActive: false }, ACTOR, null);
|
||||||
|
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
|
||||||
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DISABLED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt Login-Sperre bei Reaktivierung zurück', async () => {
|
||||||
|
userRepository.users[0] = createUserRecord({ isActive: false });
|
||||||
|
await usersService.update('user-1', { isActive: true }, ACTOR, null);
|
||||||
|
expect(userRepository.lockoutResets).toEqual(['user-1']);
|
||||||
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_ENABLED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verhindert die Deaktivierung des letzten aktiven Admins', async () => {
|
||||||
|
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||||
|
await expect(
|
||||||
|
usersService.update('admin-1', { isActive: false }, { id: 'anderer', username: 'x' }, null),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verhindert die Herabstufung des letzten aktiven Admins', async () => {
|
||||||
|
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||||
|
await expect(
|
||||||
|
usersService.update('admin-1', { role: 'USER' }, { id: 'anderer', username: 'x' }, null),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetPassword', () => {
|
||||||
|
it('setzt das Passwort, meldet Sessions ab und schreibt Audit', async () => {
|
||||||
|
await usersService.resetPassword(
|
||||||
|
'user-1',
|
||||||
|
{ newPassword: 'Neues-Passwort-123' },
|
||||||
|
ACTOR,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(userRepository.updatedPasswords).toHaveLength(1);
|
||||||
|
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
|
||||||
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('löscht einen Benutzer und schreibt Audit', async () => {
|
||||||
|
await usersService.remove('user-1', ACTOR, null);
|
||||||
|
expect(userRepository.deletedIds).toEqual(['user-1']);
|
||||||
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DELETED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verhindert Selbst-Löschung', async () => {
|
||||||
|
await expect(usersService.remove('admin-1', ACTOR, null)).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verhindert die Löschung des letzten aktiven Admins', async () => {
|
||||||
|
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||||
|
await expect(
|
||||||
|
usersService.remove('admin-1', { id: 'anderer', username: 'x' }, null),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
174
apps/platform-backend/src/users/users.service.ts
Normal file
174
apps/platform-backend/src/users/users.service.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AUDIT_ACTIONS, AuditService, type AuditAction } from '../audit/audit.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import { PasswordHasher } from './password-hasher';
|
||||||
|
import { UserRepository } from './user.repository';
|
||||||
|
import type { CreateUserDto, ResetPasswordDto, UpdateUserDto, UserRecord } from './user.types';
|
||||||
|
|
||||||
|
/** Der handelnde Benutzer (für Audit-Einträge). */
|
||||||
|
export interface ActingUser {
|
||||||
|
readonly id: string;
|
||||||
|
readonly username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung (Application-Layer): Business-Regeln für Anlegen,
|
||||||
|
* Bearbeiten, Aktivieren/Deaktivieren und Löschen von Benutzern.
|
||||||
|
*
|
||||||
|
* Schutzregeln:
|
||||||
|
* - Keine Selbst-Deaktivierung und keine Selbst-Löschung
|
||||||
|
* - Der letzte aktive Administrator kann nicht herabgestuft,
|
||||||
|
* deaktiviert oder gelöscht werden
|
||||||
|
* - Deaktivierte Benutzer werden sofort von allen Sessions abgemeldet
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly passwordHasher: PasswordHasher,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<UserRecord[]> {
|
||||||
|
return this.userRepository.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<UserRecord> {
|
||||||
|
const user = await this.userRepository.findById(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Benutzer nicht gefunden');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
input: CreateUserDto,
|
||||||
|
actor: ActingUser,
|
||||||
|
ipAddress: string | null,
|
||||||
|
): Promise<UserRecord> {
|
||||||
|
const [existingUsername, existingEmail] = await Promise.all([
|
||||||
|
this.userRepository.findByUsername(input.username),
|
||||||
|
this.userRepository.findByEmail(input.email),
|
||||||
|
]);
|
||||||
|
if (existingUsername) {
|
||||||
|
throw new ConflictException('Benutzername ist bereits vergeben');
|
||||||
|
}
|
||||||
|
if (existingEmail) {
|
||||||
|
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.create(input);
|
||||||
|
await this.auditService.record({
|
||||||
|
userId: actor.id,
|
||||||
|
username: actor.username,
|
||||||
|
action: AUDIT_ACTIONS.USER_CREATED,
|
||||||
|
details: { targetUserId: user.id, targetUsername: user.username, role: user.role },
|
||||||
|
ipAddress,
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: UpdateUserDto,
|
||||||
|
actor: ActingUser,
|
||||||
|
ipAddress: string | null,
|
||||||
|
): Promise<UserRecord> {
|
||||||
|
const user = await this.findById(id);
|
||||||
|
|
||||||
|
if (input.email !== undefined && input.email !== user.email) {
|
||||||
|
const existingEmail = await this.userRepository.findByEmail(input.email);
|
||||||
|
if (existingEmail && existingEmail.id !== id) {
|
||||||
|
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const demotesFromAdmin = user.role === 'ADMIN' && input.role === 'USER';
|
||||||
|
const deactivates = input.isActive === false && user.isActive;
|
||||||
|
const activates = input.isActive === true && !user.isActive;
|
||||||
|
|
||||||
|
if (deactivates && id === actor.id) {
|
||||||
|
throw new BadRequestException('Sie können Ihr eigenes Konto nicht deaktivieren');
|
||||||
|
}
|
||||||
|
if ((demotesFromAdmin || deactivates) && (await this.isLastActiveAdmin(user))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.userRepository.update(id, input);
|
||||||
|
|
||||||
|
if (deactivates) {
|
||||||
|
await this.sessionService.deleteAllForUser(id);
|
||||||
|
}
|
||||||
|
if (activates) {
|
||||||
|
await this.userRepository.resetLoginLockout(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const action: AuditAction = deactivates
|
||||||
|
? AUDIT_ACTIONS.USER_DISABLED
|
||||||
|
: activates
|
||||||
|
? AUDIT_ACTIONS.USER_ENABLED
|
||||||
|
: AUDIT_ACTIONS.USER_UPDATED;
|
||||||
|
await this.auditService.record({
|
||||||
|
userId: actor.id,
|
||||||
|
username: actor.username,
|
||||||
|
action,
|
||||||
|
details: { targetUserId: id, targetUsername: user.username },
|
||||||
|
ipAddress,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(
|
||||||
|
id: string,
|
||||||
|
input: ResetPasswordDto,
|
||||||
|
actor: ActingUser,
|
||||||
|
ipAddress: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await this.findById(id);
|
||||||
|
const passwordHash = await this.passwordHasher.hash(input.newPassword);
|
||||||
|
await this.userRepository.updatePassword(id, passwordHash);
|
||||||
|
await this.sessionService.deleteAllForUser(id);
|
||||||
|
await this.auditService.record({
|
||||||
|
userId: actor.id,
|
||||||
|
username: actor.username,
|
||||||
|
action: AUDIT_ACTIONS.USER_PASSWORD_RESET,
|
||||||
|
details: { targetUserId: id, targetUsername: user.username },
|
||||||
|
ipAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> {
|
||||||
|
if (id === actor.id) {
|
||||||
|
throw new BadRequestException('Sie können Ihr eigenes Konto nicht löschen');
|
||||||
|
}
|
||||||
|
const user = await this.findById(id);
|
||||||
|
if (await this.isLastActiveAdmin(user)) {
|
||||||
|
throw new BadRequestException('Der letzte aktive Administrator kann nicht gelöscht werden');
|
||||||
|
}
|
||||||
|
await this.userRepository.delete(id);
|
||||||
|
await this.auditService.record({
|
||||||
|
userId: actor.id,
|
||||||
|
username: actor.username,
|
||||||
|
action: AUDIT_ACTIONS.USER_DELETED,
|
||||||
|
details: { targetUserId: id, targetUsername: user.username },
|
||||||
|
ipAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, ob der gegebene Benutzer der einzige aktive Administrator ist. */
|
||||||
|
private async isLastActiveAdmin(user: UserRecord): Promise<boolean> {
|
||||||
|
if (user.role !== 'ADMIN' || !user.isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const activeAdminCount = await this.userRepository.countActiveAdmins();
|
||||||
|
return activeAdminCount <= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface NavItem {
|
|||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
||||||
|
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
|
||||||
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
||||||
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|||||||
60
apps/platform-frontend/src/components/ui/modal.tsx
Normal file
60
apps/platform-frontend/src/components/ui/modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/platform-frontend/src/components/ui/select.tsx
Normal file
50
apps/platform-frontend/src/components/ui/select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/platform-frontend/src/components/ui/toast.tsx
Normal file
84
apps/platform-frontend/src/components/ui/toast.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,28 +1,516 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type FormEvent, type ReactNode, useState } from 'react';
|
||||||
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { EmptyState } from '../../components/ui/states';
|
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 {
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
<div>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
<div>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
||||||
Benutzer anlegen, bearbeiten und verwalten.
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
</p>
|
Benutzer anlegen, bearbeiten und verwalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>+ Benutzer anlegen</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader title="Benutzer" description="Alle Benutzer der Plattform" />
|
<table className="w-full min-w-[720px] text-sm">
|
||||||
<CardBody>
|
<thead>
|
||||||
<EmptyState
|
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
title="Benutzerverwaltung folgt in Phase 2"
|
<th className="px-4 py-3 font-semibold">Benutzer</th>
|
||||||
description="Die vollständige Benutzerverwaltung (Liste, Anlegen, Bearbeiten, Deaktivieren) wird in Phase 2 implementiert."
|
<th className="px-4 py-3 font-semibold">Rolle</th>
|
||||||
icon={<span className="text-3xl" aria-hidden="true">👥</span>}
|
<th className="px-4 py-3 font-semibold">Status</th>
|
||||||
/>
|
<th className="px-4 py-3 font-semibold">Letzter Login</th>
|
||||||
</CardBody>
|
<th className="px-4 py-3 font-semibold">Aktionen</th>
|
||||||
</Card>
|
</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'] })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editUser && <EditUserModal user={editUser} onClose={() => setEditUser(null)} />}
|
||||||
|
{resetPasswordUser && (
|
||||||
|
<ResetPasswordModal user={resetPasswordUser} onClose={() => setResetPasswordUser(null)} />
|
||||||
|
)}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
184
apps/platform-frontend/src/features/profile/profile-page.tsx
Normal file
184
apps/platform-frontend/src/features/profile/profile-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export class ApiError extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
message: string,
|
message: string,
|
||||||
public readonly details?: Record<string, string[]>,
|
public readonly details?: Record<string, string | string[]>,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
|
|||||||
@@ -35,3 +35,75 @@ export const healthSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
export type Health = z.infer<typeof healthSchema>;
|
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>;
|
||||||
58
apps/platform-frontend/src/lib/users-api.ts
Normal file
58
apps/platform-frontend/src/lib/users-api.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AppLayout } from './components/layout/app-layout';
|
import { AppLayout } from './components/layout/app-layout';
|
||||||
import { RequireAdmin, RequireAuth } from './components/route-guards';
|
import { RequireAdmin, RequireAuth } from './components/route-guards';
|
||||||
|
import { ToastProvider } from './components/ui/toast';
|
||||||
import { AuthProvider } from './features/auth/auth-context';
|
import { AuthProvider } from './features/auth/auth-context';
|
||||||
import { LoginPage } from './features/auth/login-page';
|
import { LoginPage } from './features/auth/login-page';
|
||||||
import { DashboardPage } from './features/dashboard/dashboard-page';
|
import { DashboardPage } from './features/dashboard/dashboard-page';
|
||||||
import { SystemStatusPage } from './features/admin/system-status-page';
|
import { SystemStatusPage } from './features/admin/system-status-page';
|
||||||
import { UsersPage } from './features/admin/users-page';
|
import { UsersPage } from './features/admin/users-page';
|
||||||
|
import { ProfilePage } from './features/profile/profile-page';
|
||||||
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
|
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ function AppRoutes(): ReactNode {
|
|||||||
|
|
||||||
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={<RequireAdmin><UsersPage /></RequireAdmin>}
|
element={<RequireAdmin><UsersPage /></RequireAdmin>}
|
||||||
@@ -58,7 +61,9 @@ createRoot(rootElement).render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<ToastProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -134,8 +134,24 @@ Security Hardening (Penetrationstests, Dependency/Container-Scans, HSTS, Backup/
|
|||||||
| POST | `/api/v1/auth/logout` | Session | Beendet die aktuelle Session |
|
| POST | `/api/v1/auth/logout` | Session | Beendet die aktuelle Session |
|
||||||
| GET | `/api/v1/auth/me` | Session | Angemeldeter Benutzer (Id, Rolle, …) |
|
| GET | `/api/v1/auth/me` | Session | Angemeldeter Benutzer (Id, Rolle, …) |
|
||||||
| GET | `/api/v1/health` | Public | Systemstatus (Backend, DB-Latenz, Version, Uptime) |
|
| 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+)
|
## 9. Modul-Vertrag (Ausblick Phase 3+)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S
|
|||||||
| Phase | Bereich | Status |
|
| Phase | Bereich | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen |
|
| 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen |
|
||||||
| 2 | Benutzerverwaltung | ⏳ Geplant |
|
| 2 | Benutzerverwaltung | ✅ Abgeschlossen |
|
||||||
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ⏳ Geplant |
|
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ⏳ Geplant |
|
||||||
| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant |
|
| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant |
|
||||||
| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
|
| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
|
||||||
@@ -33,9 +33,28 @@ Definition of Done:
|
|||||||
- [x] Admin-Dashboard sichtbar (inkl. Systemstatus)
|
- [x] Admin-Dashboard sichtbar (inkl. Systemstatus)
|
||||||
- [x] Backend-Unit-Tests (Auth, Session, Passwort, Validierung)
|
- [x] Backend-Unit-Tests (Auth, Session, Passwort, Validierung)
|
||||||
|
|
||||||
## Nächste Schritte (Phase 2 – Benutzerverwaltung)
|
## Phase 2 – Benutzerverwaltung (abgeschlossen)
|
||||||
|
|
||||||
- Benutzerliste, Benutzer erstellen/bearbeiten/deaktivieren
|
Definition of Done: Admin kann Benutzer vollständig verwalten.
|
||||||
- Passwortänderung und Reset durch Admin
|
|
||||||
- `GET/POST/PATCH/DELETE /api/v1/users/*` mit `@Roles('ADMIN')`
|
- [x] `GET/POST/PATCH/DELETE /api/v1/users/*` (nur `@Roles('ADMIN')`)
|
||||||
- Frontend-Seite `/admin/users` mit Tabelle, Formular und Bestätigungsdialogen
|
- [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
|
||||||
Reference in New Issue
Block a user