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.
|
||||
|
||||
**Status: Phase 1 – Grundgerüst (abgeschlossen)**
|
||||
**Status: Phase 2 – Benutzerverwaltung (abgeschlossen)**
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
@@ -87,7 +87,15 @@ MPM/
|
||||
| Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger |
|
||||
| Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) |
|
||||
| Betrieb | Docker, Nginx, Supervisor, unprivilegierter Benutzer |
|
||||
| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, Rate Limiting, Account Lockout, Audit-Log, Helmet |
|
||||
| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, Rate Limiting, Account Lockout, Audit-Log, Helmet, RBAC |
|
||||
|
||||
## Funktionen
|
||||
|
||||
### Phase 1 – Grundgerüst
|
||||
Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), Health-Monitoring, Audit-Log, Migrationen mit Advisory-Lock, responsive Management-UI mit Design-System.
|
||||
|
||||
### Phase 2 – Benutzerverwaltung
|
||||
Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letzten Admins, sofortiger Session-Sperrung bei Deaktivierung, Passwort-Reset, eigenes Passwort ändern, Benutzerverwaltungs-UI (Tabelle, Modals, Toasts) und Profil-Seite.
|
||||
|
||||
## Annahme
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ export const AUDIT_ACTIONS = {
|
||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||
LOGIN_LOCKED: 'LOGIN_FAILED_LOCKED',
|
||||
LOGOUT: 'LOGOUT',
|
||||
USER_CREATED: 'USER_CREATED',
|
||||
USER_UPDATED: 'USER_UPDATED',
|
||||
USER_ENABLED: 'USER_ENABLED',
|
||||
USER_DISABLED: 'USER_DISABLED',
|
||||
USER_DELETED: 'USER_DELETED',
|
||||
USER_PASSWORD_RESET: 'USER_PASSWORD_RESET',
|
||||
USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED',
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||
|
||||
@@ -73,6 +73,18 @@ export class SessionService {
|
||||
await this.database.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
|
||||
}
|
||||
|
||||
/** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */
|
||||
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
|
||||
if (exceptSessionId) {
|
||||
await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [
|
||||
userId,
|
||||
exceptSessionId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await this.database.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
|
||||
}
|
||||
|
||||
/** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */
|
||||
async deleteExpired(): Promise<void> {
|
||||
await this.database.query('DELETE FROM sessions WHERE expires_at <= now()');
|
||||
|
||||
@@ -17,11 +17,21 @@ export class ZodValidationPipe implements PipeTransform {
|
||||
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
const flattened = result.error.flatten();
|
||||
const details: Record<string, string[]> = {};
|
||||
for (const [key, messages] of Object.entries(flattened.fieldErrors)) {
|
||||
if (messages) {
|
||||
details[key] = messages;
|
||||
}
|
||||
}
|
||||
if (flattened.formErrors.length > 0) {
|
||||
details.form = flattened.formErrors;
|
||||
}
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
message: 'Validierung fehlgeschlagen',
|
||||
error: 'Bad Request',
|
||||
details: result.error.flatten().fieldErrors,
|
||||
details,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
|
||||
62
apps/platform-backend/src/users/profile.controller.ts
Normal file
62
apps/platform-backend/src/users/profile.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||
import { changePasswordSchema, type ChangePasswordDto } from './user.types';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
/** Profil-Daten in API-Antworten. */
|
||||
interface ProfileResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: RoleName;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function toProfileResponse(user: UserRecord): ProfileResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Profil des angemeldeten Benutzers (jede Rolle).
|
||||
* Passwort-Änderung erfordert das aktuelle Passwort.
|
||||
*/
|
||||
@ApiTags('Profile')
|
||||
@Controller({ path: 'api/v1/profile' })
|
||||
export class ProfileController {
|
||||
constructor(private readonly profileService: ProfileService) {}
|
||||
|
||||
@Get()
|
||||
async getProfile(@CurrentUser() user: AuthUser): Promise<{ profile: ProfileResponse }> {
|
||||
const profile = await this.profileService.getProfile(user.id);
|
||||
return { profile: toProfileResponse(profile) };
|
||||
}
|
||||
|
||||
@Patch('password')
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
@Body(new ZodValidationPipe(changePasswordSchema)) body: ChangePasswordDto,
|
||||
): Promise<{ success: true }> {
|
||||
const sessionId = request.session?.id;
|
||||
if (!sessionId) {
|
||||
throw new Error('Session-Kontext fehlt');
|
||||
}
|
||||
await this.profileService.changePassword(user.id, sessionId, body, request.ip ?? null);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
121
apps/platform-backend/src/users/profile.service.spec.ts
Normal file
121
apps/platform-backend/src/users/profile.service.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { UserRecord } from './user.types';
|
||||
import { ProfileService } from './profile.service';
|
||||
|
||||
/** Erzeugt einen Benutzer-Datensatz für Tests. */
|
||||
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||
return {
|
||||
id: 'user-1',
|
||||
username: 'max',
|
||||
email: 'max@example.com',
|
||||
passwordHash: 'not-a-real-hash',
|
||||
displayName: 'Max Mustermann',
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock des UserRepository. */
|
||||
class MockUserRepository {
|
||||
public user: UserRecord | null = createUserRecord();
|
||||
public updatedPasswords: Array<{ id: string; hash: string }> = [];
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
return this.user && this.user.id === id ? this.user : null;
|
||||
}
|
||||
|
||||
async updatePassword(id: string, hash: string): Promise<void> {
|
||||
this.updatedPasswords.push({ id, hash });
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des SessionService. */
|
||||
class MockSessionService {
|
||||
public deletedForUser: Array<{ userId: string; exceptSessionId?: string }> = [];
|
||||
|
||||
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
|
||||
this.deletedForUser.push({ userId, exceptSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des AuditService. */
|
||||
class MockAuditService {
|
||||
public records: Array<{ action: string }> = [];
|
||||
|
||||
async record(entry: { action: string }): Promise<void> {
|
||||
this.records.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProfileService', () => {
|
||||
let userRepository: MockUserRepository;
|
||||
let sessionService: MockSessionService;
|
||||
let auditService: MockAuditService;
|
||||
let profileService: ProfileService;
|
||||
let passwordHasher: PasswordHasher;
|
||||
|
||||
beforeEach(async () => {
|
||||
userRepository = new MockUserRepository();
|
||||
sessionService = new MockSessionService();
|
||||
auditService = new MockAuditService();
|
||||
passwordHasher = new PasswordHasher();
|
||||
profileService = new ProfileService(
|
||||
userRepository as unknown as UserRepository,
|
||||
passwordHasher,
|
||||
sessionService as unknown as SessionService,
|
||||
auditService as unknown as AuditService,
|
||||
);
|
||||
|
||||
userRepository.user = createUserRecord({
|
||||
passwordHash: await passwordHasher.hash('Altes-Passwort-1'),
|
||||
});
|
||||
});
|
||||
|
||||
it('gibt das Profil des angemeldeten Benutzers zurück', async () => {
|
||||
const profile = await profileService.getProfile('user-1');
|
||||
expect(profile.username).toBe('max');
|
||||
});
|
||||
|
||||
it('wirft UnauthorizedException bei unbekanntem Benutzer', async () => {
|
||||
await expect(profileService.getProfile('unbekannt')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('ändert das Passwort mit korrektem aktuellen Passwort', async () => {
|
||||
await profileService.changePassword(
|
||||
'user-1',
|
||||
'session-1',
|
||||
{ currentPassword: 'Altes-Passwort-1', newPassword: 'Neues-Passwort-123' },
|
||||
'127.0.0.1',
|
||||
);
|
||||
|
||||
expect(userRepository.updatedPasswords).toHaveLength(1);
|
||||
expect(sessionService.deletedForUser).toEqual([
|
||||
{ userId: 'user-1', exceptSessionId: 'session-1' },
|
||||
]);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED);
|
||||
});
|
||||
|
||||
it('lehnt falsches aktuelles Passwort ab', async () => {
|
||||
await expect(
|
||||
profileService.changePassword(
|
||||
'user-1',
|
||||
'session-1',
|
||||
{ currentPassword: 'falsch', newPassword: 'Neues-Passwort-123' },
|
||||
null,
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
expect(userRepository.updatedPasswords).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
61
apps/platform-backend/src/users/profile.service.ts
Normal file
61
apps/platform-backend/src/users/profile.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { ChangePasswordDto, UserRecord } from './user.types';
|
||||
|
||||
/**
|
||||
* Profil-Verwaltung des angemeldeten Benutzers:
|
||||
* Eigenes Passwort ändern (mit Verifikation des aktuellen Passworts).
|
||||
* Nach der Änderung werden alle übrigen Sessions des Benutzers
|
||||
* ungültig gemacht – die aktuelle Session bleibt aktiv.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProfileService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async getProfile(userId: string): Promise<UserRecord> {
|
||||
const user = await this.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Nicht authentifiziert');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentSessionId: string,
|
||||
input: ChangePasswordDto,
|
||||
ipAddress: string | null,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Nicht authentifiziert');
|
||||
}
|
||||
|
||||
const currentPasswordValid = await this.passwordHasher.verify(
|
||||
user.passwordHash,
|
||||
input.currentPassword,
|
||||
);
|
||||
if (!currentPasswordValid) {
|
||||
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
||||
}
|
||||
|
||||
const newPasswordHash = await this.passwordHasher.hash(input.newPassword);
|
||||
await this.userRepository.updatePassword(userId, newPasswordHash);
|
||||
await this.sessionService.deleteAllForUser(userId, currentSessionId);
|
||||
|
||||
await this.auditService.record({
|
||||
userId,
|
||||
username: user.username,
|
||||
action: AUDIT_ACTIONS.USER_PASSWORD_CHANGED,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
@@ -22,20 +22,11 @@ const USER_COLUMNS = `u.id, u.username, u.email, u.password_hash, u.display_name
|
||||
u.is_active, u.failed_login_attempts, u.locked_until,
|
||||
u.last_login_at, u.created_at, u.updated_at`;
|
||||
|
||||
/** Wandelt einen Datenbank-Datensatz in die öffentliche Benutzer-Repräsentation um. */
|
||||
function toAuthUser(record: UserRecord): AuthUser {
|
||||
return {
|
||||
id: record.id,
|
||||
username: record.username,
|
||||
email: record.email,
|
||||
displayName: record.displayName,
|
||||
role: record.role,
|
||||
};
|
||||
}
|
||||
const USER_FROM = `FROM users u JOIN roles r ON r.id = u.role_id`;
|
||||
|
||||
/**
|
||||
* Benutzer-Repository (Infrastructure): Alle Datenbankzugriffe für Benutzer.
|
||||
* Enthält keine Business-Logik – nur Datenzugriff.
|
||||
* Enthält keine Business-Logik – nur parametrisierten Datenzugriff.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserRepository {
|
||||
@@ -44,11 +35,17 @@ export class UserRepository {
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
) {}
|
||||
|
||||
/** Alle Benutzer, neueste zuerst. */
|
||||
async list(): Promise<UserRecord[]> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} ORDER BY u.created_at DESC`,
|
||||
);
|
||||
return result.rows.map((row) => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS}
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.username = $1`,
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.username = $1`,
|
||||
[username],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
@@ -56,14 +53,104 @@ export class UserRepository {
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS}
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.id = $1`,
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.id = $1`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<UserRecord | null> {
|
||||
const result = await this.database.query<UserRow>(
|
||||
`SELECT ${USER_COLUMNS} ${USER_FROM} WHERE u.email = $1`,
|
||||
[email],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Legt einen Benutzer an (Passwort wird gehasht). */
|
||||
async create(input: CreateUserDto): Promise<UserRecord> {
|
||||
const passwordHash = await this.passwordHasher.hash(input.password);
|
||||
const result = await this.database.query<UserRow>(
|
||||
`INSERT INTO users (username, email, password_hash, display_name, role_id)
|
||||
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = $5))
|
||||
RETURNING id, username, email, password_hash, display_name,
|
||||
(SELECT name FROM roles WHERE id = role_id) AS role_name,
|
||||
true AS is_active, 0 AS failed_login_attempts,
|
||||
NULL::timestamptz AS locked_until, NULL::timestamptz AS last_login_at,
|
||||
now() AS created_at, now() AS updated_at`,
|
||||
[input.username, input.email, passwordHash, input.displayName, input.role],
|
||||
);
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/** Aktualisiert nur die übergebenen Felder (dynamisch, parametrisiert). */
|
||||
async update(id: string, changes: UpdateUserDto): Promise<UserRecord> {
|
||||
const setClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let parameterIndex = 1;
|
||||
|
||||
if (changes.email !== undefined) {
|
||||
setClauses.push(`email = $${parameterIndex++}`);
|
||||
params.push(changes.email);
|
||||
}
|
||||
if (changes.displayName !== undefined) {
|
||||
setClauses.push(`display_name = $${parameterIndex++}`);
|
||||
params.push(changes.displayName);
|
||||
}
|
||||
if (changes.role !== undefined) {
|
||||
setClauses.push(`role_id = (SELECT id FROM roles WHERE name = $${parameterIndex++})`);
|
||||
params.push(changes.role);
|
||||
}
|
||||
if (changes.isActive !== undefined) {
|
||||
setClauses.push(`is_active = $${parameterIndex++}`);
|
||||
params.push(changes.isActive);
|
||||
}
|
||||
setClauses.push('updated_at = now()');
|
||||
params.push(id);
|
||||
|
||||
const result = await this.database.query<UserRow>(
|
||||
`UPDATE users
|
||||
SET ${setClauses.join(', ')}
|
||||
WHERE id = $${parameterIndex}
|
||||
RETURNING id, username, email, password_hash, display_name,
|
||||
(SELECT name FROM roles WHERE id = role_id) AS role_name,
|
||||
is_active, failed_login_attempts, locked_until,
|
||||
last_login_at, created_at, updated_at`,
|
||||
params,
|
||||
);
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/** Setzt einen neuen Passwort-Hash. */
|
||||
async updatePassword(id: string, passwordHash: string): Promise<void> {
|
||||
await this.database.query(
|
||||
'UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1',
|
||||
[id, passwordHash],
|
||||
);
|
||||
}
|
||||
|
||||
/** Setzt Fehlversuchs-Zähler und Sperre zurück (bei Aktivierung). */
|
||||
async resetLoginLockout(id: string): Promise<void> {
|
||||
await this.database.query(
|
||||
'UPDATE users SET failed_login_attempts = 0, locked_until = NULL, updated_at = now() WHERE id = $1',
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.database.query('DELETE FROM users WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
/** Anzahl aktiver Administratoren (Schutz vor Verlust des letzten Admins). */
|
||||
async countActiveAdmins(): Promise<number> {
|
||||
const result = await this.database.query<{ count: number }>(
|
||||
`SELECT count(*)::int AS count
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE r.name = 'ADMIN' AND u.is_active`,
|
||||
);
|
||||
return result.rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async updateLoginSuccess(userId: string): Promise<void> {
|
||||
await this.database.query(
|
||||
`UPDATE users
|
||||
@@ -94,26 +181,6 @@ export class UserRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: RoleName;
|
||||
}): Promise<AuthUser> {
|
||||
const passwordHash = await this.passwordHasher.hash(input.password);
|
||||
const result = await this.database.query<UserRow>(
|
||||
`INSERT INTO users (username, email, password_hash, display_name, role_id)
|
||||
VALUES ($1, $2, $3, $4, (SELECT id FROM roles WHERE name = $5))
|
||||
RETURNING id, username, email, display_name,
|
||||
(SELECT name FROM roles WHERE id = role_id) AS role_name,
|
||||
true AS is_active, 0 AS failed_login_attempts, NULL::timestamptz AS locked_until,
|
||||
NULL::timestamptz AS last_login_at, now() AS created_at, now() AS updated_at`,
|
||||
[input.username, input.email, passwordHash, input.displayName, input.role],
|
||||
);
|
||||
return toAuthUser(this.mapRow(result.rows[0]));
|
||||
}
|
||||
|
||||
private mapRow(row: UserRow): UserRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -30,3 +30,47 @@ export const loginSchema = z.object({
|
||||
password: z.string().min(1).max(200),
|
||||
});
|
||||
export type LoginDto = z.infer<typeof loginSchema>;
|
||||
|
||||
/** Erlaubte Zeichen für Benutzernamen (kein Whitespace, keine Sonderzeichen). */
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/** Benutzer anlegen (Admin). */
|
||||
export const createUserSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
|
||||
.max(100)
|
||||
.regex(USERNAME_PATTERN, 'Benutzername darf nur Buchstaben, Zahlen sowie . _ - enthalten'),
|
||||
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255),
|
||||
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200),
|
||||
password: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||
role: z.enum(ROLE_NAMES),
|
||||
});
|
||||
export type CreateUserDto = z.infer<typeof createUserSchema>;
|
||||
|
||||
/** Benutzer bearbeiten (Admin) – mindestens ein Feld muss gesetzt sein. */
|
||||
export const updateUserSchema = z
|
||||
.object({
|
||||
email: z.string().trim().email('Ungültige E-Mail-Adresse').max(255).optional(),
|
||||
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').max(200).optional(),
|
||||
role: z.enum(ROLE_NAMES).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => Object.values(data).some((value) => value !== undefined), {
|
||||
message: 'Mindestens ein Feld ist erforderlich',
|
||||
});
|
||||
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
|
||||
|
||||
/** Passwort durch einen Administrator zurücksetzen. */
|
||||
export const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||
});
|
||||
export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>;
|
||||
|
||||
/** Eigenes Passwort ändern (angemeldeter Benutzer). */
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich').max(200),
|
||||
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein').max(200),
|
||||
});
|
||||
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||
118
apps/platform-backend/src/users/users.controller.ts
Normal file
118
apps/platform-backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import type { AuthUser, RoleName, UserRecord } from './user.types';
|
||||
import {
|
||||
createUserSchema,
|
||||
resetPasswordSchema,
|
||||
updateUserSchema,
|
||||
type CreateUserDto,
|
||||
type ResetPasswordDto,
|
||||
type UpdateUserDto,
|
||||
} from './user.types';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
/** Benutzerdaten in API-Antworten (ohne interne Felder). */
|
||||
interface UserResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: RoleName;
|
||||
isActive: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function toUserResponse(user: UserRecord): UserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (nur Administratoren).
|
||||
* Guards (Session, CSRF, Rollen) sind global registriert;
|
||||
* @Roles('ADMIN') erzwingt die Administrator-Rolle auf Klassenebene.
|
||||
*/
|
||||
@ApiTags('Users')
|
||||
@Roles('ADMIN')
|
||||
@Controller({ path: 'api/v1/users' })
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
async list(): Promise<{ users: UserResponse[] }> {
|
||||
const users = await this.usersService.list();
|
||||
return { users: users.map(toUserResponse) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserDto,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ user: UserResponse }> {
|
||||
const user = await this.usersService.create(body, actor, request.ip ?? null);
|
||||
return { user: toUserResponse(user) };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getById(@Param('id', ParseUUIDPipe) id: string): Promise<{ user: UserResponse }> {
|
||||
const user = await this.usersService.findById(id);
|
||||
return { user: toUserResponse(user) };
|
||||
}
|
||||
|
||||
@Patch(':id/password')
|
||||
async resetPassword(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(resetPasswordSchema)) body: ResetPasswordDto,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ success: true }> {
|
||||
await this.usersService.resetPassword(id, body, actor, request.ip ?? null);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserDto,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ user: UserResponse }> {
|
||||
const user = await this.usersService.update(id, body, actor, request.ip ?? null);
|
||||
return { user: toUserResponse(user) };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ success: true }> {
|
||||
await this.usersService.remove(id, actor, request.ip ?? null);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { ProfileController } from './profile.controller';
|
||||
import { ProfileService } from './profile.service';
|
||||
import { SeedService } from './seed.service';
|
||||
import { UserRepository } from './user.repository';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
/** Benutzerverwaltung (Phase 1: Modell, Rollen, Seed). */
|
||||
/** Benutzerverwaltung: Admin-API, Profil, Rollen, Seed. */
|
||||
@Module({
|
||||
imports: [ConfigModule, DatabaseModule],
|
||||
providers: [UserRepository, PasswordHasher, SeedService],
|
||||
imports: [ConfigModule, DatabaseModule, AuditModule],
|
||||
controllers: [UsersController, ProfileController],
|
||||
providers: [UserRepository, PasswordHasher, SeedService, UsersService, ProfileService, SessionService],
|
||||
exports: [UserRepository, PasswordHasher],
|
||||
})
|
||||
export class UsersModule {}
|
||||
253
apps/platform-backend/src/users/users.service.spec.ts
Normal file
253
apps/platform-backend/src/users/users.service.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { CreateUserDto, UserRecord } from './user.types';
|
||||
import { UsersService, type ActingUser } from './users.service';
|
||||
|
||||
/** Erzeugt einen Benutzer-Datensatz für Tests. */
|
||||
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||
return {
|
||||
id: 'user-1',
|
||||
username: 'max',
|
||||
email: 'max@example.com',
|
||||
passwordHash: 'not-a-real-hash',
|
||||
displayName: 'Max Mustermann',
|
||||
role: 'USER',
|
||||
isActive: true,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' };
|
||||
|
||||
/** Mock des UserRepository. */
|
||||
class MockUserRepository {
|
||||
public users: UserRecord[] = [createUserRecord()];
|
||||
public deletedIds: string[] = [];
|
||||
public updatedPasswords: Array<{ id: string; hash: string }> = [];
|
||||
public lockoutResets: string[] = [];
|
||||
|
||||
async list(): Promise<UserRecord[]> {
|
||||
return this.users;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
return this.users.find((user) => user.id === id) ?? null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
return this.users.find((user) => user.username === username) ?? null;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<UserRecord | null> {
|
||||
return this.users.find((user) => user.email === email) ?? null;
|
||||
}
|
||||
|
||||
async create(input: CreateUserDto): Promise<UserRecord> {
|
||||
const user = createUserRecord({
|
||||
id: 'new-user',
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
role: input.role,
|
||||
});
|
||||
this.users.push(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: string, changes: Partial<UserRecord>): Promise<UserRecord> {
|
||||
const index = this.users.findIndex((user) => user.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('nicht gefunden');
|
||||
}
|
||||
this.users[index] = { ...this.users[index], ...changes };
|
||||
return this.users[index];
|
||||
}
|
||||
|
||||
async updatePassword(id: string, hash: string): Promise<void> {
|
||||
this.updatedPasswords.push({ id, hash });
|
||||
}
|
||||
|
||||
async resetLoginLockout(id: string): Promise<void> {
|
||||
this.lockoutResets.push(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.deletedIds.push(id);
|
||||
this.users = this.users.filter((user) => user.id !== id);
|
||||
}
|
||||
|
||||
async countActiveAdmins(): Promise<number> {
|
||||
return this.users.filter((user) => user.role === 'ADMIN' && user.isActive).length;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des SessionService. */
|
||||
class MockSessionService {
|
||||
public deletedSessionsForUser: string[] = [];
|
||||
|
||||
async deleteAllForUser(userId: string): Promise<void> {
|
||||
this.deletedSessionsForUser.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des AuditService. */
|
||||
class MockAuditService {
|
||||
public records: Array<{ action: string; userId: string | null }> = [];
|
||||
|
||||
async record(entry: { action: string; userId: string | null }): Promise<void> {
|
||||
this.records.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
describe('UsersService', () => {
|
||||
let userRepository: MockUserRepository;
|
||||
let sessionService: MockSessionService;
|
||||
let auditService: MockAuditService;
|
||||
let usersService: UsersService;
|
||||
let passwordHasher: PasswordHasher;
|
||||
|
||||
const createUserInput: CreateUserDto = {
|
||||
username: 'neu',
|
||||
email: 'neu@example.com',
|
||||
displayName: 'Neuer Benutzer',
|
||||
password: 'Sicheres-Passwort-1',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = new MockUserRepository();
|
||||
sessionService = new MockSessionService();
|
||||
auditService = new MockAuditService();
|
||||
passwordHasher = new PasswordHasher();
|
||||
usersService = new UsersService(
|
||||
userRepository as unknown as UserRepository,
|
||||
passwordHasher,
|
||||
sessionService as unknown as SessionService,
|
||||
auditService as unknown as AuditService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('gibt alle Benutzer zurück', async () => {
|
||||
const users = await usersService.list();
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].username).toBe('max');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('wirft NotFoundException bei unbekannter ID', async () => {
|
||||
await expect(usersService.findById('unbekannt')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('legt einen neuen Benutzer an und schreibt Audit', async () => {
|
||||
const user = await usersService.create(createUserInput, ACTOR, '127.0.0.1');
|
||||
expect(user.username).toBe('neu');
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_CREATED);
|
||||
});
|
||||
|
||||
it('lehnt doppelte Benutzernamen ab', async () => {
|
||||
await expect(
|
||||
usersService.create({ ...createUserInput, username: 'max' }, ACTOR, null),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('lehnt doppelte E-Mail-Adressen ab', async () => {
|
||||
await expect(
|
||||
usersService.create({ ...createUserInput, email: 'max@example.com' }, ACTOR, null),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('aktualisiert den Anzeigenamen und schreibt Audit', async () => {
|
||||
const updated = await usersService.update(
|
||||
'user-1',
|
||||
{ displayName: 'Max M.' },
|
||||
ACTOR,
|
||||
null,
|
||||
);
|
||||
expect(updated.displayName).toBe('Max M.');
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_UPDATED);
|
||||
});
|
||||
|
||||
it('verhindert Selbst-Deaktivierung', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.update('admin-1', { isActive: false }, ACTOR, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('meldet deaktivierte Benutzer von allen Sessions ab', async () => {
|
||||
await usersService.update('user-1', { isActive: false }, ACTOR, null);
|
||||
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DISABLED);
|
||||
});
|
||||
|
||||
it('setzt Login-Sperre bei Reaktivierung zurück', async () => {
|
||||
userRepository.users[0] = createUserRecord({ isActive: false });
|
||||
await usersService.update('user-1', { isActive: true }, ACTOR, null);
|
||||
expect(userRepository.lockoutResets).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_ENABLED);
|
||||
});
|
||||
|
||||
it('verhindert die Deaktivierung des letzten aktiven Admins', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.update('admin-1', { isActive: false }, { id: 'anderer', username: 'x' }, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('verhindert die Herabstufung des letzten aktiven Admins', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.update('admin-1', { role: 'USER' }, { id: 'anderer', username: 'x' }, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('setzt das Passwort, meldet Sessions ab und schreibt Audit', async () => {
|
||||
await usersService.resetPassword(
|
||||
'user-1',
|
||||
{ newPassword: 'Neues-Passwort-123' },
|
||||
ACTOR,
|
||||
null,
|
||||
);
|
||||
expect(userRepository.updatedPasswords).toHaveLength(1);
|
||||
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('löscht einen Benutzer und schreibt Audit', async () => {
|
||||
await usersService.remove('user-1', ACTOR, null);
|
||||
expect(userRepository.deletedIds).toEqual(['user-1']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_DELETED);
|
||||
});
|
||||
|
||||
it('verhindert Selbst-Löschung', async () => {
|
||||
await expect(usersService.remove('admin-1', ACTOR, null)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('verhindert die Löschung des letzten aktiven Admins', async () => {
|
||||
userRepository.users[0] = createUserRecord({ id: 'admin-1', role: 'ADMIN' });
|
||||
await expect(
|
||||
usersService.remove('admin-1', { id: 'anderer', username: 'x' }, null),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
174
apps/platform-backend/src/users/users.service.ts
Normal file
174
apps/platform-backend/src/users/users.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService, type AuditAction } from '../audit/audit.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { PasswordHasher } from './password-hasher';
|
||||
import { UserRepository } from './user.repository';
|
||||
import type { CreateUserDto, ResetPasswordDto, UpdateUserDto, UserRecord } from './user.types';
|
||||
|
||||
/** Der handelnde Benutzer (für Audit-Einträge). */
|
||||
export interface ActingUser {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (Application-Layer): Business-Regeln für Anlegen,
|
||||
* Bearbeiten, Aktivieren/Deaktivieren und Löschen von Benutzern.
|
||||
*
|
||||
* Schutzregeln:
|
||||
* - Keine Selbst-Deaktivierung und keine Selbst-Löschung
|
||||
* - Der letzte aktive Administrator kann nicht herabgestuft,
|
||||
* deaktiviert oder gelöscht werden
|
||||
* - Deaktivierte Benutzer werden sofort von allen Sessions abgemeldet
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly passwordHasher: PasswordHasher,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async list(): Promise<UserRecord[]> {
|
||||
return this.userRepository.list();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserRecord> {
|
||||
const user = await this.userRepository.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Benutzer nicht gefunden');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateUserDto,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<UserRecord> {
|
||||
const [existingUsername, existingEmail] = await Promise.all([
|
||||
this.userRepository.findByUsername(input.username),
|
||||
this.userRepository.findByEmail(input.email),
|
||||
]);
|
||||
if (existingUsername) {
|
||||
throw new ConflictException('Benutzername ist bereits vergeben');
|
||||
}
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.create(input);
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.USER_CREATED,
|
||||
details: { targetUserId: user.id, targetUsername: user.username, role: user.role },
|
||||
ipAddress,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateUserDto,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<UserRecord> {
|
||||
const user = await this.findById(id);
|
||||
|
||||
if (input.email !== undefined && input.email !== user.email) {
|
||||
const existingEmail = await this.userRepository.findByEmail(input.email);
|
||||
if (existingEmail && existingEmail.id !== id) {
|
||||
throw new ConflictException('E-Mail-Adresse ist bereits vergeben');
|
||||
}
|
||||
}
|
||||
|
||||
const demotesFromAdmin = user.role === 'ADMIN' && input.role === 'USER';
|
||||
const deactivates = input.isActive === false && user.isActive;
|
||||
const activates = input.isActive === true && !user.isActive;
|
||||
|
||||
if (deactivates && id === actor.id) {
|
||||
throw new BadRequestException('Sie können Ihr eigenes Konto nicht deaktivieren');
|
||||
}
|
||||
if ((demotesFromAdmin || deactivates) && (await this.isLastActiveAdmin(user))) {
|
||||
throw new BadRequestException(
|
||||
'Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden',
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.userRepository.update(id, input);
|
||||
|
||||
if (deactivates) {
|
||||
await this.sessionService.deleteAllForUser(id);
|
||||
}
|
||||
if (activates) {
|
||||
await this.userRepository.resetLoginLockout(id);
|
||||
}
|
||||
|
||||
const action: AuditAction = deactivates
|
||||
? AUDIT_ACTIONS.USER_DISABLED
|
||||
: activates
|
||||
? AUDIT_ACTIONS.USER_ENABLED
|
||||
: AUDIT_ACTIONS.USER_UPDATED;
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action,
|
||||
details: { targetUserId: id, targetUsername: user.username },
|
||||
ipAddress,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
id: string,
|
||||
input: ResetPasswordDto,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<void> {
|
||||
const user = await this.findById(id);
|
||||
const passwordHash = await this.passwordHasher.hash(input.newPassword);
|
||||
await this.userRepository.updatePassword(id, passwordHash);
|
||||
await this.sessionService.deleteAllForUser(id);
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.USER_PASSWORD_RESET,
|
||||
details: { targetUserId: id, targetUsername: user.username },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> {
|
||||
if (id === actor.id) {
|
||||
throw new BadRequestException('Sie können Ihr eigenes Konto nicht löschen');
|
||||
}
|
||||
const user = await this.findById(id);
|
||||
if (await this.isLastActiveAdmin(user)) {
|
||||
throw new BadRequestException('Der letzte aktive Administrator kann nicht gelöscht werden');
|
||||
}
|
||||
await this.userRepository.delete(id);
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.USER_DELETED,
|
||||
details: { targetUserId: id, targetUsername: user.username },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/** Prüft, ob der gegebene Benutzer der einzige aktive Administrator ist. */
|
||||
private async isLastActiveAdmin(user: UserRecord): Promise<boolean> {
|
||||
if (user.role !== 'ADMIN' || !user.isActive) {
|
||||
return false;
|
||||
}
|
||||
const activeAdminCount = await this.userRepository.countActiveAdmins();
|
||||
return activeAdminCount <= 1;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface NavItem {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
||||
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
|
||||
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
||||
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
||||
];
|
||||
|
||||
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 { Card, CardBody, CardHeader } from '../../components/ui/card';
|
||||
import { EmptyState } from '../../components/ui/states';
|
||||
import { type FormEvent, type ReactNode, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { Select } from '../../components/ui/select';
|
||||
import { useToast } from '../../components/ui/toast';
|
||||
import { ApiError } from '../../lib/api-client';
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
fetchUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
} from '../../lib/users-api';
|
||||
import {
|
||||
createUserSchema,
|
||||
resetPasswordSchema,
|
||||
ROLE_NAMES,
|
||||
type RoleName,
|
||||
type User,
|
||||
} from '../../lib/schemas';
|
||||
|
||||
/** Platzhalter für die Benutzerverwaltung (Phase 2). */
|
||||
/** Formular-Fehler aus Zod-Issues (nur erste Meldung pro Feld). */
|
||||
function fieldErrorsFromZod(
|
||||
issues: Array<{ path: (string | number | symbol)[]; message: string }>,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of issues) {
|
||||
const key = String(issue.path[0] ?? 'form');
|
||||
if (!errors[key]) {
|
||||
errors[key] = issue.message;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** API-Fehler-Details in einfache Feldmeldungen umwandeln. */
|
||||
function fieldErrorsFromApi(details: Record<string, string | string[]> | undefined): Record<string, string> {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
const errors: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(details)) {
|
||||
errors[key] = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Datumsformat für Tabellenanzeigen. */
|
||||
function formatDate(isoDate: string | null): string {
|
||||
if (!isoDate) {
|
||||
return '–';
|
||||
}
|
||||
return new Date(isoDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** Formular: Neuen Benutzer anlegen. */
|
||||
function CreateUserModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: (user) => {
|
||||
showToast('success', `Benutzer "${user.username}" wurde angelegt`);
|
||||
onCreated();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message);
|
||||
setFieldErrors(fieldErrorsFromApi(error.details));
|
||||
} else {
|
||||
setFormError('Benutzer konnte nicht angelegt werden');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
setFieldErrors({});
|
||||
setFormError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const parsed = createUserSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
email: formData.get('email'),
|
||||
displayName: formData.get('displayName'),
|
||||
password: formData.get('password'),
|
||||
role: formData.get('role'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setFieldErrors(fieldErrorsFromZod(parsed.error.issues));
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(parsed.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Benutzer anlegen"
|
||||
description="Neuen Benutzer für die Plattform registrieren"
|
||||
onClose={onClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<Input label="Benutzername" name="username" error={fieldErrors.username} autoFocus />
|
||||
<Input label="Anzeigename" name="displayName" error={fieldErrors.displayName} />
|
||||
<Input label="E-Mail-Adresse" name="email" type="email" error={fieldErrors.email} />
|
||||
<Input
|
||||
label="Passwort"
|
||||
name="password"
|
||||
type="password"
|
||||
hint="Mindestens 10 Zeichen"
|
||||
error={fieldErrors.password}
|
||||
/>
|
||||
<Select
|
||||
label="Rolle"
|
||||
name="role"
|
||||
defaultValue="USER"
|
||||
error={fieldErrors.role}
|
||||
options={ROLE_NAMES.map((role) => ({
|
||||
value: role,
|
||||
label: role === 'ADMIN' ? 'Administrator' : 'Benutzer',
|
||||
}))}
|
||||
/>
|
||||
{formError && (
|
||||
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" loading={createMutation.isPending}>
|
||||
Anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formular: Benutzer bearbeiten. */
|
||||
function EditUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (input: { email: string; displayName: string; role: RoleName }) =>
|
||||
updateUser(user.id, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showToast('success', 'Benutzer wurde gespeichert');
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message);
|
||||
setFieldErrors(fieldErrorsFromApi(error.details));
|
||||
} else {
|
||||
setFormError('Benutzer konnte nicht gespeichert werden');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
setFieldErrors({});
|
||||
setFormError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const role = String(formData.get('role'));
|
||||
updateMutation.mutate({
|
||||
email: String(formData.get('email')),
|
||||
displayName: String(formData.get('displayName')),
|
||||
role: role === 'ADMIN' ? 'ADMIN' : 'USER',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open title="Benutzer bearbeiten" description={`@${user.username}`} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<Input
|
||||
label="Anzeigename"
|
||||
name="displayName"
|
||||
defaultValue={user.displayName}
|
||||
error={fieldErrors.displayName}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail-Adresse"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={user.email}
|
||||
error={fieldErrors.email}
|
||||
/>
|
||||
<Select
|
||||
label="Rolle"
|
||||
name="role"
|
||||
defaultValue={user.role}
|
||||
error={fieldErrors.role}
|
||||
options={ROLE_NAMES.map((role) => ({
|
||||
value: role,
|
||||
label: role === 'ADMIN' ? 'Administrator' : 'Benutzer',
|
||||
}))}
|
||||
/>
|
||||
{formError && (
|
||||
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" loading={updateMutation.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formular: Passwort zurücksetzen. */
|
||||
function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (input: { newPassword: string }) => resetUserPassword(user.id, input),
|
||||
onSuccess: () => {
|
||||
showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message);
|
||||
setFieldErrors(fieldErrorsFromApi(error.details));
|
||||
} else {
|
||||
setFormError('Passwort konnte nicht zurückgesetzt werden');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
setFieldErrors({});
|
||||
setFormError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const parsed = resetPasswordSchema.safeParse({
|
||||
newPassword: formData.get('newPassword'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setFieldErrors(fieldErrorsFromZod(parsed.error.issues));
|
||||
return;
|
||||
}
|
||||
resetMutation.mutate(parsed.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="Passwort zurücksetzen"
|
||||
description={`Neues Passwort für @${user.username} festlegen. Der Benutzer wird von allen Sitzungen abgemeldet.`}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<Input
|
||||
label="Neues Passwort"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
hint="Mindestens 10 Zeichen"
|
||||
error={fieldErrors.newPassword}
|
||||
autoFocus
|
||||
/>
|
||||
{formError && (
|
||||
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" variant="danger" loading={resetMutation.isPending}>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bestätigungsdialog: Benutzer löschen. */
|
||||
function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteUser(user.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showToast('success', `Benutzer "${user.username}" wurde gelöscht`);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="Benutzer löschen"
|
||||
description={`Möchten Sie "${user.displayName}" (@${user.username}) wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
Endgültig löschen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */
|
||||
export function UsersPage(): ReactNode {
|
||||
const { user: currentUser } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: fetchUsers,
|
||||
});
|
||||
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: (target: User) => updateUser(target.id, { isActive: !target.isActive }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showToast('success', 'Status wurde aktualisiert');
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Aktualisierung fehlgeschlagen');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Benutzer anlegen, bearbeiten und verwalten.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Benutzer anlegen, bearbeiten und verwalten.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>+ Benutzer anlegen</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Benutzer" description="Alle Benutzer der Plattform" />
|
||||
<CardBody>
|
||||
<EmptyState
|
||||
title="Benutzerverwaltung folgt in Phase 2"
|
||||
description="Die vollständige Benutzerverwaltung (Liste, Anlegen, Bearbeiten, Deaktivieren) wird in Phase 2 implementiert."
|
||||
icon={<span className="text-3xl" aria-hidden="true">👥</span>}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-4 py-3 font-semibold">Benutzer</th>
|
||||
<th className="px-4 py-3 font-semibold">Rolle</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="px-4 py-3 font-semibold">Letzter Login</th>
|
||||
<th className="px-4 py-3 font-semibold">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usersQuery.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
|
||||
Benutzer werden geladen…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{usersQuery.isError && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-red-600">
|
||||
Benutzer konnten nicht geladen werden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{usersQuery.data?.map((user) => (
|
||||
<tr key={user.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-900">{user.displayName}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
@{user.username} · {user.email}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
|
||||
${
|
||||
user.role === 'ADMIN'
|
||||
? 'bg-brand-50 text-brand-700 ring-brand-600/20'
|
||||
: 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
||||
}`}
|
||||
>
|
||||
{user.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
|
||||
${
|
||||
user.isActive
|
||||
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
|
||||
: 'bg-red-50 text-red-700 ring-red-600/20'
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? 'Aktiv' : 'Deaktiviert'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{formatDate(user.lastLoginAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditUser(user)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setResetPasswordUser(user)}>
|
||||
Passwort
|
||||
</Button>
|
||||
{user.id !== currentUser?.id && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
toggleActiveMutation.isPending &&
|
||||
toggleActiveMutation.variables?.id === user.id
|
||||
}
|
||||
onClick={() => toggleActiveMutation.mutate(user)}
|
||||
>
|
||||
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setDeleteTarget(user)}>
|
||||
Löschen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{usersQuery.data?.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-slate-500">
|
||||
Noch keine Benutzer vorhanden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createOpen && (
|
||||
<CreateUserModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => void queryClient.invalidateQueries({ queryKey: ['users'] })}
|
||||
/>
|
||||
)}
|
||||
{editUser && <EditUserModal user={editUser} onClose={() => setEditUser(null)} />}
|
||||
{resetPasswordUser && (
|
||||
<ResetPasswordModal user={resetPasswordUser} onClose={() => setResetPasswordUser(null)} />
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} />
|
||||
)}
|
||||
</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(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
public readonly details?: Record<string, string[]>,
|
||||
public readonly details?: Record<string, string | string[]>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
|
||||
@@ -35,3 +35,75 @@ export const healthSchema = z.object({
|
||||
}),
|
||||
});
|
||||
export type Health = z.infer<typeof healthSchema>;
|
||||
|
||||
/** Benutzer-Datensatz der Admin-API (/api/v1/users). */
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
displayName: z.string(),
|
||||
role: z.enum(ROLE_NAMES),
|
||||
isActive: z.boolean(),
|
||||
lastLoginAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
/** Profil des angemeldeten Benutzers (/api/v1/profile). */
|
||||
export const profileSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
displayName: z.string(),
|
||||
role: z.enum(ROLE_NAMES),
|
||||
lastLoginAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
export type Profile = z.infer<typeof profileSchema>;
|
||||
|
||||
/** Benutzer anlegen (Client-Validierung ist UX; Server validiert erneut). */
|
||||
export const createUserSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
|
||||
.max(100)
|
||||
.regex(/^[A-Za-z0-9._-]+$/, 'Nur Buchstaben, Zahlen sowie . _ - erlaubt'),
|
||||
email: z.string().trim().email('Ungültige E-Mail-Adresse'),
|
||||
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich'),
|
||||
password: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
|
||||
role: z.enum(ROLE_NAMES),
|
||||
});
|
||||
export type CreateUserDto = z.infer<typeof createUserSchema>;
|
||||
|
||||
/** Benutzer bearbeiten. */
|
||||
export const updateUserSchema = z
|
||||
.object({
|
||||
email: z.string().trim().email('Ungültige E-Mail-Adresse').optional(),
|
||||
displayName: z.string().trim().min(1, 'Anzeigename ist erforderlich').optional(),
|
||||
role: z.enum(ROLE_NAMES).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => Object.values(data).some((value) => value !== undefined), {
|
||||
message: 'Mindestens ein Feld ist erforderlich',
|
||||
});
|
||||
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
|
||||
|
||||
/** Passwort zurücksetzen (Admin). */
|
||||
export const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
|
||||
});
|
||||
export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>;
|
||||
|
||||
/** Eigenes Passwort ändern. */
|
||||
export const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich'),
|
||||
newPassword: z.string().min(10, 'Passwort muss mindestens 10 Zeichen lang sein'),
|
||||
confirmPassword: z.string().min(1, 'Passwortbestätigung ist erforderlich'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwörter stimmen nicht überein',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||
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 { AppLayout } from './components/layout/app-layout';
|
||||
import { RequireAdmin, RequireAuth } from './components/route-guards';
|
||||
import { ToastProvider } from './components/ui/toast';
|
||||
import { AuthProvider } from './features/auth/auth-context';
|
||||
import { LoginPage } from './features/auth/login-page';
|
||||
import { DashboardPage } from './features/dashboard/dashboard-page';
|
||||
import { SystemStatusPage } from './features/admin/system-status-page';
|
||||
import { UsersPage } from './features/admin/users-page';
|
||||
import { ProfilePage } from './features/profile/profile-page';
|
||||
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
|
||||
import './index.css';
|
||||
|
||||
@@ -31,6 +33,7 @@ function AppRoutes(): ReactNode {
|
||||
|
||||
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={<RequireAdmin><UsersPage /></RequireAdmin>}
|
||||
@@ -58,7 +61,9 @@ createRoot(rootElement).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
<ToastProvider>
|
||||
<AppRoutes />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -134,8 +134,24 @@ Security Hardening (Penetrationstests, Dependency/Container-Scans, HSTS, Backup/
|
||||
| POST | `/api/v1/auth/logout` | Session | Beendet die aktuelle Session |
|
||||
| GET | `/api/v1/auth/me` | Session | Angemeldeter Benutzer (Id, Rolle, …) |
|
||||
| GET | `/api/v1/health` | Public | Systemstatus (Backend, DB-Latenz, Version, Uptime) |
|
||||
| GET | `/api/v1/users` | Admin | Alle Benutzer |
|
||||
| POST | `/api/v1/users` | Admin | Benutzer anlegen (409 bei Duplikat) |
|
||||
| GET | `/api/v1/users/:id` | Admin | Einzelner Benutzer |
|
||||
| PATCH | `/api/v1/users/:id` | Admin | Bearbeiten / Aktivieren / Deaktivieren |
|
||||
| PATCH | `/api/v1/users/:id/password` | Admin | Passwort zurücksetzen |
|
||||
| DELETE | `/api/v1/users/:id` | Admin | Benutzer löschen |
|
||||
| GET | `/api/v1/profile` | Session | Eigenes Profil |
|
||||
| PATCH | `/api/v1/profile/password` | Session | Eigenes Passwort ändern |
|
||||
|
||||
OpenAPI/Swagger UI: `/api/docs` · Ab Phase 2: `/api/v1/users/*`, `/api/v1/modules/*`, `/api/v1/permissions/*`, `/api/v1/audit/*`.
|
||||
OpenAPI/Swagger UI: `/api/docs` · Ab Phase 3: `/api/v1/modules/*`, `/api/v1/permissions/*`, `/api/v1/audit/*`.
|
||||
|
||||
### Schutzregeln der Benutzerverwaltung
|
||||
|
||||
- Keine Selbst-Deaktivierung und keine Selbst-Löschung (400)
|
||||
- Der letzte aktive Administrator kann nicht herabgestuft, deaktiviert oder gelöscht werden (400)
|
||||
- Deaktivierte Benutzer werden sofort von allen Sessions abgemeldet
|
||||
- Passwort-Reset (Admin) meldet den Benutzer von allen Sessions ab
|
||||
- Eigenes Passwort ändern meldet alle übrigen Sessions ab (aktuelle bleibt aktiv)
|
||||
|
||||
## 9. Modul-Vertrag (Ausblick Phase 3+)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S
|
||||
| Phase | Bereich | Status |
|
||||
|---|---|---|
|
||||
| 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen |
|
||||
| 2 | Benutzerverwaltung | ⏳ Geplant |
|
||||
| 2 | Benutzerverwaltung | ✅ Abgeschlossen |
|
||||
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ⏳ Geplant |
|
||||
| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant |
|
||||
| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
|
||||
@@ -33,9 +33,28 @@ Definition of Done:
|
||||
- [x] Admin-Dashboard sichtbar (inkl. Systemstatus)
|
||||
- [x] Backend-Unit-Tests (Auth, Session, Passwort, Validierung)
|
||||
|
||||
## Nächste Schritte (Phase 2 – Benutzerverwaltung)
|
||||
## Phase 2 – Benutzerverwaltung (abgeschlossen)
|
||||
|
||||
- Benutzerliste, Benutzer erstellen/bearbeiten/deaktivieren
|
||||
- Passwortänderung und Reset durch Admin
|
||||
- `GET/POST/PATCH/DELETE /api/v1/users/*` mit `@Roles('ADMIN')`
|
||||
- Frontend-Seite `/admin/users` mit Tabelle, Formular und Bestätigungsdialogen
|
||||
Definition of Done: Admin kann Benutzer vollständig verwalten.
|
||||
|
||||
- [x] `GET/POST/PATCH/DELETE /api/v1/users/*` (nur `@Roles('ADMIN')`)
|
||||
- [x] Benutzerliste, Benutzer anlegen (Zod-Validierung, Duplikat-Schutz 409)
|
||||
- [x] Benutzer bearbeiten (Anzeigename, E-Mail, Rolle)
|
||||
- [x] Benutzer deaktivieren/aktivieren (Sessions werden sofort ungültig)
|
||||
- [x] Passwort-Reset durch Admin (alle Sessions des Benutzers werden gelöscht)
|
||||
- [x] Schutzregeln: keine Selbst-Deaktivierung/-Löschung, letzter aktiver Admin geschützt
|
||||
- [x] Profil-Endpunkte (`GET /api/v1/profile`, `PATCH /api/v1/profile/password`)
|
||||
- [x] Eigenes Passwort ändern (Verifikation des aktuellen Passworts, andere Sessions werden abgemeldet)
|
||||
- [x] Frontend: Benutzerverwaltungs-Seite (Tabelle, Create/Edit/Reset/Delete-Modals, Toasts)
|
||||
- [x] Frontend: Profil-Seite mit Passwortänderung
|
||||
- [x] UI-Komponenten: Modal, Select, Toast (Design-System erweitert)
|
||||
- [x] Backend-Tests: 49 bestanden (inkl. UsersService, ProfileService)
|
||||
- [x] E2E verifiziert: CRUD, RBAC (User → 403), Login-Sperre nach Deaktivierung, Passwort-Flows
|
||||
|
||||
## Nächste Schritte (Phase 3 – Modul-System)
|
||||
|
||||
- Modul-Datenmodell (`modules`-Tabelle) und Manifest-Vertrag (`module.json`)
|
||||
- Modul-Installation als ZIP-Paket (Validierung, Dateien, Registrierung)
|
||||
- Modul-Lifecycle (INSTALLED/STARTING/RUNNING/STOPPING/STOPPED/ERROR/DISABLED)
|
||||
- Prozessverwaltung der Modul-Prozesse über Supervisor
|
||||
- Healthchecks und Statusanzeige im Admin-Bereich
|
||||
Reference in New Issue
Block a user