feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)

This commit is contained in:
MPM Dev
2026-10-06 14:22:11 +02:00
commit a7e1c421f2
85 changed files with 17701 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
/**
* Zentraler API-Client des Frontends.
* - Sendet automatisch das CSRF-Token bei zustandsändernden Requests
* - Behandelt 401 (nicht authentifiziert) einheitlich
* - Wirft strukturierte ApiError-Objekte statt roher Response-Objekte
*/
/** Strukturierter API-Fehler mit Statuscode und Meldungen. */
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: Record<string, string[]>,
) {
super(message);
this.name = 'ApiError';
}
}
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('=');
if (name === 'mpm_csrf') {
return decodeURIComponent(value.join('='));
}
}
return null;
}
/** Callback, der bei einem 401-Fehler aufgerufen wird (Auth-Context setzt ihn). */
let unauthorizedHandler: (() => void) | null = null;
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
}
/** Führt einen API-Request aus und parst die JSON-Antwort. */
export async function apiRequest<TResponse>(
path: string,
options: RequestOptions = {},
): Promise<TResponse> {
const { method = 'GET', body, signal } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (method !== 'GET') {
const csrfToken = readCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
if (response.status === 401 && unauthorizedHandler) {
unauthorizedHandler();
}
if (!response.ok) {
let message = 'Ein unerwarteter Fehler ist aufgetreten.';
let details: Record<string, string[]> | undefined;
try {
const errorBody = (await response.json()) as {
message?: string | string[];
details?: Record<string, string[]>;
};
if (typeof errorBody.message === 'string') {
message = errorBody.message;
} else if (Array.isArray(errorBody.message)) {
message = errorBody.message.join(', ');
}
details = errorBody.details;
} catch {
// Antwort enthält kein JSON – Standardmeldung verwenden.
}
throw new ApiError(response.status, message, details);
}
return (await response.json()) as TResponse;
}

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
/** Globale Plattform-Rollen (muss zum Backend passen). */
export const ROLE_NAMES = ['ADMIN', 'USER'] as const;
export type RoleName = (typeof ROLE_NAMES)[number];
/** Öffentliche Benutzerdaten (API-Vertrag /api/v1/auth/me). */
export const authUserSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
displayName: z.string(),
role: z.enum(ROLE_NAMES),
});
export type AuthUser = z.infer<typeof authUserSchema>;
/** Login-Anfrage (Client-Validierung ist UX; Server validiert erneut). */
export const loginSchema = z.object({
username: z.string().trim().min(1, 'Benutzername ist erforderlich'),
password: z.string().min(1, 'Passwort ist erforderlich'),
});
export type LoginDto = z.infer<typeof loginSchema>;
/** Health-Antwort (API-Vertrag /api/v1/health). */
export const healthSchema = z.object({
status: z.enum(['healthy', 'unhealthy']),
version: z.string(),
uptimeSeconds: z.number(),
components: z.object({
backend: z.enum(['healthy']),
database: z.object({
status: z.enum(['healthy', 'unhealthy']),
latencyMs: z.number(),
}),
}),
});
export type Health = z.infer<typeof healthSchema>;