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

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

View File

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

View File

@@ -34,4 +34,76 @@ 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>;

View File

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