feat: Phase 2 – Benutzerverwaltung (CRUD-API, Profil, UI)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user