feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)
This commit is contained in:
95
apps/platform-frontend/src/lib/api-client.ts
Normal file
95
apps/platform-frontend/src/lib/api-client.ts
Normal 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;
|
||||
}
|
||||
37
apps/platform-frontend/src/lib/schemas.ts
Normal file
37
apps/platform-frontend/src/lib/schemas.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user