3 Commits

Author SHA1 Message Date
leon
21e2ccefb2 added new features 3.0 2026-10-10 15:51:30 +02:00
leon
84d8697f23 Polish module actions and marketplace updates 2026-10-09 23:43:05 +02:00
leon
fd9de823ea MPM marketplace and UI updates 2026-10-08 21:13:57 +02:00
88 changed files with 6255 additions and 922 deletions

View File

@@ -10,7 +10,7 @@ POSTGRES_PASSWORD=<sicheres-passwort>
POSTGRES_DB=mpm POSTGRES_DB=mpm
# --- Plattform-Container --------------------------------------- # --- Plattform-Container ---------------------------------------
NODE_ENV=production NODE_ENV=development
PORT=3000 PORT=3000
# Host-Port, unter dem die Plattform erreichbar ist # Host-Port, unter dem die Plattform erreichbar ist
APP_PORT=8080 APP_PORT=8080
@@ -22,7 +22,7 @@ DATABASE_URL=postgresql://mpm:<sicheres-passwort>@postgres:5432/mpm
# Session-Gültigkeit in Minuten (kurz halten) # Session-Gültigkeit in Minuten (kurz halten)
SESSION_TTL_MINUTES=120 SESSION_TTL_MINUTES=120
# "true" sobald die Plattform hinter HTTPS/TLS betrieben wird # "true" sobald die Plattform hinter HTTPS/TLS betrieben wird
COOKIE_SECURE=true COOKIE_SECURE=false
# "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist # "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist
BEHIND_PROXY=true BEHIND_PROXY=true
@@ -30,6 +30,10 @@ BEHIND_PROXY=true
# MPM braucht den Socket, um eigene Modul-Container zu verwalten. # MPM braucht den Socket, um eigene Modul-Container zu verwalten.
DOCKER_SOCKET_GID=0 DOCKER_SOCKET_GID=0
# Schlüssel für verschlüsselte Modulkonfigurationen (mindestens 32 Zeichen).
# Lokal dauerhaft generieren und geheim halten.
MODULE_CONFIG_ENCRYPTION_KEY=
# GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0). # GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0).
# MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten. # MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten.
DOCKER_SOCKET_GID=0 DOCKER_SOCKET_GID=0
@@ -42,6 +46,9 @@ ADMIN_PASSWORD=<mindestens-10-zeichen>
# --- Marketplace OAuth (optional) -------------------------------- # --- Marketplace OAuth (optional) --------------------------------
# Lokal: Host-Adresse einschließlich APP_PORT; Callback-Pfad wird von MPM ergänzt. # Lokal: Host-Adresse einschließlich APP_PORT; Callback-Pfad wird von MPM ergänzt.
MARKETPLACE_PUBLIC_URL=http://127.0.0.1:8080 MARKETPLACE_PUBLIC_URL=http://127.0.0.1:8080
# Browser-Origin für Module. Leer = lokal automatisch localhost/127.0.0.1,
# bei Domains automatisch modules.<Plattformhost>. DNS und TLS dafür einrichten.
MODULE_PUBLIC_ORIGIN=
# Zufälliger, dauerhafter Wert (mindestens 32 Zeichen), z. B. openssl rand -base64 32. # Zufälliger, dauerhafter Wert (mindestens 32 Zeichen), z. B. openssl rand -base64 32.
MARKETPLACE_TOKEN_ENCRYPTION_KEY= MARKETPLACE_TOKEN_ENCRYPTION_KEY=
# OAuth-App Callback: http://127.0.0.1:8080/api/v1/marketplace/oauth/github/callback # OAuth-App Callback: http://127.0.0.1:8080/api/v1/marketplace/oauth/github/callback

View File

@@ -44,7 +44,10 @@ RUN groupadd --gid 1001 app \
&& mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor /app/data/modules /app/data/logs \ && mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor /app/data/modules /app/data/logs \
&& chown -R app:app /tmp/nginx /var/log/supervisor /app/data && chown -R app:app /tmp/nginx /var/log/supervisor /app/data
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf.template
COPY docker/nginx/render-config.cjs /usr/local/lib/mpm/render-nginx-config.cjs
COPY docker/nginx/start-nginx.sh /usr/local/bin/start-mpm-nginx
RUN chmod +x /usr/local/bin/start-mpm-nginx
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist
@@ -56,6 +59,6 @@ USER root
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/api/v1/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" CMD node -e "const http=require('node:http');const host=new URL(process.env.MARKETPLACE_PUBLIC_URL??'http://127.0.0.1:8080').host;http.get({hostname:'127.0.0.1',port:8080,path:'/api/v1/health',headers:{Host:host}},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]

View File

@@ -36,7 +36,8 @@ Für lokale Frontend-/Backend-Entwicklung außerhalb von Docker zusätzlich Node
5. Secrets dieses Arbeitsplatzes getrennt halten. Keine Passwörter, OAuth-Secrets, Zugriffstokens, Cookies oder privaten Schlüssel in Quellcode, Dokumentation, Kommandoausgaben, Commits oder Issues übernehmen. Beispielwerte in `.env.example` sind Platzhalter. 5. Secrets dieses Arbeitsplatzes getrennt halten. Keine Passwörter, OAuth-Secrets, Zugriffstokens, Cookies oder privaten Schlüssel in Quellcode, Dokumentation, Kommandoausgaben, Commits oder Issues übernehmen. Beispielwerte in `.env.example` sind Platzhalter.
6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden. 6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden.
7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten. 7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten.
8. `APP_PORT` bei Bedarf anpassen, falls 8080 belegt ist. `MARKETPLACE_PUBLIC_URL` muss die vom Browser erreichbare Basisadresse samt Port enthalten, etwa `http://127.0.0.1:8080`. Für Modulkonfigurationen zusätzlich einen dauerhaften `MODULE_CONFIG_ENCRYPTION_KEY` mit mindestens 32 Zeichen generieren und geheim halten. Ohne diesen Schlüssel lassen sich gespeicherte Modul-Secrets nicht entschlüsseln; bei Schlüsselverlust oder Rotation müssen die Modulkonfigurationen erneuert werden.
8. `APP_PORT` bei Bedarf anpassen, falls 8080 belegt ist. `MARKETPLACE_PUBLIC_URL` muss die vom Browser erreichbare Basisadresse samt Port enthalten, etwa `http://127.0.0.1:8080`. Diese Adresse auch zum Öffnen der Plattform verwenden. Moduloberflächen nutzen einen eigenen Host: lokal automatisch den jeweils anderen Loopback-Namen (`localhost` oder `127.0.0.1`), bei öffentlichen Domains standardmäßig `modules.<Plattformhost>`. Für einen anderen Host `MODULE_PUBLIC_ORIGIN` setzen; in Produktion DNS und HTTPS für beide Hosts einrichten.
9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen. 9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen.
Die Datenbankverbindung innerhalb des Compose-Netzwerks verwendet den Hostnamen `postgres`. Bei Backend-Ausführung direkt auf dem Host muss `DATABASE_URL` auf `127.0.0.1:5432` zeigen. Niemals den Compose-internen Hostnamen `postgres` für einen Backendprozess auf dem Host verwenden. Die Datenbankverbindung innerhalb des Compose-Netzwerks verwendet den Hostnamen `postgres`. Bei Backend-Ausführung direkt auf dem Host muss `DATABASE_URL` auf `127.0.0.1:5432` zeigen. Niemals den Compose-internen Hostnamen `postgres` für einen Backendprozess auf dem Host verwenden.
@@ -50,7 +51,7 @@ docker compose up --build -d
docker compose ps docker compose ps
``` ```
Danach die in `APP_PORT` konfigurierte Adresse öffnen (Standard `http://localhost:8080`). Das initiale Admin-Konto wird beim ersten Datenbankstart aus `ADMIN_USERNAME`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` angelegt. Spätere Änderungen dieser Variablen ändern ein bereits angelegtes Datenbankkonto nicht automatisch. Danach die in `MARKETPLACE_PUBLIC_URL` konfigurierte Adresse öffnen (Beispiel: `http://127.0.0.1:8080`). Das initiale Admin-Konto wird beim ersten Datenbankstart aus `ADMIN_USERNAME`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` angelegt. Spätere Änderungen dieser Variablen ändern ein bereits angelegtes Datenbankkonto nicht automatisch.
Logs und Neustart: Logs und Neustart:
@@ -96,7 +97,7 @@ Der Backendprozess benötigt gültige Variablen aus `.env`; beim lokalen Start m
- Datenbankänderungen als neue Migration ergänzen; bestehende Migrationen nicht nachträglich umschreiben, wenn sie schon angewendet sein könnten. - Datenbankänderungen als neue Migration ergänzen; bestehende Migrationen nicht nachträglich umschreiben, wenn sie schon angewendet sein könnten.
- UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten. - UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten.
- Abhängigkeiten nur bei Bedarf ändern und Lockfiles konsistent halten. - Abhängigkeiten nur bei Bedarf ändern und Lockfiles konsistent halten.
- Keine Builds, Tests, Deployments, Commits oder Pushes ausführen, wenn der Nutzer das nicht angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben. - Nach Codeänderungen, die den lokalen Plattformcontainer betreffen, diesen neu bauen und starten, damit der Container die aktuellen Änderungen erhält. Tests, externe Deployments, Commits und Pushes nur ausführen, wenn der Nutzer sie angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben.
- Bei einem gewünschten Push Ziel-Remote und Branch verifizieren, den kompletten Commit-Diff auf Secrets prüfen und keine Force-Pushes ausführen, außer der Nutzer weist sie ausdrücklich an. - Bei einem gewünschten Push Ziel-Remote und Branch verifizieren, den kompletten Commit-Diff auf Secrets prüfen und keine Force-Pushes ausführen, außer der Nutzer weist sie ausdrücklich an.
## Häufige Arbeitsplatzprobleme ## Häufige Arbeitsplatzprobleme

View File

@@ -41,9 +41,9 @@ Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Phasen: [`docs/PHASES
docker compose up --build -d docker compose up --build -d
# 3. Öffnen # 3. Öffnen
# http://localhost:8080 # Die Adresse aus MARKETPLACE_PUBLIC_URL öffnen, z. B. http://127.0.0.1:8080
# Anmeldung: ADMIN_USERNAME / ADMIN_PASSWORD aus .env # Anmeldung: ADMIN_USERNAME / ADMIN_PASSWORD aus .env
# API-Dokumentation (Swagger): http://localhost:8080/api/docs # API-Dokumentation (Swagger): <MARKETPLACE_PUBLIC_URL>/api/docs
``` ```
Definition of Done Phase 1: Webseite erreichbar ✓ Login möglich ✓ Admin-Dashboard sichtbar ✓ Definition of Done Phase 1: Webseite erreichbar ✓ Login möglich ✓ Admin-Dashboard sichtbar ✓
@@ -102,7 +102,7 @@ Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letz
Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, eigene Compose-Stacks je Modul, Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Datenvolumes. Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, eigene Compose-Stacks je Modul, Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Datenvolumes.
### Phase 4 – Gateway & Routing ### Phase 4 – Gateway & Routing
Dynamisches Routing `/slug` über Nginx → Modul-Gateway (Middleware): Session-Check, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts. Dynamisches Routing `/<slug>` auf einem eigenen Modulhost über Nginx → Modul-Gateway (Middleware): getrennte Browser-Session, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Der Einstieg erfolgt über einen authentifizierten Einmal-Ticket-Redirect vom Plattformhost. Module müssen Assets und APIs unter `/<slug>/` bereitstellen; die Management-API ist auf dem Modulhost nicht erreichbar. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts.
### Phase 5 – Berechtigungssystem ### Phase 5 – Berechtigungssystem
Zweistufiges Rechtekonzept: Plattform-Rollen (ADMIN/USER) + Modul-Berechtigungen (`user_module_permissions`, GRANTED/DENIED). Admin-API für Zuweisungen, Gateway prüft Berechtigungen fail-closed, Dashboard zeigt nur freigegebene Module als Kacheln. Zweistufiges Rechtekonzept: Plattform-Rollen (ADMIN/USER) + Modul-Berechtigungen (`user_module_permissions`, GRANTED/DENIED). Admin-API für Zuweisungen, Gateway prüft Berechtigungen fail-closed, Dashboard zeigt nur freigegebene Module als Kacheln.

View File

@@ -15,10 +15,12 @@ export const AUDIT_ACTIONS = {
USER_PASSWORD_RESET: 'USER_PASSWORD_RESET', USER_PASSWORD_RESET: 'USER_PASSWORD_RESET',
USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED', USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED',
MODULE_INSTALLED: 'MODULE_INSTALLED', MODULE_INSTALLED: 'MODULE_INSTALLED',
MODULE_UPDATED: 'MODULE_UPDATED',
MODULE_REMOVED: 'MODULE_REMOVED', MODULE_REMOVED: 'MODULE_REMOVED',
MODULE_STARTED: 'MODULE_STARTED', MODULE_STARTED: 'MODULE_STARTED',
MODULE_STOPPED: 'MODULE_STOPPED', MODULE_STOPPED: 'MODULE_STOPPED',
MODULE_RESTARTED: 'MODULE_RESTARTED', MODULE_RESTARTED: 'MODULE_RESTARTED',
MODULE_CONFIG_UPDATED: 'MODULE_CONFIG_UPDATED',
MODULE_ENABLED: 'MODULE_ENABLED', MODULE_ENABLED: 'MODULE_ENABLED',
MODULE_DISABLED: 'MODULE_DISABLED', MODULE_DISABLED: 'MODULE_DISABLED',
PERMISSION_GRANTED: 'PERMISSION_GRANTED', PERMISSION_GRANTED: 'PERMISSION_GRANTED',

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, Inject, Post, Req, Res, UseGuards } from '@nestjs/common'; import { Body, Controller, ForbiddenException, Get, HttpCode, Inject, NotFoundException, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -8,6 +8,8 @@ import type { AuthenticatedRequest } from './authenticated-request';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CsrfGuard } from './guards/csrf.guard'; import { CsrfGuard } from './guards/csrf.guard';
import { SessionGuard } from './guards/session.guard'; import { SessionGuard } from './guards/session.guard';
import { SessionService } from './session.service';
import { requireSameOrigin } from './request-origin';
import { loginSchema, type LoginDto } from '../users/user.types'; import { loginSchema, type LoginDto } from '../users/user.types';
import type { AuthUser } from '../users/user.types'; import type { AuthUser } from '../users/user.types';
@@ -38,6 +40,7 @@ function toAuthUserResponse(user: AuthUser): AuthUserResponse {
export class AuthController { export class AuthController {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly sessionService: SessionService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
@@ -49,6 +52,7 @@ export class AuthController {
@Req() request: AuthenticatedRequest & Request, @Req() request: AuthenticatedRequest & Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<{ user: AuthUserResponse }> { ): Promise<{ user: AuthUserResponse }> {
requireSameOrigin(request, this.config.marketplace.publicUrl);
const result = await this.authService.login({ const result = await this.authService.login({
username: body.username, username: body.username,
password: body.password, password: body.password,
@@ -57,14 +61,14 @@ export class AuthController {
// Express expects cookie maxAge in milliseconds (the DB TTL is in minutes). // Express expects cookie maxAge in milliseconds (the DB TTL is in minutes).
const cookieMaxAgeMs = this.config.security.sessionTtlMinutes * 60 * 1000; const cookieMaxAgeMs = this.config.security.sessionTtlMinutes * 60 * 1000;
response.cookie('mpm_session', result.sessionToken, { response.cookie(this.config.security.cookieSecure ? '__Host-mpm_session' : 'mpm_session', result.sessionToken, {
httpOnly: true, httpOnly: true,
secure: this.config.security.cookieSecure, secure: this.config.security.cookieSecure,
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
maxAge: cookieMaxAgeMs, maxAge: cookieMaxAgeMs,
}); });
response.cookie('mpm_csrf', result.session.csrfToken, { response.cookie(this.config.security.cookieSecure ? '__Host-mpm_csrf' : 'mpm_csrf', result.session.csrfToken, {
httpOnly: false, httpOnly: false,
secure: this.config.security.cookieSecure, secure: this.config.security.cookieSecure,
sameSite: 'lax', sameSite: 'lax',
@@ -75,6 +79,63 @@ export class AuthController {
return { user: toAuthUserResponse(result.user) }; return { user: toAuthUserResponse(result.user) };
} }
/** Navigationspunkt auf dem Plattformhost für einen eigenen Modul-Origin. */
@UseGuards(SessionGuard)
@Get('module-open/:slug')
async openModule(
@Param('slug') slug: string,
@CurrentUser() user: AuthUser,
@Req() request: AuthenticatedRequest & Request,
@Res() response: Response,
): Promise<void> {
if (!/^[a-z0-9][a-z0-9-]{2,100}$/.test(slug) || !request.session) {
throw new NotFoundException('Modul nicht gefunden');
}
const ticket = await this.sessionService.createModuleAccessTicket(request.session.id, user.id, slug);
response.setHeader('Cache-Control', 'no-store');
response.setHeader('Referrer-Policy', 'no-referrer');
response.redirect(303, `${this.config.modulePublicOrigin}/__mpm_module_handoff?ticket=${encodeURIComponent(ticket)}`);
}
/** Einmaliger Cookie-Übergang auf dem separaten Modulhost. */
@Public()
@Get('module-handoff')
async moduleHandoff(
@Query('ticket') ticket: string,
@Req() request: Request,
@Res() response: Response,
): Promise<void> {
if (request.headers.host !== new URL(this.config.modulePublicOrigin).host) {
throw new ForbiddenException('Ungültiger Modul-Host');
}
if (typeof ticket !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(ticket)) {
throw new ForbiddenException('Ungültiges Modul-Ticket');
}
const exchanged = await this.sessionService.exchangeModuleAccessTicket(
ticket,
this.config.security.sessionTtlMinutes,
);
if (!exchanged) {
throw new ForbiddenException('Modul-Ticket ist abgelaufen oder bereits verwendet');
}
// Ein Browser, der diesen Host früher als Plattformhost genutzt hat,
// darf keine alten Plattform-Cookies an Modul-JavaScript weitergeben.
response.clearCookie('mpm_session', { path: '/' });
response.clearCookie('mpm_csrf', { path: '/' });
response.clearCookie('__Host-mpm_session', { path: '/', secure: true });
response.clearCookie('__Host-mpm_csrf', { path: '/', secure: true });
response.cookie('mpm_module_session', exchanged.token, {
httpOnly: true,
secure: this.config.security.cookieSecure,
sameSite: 'lax',
path: `/${exchanged.moduleSlug}`,
maxAge: this.config.security.sessionTtlMinutes * 60 * 1000,
});
response.setHeader('Cache-Control', 'no-store');
response.setHeader('Referrer-Policy', 'no-referrer');
response.redirect(303, `${this.config.modulePublicOrigin}/${exchanged.moduleSlug}`);
}
@UseGuards(SessionGuard, CsrfGuard) @UseGuards(SessionGuard, CsrfGuard)
@Post('logout') @Post('logout')
@HttpCode(200) @HttpCode(200)
@@ -88,6 +149,8 @@ export class AuthController {
} }
response.clearCookie('mpm_session', { path: '/' }); response.clearCookie('mpm_session', { path: '/' });
response.clearCookie('mpm_csrf', { path: '/' }); response.clearCookie('mpm_csrf', { path: '/' });
response.clearCookie('__Host-mpm_session', { path: '/', secure: true });
response.clearCookie('__Host-mpm_csrf', { path: '/', secure: true });
return { success: true }; return { success: true };
} }

View File

@@ -25,8 +25,9 @@ function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig
...overrides, ...overrides,
}, },
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
modulePublicOrigin: 'http://localhost:8081',
}; };
} }
@@ -80,7 +81,7 @@ class MockSessionService {
}, },
}; };
async create(): Promise<{ token: string; data: SessionData }> { async createForVerifiedPassword(): Promise<{ token: string; data: SessionData }> {
return this.createResult; return this.createResult;
} }

View File

@@ -25,6 +25,10 @@ const INVALID_CREDENTIALS_MESSAGE = 'Benutzername oder Passwort ist falsch';
*/ */
@Injectable() @Injectable()
export class AuthService { export class AuthService {
// Ein echter Argon2-Hash für unbekannte Benutzernamen hält den teuren
// Verifikationsschritt in beiden Fehlpfaden vergleichbar.
private readonly dummyPasswordHash: Promise<string>;
constructor( constructor(
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher, private readonly passwordHasher: PasswordHasher,
@@ -32,7 +36,9 @@ export class AuthService {
private readonly rateLimiter: RateLimiterService, private readonly rateLimiter: RateLimiterService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {
this.dummyPasswordHash = this.passwordHasher.hash('mpm-invalid-user-placeholder');
}
async login(input: { async login(input: {
username: string; username: string;
@@ -62,6 +68,7 @@ export class AuthService {
// Gleiches Verhalten für "unbekannter Benutzer" und "falsches Passwort" // Gleiches Verhalten für "unbekannter Benutzer" und "falsches Passwort"
// (keine User-Enumeration). // (keine User-Enumeration).
if (!user) { if (!user) {
await this.passwordHasher.verify(await this.dummyPasswordHash, input.password);
await this.auditService.record({ await this.auditService.record({
userId: null, userId: null,
username: input.username, username: input.username,
@@ -96,12 +103,23 @@ export class AuthService {
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
} }
await this.userRepository.updateLoginSuccess(user.id); const createdSession = await this.sessionService.createForVerifiedPassword(
const { token, data } = await this.sessionService.create(
user.id, user.id,
user.passwordHash,
security.sessionTtlMinutes, security.sessionTtlMinutes,
); );
if (!createdSession) {
await this.auditService.record({
userId: user.id,
username: user.username,
action: 'LOGIN_FAILED',
details: { reason: 'PASSWORD_CHANGED_DURING_LOGIN' },
ipAddress: input.ipAddress,
});
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
}
const { token, data } = createdSession;
await this.userRepository.updateLoginSuccess(user.id);
await this.auditService.record({ await this.auditService.record({
userId: user.id, userId: user.id,

View File

@@ -4,6 +4,7 @@ import { UserRepository } from '../../users/user.repository';
import type { UserRecord } from '../../users/user.types'; import type { UserRecord } from '../../users/user.types';
import { SessionService } from '../session.service'; import { SessionService } from '../session.service';
import { SessionGuard } from './session.guard'; import { SessionGuard } from './session.guard';
import type { AppConfig } from '../../config/config.tokens';
/** Erzeugt einen Benutzer-Datensatz für Tests. */ /** Erzeugt einen Benutzer-Datensatz für Tests. */
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord { function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
@@ -74,6 +75,7 @@ describe('SessionGuard', () => {
sessionService as unknown as SessionService, sessionService as unknown as SessionService,
userRepository as unknown as UserRepository, userRepository as unknown as UserRepository,
reflector, reflector,
{ security: { cookieSecure: false } } as AppConfig,
); );
}); });

View File

@@ -1,5 +1,6 @@
import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { Inject, type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { APP_CONFIG, type AppConfig } from '../../config/config.tokens';
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
import { UserRepository } from '../../users/user.repository'; import { UserRepository } from '../../users/user.repository';
import type { AuthenticatedRequest } from '../authenticated-request'; import type { AuthenticatedRequest } from '../authenticated-request';
@@ -11,7 +12,7 @@ interface RequestWithCookieHeader {
} }
/** Extrahiert das Session-Cookie aus einem Request. */ /** Extrahiert das Session-Cookie aus einem Request. */
export function extractSessionToken(request: RequestWithCookieHeader): string | null { export function extractSessionToken(request: RequestWithCookieHeader, secureCookie = false): string | null {
const cookieHeader = request.headers.cookie; const cookieHeader = request.headers.cookie;
if (!cookieHeader) { if (!cookieHeader) {
return null; return null;
@@ -19,7 +20,7 @@ export function extractSessionToken(request: RequestWithCookieHeader): string |
let sessionToken: string | null = null; let sessionToken: string | null = null;
for (const part of cookieHeader.split(';')) { for (const part of cookieHeader.split(';')) {
const [name, ...value] = part.trim().split('='); const [name, ...value] = part.trim().split('=');
if (name === 'mpm_session') { if (name === (secureCookie ? '__Host-mpm_session' : 'mpm_session')) {
// Browsers may send same-name cookies from an older, narrower Path // Browsers may send same-name cookies from an older, narrower Path
// before the current Path=/ cookie. The last value is the root cookie. // before the current Path=/ cookie. The last value is the root cookie.
sessionToken = decodeURIComponent(value.join('=')); sessionToken = decodeURIComponent(value.join('='));
@@ -40,6 +41,7 @@ export class SessionGuard implements CanActivate {
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly reflector: Reflector, private readonly reflector: Reflector,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -52,7 +54,7 @@ export class SessionGuard implements CanActivate {
} }
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = extractSessionToken(request); const token = extractSessionToken(request, this.config.security.cookieSecure);
if (!token) { if (!token) {
throw new UnauthorizedException('Nicht authentifiziert'); throw new UnauthorizedException('Nicht authentifiziert');
} }

View File

@@ -0,0 +1,23 @@
import { ForbiddenException } from '@nestjs/common';
import type { Request } from 'express';
/**
* Browsers senden bei POST/PUT/PATCH/DELETE einen Origin-Header. Der Vergleich
* verhindert auch Anfragen von einer anderen Subdomain derselben Site.
*/
export function requireSameOrigin(request: Request, canonicalOrigin: string): void {
const origin = request.headers.origin;
const host = request.headers.host;
if (typeof origin !== 'string' || !host) {
throw new ForbiddenException('Ungültiger Request-Ursprung');
}
const canonical = new URL(canonicalOrigin);
// Hinter einem TLS-Reverse-Proxy sieht NestJS eventuell nur HTTP. Für den
// konfigurierten öffentlichen Host ist die veröffentlichte URL maßgeblich.
const protocol = canonical.host === host ? canonical.protocol : `${request.protocol}:`;
const expected = `${protocol}//${host}`;
if (origin !== expected) {
throw new ForbiddenException('Ungültiger Request-Ursprung');
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
@@ -17,6 +17,12 @@ interface SessionRow {
expires_at: Date; expires_at: Date;
} }
interface ModuleAccessRow {
user_id: string;
module_slug: string;
platform_session_id: string;
}
/** /**
* Serverseitige Session-Verwaltung (Infrastructure): * Serverseitige Session-Verwaltung (Infrastructure):
* - 256-Bit-Zufalls-Token, in der DB wird nur der SHA-256-Hash gespeichert * - 256-Bit-Zufalls-Token, in der DB wird nur der SHA-256-Hash gespeichert
@@ -43,6 +49,121 @@ export class SessionService {
return { token, data: this.mapRow(result.rows[0]) }; return { token, data: this.mapRow(result.rows[0]) };
} }
/**
* Erstellt die Session nur, wenn der gerade verifizierte Passwort-Hash noch
* aktuell ist. Die Zeilensperre serialisiert diesen Schritt mit Resets:
* entweder wird die Session vom Reset gelöscht oder der alte Hash abgewiesen.
*/
async createForVerifiedPassword(
userId: string,
verifiedPasswordHash: string,
ttlMinutes: number,
): Promise<{ token: string; data: SessionData } | null> {
const token = randomBytes(32).toString('base64url');
const csrfToken = randomBytes(32).toString('base64url');
return this.database.transaction(async (client) => {
const user = await client.query<{ password_hash: string; is_active: boolean }>(
'SELECT password_hash, is_active FROM users WHERE id = $1 FOR UPDATE',
[userId],
);
if (!user.rows[0]?.is_active || user.rows[0].password_hash !== verifiedPasswordHash) {
return null;
}
const result = await client.query<SessionRow>(
`INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at)
VALUES ($1, $2, $3, now() + make_interval(mins => $4::int))
RETURNING id, user_id, csrf_token, expires_at`,
[userId, this.hashToken(token), csrfToken, ttlMinutes],
);
return { token, data: this.mapRow(result.rows[0]) };
});
}
/** Einmal-Ticket, das an die noch gültige Plattform-Session gebunden ist. */
async createModuleAccessTicket(
platformSessionId: string,
userId: string,
moduleSlug: string,
): Promise<string> {
const ticket = randomBytes(32).toString('base64url');
await this.database.transaction(async (client) => {
const platformSession = await client.query(
'SELECT id FROM sessions WHERE id = $1 AND user_id = $2 AND expires_at > now() FOR SHARE',
[platformSessionId, userId],
);
if (!platformSession.rows[0]) throw new UnauthorizedException('Nicht authentifiziert');
await client.query('DELETE FROM module_access_tickets WHERE expires_at <= now()');
await client.query('DELETE FROM module_sessions WHERE expires_at <= now()');
await client.query(
`INSERT INTO module_access_tickets
(ticket_hash, platform_session_id, user_id, module_slug, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '60 seconds')`,
[this.hashToken(ticket), platformSessionId, userId, moduleSlug],
);
});
return ticket;
}
/** Verbraucht das Ticket atomar und legt nur für den angegebenen Modulpfad eine Session an. */
async exchangeModuleAccessTicket(
ticket: string,
ttlMinutes: number,
): Promise<{ token: string; userId: string; moduleSlug: string } | null> {
const token = randomBytes(32).toString('base64url');
return this.database.transaction(async (client) => {
const ticketHash = this.hashToken(ticket);
const ticketRow = await client.query<ModuleAccessRow>(
`SELECT user_id, module_slug, platform_session_id FROM module_access_tickets
WHERE ticket_hash = $1 AND expires_at > now()`,
[ticketHash],
);
if (!ticketRow.rows[0]) return null;
// Dieselbe Zeilensperre wie bei Passwortwechsel und Login verhindert,
// dass ein Reset nach der Prüfung eine neue Modulsession überlebt.
const activeUser = await client.query<{ id: string }>(
'SELECT id FROM users WHERE id = $1 AND is_active FOR UPDATE',
[ticketRow.rows[0].user_id],
);
if (!activeUser.rows[0]) return null;
const consumed = await client.query<ModuleAccessRow>(
`DELETE FROM module_access_tickets t
WHERE t.ticket_hash = $1 AND t.expires_at > now()
AND EXISTS (
SELECT 1 FROM sessions s
WHERE s.id = t.platform_session_id AND s.expires_at > now()
)
RETURNING t.user_id, t.module_slug, t.platform_session_id`,
[ticketHash],
);
if (!consumed.rows[0]) return null;
await client.query(
`INSERT INTO module_sessions (token_hash, platform_session_id, user_id, module_slug, expires_at)
VALUES ($1, $2, $3, $4, now() + make_interval(mins => $5::int))`,
[this.hashToken(token), consumed.rows[0].platform_session_id, consumed.rows[0].user_id, consumed.rows[0].module_slug, ttlMinutes],
);
return {
token,
userId: consumed.rows[0].user_id,
moduleSlug: consumed.rows[0].module_slug,
};
});
}
/** Modul-Cookies gelten ausschließlich für das ausgestellte Modul. */
async findValidModuleSession(token: string, moduleSlug: string): Promise<string | null> {
const result = await this.database.query<{ user_id: string }>(
`SELECT m.user_id FROM module_sessions m
JOIN sessions s ON s.id = m.platform_session_id
WHERE m.token_hash = $1 AND m.module_slug = $2
AND m.expires_at > now() AND s.expires_at > now()`,
[this.hashToken(token), moduleSlug],
);
return result.rows[0]?.user_id ?? null;
}
/** Findet eine gültige Session anhand des Klartext-Tokens. */ /** Findet eine gültige Session anhand des Klartext-Tokens. */
async findValid(token: string): Promise<SessionData | null> { async findValid(token: string): Promise<SessionData | null> {
const result = await this.database.query<SessionRow>( const result = await this.database.query<SessionRow>(
@@ -75,6 +196,8 @@ export class SessionService {
/** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */ /** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */
async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> { async deleteAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
await this.database.query('DELETE FROM module_sessions WHERE user_id = $1', [userId]);
await this.database.query('DELETE FROM module_access_tickets WHERE user_id = $1', [userId]);
if (exceptSessionId) { if (exceptSessionId) {
await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [ await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [
userId, userId,
@@ -87,6 +210,8 @@ export class SessionService {
/** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */ /** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */
async deleteExpired(): Promise<void> { async deleteExpired(): Promise<void> {
await this.database.query('DELETE FROM module_access_tickets WHERE expires_at <= now()');
await this.database.query('DELETE FROM module_sessions WHERE expires_at <= now()');
await this.database.query('DELETE FROM sessions WHERE expires_at <= now()'); await this.database.query('DELETE FROM sessions WHERE expires_at <= now()');
} }

View File

@@ -35,6 +35,7 @@ export interface RuntimeConfig {
readonly modulesDir: string; readonly modulesDir: string;
readonly logsDir: string; readonly logsDir: string;
readonly moduleUidBase?: number; readonly moduleUidBase?: number;
readonly moduleConfigurationEncryptionKey: string;
} }
export interface MarketplaceProviderConfig { export interface MarketplaceProviderConfig {
@@ -61,6 +62,22 @@ export interface AppConfig {
readonly adminSeed: AdminSeedConfig; readonly adminSeed: AdminSeedConfig;
readonly runtime: RuntimeConfig; readonly runtime: RuntimeConfig;
readonly marketplace: MarketplaceConfig; readonly marketplace: MarketplaceConfig;
/** Eigener Browser-Host für Moduloberflächen (Host muss von MPM abweichen). */
readonly modulePublicOrigin: string;
}
function moduleOriginFor(publicUrl: string, configuredOrigin: string): string {
if (configuredOrigin) return configuredOrigin;
const platform = new URL(publicUrl);
const modules = new URL(platform.origin);
if (platform.hostname === 'localhost') {
modules.hostname = '127.0.0.1';
} else if (platform.hostname === '127.0.0.1' || platform.hostname === '[::1]') {
modules.hostname = 'localhost';
} else {
modules.hostname = `modules.${platform.hostname}`;
}
return modules.origin;
} }
const booleanFromString = z const booleanFromString = z
@@ -85,8 +102,10 @@ const environmentSchema = z.object({
MODULES_DIR: z.string().min(1).default('./data/modules'), MODULES_DIR: z.string().min(1).default('./data/modules'),
MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'), MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'),
LOGS_DIR: z.string().min(1).default('./data/logs'), LOGS_DIR: z.string().min(1).default('./data/logs'),
MODULE_CONFIG_ENCRYPTION_KEY: z.string().default(''),
MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(), MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(),
MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'), MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'),
MODULE_PUBLIC_ORIGIN: z.string().default(''),
MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''), MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''),
GITHUB_OAUTH_CLIENT_ID: z.string().default(''), GITHUB_OAUTH_CLIENT_ID: z.string().default(''),
GITHUB_OAUTH_CLIENT_SECRET: z.string().default(''), GITHUB_OAUTH_CLIENT_SECRET: z.string().default(''),
@@ -97,6 +116,26 @@ const environmentSchema = z.object({
FORGEJO_OAUTH_CLIENT_ID: z.string().default(''), FORGEJO_OAUTH_CLIENT_ID: z.string().default(''),
FORGEJO_OAUTH_CLIENT_SECRET: z.string().default(''), FORGEJO_OAUTH_CLIENT_SECRET: z.string().default(''),
}).superRefine((environment, context) => { }).superRefine((environment, context) => {
try {
const platform = new URL(environment.MARKETPLACE_PUBLIC_URL);
const moduleOrigin = moduleOriginFor(environment.MARKETPLACE_PUBLIC_URL, environment.MODULE_PUBLIC_ORIGIN);
const modules = new URL(moduleOrigin);
if (modules.origin !== moduleOrigin || modules.hostname === platform.hostname || modules.username || modules.password || modules.search || modules.hash || modules.pathname !== '/') {
throw new Error('origin');
}
if (environment.NODE_ENV === 'production' && modules.protocol !== 'https:') {
throw new Error('https');
}
if (!/^[a-z0-9.-]+$/i.test(modules.hostname)) {
throw new Error('hostname');
}
} catch {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['MODULE_PUBLIC_ORIGIN'],
message: 'Modul-Origin benötigt einen eigenen Host (in Produktion HTTPS), ohne Pfad oder Zugangsdaten',
});
}
if (environment.NODE_ENV === 'production' && !environment.COOKIE_SECURE) { if (environment.NODE_ENV === 'production' && !environment.COOKIE_SECURE) {
context.addIssue({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -184,6 +223,7 @@ export function loadConfiguration(): AppConfig {
runtime: { runtime: {
modulesDir: path.resolve(environment.MODULES_DIR), modulesDir: path.resolve(environment.MODULES_DIR),
logsDir: path.resolve(environment.LOGS_DIR), logsDir: path.resolve(environment.LOGS_DIR),
moduleConfigurationEncryptionKey: environment.MODULE_CONFIG_ENCRYPTION_KEY,
...(environment.MODULE_UID_BASE !== undefined ...(environment.MODULE_UID_BASE !== undefined
? { moduleUidBase: environment.MODULE_UID_BASE } ? { moduleUidBase: environment.MODULE_UID_BASE }
: {}), : {}),
@@ -203,5 +243,6 @@ export function loadConfiguration(): AppConfig {
: {}), : {}),
}, },
}, },
modulePublicOrigin: moduleOriginFor(environment.MARKETPLACE_PUBLIC_URL, environment.MODULE_PUBLIC_ORIGIN),
}; };
} }

View File

@@ -0,0 +1,32 @@
import type { Migration } from '../migration.types';
/** Einmalige Übergabe vom Plattformhost auf den getrennten Modulhost. */
export const migration013ModuleBrowserSessions: Migration = {
id: '013-module-browser-sessions',
description: 'Einmal-Tickets und getrennte Browser-Sessions für Module',
up: async (client) => {
await client.query(`
CREATE TABLE module_access_tickets (
ticket_hash TEXT PRIMARY KEY,
platform_session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_slug TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
)
`);
await client.query(`
CREATE TABLE module_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT NOT NULL UNIQUE,
platform_session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_slug TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await client.query('CREATE INDEX idx_module_sessions_user ON module_sessions(user_id)');
await client.query('CREATE INDEX idx_module_sessions_expiry ON module_sessions(expires_at)');
await client.query('CREATE INDEX idx_module_tickets_expiry ON module_access_tickets(expires_at)');
},
};

View File

@@ -8,6 +8,9 @@ import { migration007MarketplaceCatalog } from '../../modules/migrations/007-mar
import { migration008MarketplaceSourceBranch } from '../../modules/migrations/008-marketplace-source-branch'; import { migration008MarketplaceSourceBranch } from '../../modules/migrations/008-marketplace-source-branch';
import { migration009MarketplaceInstallations } from '../../modules/migrations/009-marketplace-installations'; import { migration009MarketplaceInstallations } from '../../modules/migrations/009-marketplace-installations';
import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers'; import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers';
import { migration011ModuleConfiguration } from '../../modules/migrations/011-module-configuration';
import { migration012MarketplaceBranchUpdates } from '../../modules/migrations/012-marketplace-branch-updates';
import { migration013ModuleBrowserSessions } from './013-module-browser-sessions';
/** Registrierte Migrationen in aufsteigender Reihenfolge. */ /** Registrierte Migrationen in aufsteigender Reihenfolge. */
export const MIGRATIONS = [ export const MIGRATIONS = [
@@ -21,4 +24,7 @@ export const MIGRATIONS = [
migration008MarketplaceSourceBranch, migration008MarketplaceSourceBranch,
migration009MarketplaceInstallations, migration009MarketplaceInstallations,
migration010ModuleContainers, migration010ModuleContainers,
migration011ModuleConfiguration,
migration012MarketplaceBranchUpdates,
migration013ModuleBrowserSessions,
]; ];

View File

@@ -42,7 +42,7 @@ async function bootstrap(): Promise<void> {
.setTitle('MPM Management API') .setTitle('MPM Management API')
.setDescription('Zentrale Management-API der MPM-Plattform (Auth, RBAC, Health)') .setDescription('Zentrale Management-API der MPM-Plattform (Auth, RBAC, Health)')
.setVersion('0.1.0') .setVersion('0.1.0')
.addCookieAuth('mpm_session') .addCookieAuth(config.security.cookieSecure ? '__Host-mpm_session' : 'mpm_session')
.build(); .build();
const document = SwaggerModule.createDocument(app, swaggerConfig); const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);

View File

@@ -16,11 +16,46 @@ export type ModuleStatus = (typeof MODULE_STATUSES)[number];
export const MODULE_PORT_MIN = 41000; export const MODULE_PORT_MIN = 41000;
export const MODULE_PORT_MAX = 41999; export const MODULE_PORT_MAX = 41999;
/** Vom Modul deklarierte, durch Admins setzbare Compose-Umgebungsvariable. */
export const moduleConfigurationFieldSchema = z.object({
key: z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/),
label: z.string().trim().min(1).max(100),
description: z.string().max(500).default(''),
type: z.enum(['text', 'url', 'boolean']).default('text'),
secret: z.boolean().default(false),
required: z.boolean().default(true),
defaultValue: z.string().max(8192).optional(),
services: z.array(z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/)).min(1).max(20),
}).superRefine((field, context) => {
if (field.secret && field.defaultValue !== undefined) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Geheimnisse dürfen keinen Manifest-Standardwert haben' });
}
if (field.required && field.defaultValue !== undefined && !field.defaultValue.trim()) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Pflichtfelder benötigen einen nicht leeren Standardwert' });
}
if (field.type === 'boolean' && field.defaultValue !== undefined && !['true', 'false'].includes(field.defaultValue)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Boolean-Standardwerte müssen true oder false sein' });
}
if (field.type === 'url' && field.defaultValue !== undefined) {
try {
if (!['http:', 'https:'].includes(new URL(field.defaultValue).protocol)) throw new Error('protocol');
} catch {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'URL-Standardwerte müssen HTTP oder HTTPS verwenden' });
}
}
const processEnvironmentKey = /^(PATH|HOME|TMPDIR|PORT|NODE_ENV|MPM_MODULE_DATA_DIR|MPM_MODULE_IDENTITY_KEY|DOCKER_.*|COMPOSE_.*|NODE_.*|NPM_.*|PYTHON.*|BASH_.*|LD_.*|DYLD_.*|RUBY.*|PERL.*|GIT_.*|SSH_AUTH_SOCK)$/;
if (processEnvironmentKey.test(field.key)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['key'], message: 'Dieser Umgebungsvariablenname ist für MPM reserviert' });
}
});
export type ModuleConfigurationField = z.infer<typeof moduleConfigurationFieldSchema>;
/** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */ /** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */
const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/; const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/;
/** URL-Slugs für das spätere Routing (/slug). */ /** URL-Slugs für das spätere Routing (/slug). */
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{2,99}$/; const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{2,99}$/;
const RESERVED_SLUGS = new Set(['api', 'assets', 'login', 'admin', 'profile', '403', '404']);
/** Semantische Versionierung (major.minor.patch). */ /** Semantische Versionierung (major.minor.patch). */
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -35,7 +70,9 @@ export const moduleManifestSchema = z.object({
.regex(MODULE_ID_PATTERN, 'Modul-ID muss dem Muster [a-z][a-z0-9-]{2,63} folgen'), .regex(MODULE_ID_PATTERN, 'Modul-ID muss dem Muster [a-z][a-z0-9-]{2,63} folgen'),
name: z.string().trim().min(1, 'Name ist erforderlich').max(100), name: z.string().trim().min(1, 'Name ist erforderlich').max(100),
version: z.string().regex(VERSION_PATTERN, 'Version muss dem Muster major.minor.patch folgen'), version: z.string().regex(VERSION_PATTERN, 'Version muss dem Muster major.minor.patch folgen'),
slug: z.string().regex(SLUG_PATTERN, 'Slug muss dem Muster [a-z0-9-]{3,100} folgen'), slug: z.string()
.regex(SLUG_PATTERN, 'Slug muss dem Muster [a-z0-9-]{3,100} folgen')
.refine((slug) => !RESERVED_SLUGS.has(slug), 'Dieser Slug ist für die Plattform reserviert'),
description: z.string().max(500).default(''), description: z.string().max(500).default(''),
author: z.string().max(200).default(''), author: z.string().max(200).default(''),
runtime: z.literal('node'), runtime: z.literal('node'),
@@ -54,6 +91,15 @@ export const moduleManifestSchema = z.object({
apiVersion: z.literal('v1'), apiVersion: z.literal('v1'),
composeFile: z.string().min(1).max(200).optional(), composeFile: z.string().min(1).max(200).optional(),
appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(), appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(),
configuration: z.array(moduleConfigurationFieldSchema).max(50).default([]),
}).superRefine((manifest, context) => {
const keys = new Set<string>();
manifest.configuration.forEach((field, index) => {
if (keys.has(field.key)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['configuration', index, 'key'], message: 'Konfigurationsschlüssel dürfen nicht doppelt vorkommen' });
}
keys.add(field.key);
});
}); });
export type ModuleManifest = z.infer<typeof moduleManifestSchema>; export type ModuleManifest = z.infer<typeof moduleManifestSchema>;
@@ -75,4 +121,6 @@ export interface ModuleRecord {
readonly updatedAt: Date; readonly updatedAt: Date;
readonly composeFile?: string | null; readonly composeFile?: string | null;
readonly appService?: string | null; readonly appService?: string | null;
readonly configuration: readonly ModuleConfigurationField[];
readonly configurationReady: boolean;
} }

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common'; import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -6,9 +6,12 @@ import { Public } from '../common/decorators/public.decorator';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import type { AuthUser } from '../users/user.types'; import type { AuthUser } from '../users/user.types';
import { SessionService } from '../auth/session.service'; import { SessionService } from '../auth/session.service';
import { extractSessionToken } from '../auth/guards/session.guard';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import type { AuthenticatedRequest } from '../auth/authenticated-request'; import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { MarketplaceService } from './marketplace.service'; import { MarketplaceService } from './marketplace.service';
import { ModulesService } from './modules.service'; import { ModulesService } from './modules.service';
import type { ModuleRecord } from './manifest.types';
@ApiTags('Marketplace') @ApiTags('Marketplace')
@Controller({ path: 'api/v1/marketplace' }) @Controller({ path: 'api/v1/marketplace' })
@@ -17,6 +20,7 @@ export class MarketplaceController {
private readonly marketplaceService: MarketplaceService, private readonly marketplaceService: MarketplaceService,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly modulesService: ModulesService, private readonly modulesService: ModulesService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
@Get('providers') @Get('providers')
@@ -42,12 +46,16 @@ export class MarketplaceController {
): Promise<{ module: { ): Promise<{ module: {
id: string; moduleId: string; name: string; slug: string; version: string; description: string; id: string; moduleId: string; name: string; slug: string; version: string; description: string;
author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string; author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string;
configuration: ModuleRecord['configuration']; configurationReady: boolean;
} }> { } }> {
const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository); const branch = await this.marketplaceService.defaultBranch(provider, owner, repository);
const { archive, commit } = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository, branch);
const manifest = await this.modulesService.validatePackage(archive); const manifest = await this.modulesService.validatePackage(archive);
const module = await this.modulesService.findByModuleId(manifest.id) ?? if (await this.modulesService.findByModuleId(manifest.id)) {
await this.modulesService.install(archive, actor, request.ip ?? null); throw new BadRequestException('Ein Modul mit dieser ID ist bereits installiert');
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id); }
const module = await this.modulesService.install(archive, actor, request.ip ?? null);
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id, branch, commit);
return { return {
module: { module: {
id: module.id, id: module.id,
@@ -62,10 +70,62 @@ export class MarketplaceController {
healthcheckUrl: module.healthcheckUrl, healthcheckUrl: module.healthcheckUrl,
enabled: module.enabled, enabled: module.enabled,
createdAt: module.createdAt.toISOString(), createdAt: module.createdAt.toISOString(),
configuration: module.configuration,
configurationReady: module.configurationReady,
}, },
}; };
} }
@Get('modules/:moduleId/updates')
@Roles('ADMIN')
updates(@Param('moduleId') moduleId: string) {
return this.marketplaceService.availableUpdates(moduleId);
}
@Get('operations/:operationId')
@Roles('ADMIN')
operationProgress(@Param('operationId') operationId: string, @CurrentUser() actor: AuthUser) {
return this.marketplaceService.getOperationProgress(operationId, actor.id);
}
@Post('modules/:moduleId/update')
@Roles('ADMIN')
async updateModule(
@Param('moduleId') moduleId: string,
@Body() body: { branch?: unknown; operationId?: unknown },
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest,
): Promise<{ module: ModuleRecord }> {
if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen');
const operationId = this.marketplaceService.beginOperation(body.operationId, actor.id);
const report = (phase: string, message: string, progress: number) =>
this.marketplaceService.reportOperation(operationId, actor.id, phase, message, progress);
try {
report('branch', 'Branch wird geprüft', 8);
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch, report);
report('validation', 'Update-Paket wird geprüft', 40);
const module = await this.modulesService.updateFromMarketplace(
moduleId,
update.archive,
actor,
request.ip ?? null,
report,
async () => {
report('commit', 'Neue Version wird registriert', 96);
await this.marketplaceService.commitInstalledBranch(
moduleId, update.provider, update.owner, update.repository, update.branch, update.commit,
update.previousBranch, update.previousCommit,
);
},
);
this.marketplaceService.finishOperation(operationId, actor.id, true);
return { module };
} catch (error) {
this.marketplaceService.finishOperation(operationId, actor.id, false);
throw error;
}
}
@Post('connections/:provider/start') @Post('connections/:provider/start')
@Roles('ADMIN') @Roles('ADMIN')
async startConnection( async startConnection(
@@ -102,7 +162,7 @@ export class MarketplaceController {
destination.searchParams.set('reason', 'callback'); destination.searchParams.set('reason', 'callback');
} else { } else {
try { try {
const token = request.cookies?.mpm_session as string | undefined; const token = extractSessionToken(request, this.config.security.cookieSecure);
const session = token ? await this.sessionService.findValid(token) : null; const session = token ? await this.sessionService.findValid(token) : null;
if (!session) { if (!session) {
destination.searchParams.set('marketplace', 'error'); destination.searchParams.set('marketplace', 'error');

View File

@@ -1,10 +1,14 @@
import { import {
BadGatewayException, BadGatewayException,
BadRequestException, BadRequestException,
ConflictException,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
Logger,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { createCipheriv, createHash, randomBytes } from 'node:crypto'; import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
@@ -37,8 +41,51 @@ interface ProviderIdentity {
readonly username?: string; readonly username?: string;
} }
export interface MarketplaceBranch {
name: string;
commit: string;
}
export interface MarketplaceUpdatePackage {
archive: Buffer;
commit: string;
provider: MarketplaceProvider;
owner: string;
repository: string;
branch: string;
previousBranch: string;
previousCommit: string | null;
}
export interface MarketplaceRepositoryArchive {
archive: Buffer;
commit: string;
}
export interface MarketplaceOperationProgress {
status: 'running' | 'completed' | 'failed';
message: string;
phase: string;
progress: number;
}
interface MarketplaceInstallationRow {
module_id: string;
provider: MarketplaceProvider;
owner: string;
repository: string;
installed_branch: string;
installed_commit: string | null;
available_branches: MarketplaceBranch[];
observed_branches: MarketplaceBranch[];
}
const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024; const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024;
function isCommitSha(value: string): boolean {
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
}
function isProvider(value: string): value is MarketplaceProvider { function isProvider(value: string): value is MarketplaceProvider {
return MARKETPLACE_PROVIDERS.includes(value as MarketplaceProvider); return MARKETPLACE_PROVIDERS.includes(value as MarketplaceProvider);
} }
@@ -54,13 +101,79 @@ function safeBaseUrl(value: string): string {
return url.toString().replace(/\/$/, ''); return url.toString().replace(/\/$/, '');
} }
function branchVersion(branch: string): number[] | null {
const match = branch.match(/(?:^|[-_/.])v?(\d+(?:\.\d+)+)$/i);
return match ? match[1].split('.').map(Number) : null;
}
function isNewerVersionBranch(candidate: string, installed: string): boolean {
const candidateVersion = branchVersion(candidate);
const installedVersion = branchVersion(installed);
// Preserve support for repositories that use non-versioned release branch
// names. When both names carry versions, only offer a strictly newer one.
if (!candidateVersion || !installedVersion) return true;
const length = Math.max(candidateVersion.length, installedVersion.length);
for (let index = 0; index < length; index += 1) {
const candidatePart = candidateVersion[index] ?? 0;
const installedPart = installedVersion[index] ?? 0;
if (candidatePart !== installedPart) return candidatePart > installedPart;
}
return false;
}
@Injectable() @Injectable()
export class MarketplaceService { export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MarketplaceService.name);
private branchCheckTimer: NodeJS.Timeout | undefined;
private readonly operationProgress = new Map<string, { userId: string; state: MarketplaceOperationProgress }>();
constructor( constructor(
private readonly database: DatabaseService, private readonly database: DatabaseService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
onModuleInit(): void {
const initialCheck = setTimeout(() => void this.refreshAllBranchSnapshots(), 10_000);
initialCheck.unref();
this.branchCheckTimer = setInterval(() => void this.refreshAllBranchSnapshots(), 60 * 1000);
this.branchCheckTimer.unref();
}
onModuleDestroy(): void {
if (this.branchCheckTimer) clearInterval(this.branchCheckTimer);
}
beginOperation(operationId: unknown, userId: string): string | null {
if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) return null;
const state: MarketplaceOperationProgress = { status: 'running', phase: 'starting', message: 'Update wird vorbereitet', progress: 3 };
this.operationProgress.set(operationId, { userId, state });
return operationId;
}
reportOperation(operationId: string | null, userId: string, phase: string, message: string, progress: number): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId || operation.state.status !== 'running') return;
operation.state = { status: 'running', phase, message, progress: Math.max(0, Math.min(99, progress)) };
}
finishOperation(operationId: string | null, userId: string, success: boolean): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) return;
operation.state = success
? { status: 'completed', phase: 'completed', message: 'Installation abgeschlossen', progress: 100 }
: { ...operation.state, status: 'failed', phase: 'failed', message: 'Installation fehlgeschlagen' };
const cleanup = setTimeout(() => this.operationProgress.delete(operationId), 15 * 60 * 1000);
cleanup.unref();
}
getOperationProgress(operationId: string, userId: string): MarketplaceOperationProgress {
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) throw new NotFoundException('Installationsvorgang wurde nicht gefunden');
return operation.state;
}
async providers(): Promise<ProviderStatus[]> { async providers(): Promise<ProviderStatus[]> {
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>( const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
'SELECT provider, account_login FROM marketplace_connections', 'SELECT provider, account_login FROM marketplace_connections',
@@ -204,17 +317,67 @@ export class MarketplaceService {
})); }));
} }
async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string): Promise<void> { async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string, branch: string, commit: string): Promise<void> {
const provider = this.requireProvider(providerParam); const provider = this.requireProvider(providerParam);
if (!isCommitSha(commit)) throw new BadRequestException('Commit-ID ist ungültig');
let branches: MarketplaceBranch[] = [];
try {
branches = await this.fetchBranches(provider, owner, repository);
} catch (error) {
this.logger.warn(`Branch-Prüfung für Modul ${moduleId} nach Installation fehlgeschlagen: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`);
}
await this.database.query( await this.database.query(
`INSERT INTO marketplace_module_installations (provider, owner, repository, module_id) `INSERT INTO marketplace_module_installations
VALUES ($1, $2, $3, $4) (provider, owner, repository, module_id, installed_branch, installed_commit, available_branches, observed_branches, branches_checked_at)
ON CONFLICT (provider, owner, repository) DO UPDATE SET module_id = EXCLUDED.module_id`, VALUES ($1, $2, $3, $4, $5, $6, '[]'::jsonb, $7::jsonb, now())
[provider, owner, repository, moduleId], ON CONFLICT (provider, owner, repository) DO UPDATE SET
module_id = EXCLUDED.module_id, installed_branch = EXCLUDED.installed_branch,
installed_commit = EXCLUDED.installed_commit, available_branches = '[]'::jsonb,
observed_branches = EXCLUDED.observed_branches, branches_checked_at = now()`,
[provider, owner, repository, moduleId, branch, commit, JSON.stringify(branches)],
); );
} }
async downloadRepositoryArchive(providerParam: string, owner: string, repository: string): Promise<Buffer> { async defaultBranch(providerParam: string, owner: string, repository: string): Promise<string> {
const provider = this.requireProvider(providerParam);
const config = this.config.marketplace.providers[provider];
if (!config) throw new BadRequestException('Forge-Anbieter ist nicht konfiguriert');
const repo = await this.forgeJson<Record<string, unknown>>(provider, config.baseUrl, null,
this.repositoryApiPath(provider, owner, repository));
const branch = String(repo.default_branch ?? 'main');
if (!branch || branch.length > 200) throw new BadRequestException('Standard-Branch ist ungültig');
return branch;
}
async availableUpdates(moduleId: string): Promise<{ installedBranch: string; installedCommit: string | null; branches: MarketplaceBranch[] }> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches, observed_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
);
const row = result.rows[0];
if (!row) return { installedBranch: '', installedCommit: null, branches: [] };
return {
installedBranch: row.installed_branch,
installedCommit: row.installed_commit,
// The default branch is the source branch used for installation, not a
// release candidate. Other branches are selectable only when they point
// to a commit newer/different from the currently installed snapshot.
branches: Array.isArray(row.available_branches)
? row.available_branches.filter((branch) => branch.name !== 'main'
&& branch.name !== row.installed_branch
&& isNewerVersionBranch(branch.name, row.installed_branch)
&& (!row.installed_commit || branch.commit.toLowerCase() !== row.installed_commit.toLowerCase()))
: [],
};
}
async downloadRepositoryArchive(
providerParam: string,
owner: string,
repository: string,
branch?: string,
pinnedCommit?: string,
): Promise<MarketplaceRepositoryArchive> {
const provider = this.requireProvider(providerParam); const provider = this.requireProvider(providerParam);
if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) { if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) {
throw new BadRequestException('Repository-Angabe ist ungueltig'); throw new BadRequestException('Repository-Angabe ist ungueltig');
@@ -228,18 +391,158 @@ export class MarketplaceService {
this.repositoryApiPath(provider, owner, repository), this.repositoryApiPath(provider, owner, repository),
); );
if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt'); if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt');
const defaultBranch = String(repo.default_branch ?? 'main'); const selectedBranch = branch ?? String(repo.default_branch ?? 'main');
if (!defaultBranch || defaultBranch.length > 200) throw new BadRequestException('Standard-Branch ist ungueltig'); if (!selectedBranch || selectedBranch.length > 200 || selectedBranch.includes('\0')) throw new BadRequestException('Branch ist ungueltig');
// Resolve once, then request the immutable commit instead of the movable branch ref.
const commit = pinnedCommit ?? (await this.getBranch(provider, owner, repository, selectedBranch)).commit;
if (!isCommitSha(commit)) throw new BadGatewayException('Forge hat eine ungültige Commit-ID geliefert');
const archivePath = provider === 'github' const archivePath = provider === 'github'
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(defaultBranch) ? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + commit
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(defaultBranch) + '.zip'; : new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + commit + '.zip';
const archiveUrl = provider === 'github' const archiveUrl = provider === 'github'
? new URL(archivePath, 'https://api.github.com').toString() ? new URL(archivePath, 'https://api.github.com').toString()
: new URL(archivePath, providerConfig.baseUrl).toString(); : new URL(archivePath, providerConfig.baseUrl).toString();
const allowedHosts = this.downloadHosts(providerConfig.baseUrl, provider); const allowedHosts = this.downloadHosts(providerConfig.baseUrl, provider);
const headers: Record<string, string> = provider === 'github' ? { 'User-Agent': 'MPM-Module-Marketplace' } : {}; const headers: Record<string, string> = provider === 'github' ? { 'User-Agent': 'MPM-Module-Marketplace' } : {};
const archive = await this.downloadBounded(archiveUrl, headers, allowedHosts, MAX_MARKETPLACE_DOWNLOAD); const archive = await this.downloadBounded(archiveUrl, headers, allowedHosts, MAX_MARKETPLACE_DOWNLOAD);
return this.normalizeRepositoryArchive(archive); return { archive: await this.normalizeRepositoryArchive(archive), commit: commit.toLowerCase() };
}
async updateInstalledBranch(
moduleId: string,
branch: string,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<MarketplaceUpdatePackage> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
);
const installation = result.rows[0];
if (!installation) throw new NotFoundException('Für dieses Modul ist keine Marketplace-Quelle hinterlegt');
if (branch === 'main') throw new BadRequestException('Der Haupt-Branch main ist keine Update-Version');
if (branch === installation.installed_branch) throw new BadRequestException('Diese Branch ist bereits installiert');
if (!isNewerVersionBranch(branch, installation.installed_branch)) {
throw new BadRequestException('Diese Versions-Branch ist älter oder gleich der installierten Version');
}
if (!Array.isArray(installation.available_branches) || !installation.available_branches.some((item) => item.name === branch)) {
throw new BadRequestException('Diese Branch wurde bei der letzten Repository-Prüfung nicht als Update gefunden');
}
onProgress?.('branch', 'Branch wird geprüft', 12);
const branchInfo = await this.getBranch(installation.provider, installation.owner, installation.repository, branch);
if (installation.installed_commit && branchInfo.commit.toLowerCase() === installation.installed_commit.toLowerCase()) {
throw new BadRequestException('Diese Branch enthält keine Änderungen gegenüber der installierten Version');
}
onProgress?.('download', 'Update-Archiv wird geladen', 28);
const { archive } = await this.downloadRepositoryArchive(
installation.provider, installation.owner, installation.repository, branch, branchInfo.commit,
);
// Caller updates and validates the module files before this source record is advanced.
return { archive, commit: branchInfo.commit, provider: installation.provider,
owner: installation.owner, repository: installation.repository, branch,
previousBranch: installation.installed_branch, previousCommit: installation.installed_commit };
}
async commitInstalledBranch(
moduleId: string,
provider: MarketplaceProvider,
owner: string,
repository: string,
branch: string,
commit: string,
expectedBranch: string,
expectedCommit: string | null,
): Promise<void> {
const result = await this.database.query<{ module_id: string }>(
`UPDATE marketplace_module_installations SET installed_branch = $2, installed_commit = $3,
available_branches = COALESCE((
SELECT jsonb_agg(item.value) FROM jsonb_array_elements(available_branches) AS item(value)
WHERE item.value->>'name' <> $2
), '[]'::jsonb), branches_checked_at = now()
WHERE module_id = $1 AND provider = $4 AND owner = $5 AND repository = $6
AND installed_branch = $7 AND installed_commit IS NOT DISTINCT FROM $8
RETURNING module_id`,
[moduleId, branch, commit, provider, owner, repository, expectedBranch, expectedCommit],
);
if (!result.rows.length) {
throw new ConflictException('Die installierte Branch wurde zwischenzeitlich geändert. Bitte Updates neu laden.');
}
try {
await this.refreshInstallation(moduleId);
} catch (error) {
this.logger.warn(`Branch-Prüfung für Modul ${moduleId} nach Update fehlgeschlagen: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`);
}
}
private async getBranch(provider: MarketplaceProvider, owner: string, repository: string, branch: string): Promise<MarketplaceBranch> {
if (!/^[A-Za-z0-9_.\-/]{1,200}$/.test(branch) || branch.startsWith('/') || branch.split('/').includes('..')) {
throw new BadRequestException('Branch-Name ist ungültig');
}
const basePath = provider === 'github'
? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`
: `${new URL(this.config.marketplace.providers[provider]!.baseUrl).pathname.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`;
const value = await this.forgeJson<Record<string, unknown>>(provider, this.config.marketplace.providers[provider]!.baseUrl, null, basePath);
const commit = value.commit as Record<string, unknown> | undefined;
const sha = String(commit?.id ?? commit?.sha ?? '');
if (!isCommitSha(sha)) throw new BadGatewayException('Forge hat eine ungültige Commit-ID geliefert');
return { name: String(value.name ?? branch), commit: sha };
}
private async refreshAllBranchSnapshots(): Promise<void> {
try {
const result = await this.database.query<{ module_id: string }>('SELECT module_id FROM marketplace_module_installations');
for (const row of result.rows) {
try { await this.refreshInstallation(row.module_id); }
catch (error) { this.logger.warn(`Branch-Prüfung für Modul ${row.module_id} fehlgeschlagen: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`); }
}
} catch (error) {
this.logger.warn(`Tägliche Marketplace-Branch-Prüfung nicht verfügbar: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`);
}
}
private async refreshInstallation(moduleId: string): Promise<void> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches, observed_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
);
const row = result.rows[0];
if (!row) return;
const providerConfig = this.config.marketplace.providers[row.provider];
if (!providerConfig) return;
const branches = await this.fetchBranches(row.provider, row.owner, row.repository);
// Every branch other than the installed one is a selectable tested version.
// Do not rely on whether it existed when this installation was first recorded:
// removing and reinstalling a module must not hide an available release branch.
const installedCommit = row.installed_commit?.toLowerCase();
const candidates = branches.filter((branch) => branch.name !== 'main'
&& branch.name !== row.installed_branch
&& isNewerVersionBranch(branch.name, row.installed_branch)
&& (!installedCommit || branch.commit.toLowerCase() !== installedCommit));
await this.database.query(
`UPDATE marketplace_module_installations SET available_branches = $2::jsonb,
observed_branches = $3::jsonb, branches_checked_at = now() WHERE module_id = $1`,
[moduleId, JSON.stringify(candidates), JSON.stringify(branches)],
);
}
private async fetchBranches(provider: MarketplaceProvider, owner: string, repository: string): Promise<MarketplaceBranch[]> {
const providerConfig = this.config.marketplace.providers[provider];
if (!providerConfig) return [];
const branches: MarketplaceBranch[] = [];
const pageSize = provider === 'github' ? 100 : 50;
for (let page = 1; page <= 100; page += 1) {
const path = provider === 'github'
? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?per_page=${pageSize}&page=${page}`
: `${new URL(providerConfig.baseUrl).pathname.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?limit=${pageSize}&page=${page}`;
const values = await this.forgeJson<Array<Record<string, unknown>>>(provider, providerConfig.baseUrl, null, path);
branches.push(...values.flatMap((value) => {
const name = String(value.name ?? '');
const commit = value.commit as Record<string, unknown> | undefined;
const sha = String(commit?.sha ?? commit?.id ?? '');
return name && sha ? [{ name, commit: sha }] : [];
}));
if (values.length < pageSize) break;
}
return branches;
} }
private async normalizeRepositoryArchive(archive: Buffer): Promise<Buffer> { private async normalizeRepositoryArchive(archive: Buffer): Promise<Buffer> {

View File

@@ -0,0 +1,22 @@
import type { Migration } from '../../database/migration.types';
export const migration011ModuleConfiguration: Migration = {
id: '011-module-configuration',
description: 'Deklarierte Modulkonfiguration und verschlüsselte Werte speichern',
up: async (client) => {
await client.query(`
ALTER TABLE modules
ADD COLUMN configuration_schema JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN configuration_ready BOOLEAN NOT NULL DEFAULT false
`);
await client.query(`
CREATE TABLE module_configurations (
module_id UUID PRIMARY KEY REFERENCES modules(id) ON DELETE CASCADE,
ciphertext TEXT NOT NULL,
iv TEXT NOT NULL,
auth_tag TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
},
};

View File

@@ -0,0 +1,16 @@
import type { Migration } from '../../database/migration.types';
export const migration012MarketplaceBranchUpdates: Migration = {
id: '012-marketplace-branch-updates',
description: 'Installierte Branches und verfügbare Modul-Updates speichern',
up: async (client) => {
await client.query(`
ALTER TABLE marketplace_module_installations
ADD COLUMN installed_branch TEXT NOT NULL DEFAULT 'main',
ADD COLUMN installed_commit TEXT,
ADD COLUMN available_branches JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN observed_branches JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN branches_checked_at TIMESTAMPTZ
`);
},
};

View File

@@ -0,0 +1,208 @@
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import { DatabaseService } from '../database/database.service';
import type { ModuleConfigurationField, ModuleRecord } from './manifest.types';
interface EncryptedConfigurationRow {
ciphertext: string;
iv: string;
auth_tag: string;
}
export interface ModuleConfigurationFieldState {
key: string;
label: string;
description: string;
type: ModuleConfigurationField['type'];
secret: boolean;
required: boolean;
services: readonly string[];
isSet: boolean;
value?: string;
}
export interface ModuleConfigurationState {
ready: boolean;
fields: ModuleConfigurationFieldState[];
}
@Injectable()
export class ModuleConfigurationService {
constructor(
private readonly database: DatabaseService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async state(module: ModuleRecord): Promise<ModuleConfigurationState> {
const values = await this.values(module);
const fields = module.configuration.map((field) => {
const value = values[field.key];
return {
key: field.key,
label: field.label,
description: field.description,
type: field.type,
secret: field.secret,
required: field.required,
services: field.services,
isSet: value !== undefined && value.length > 0,
...(!field.secret && value !== undefined ? { value } : {}),
};
});
return {
ready: module.configuration.every((field) => !field.required || Boolean(values[field.key]?.trim())),
fields,
};
}
async resolvedValues(module: ModuleRecord): Promise<Record<string, string>> {
const values = await this.values(module);
const missing = module.configuration.filter((field) => field.required && !values[field.key]?.trim());
if (missing.length) {
throw new BadRequestException(`Konfiguration erforderlich: ${missing.map((field) => field.label).join(', ')}`);
}
return values;
}
async save(
module: ModuleRecord,
input: { values?: unknown; clearKeys?: unknown },
): Promise<{ state: ModuleConfigurationState; changedKeys: string[] }> {
if (module.configuration.length === 0) {
throw new BadRequestException('Dieses Modul benötigt keine Konfiguration');
}
const valuesInput = input.values ?? {};
const clearInput = input.clearKeys ?? [];
if (!valuesInput || typeof valuesInput !== 'object' || Array.isArray(valuesInput)) {
throw new BadRequestException('Konfigurationswerte müssen ein Objekt sein');
}
if (!Array.isArray(clearInput) || clearInput.some((key) => typeof key !== 'string')) {
throw new BadRequestException('clearKeys muss eine Liste aus Schlüsseln sein');
}
this.assertEncryptionKey();
const allowed = new Map(module.configuration.map((field) => [field.key, field]));
const unknown = [
...Object.keys(valuesInput as Record<string, unknown>),
...(clearInput as string[]),
].filter((key) => !allowed.has(key));
if (unknown.length) throw new BadRequestException(`Unbekannte Konfigurationsfelder: ${[...new Set(unknown)].join(', ')}`);
const current = await this.savedValues(module.id);
const next = { ...current };
const changed = new Set<string>();
for (const [key, rawValue] of Object.entries(valuesInput as Record<string, unknown>)) {
const field = allowed.get(key)!;
if (typeof rawValue !== 'string' || rawValue.length > 8192 || rawValue.includes('\0')) {
throw new BadRequestException(`Ungültiger Wert für ${field.label}`);
}
if (rawValue === '' && field.secret) continue;
if (rawValue === '') {
if (Object.hasOwn(next, key)) changed.add(key);
delete next[key];
continue;
}
if (field.type === 'boolean' && rawValue !== 'true' && rawValue !== 'false') {
throw new BadRequestException(`${field.label} muss true oder false sein`);
}
if (field.type === 'url') {
try {
const url = new URL(rawValue);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('protocol');
} catch {
throw new BadRequestException(`${field.label} muss eine gültige HTTP- oder HTTPS-URL sein`);
}
}
if (next[key] !== rawValue) changed.add(key);
next[key] = rawValue;
}
for (const key of clearInput as string[]) {
if (Object.hasOwn(next, key)) changed.add(key);
delete next[key];
}
for (const field of module.configuration) {
if (field.defaultValue !== undefined && next[field.key] === undefined) next[field.key] = field.defaultValue;
}
const ready = module.configuration.every((field) => !field.required || Boolean(next[field.key]?.trim()));
if (module.status === 'RUNNING' && !ready) {
throw new BadRequestException('Ein laufendes Modul kann nicht ohne vollständige Pflichtkonfiguration gespeichert werden');
}
const explicitValues = Object.fromEntries(
Object.entries(next).filter(([key, value]) => {
const field = allowed.get(key);
return field && value !== field.defaultValue;
}),
);
const encrypted = this.encrypt(JSON.stringify(explicitValues));
await this.database.query(
`INSERT INTO module_configurations (module_id, ciphertext, iv, auth_tag, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (module_id) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
iv = EXCLUDED.iv,
auth_tag = EXCLUDED.auth_tag,
updated_at = now()`,
[module.id, encrypted.ciphertext, encrypted.iv, encrypted.authTag],
);
await this.database.query(
'UPDATE modules SET configuration_ready = $2, updated_at = now() WHERE id = $1',
[module.id, ready],
);
return { state: await this.state({ ...module, configurationReady: ready }), changedKeys: [...changed] };
}
async values(module: ModuleRecord): Promise<Record<string, string>> {
const values = await this.savedValues(module.id);
for (const field of module.configuration) {
if (values[field.key] === undefined && field.defaultValue !== undefined) values[field.key] = field.defaultValue;
}
return values;
}
private async savedValues(moduleId: string): Promise<Record<string, string>> {
const result = await this.database.query<EncryptedConfigurationRow>(
'SELECT ciphertext, iv, auth_tag FROM module_configurations WHERE module_id = $1',
[moduleId],
);
const row = result.rows[0];
if (!row) return {};
this.assertEncryptionKey();
try {
const key = createHash('sha256').update(this.config.runtime.moduleConfigurationEncryptionKey).digest();
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(row.iv, 'base64'));
decipher.setAuthTag(Buffer.from(row.auth_tag, 'base64'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(row.ciphertext, 'base64')),
decipher.final(),
]).toString('utf8');
const parsed: unknown = JSON.parse(plaintext);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) ||
Object.values(parsed).some((value) => typeof value !== 'string')) {
throw new Error('invalid shape');
}
return parsed as Record<string, string>;
} catch {
throw new InternalServerErrorException('Gespeicherte Modulkonfiguration kann nicht entschlüsselt werden');
}
}
private encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
const key = createHash('sha256').update(this.config.runtime.moduleConfigurationEncryptionKey).digest();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
ciphertext: ciphertext.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
};
}
private assertEncryptionKey(): void {
if (this.config.runtime.moduleConfigurationEncryptionKey.length < 32) {
throw new InternalServerErrorException('MODULE_CONFIG_ENCRYPTION_KEY muss mindestens 32 Zeichen lang sein');
}
}
}

View File

@@ -1,11 +1,12 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { chmod, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { stringify, parseDocument } from 'yaml'; import { stringify, parseDocument } from 'yaml';
import { ModuleIdentityService } from './module-identity.service'; import { ModuleIdentityService } from './module-identity.service';
import type { ModuleRecord } from './manifest.types'; import type { ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service';
const SAFE_SERVICE_KEYS = new Set([ const SAFE_SERVICE_KEYS = new Set([
'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes', 'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes',
@@ -14,6 +15,29 @@ const SAFE_SERVICE_KEYS = new Set([
'stop_grace_period', 'read_only', 'tty', 'stdin_open', 'stop_grace_period', 'read_only', 'tty', 'stdin_open',
]); ]);
const SAFE_BUILD_KEYS = new Set(['context', 'dockerfile', 'target', 'args']);
const MAX_MODULE_SERVICES = 8;
const MODULE_MEMORY_LIMIT = '512m';
const MODULE_CPU_LIMIT = 1;
const MODULE_PIDS_LIMIT = 256;
const COMPOSE_COMMAND_TIMEOUT_MS = 15 * 60_000;
const COMPOSE_CONTROL_TIMEOUT_MS = 2 * 60_000;
const DOCKER_COMMAND_TIMEOUT_MS = 45_000;
const COMMAND_TERMINATION_GRACE_MS = 5_000;
/** Ein Docker-CLI-Fehler mit einer für die Admin-Oberfläche bereinigten Diagnose. */
export class ModuleCommandError extends Error {
constructor(
readonly exitCode: number | null,
readonly diagnostic: string,
readonly timedOut = false,
) {
super(timedOut ? 'Docker-Befehl hat das Zeitlimit überschritten' :
exitCode === null ? 'Docker-Befehl konnte nicht gestartet werden' : `Docker-Befehl endete mit Status ${exitCode}`);
this.name = 'ModuleCommandError';
}
}
/** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */ /** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */
@Injectable() @Injectable()
export class ModuleContainerManager { export class ModuleContainerManager {
@@ -21,41 +45,67 @@ export class ModuleContainerManager {
private readonly dockerHost = process.env.MODULE_DOCKER_HOST ?? 'unix:///var/run/docker.sock'; private readonly dockerHost = process.env.MODULE_DOCKER_HOST ?? 'unix:///var/run/docker.sock';
private readonly mpmContainer = process.env.MPM_CONTAINER_NAME ?? ''; private readonly mpmContainer = process.env.MPM_CONTAINER_NAME ?? '';
constructor(private readonly identityService: ModuleIdentityService) {} constructor(
private readonly identityService: ModuleIdentityService,
private readonly configurationService: ModuleConfigurationService,
) {}
async start(module: ModuleRecord): Promise<void> { async start(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); const { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues } = await this.prepare(module);
try {
// Recreate stopped containers and project networks before each start. This // Recreate stopped containers and project networks before each start. This
// prevents Compose v1 from trying to reconcile stale Docker Desktop network // prevents Compose v1 from reconciling stale Docker Desktop network defaults;
// defaults after a stop; named data volumes are deliberately left untouched. // named data volumes are deliberately left untouched.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']); await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans'], cleanupValues);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'rm', gatewayNetwork], true); await this.runDocker(['network', 'rm', gatewayNetwork], true);
await this.runDocker(['network', 'create', gatewayNetwork]); await this.runDocker(['network', 'create', gatewayNetwork]);
await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans']); await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans'], moduleValues);
if (this.mpmContainer) { if (this.mpmContainer) {
await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]); await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]);
} }
this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`); this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`);
} finally {
await rm(overridePath, { force: true });
}
} }
async stop(module: ModuleRecord): Promise<void> { async stop(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop']); try {
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop'], cleanupValues);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`); this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`);
} finally {
await rm(overridePath, { force: true });
}
} }
async remove(module: ModuleRecord): Promise<void> { async remove(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
try {
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
// Compose down removes every app/database container and its networks. Named // Removing a module is an explicit delete: remove its project-scoped data volumes too.
// volumes remain, so uninstalling code does not silently destroy database data. await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--volumes', '--remove-orphans'], cleanupValues);
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']);
await this.runDocker(['network', 'rm', gatewayNetwork], true); await this.runDocker(['network', 'rm', gatewayNetwork], true);
this.logger.log(`Container und Datenvolumes für "${module.moduleId}" entfernt`);
} finally {
await rm(overridePath, { force: true }); await rm(overridePath, { force: true });
this.logger.log(`Container für "${module.moduleId}" entfernt; Datenvolumes bleiben erhalten`); }
}
/** Removes containers from a failed start while retaining all named data volumes. */
async cleanupFailedStart(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
try {
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans'], cleanupValues);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
this.logger.log(`Teilweise gestarteter Stack für "${module.moduleId}" ohne Datenverlust bereinigt`);
} finally {
await rm(overridePath, { force: true });
}
} }
private async prepare(module: ModuleRecord): Promise<{ private async prepare(module: ModuleRecord): Promise<{
@@ -63,6 +113,8 @@ export class ModuleContainerManager {
overridePath: string; overridePath: string;
projectName: string; projectName: string;
gatewayNetwork: string; gatewayNetwork: string;
moduleValues: Record<string, string>;
cleanupValues: Record<string, string>;
}> { }> {
if (!module.composeFile || !module.appService) { if (!module.composeFile || !module.appService) {
throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration'); throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration');
@@ -70,23 +122,51 @@ export class ModuleContainerManager {
const root = path.resolve(module.path); const root = path.resolve(module.path);
const composePath = path.resolve(root, module.composeFile); const composePath = path.resolve(root, module.composeFile);
if (!composePath.startsWith(root + path.sep)) throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets'); if (!composePath.startsWith(root + path.sep)) throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets');
const source = await readFile(composePath, 'utf8').catch(() => { const actualRoot = await realpath(root);
const actualCompose = await realpath(composePath).catch(() => {
throw new BadRequestException(`Compose-Datei "${module.composeFile}" wurde nicht gefunden`); throw new BadRequestException(`Compose-Datei "${module.composeFile}" wurde nicht gefunden`);
}); });
if (!this.isInside(actualRoot, actualCompose)) {
throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets');
}
const source = await readFile(composePath, 'utf8');
const document = parseDocument(source, { uniqueKeys: true }); const document = parseDocument(source, { uniqueKeys: true });
if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML'); if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML');
const compose = document.toJS() as Record<string, unknown>; const compose = document.toJS() as Record<string, unknown>;
this.validateCompose(compose, module); await this.validateCompose(compose, module, actualRoot, path.dirname(composePath));
const serviceMap = compose.services as Record<string, unknown>;
const moduleValues = await this.configurationService.values(module);
// Compose validates required interpolations even for stop/down. Supply
// harmless placeholders only to cleanup commands so incomplete modules
// can still be stopped and removed. Never pass these placeholders to `up`.
const cleanupValues = { ...moduleValues };
for (const field of module.configuration) {
cleanupValues[field.key] ??= 'mpm-unset-configuration';
}
for (const match of source.matchAll(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)/g)) {
const key = match[1];
if (key && !Object.hasOwn(cleanupValues, key) &&
!/^(PATH|HOME|TMPDIR|NODE_OPTIONS|PYTHONPATH|DOCKER_HOST|DOCKER_CONTEXT)$/.test(key)) {
cleanupValues[key] = 'mpm-unset-configuration';
}
}
const projectName = `mpm-${module.moduleId}`; const projectName = `mpm-${module.moduleId}`;
const gatewayNetwork = `mpm-module-${module.moduleId}-gateway`; const gatewayNetwork = `mpm-module-${module.moduleId}-gateway`;
// Keep generated secrets outside the package/build context so Dockerfiles // Keep generated secrets outside the package/build context so Dockerfiles
// cannot accidentally copy them into an application image. // cannot accidentally copy them into an application image.
const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`); const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`);
const override = { const overrideServices: Record<string, Record<string, unknown>> = {};
version: '3.8', for (const serviceName of Object.keys(serviceMap)) {
services: { overrideServices[serviceName] = {
[module.appService]: { security_opt: ['no-new-privileges:true'],
mem_limit: MODULE_MEMORY_LIMIT,
cpus: MODULE_CPU_LIMIT,
pids_limit: MODULE_PIDS_LIMIT,
};
}
overrideServices[module.appService] = {
...overrideServices[module.appService],
container_name: `mpm-${module.moduleId}-app`, container_name: `mpm-${module.moduleId}-app`,
environment: { environment: {
PORT: String(module.internalPort), PORT: String(module.internalPort),
@@ -99,18 +179,40 @@ export class ModuleContainerManager {
default: {}, default: {},
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] }, 'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
}, },
security_opt: ['no-new-privileges:true'], };
}, for (const field of module.configuration) {
}, const value = moduleValues[field.key];
if (value === undefined) continue;
for (const serviceName of field.services) {
if (!Object.hasOwn(serviceMap, serviceName)) {
throw new BadRequestException(`Konfiguration ${field.key} verweist auf fehlenden Compose-Service "${serviceName}"`);
}
const serviceOverride = overrideServices[serviceName] ?? {};
const environment = (serviceOverride.environment ?? {}) as Record<string, string>;
overrideServices[serviceName] = {
...serviceOverride,
environment: { ...environment, [field.key]: value },
};
}
}
const override = {
version: '3.8',
services: overrideServices,
volumes: { 'mpm-runtime-data': {} }, volumes: { 'mpm-runtime-data': {} },
networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } }, networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } },
}; };
try {
await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 }); await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 });
await writeFile(overridePath, stringify(override), { mode: 0o600 }); await writeFile(overridePath, stringify(override), { mode: 0o600 });
return { composePath, overridePath, projectName, gatewayNetwork }; await chmod(overridePath, 0o600);
} catch (error) {
await rm(overridePath, { force: true });
throw error;
}
return { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues };
} }
private validateCompose(compose: Record<string, unknown>, module: ModuleRecord): void { private async validateCompose(compose: Record<string, unknown>, module: ModuleRecord, root: string, composeDir: string): Promise<void> {
if (!compose || typeof compose !== 'object' || Array.isArray(compose)) { if (!compose || typeof compose !== 'object' || Array.isArray(compose)) {
throw new BadRequestException('Compose-Datei muss ein YAML-Objekt enthalten'); throw new BadRequestException('Compose-Datei muss ein YAML-Objekt enthalten');
} }
@@ -123,6 +225,9 @@ export class ModuleContainerManager {
throw new BadRequestException('Compose benötigt mindestens einen Service'); throw new BadRequestException('Compose benötigt mindestens einen Service');
} }
const serviceMap = services as Record<string, unknown>; const serviceMap = services as Record<string, unknown>;
if (Object.keys(serviceMap).length > MAX_MODULE_SERVICES) {
throw new BadRequestException(`Compose darf höchstens ${MAX_MODULE_SERVICES} Services enthalten`);
}
if (!Object.hasOwn(serviceMap, module.appService!)) { if (!Object.hasOwn(serviceMap, module.appService!)) {
throw new BadRequestException(`Compose-Service "${module.appService}" fehlt`); throw new BadRequestException(`Compose-Service "${module.appService}" fehlt`);
} }
@@ -145,9 +250,8 @@ export class ModuleContainerManager {
service.container_name !== undefined || service.secrets !== undefined || service.configs !== undefined) { service.container_name !== undefined || service.secrets !== undefined || service.configs !== undefined) {
throw new BadRequestException(`Compose-Service "${name}" darf keine Host- oder privilegierten Ressourcen verwenden`); throw new BadRequestException(`Compose-Service "${name}" darf keine Host- oder privilegierten Ressourcen verwenden`);
} }
if (service.build !== undefined) this.validateBuild(service.build, module.path); if (service.build !== undefined) await this.validateBuild(service.build, root, composeDir);
if (service.volumes !== undefined) this.validateVolumes(service.volumes); if (service.volumes !== undefined) this.validateVolumes(service.volumes);
service.security_opt = ['no-new-privileges:true'];
} }
for (const [name, volume] of Object.entries(definedVolumes)) { for (const [name, volume] of Object.entries(definedVolumes)) {
if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null && if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null &&
@@ -175,18 +279,64 @@ export class ModuleContainerManager {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
private validateBuild(build: unknown, modulePath: string): void { private async validateBuild(build: unknown, root: string, composeDir: string): Promise<void> {
const context = typeof build === 'string' const options: Record<string, unknown> | null = typeof build === 'string'
? build ? { context: build }
: build && typeof build === 'object' && !Array.isArray(build) : build && typeof build === 'object' && !Array.isArray(build)
? String((build as Record<string, unknown>).context ?? '.') ? build as Record<string, unknown>
: ''; : null;
if (!context || path.isAbsolute(context) || context.split(/[\\/]/).includes('..')) { if (!options || Object.keys(options).some((key) => !SAFE_BUILD_KEYS.has(key))) {
throw new BadRequestException('Build-Kontext muss innerhalb des Modulpakets liegen'); throw new BadRequestException('Compose-Build enthält nicht erlaubte Optionen');
} }
const resolved = path.resolve(modulePath, context); const context = options.context ?? '.';
if (resolved !== modulePath && !resolved.startsWith(modulePath + path.sep)) { if (typeof context !== 'string' || !this.isStaticRelativePath(context)) {
throw new BadRequestException('Build-Kontext liegt außerhalb des Modulpakets'); throw new BadRequestException('Build-Kontext muss ein fester relativer Pfad sein');
}
const contextPath = path.resolve(composeDir, context);
const actualContext = await realpath(contextPath).catch(() => {
throw new BadRequestException('Build-Kontext wurde nicht gefunden');
});
if (!this.isInside(root, actualContext) || !(await stat(actualContext)).isDirectory()) {
throw new BadRequestException('Build-Kontext muss ein Verzeichnis innerhalb des Modulpakets sein');
}
const dockerfile = options.dockerfile ?? 'Dockerfile';
if (typeof dockerfile !== 'string' || !this.isStaticRelativePath(dockerfile, true)) {
throw new BadRequestException('Dockerfile muss ein fester relativer Pfad sein');
}
const actualDockerfile = await realpath(path.resolve(actualContext, dockerfile)).catch(() => {
throw new BadRequestException('Dockerfile wurde nicht gefunden');
});
if (!this.isInside(root, actualDockerfile) || !(await stat(actualDockerfile)).isFile()) {
throw new BadRequestException('Dockerfile muss eine Datei innerhalb des Modulpakets sein');
}
if (options.target !== undefined && (typeof options.target !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(options.target))) {
throw new BadRequestException('Compose-Build-Target ist ungültig');
}
if (options.args !== undefined) this.validateBuildArgs(options.args);
}
private isStaticRelativePath(value: string, allowParent = false): boolean {
return value.length > 0 && !path.isAbsolute(value) && !value.includes('\\') && !value.includes('$') &&
!value.includes(':') && !value.includes('#') && !value.startsWith('~') &&
(allowParent || !value.split('/').includes('..'));
}
private isInside(root: string, target: string): boolean {
return target === root || target.startsWith(root + path.sep);
}
private validateBuildArgs(args: unknown): void {
if (Array.isArray(args)) {
if (!args.every((arg) => typeof arg === 'string' && /^[A-Za-z_][A-Za-z0-9_]*(=.*)?$/.test(arg))) {
throw new BadRequestException('Compose-Build-Argumente sind ungültig');
}
return;
}
if (!args || typeof args !== 'object' || Object.entries(args).some(([key, value]) =>
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ||
(value !== null && !['string', 'number', 'boolean'].includes(typeof value)))) {
throw new BadRequestException('Compose-Build-Argumente sind ungültig');
} }
} }
@@ -204,41 +354,117 @@ export class ModuleContainerManager {
} }
} }
private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[]): Promise<void> { private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[], config: Record<string, string>): Promise<void> {
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd); const timeoutMs = args[0] === 'up' ? COMPOSE_COMMAND_TIMEOUT_MS : COMPOSE_CONTROL_TIMEOUT_MS;
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd, false, config, timeoutMs);
} }
private runDocker(args: string[], ignoreFailure = false): Promise<void> { private runDocker(args: string[], ignoreFailure = false): Promise<void> {
return this.run('docker', args, process.cwd(), ignoreFailure); return this.run('docker', args, process.cwd(), ignoreFailure, {}, DOCKER_COMMAND_TIMEOUT_MS);
} }
private run(command: string, args: string[], cwd: string, ignoreFailure = false): Promise<void> { private run(command: string, args: string[], cwd: string, ignoreFailure = false, extraEnv: Record<string, string> = {}, timeoutMs = DOCKER_COMMAND_TIMEOUT_MS): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(command, args, { const child = spawn(command, args, {
cwd, cwd,
// Give Docker Compose and its subprocesses one process group so a
// timeout can stop the whole operation before cleanup starts.
detached: process.platform !== 'win32',
env: { env: {
...extraEnv,
PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
// Do not let a root-owned /root/.docker configuration affect a child // Do not let a root-owned /root/.docker configuration affect a child
// command started by the unprivileged backend user. // command started by the unprivileged backend user.
HOME: '/tmp', HOME: '/tmp',
DOCKER_HOST: this.dockerHost, DOCKER_HOST: this.dockerHost,
}, },
stdio: ['ignore', 'ignore', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
let stdout = '';
let stderr = ''; let stderr = '';
const keepTail = (current: string, chunk: string): string => (current + chunk).slice(-12_000);
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => { stdout = keepTail(stdout, chunk); });
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-2000); }); child.stderr.on('data', (chunk: string) => { stderr = keepTail(stderr, chunk); });
let settled = false;
let timedOut = false;
let terminationTimer: NodeJS.Timeout | undefined;
const stopProcessGroup = (signal: NodeJS.Signals): void => {
if (process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// The process group may already have exited.
}
}
child.kill(signal);
};
const timeout = setTimeout(() => {
if (settled) return;
timedOut = true;
terminationTimer = setTimeout(() => stopProcessGroup('SIGKILL'), COMMAND_TERMINATION_GRACE_MS);
terminationTimer.unref();
stopProcessGroup('SIGTERM');
}, timeoutMs);
child.once('error', (error) => { child.once('error', (error) => {
if (timedOut) return;
clearTimeout(timeout);
if (terminationTimer) clearTimeout(terminationTimer);
if (settled) return;
settled = true;
if (ignoreFailure) resolve(); if (ignoreFailure) resolve();
else reject(new Error(`${command} konnte nicht gestartet werden: ${error.message}`)); else {
const errorCode = (error as NodeJS.ErrnoException).code ?? 'unbekannt';
this.logger.error(`${command} konnte nicht gestartet werden (${errorCode})`);
reject(new ModuleCommandError(null, `Der Befehl „${command}“ konnte nicht gestartet werden. Prüfe, ob Docker auf dem System verfügbar ist.`));
}
}); });
child.once('close', (code) => { child.once('close', (code) => {
clearTimeout(timeout);
if (terminationTimer) clearTimeout(terminationTimer);
if (settled) return;
settled = true;
if (timedOut) {
const duration = timeoutMs < 60_000 ? `${timeoutMs / 1_000} Sekunden` : `${timeoutMs / 60_000} Minuten`;
const diagnostic = `${command} hat das Zeitlimit von ${duration} überschritten.`;
this.logger.error(diagnostic);
reject(new ModuleCommandError(null, diagnostic, true));
return;
}
if (code === 0 || ignoreFailure) resolve(); if (code === 0 || ignoreFailure) resolve();
else { else {
this.logger.error(`${command} ${args[args.length - 1]} schlug mit Status ${code} fehl: ${stderr.trim()}`); const diagnostic = this.sanitizeDiagnostic(`${stdout}\n${stderr}`, extraEnv);
reject(new Error(`${command} schlug mit Status ${code} fehl`)); const exitCode = code ?? 1;
this.logger.error(`${command} schlug mit Status ${exitCode} fehl: ${diagnostic}`);
reject(new ModuleCommandError(exitCode, diagnostic));
} }
}); });
}); });
} }
/** Entfernt Umgebungswerte, ANSI-Codes und wahrscheinliche Secrets aus CLI-Ausgaben. */
private sanitizeDiagnostic(output: string, environment: Record<string, string>): string {
let safe = output
.replace(/\u001b\[[0-9;]*m/g, '')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.replace(/\r/g, '')
.trim();
const configuredValues = Object.values(environment)
.filter((value) => value.length >= 4)
.sort((left, right) => right.length - left.length);
for (const value of configuredValues) {
safe = safe.split(value).join('[geschwärzt]');
}
safe = safe.replace(
/(["']?[A-Z0-9_-]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|AUTHORIZATION|COOKIE)[A-Z0-9_-]*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi,
'$1[geschwärzt]',
);
if (!safe) return 'Docker Compose ist ohne eine Fehlerbeschreibung fehlgeschlagen.';
return safe.slice(-2_500);
}
} }

View File

@@ -25,6 +25,8 @@ function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord
enabled: true, enabled: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides, ...overrides,
}; };
} }
@@ -52,7 +54,11 @@ function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
function createRequest(url: string, cookie?: string): Request { function createRequest(url: string, cookie?: string): Request {
return { return {
url, url,
headers: cookie ? { cookie } : {}, method: 'GET',
headers: {
host: 'localhost:8081',
...(cookie ? { cookie } : {}),
},
} as unknown as Request; } as unknown as Request;
} }
@@ -77,8 +83,8 @@ function createResponse(): Response & { sentStatus: number; sentBody: unknown }
class MockSessionService { class MockSessionService {
public session: SessionData | null = null; public session: SessionData | null = null;
async findValid(): Promise<SessionData | null> { async findValidModuleSession(): Promise<string | null> {
return this.session; return this.session?.userId ?? null;
} }
} }
@@ -133,6 +139,7 @@ describe('ModuleGatewayMiddleware', () => {
sessionService as unknown as SessionService, sessionService as unknown as SessionService,
userRepository as unknown as UserRepository, userRepository as unknown as UserRepository,
permissionsService as unknown as ModulePermissionsService, permissionsService as unknown as ModulePermissionsService,
{ modulePublicOrigin: 'http://localhost:8081' } as never,
); );
sessionService.session = { sessionService.session = {
id: 'session-1', id: 'session-1',
@@ -161,7 +168,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 401 bei ungültiger Session', async () => { it('antwortet 401 bei ungültiger Session', async () => {
sessionService.session = null; sessionService.session = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=invalid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=invalid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -170,7 +177,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 401 bei deaktiviertem Benutzer', async () => { it('antwortet 401 bei deaktiviertem Benutzer', async () => {
userRepository.user = createUserRecord({ isActive: false }); userRepository.user = createUserRecord({ isActive: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -179,7 +186,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 404 bei unbekanntem Modul-Slug', async () => { it('antwortet 404 bei unbekanntem Modul-Slug', async () => {
moduleRepository.module = null; moduleRepository.module = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -188,7 +195,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 503 bei gestopptem Modul', async () => { it('antwortet 503 bei gestopptem Modul', async () => {
moduleRepository.module = createModuleRecord({ status: 'STOPPED' }); moduleRepository.module = createModuleRecord({ status: 'STOPPED' });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -197,7 +204,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 503 bei deaktiviertem Modul', async () => { it('antwortet 503 bei deaktiviertem Modul', async () => {
moduleRepository.module = createModuleRecord({ enabled: false }); moduleRepository.module = createModuleRecord({ enabled: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -206,7 +213,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => { it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => {
permissionsService.hasAccessResult = false; permissionsService.hasAccessResult = false;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
await middleware.use(request, response, makeNext()); await middleware.use(request, response, makeNext());
@@ -215,7 +222,7 @@ describe('ModuleGatewayMiddleware', () => {
it('leitet USER-Requests mit GRANTED-Berechtigung an den Proxy weiter', async () => { it('leitet USER-Requests mit GRANTED-Berechtigung an den Proxy weiter', async () => {
permissionsService.hasAccessResult = true; permissionsService.hasAccessResult = true;
const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/health', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
const proxySpy = jest const proxySpy = jest
@@ -233,7 +240,7 @@ describe('ModuleGatewayMiddleware', () => {
it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => { it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => {
userRepository.user = createUserRecord({ role: 'ADMIN' }); userRepository.user = createUserRecord({ role: 'ADMIN' });
const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/health', 'mpm_module_session=valid');
const response = createResponse(); const response = createResponse();
// proxy.web würde einen echten Request starten – hier nur prüfen, // proxy.web würde einen echten Request starten – hier nur prüfen,

View File

@@ -1,8 +1,9 @@
import { Injectable, type NestMiddleware } from '@nestjs/common'; import { ForbiddenException, Inject, Injectable, type NestMiddleware } from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import httpProxy from 'http-proxy'; import httpProxy from 'http-proxy';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import { SessionService } from '../auth/session.service'; import { SessionService } from '../auth/session.service';
import { extractSessionToken } from '../auth/guards/session.guard'; import { requireSameOrigin } from '../auth/request-origin';
import { UserRepository } from '../users/user.repository'; import { UserRepository } from '../users/user.repository';
import { ModuleRepository } from './module.repository'; import { ModuleRepository } from './module.repository';
import { ModulePermissionsService } from './module-permissions.service'; import { ModulePermissionsService } from './module-permissions.service';
@@ -10,6 +11,23 @@ import { ModuleIdentityService } from './module-identity.service';
/** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */ /** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */
const GATEWAY_PREFIX = '/api/v1/gateway/'; const GATEWAY_PREFIX = '/api/v1/gateway/';
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function moduleCookie(request: Request): string | null {
let value: string | null = null;
for (const part of (request.headers.cookie ?? '').split(';')) {
const [name, ...parts] = part.trim().split('=');
if (name === 'mpm_module_session') value = decodeURIComponent(parts.join('='));
}
return value;
}
function cookiesForModule(cookieHeader: string | undefined): string | undefined {
const remaining = (cookieHeader ?? '').split(';').map((part) => part.trim()).filter((part) =>
part && !/^(mpm_module_session|mpm_session|mpm_csrf|__Host-mpm_session|__Host-mpm_csrf)=/.test(part),
);
return remaining.length ? remaining.join('; ') : undefined;
}
/** /**
* Modul-Gateway (Phase 4/5): Dynamisches Routing /slug → Modul-Prozess. * Modul-Gateway (Phase 4/5): Dynamisches Routing /slug → Modul-Prozess.
@@ -35,6 +53,7 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly permissionsService: ModulePermissionsService, private readonly permissionsService: ModulePermissionsService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
private readonly identityService: ModuleIdentityService = new ModuleIdentityService(), private readonly identityService: ModuleIdentityService = new ModuleIdentityService(),
) { ) {
this.proxy = httpProxy.createProxyServer({ this.proxy = httpProxy.createProxyServer({
@@ -69,6 +88,21 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
proxyRequest.setHeader('Content-Length', Buffer.byteLength(body)); proxyRequest.setHeader('Content-Length', Buffer.byteLength(body));
proxyRequest.write(body); proxyRequest.write(body);
}); });
// Modul-Cookies dürfen weder für die ganze Parent-Domain noch für andere
// Module gelten. Eigene App-Cookies bleiben innerhalb des Modulpfads nutzbar.
this.proxy.on('proxyRes', (proxyResponse, request) => {
const slug = (request as Request & { mpmModuleSlug?: string }).mpmModuleSlug;
const cookies = proxyResponse.headers['set-cookie'];
if (!slug || !cookies) return;
proxyResponse.headers['set-cookie'] = cookies.map((cookie) => {
const [nameAndValue, ...attributes] = cookie.split(';');
const restrictedAttributes = attributes.filter((attribute) =>
!/^\s*(domain|path|samesite)\s*=/i.test(attribute),
);
return `${nameAndValue};${restrictedAttributes.join(';')}; Path=/${slug}; SameSite=Lax`;
});
});
} }
async use(request: Request, response: Response, next: NextFunction): Promise<void> { async use(request: Request, response: Response, next: NextFunction): Promise<void> {
@@ -77,26 +111,45 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
return; return;
} }
// Die API ist auf dem Modulhost nicht sichtbar. Das Gateway darf nur
// Requests vom dedizierten Browser-Origin weiterleiten.
if (request.headers.host !== new URL(this.config.modulePublicOrigin).host) {
response.status(403).json({ statusCode: 403, message: 'Ungültiger Modul-Host' });
return;
}
if (STATE_CHANGING_METHODS.has(request.method)) {
try {
requireSameOrigin(request, this.config.modulePublicOrigin);
} catch (error) {
if (error instanceof ForbiddenException) {
response.status(403).json({ statusCode: 403, message: error.message });
return;
}
throw error;
}
}
// Slug aus dem Gateway-Pfad extrahieren: /api/v1/gateway/<slug>/<rest> // Slug aus dem Gateway-Pfad extrahieren: /api/v1/gateway/<slug>/<rest>
const pathAfterPrefix = request.url.slice(GATEWAY_PREFIX.length); const gatewayUrl = new URL(request.url, 'http://gateway.internal');
const pathAfterPrefix = gatewayUrl.pathname.slice(GATEWAY_PREFIX.length);
const slashIndex = pathAfterPrefix.indexOf('/'); const slashIndex = pathAfterPrefix.indexOf('/');
const slug = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex); const slug = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex);
const modulePath = slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex); const modulePath = (slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex)) + gatewayUrl.search;
// 1. Authentifizierung: Session aus Cookie laden // 1. Authentifizierung: Session aus Cookie laden
const token = extractSessionToken(request); const token = moduleCookie(request);
if (!token) { if (!token) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' }); response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return; return;
} }
const session = await this.sessionService.findValid(token); const userId = await this.sessionService.findValidModuleSession(token, slug);
if (!session) { if (!userId) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' }); response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return; return;
} }
const user = await this.userRepository.findById(session.userId); const user = await this.userRepository.findById(userId);
if (!user || !user.isActive) { if (!user || !user.isActive) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' }); response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return; return;
@@ -139,8 +192,11 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
request.headers['x-user-role'] = user.role; request.headers['x-user-role'] = user.role;
request.headers['x-mpm-identity-timestamp'] = signedIdentity.timestamp; request.headers['x-mpm-identity-timestamp'] = signedIdentity.timestamp;
request.headers['x-mpm-identity-signature'] = signedIdentity.signature; request.headers['x-mpm-identity-signature'] = signedIdentity.signature;
// Session-Cookie niemals an das Modul weiterleiten (request as Request & { mpmModuleSlug?: string }).mpmModuleSlug = slug;
delete request.headers.cookie; // Nur eigene App-Cookies, nie Plattform- oder Modul-Gateway-Cookies weiterreichen.
const appCookies = cookiesForModule(request.headers.cookie);
if (appCookies) request.headers.cookie = appCookies;
else delete request.headers.cookie;
this.proxy.web(request, response, { this.proxy.web(request, response, {
target: `http://${module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1'}:${module.internalPort}`, target: `http://${module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1'}:${module.internalPort}`,

View File

@@ -1,11 +1,17 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { moduleManifestSchema, type ModuleManifest } from './manifest.types'; import { moduleManifestSchema, type ModuleManifest } from './manifest.types';
import { secureModuleDirectory } from './module-filesystem'; import { secureModuleDirectory } from './module-filesystem';
import { parseDocument } from 'yaml';
/** Maximale Größe eines Modul-Pakets (10 MB). */ /** Maximale Größe eines Modul-Pakets (10 MB). */
const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024; const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024;
const MAX_EXTRACTED_SIZE_BYTES = 50 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 2000;
const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024;
const MAX_METADATA_FILE_BYTES = 1024 * 1024;
/** Dateien, die in einem Modul-Paket erwartet werden. */ /** Dateien, die in einem Modul-Paket erwartet werden. */
const REQUIRED_MANIFEST_FILE = 'module.json'; const REQUIRED_MANIFEST_FILE = 'module.json';
@@ -26,20 +32,19 @@ export class ModuleInstaller {
/** Validiert ein hochgeladenes Paket und gibt das Manifest zurück. */ /** Validiert ein hochgeladenes Paket und gibt das Manifest zurück. */
async validatePackage(buffer: Buffer): Promise<ModuleManifest> { async validatePackage(buffer: Buffer): Promise<ModuleManifest> {
if (buffer.length === 0) { this.assertCompressedSize(buffer);
throw new BadRequestException('Paket ist leer');
}
if (buffer.length > MAX_PACKAGE_SIZE_BYTES) {
throw new BadRequestException('Paket ist zu groß (maximal 10 MB)');
}
const AdmZip = (await import('adm-zip')).default; const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer); const zip = new AdmZip(buffer);
this.assertSafeArchive(zip.getEntries(), path.resolve('/module-package'));
const manifestEntry = zip.getEntry(REQUIRED_MANIFEST_FILE); const manifestEntry = zip.getEntry(REQUIRED_MANIFEST_FILE);
if (!manifestEntry) { if (!manifestEntry) {
throw new BadRequestException(`Paket enthält keine ${REQUIRED_MANIFEST_FILE}`); throw new BadRequestException(`Paket enthält keine ${REQUIRED_MANIFEST_FILE}`);
} }
if (manifestEntry.header.size > MAX_METADATA_FILE_BYTES) {
throw new BadRequestException(`${REQUIRED_MANIFEST_FILE} ist zu groß`);
}
let manifestJson: unknown; let manifestJson: unknown;
try { try {
@@ -73,9 +78,28 @@ export class ModuleInstaller {
manifest.composeFile.split('/').includes('..')) { manifest.composeFile.split('/').includes('..')) {
throw new BadRequestException('composeFile muss ein relativer Pfad innerhalb des Modulpakets sein'); throw new BadRequestException('composeFile muss ein relativer Pfad innerhalb des Modulpakets sein');
} }
if (!zip.getEntry(manifest.composeFile)) { const composeEntry = zip.getEntry(manifest.composeFile);
if (!composeEntry) {
throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`); throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`);
} }
if (composeEntry.header.size > MAX_METADATA_FILE_BYTES) {
throw new BadRequestException('Compose-Datei ist zu groß');
}
const composeDocument = parseDocument(composeEntry.getData().toString('utf8'), { uniqueKeys: true });
if (composeDocument.errors.length) {
throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition');
}
const compose = composeDocument.toJS() as { services?: Record<string, unknown> } | null;
if (!compose?.services || typeof compose.services !== 'object' || Array.isArray(compose.services)) {
throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition');
}
for (const field of manifest.configuration) {
for (const service of field.services) {
if (!Object.hasOwn(compose.services, service)) {
throw new BadRequestException(`Konfiguration ${field.key} verweist auf fehlenden Compose-Service "${service}"`);
}
}
}
return manifest; return manifest;
} }
@@ -89,23 +113,14 @@ export class ModuleInstaller {
manifest: ModuleManifest, manifest: ModuleManifest,
modulesDir: string, modulesDir: string,
): Promise<{ directory: string; manifest: ModuleManifest }> { ): Promise<{ directory: string; manifest: ModuleManifest }> {
this.assertCompressedSize(buffer);
const directory = path.join(modulesDir, manifest.id); const directory = path.join(modulesDir, manifest.id);
// Zip-Slip-Schutz: Alle Einträge müssen innerhalb des Zielverzeichnisses liegen. // Zip-Slip-Schutz: Alle Einträge müssen innerhalb des Zielverzeichnisses liegen.
const AdmZip = (await import('adm-zip')).default; const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer); const zip = new AdmZip(buffer);
const resolvedDirectory = path.resolve(directory); const resolvedDirectory = path.resolve(directory);
this.assertSafeArchive(zip.getEntries(), resolvedDirectory);
for (const entry of zip.getEntries()) {
const entryName = entry.entryName;
if (entryName.startsWith('/') || entryName.includes('..') || /^[A-Za-z]:/.test(entryName)) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
const resolvedEntry = path.resolve(resolvedDirectory, entryName);
if (!resolvedEntry.startsWith(resolvedDirectory + path.sep)) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
}
// Bestehende Installation entfernen (Update-Szenario). // Bestehende Installation entfernen (Update-Szenario).
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
@@ -125,6 +140,50 @@ export class ModuleInstaller {
return { directory, manifest }; return { directory, manifest };
} }
/** Stages and atomically swaps an installed module directory, retaining a rollback copy. */
async replace(
buffer: Buffer,
manifest: ModuleManifest,
modulesDir: string,
): Promise<{ directory: string; backupDirectory: string }> {
this.assertCompressedSize(buffer);
const directory = path.join(modulesDir, manifest.id);
const suffix = randomUUID();
const stagingDirectory = path.join(modulesDir, `.update-${manifest.id}-${suffix}`);
const backupDirectory = path.join(modulesDir, `.backup-${manifest.id}-${suffix}`);
const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer);
const resolvedStage = path.resolve(stagingDirectory);
this.assertSafeArchive(zip.getEntries(), resolvedStage);
try {
await mkdir(stagingDirectory, { recursive: true });
zip.extractAllTo(resolvedStage, true);
await secureModuleDirectory(resolvedStage);
await writeFile(path.join(stagingDirectory, '.installed.json'),
JSON.stringify({ installedAt: new Date().toISOString(), manifest }, null, 2), 'utf8');
await rename(directory, backupDirectory);
try {
await rename(stagingDirectory, directory);
} catch (error) {
await rename(backupDirectory, directory);
throw error;
}
return { directory, backupDirectory };
} catch (error) {
await rm(stagingDirectory, { recursive: true, force: true });
throw error;
}
}
async rollbackReplacement(directory: string, backupDirectory: string): Promise<void> {
await rm(directory, { recursive: true, force: true });
await rename(backupDirectory, directory);
}
async finalizeReplacement(backupDirectory: string): Promise<void> {
await rm(backupDirectory, { recursive: true, force: true });
}
/** Entfernt eine Modul-Installation vom Dateisystem. */ /** Entfernt eine Modul-Installation vom Dateisystem. */
async remove(modulesDir: string, moduleId: string): Promise<void> { async remove(modulesDir: string, moduleId: string): Promise<void> {
const directory = path.join(modulesDir, moduleId); const directory = path.join(modulesDir, moduleId);
@@ -141,4 +200,48 @@ export class ModuleInstaller {
return null; return null;
} }
} }
private assertCompressedSize(buffer: Buffer): void {
if (buffer.length === 0) throw new BadRequestException('Paket ist leer');
if (buffer.length > MAX_PACKAGE_SIZE_BYTES) {
throw new BadRequestException('Paket ist zu groß (maximal 10 MB)');
}
}
private assertSafeArchive(
entries: ReadonlyArray<{ entryName: string; header: { size: number; attr: number } }>,
destination: string,
): void {
if (entries.length > MAX_ARCHIVE_ENTRIES) {
throw new BadRequestException(`Paket enthält zu viele Dateien (maximal ${MAX_ARCHIVE_ENTRIES})`);
}
let extractedSize = 0;
const seenTargets = new Set<string>();
for (const entry of entries) {
const name = entry.entryName;
if (!name || name.startsWith('/') || name.includes('\\') || name.includes('\0') ||
/^[A-Za-z]:/.test(name) || name.split('/').some((part) => part === '..' || part === '.')) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${name}`);
}
const resolvedEntry = path.resolve(destination, name);
if (!resolvedEntry.startsWith(destination + path.sep)) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${name}`);
}
if (seenTargets.has(resolvedEntry)) {
throw new BadRequestException(`Doppelter Pfad im Paket: ${name}`);
}
seenTargets.add(resolvedEntry);
if (((entry.header.attr >>> 16) & 0xf000) === 0xa000) {
throw new BadRequestException(`Symbolischer Link im Paket ist nicht erlaubt: ${name}`);
}
const size = entry.header.size;
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_SINGLE_FILE_BYTES) {
throw new BadRequestException('Paket enthält eine zu große oder ungültige Datei');
}
extractedSize += size;
if (extractedSize > MAX_EXTRACTED_SIZE_BYTES) {
throw new BadRequestException('Entpacktes Paket ist zu groß (maximal 50 MB)');
}
}
}
} }

View File

@@ -0,0 +1,19 @@
/** Serializes filesystem and Docker changes for each installed module. */
export class ModuleOperationLock {
private readonly pending = new Map<string, Promise<void>>();
async run<T>(moduleId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.pending.get(moduleId);
let release!: () => void;
const current = new Promise<void>((resolve) => { release = resolve; });
this.pending.set(moduleId, current);
if (previous) await previous;
try {
return await operation();
} finally {
if (this.pending.get(moduleId) === current) this.pending.delete(moduleId);
release();
}
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import type { ModuleRecord } from './manifest.types'; import type { ModuleRecord } from './manifest.types';
import type { ModuleConfigurationField } from './manifest.types';
/** Modul-Berechtigung eines Benutzers. */ /** Modul-Berechtigung eines Benutzers. */
export interface ModulePermissionRecord { export interface ModulePermissionRecord {
@@ -89,7 +90,7 @@ export class ModulePermissionRepository {
const result = await this.database.query( const result = await this.database.query(
`SELECT m.id, m.module_id, m.name, m.slug, m.version, m.description, m.author, `SELECT m.id, m.module_id, m.name, m.slug, m.version, m.description, m.author,
m.path, m.status, m.internal_port, m.healthcheck_url, m.enabled, m.path, m.status, m.internal_port, m.healthcheck_url, m.enabled,
m.created_at, m.updated_at m.created_at, m.updated_at, m.configuration_schema, m.configuration_ready
FROM user_module_permissions p FROM user_module_permissions p
JOIN modules m ON m.id = p.module_id JOIN modules m ON m.id = p.module_id
WHERE p.user_id = $1 AND p.permission = 'GRANTED' AND m.enabled WHERE p.user_id = $1 AND p.permission = 'GRANTED' AND m.enabled
@@ -111,6 +112,8 @@ export class ModulePermissionRepository {
enabled: row.enabled, enabled: row.enabled,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
configuration: row.configuration_schema as ModuleConfigurationField[],
configurationReady: row.configuration_ready as boolean,
})); }));
} }

View File

@@ -28,6 +28,8 @@ function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord
enabled: true, enabled: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides, ...overrides,
}; };
} }

View File

@@ -46,7 +46,18 @@ export class ModuleProcessManager implements OnModuleDestroy {
if (module.composeFile && module.appService) { if (module.composeFile && module.appService) {
await secureModuleDirectory(module.path); await secureModuleDirectory(module.path);
try {
await this.containerManager.start(module); await this.containerManager.start(module);
} catch (error) {
// Compose kann beim Build oder beim Start teilweise Container angelegt
// haben. Bereinige den Stack, bevor der ursprüngliche Fehler zurückgeht.
try {
await this.containerManager.cleanupFailedStart(module);
} catch {
this.logger.error(`Teilweise gestarteter Container-Stack für "${module.moduleId}" konnte nicht bereinigt werden`);
}
throw error;
}
this.containerModules.set(module.moduleId, module); this.containerModules.set(module.moduleId, module);
return; return;
} }

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import type { ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types'; import type { ModuleConfigurationField, ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types';
interface ModuleRow { interface ModuleRow {
id: string; id: string;
@@ -19,11 +19,13 @@ interface ModuleRow {
updated_at: Date; updated_at: Date;
compose_file: string | null; compose_file: string | null;
app_service: string | null; app_service: string | null;
configuration_schema: ModuleConfigurationField[];
configuration_ready: boolean;
} }
const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path, const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path,
status, internal_port, healthcheck_url, enabled, created_at, updated_at, status, internal_port, healthcheck_url, enabled, created_at, updated_at,
compose_file, app_service`; compose_file, app_service, configuration_schema, configuration_ready`;
/** /**
* Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry. * Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry.
@@ -84,8 +86,8 @@ export class ModuleRepository {
const result = await this.database.query<ModuleRow>( const result = await this.database.query<ModuleRow>(
`INSERT INTO modules `INSERT INTO modules
(module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url, (module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url,
compose_file, app_service) compose_file, app_service, configuration_schema, configuration_ready)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11, $12::jsonb, $13)
RETURNING ${MODULE_COLUMNS}`, RETURNING ${MODULE_COLUMNS}`,
[ [
manifest.id, manifest.id,
@@ -99,6 +101,8 @@ export class ModuleRepository {
manifest.healthcheck, manifest.healthcheck,
manifest.composeFile ?? null, manifest.composeFile ?? null,
manifest.appService ?? null, manifest.appService ?? null,
JSON.stringify(manifest.configuration),
manifest.configuration.every((field) => !field.required || field.defaultValue !== undefined),
], ],
); );
return this.mapRow(result.rows[0]); return this.mapRow(result.rows[0]);
@@ -118,6 +122,18 @@ export class ModuleRepository {
); );
} }
async updateManifest(id: string, manifest: ModuleManifest, configurationReady: boolean): Promise<void> {
await this.database.query(
`UPDATE modules SET name = $2, version = $3, description = $4, author = $5,
internal_port = $6, healthcheck_url = $7, compose_file = $8, app_service = $9,
configuration_schema = $10::jsonb, configuration_ready = $11, updated_at = now()
WHERE id = $1`,
[id, manifest.name, manifest.version, manifest.description, manifest.author, manifest.port,
manifest.healthcheck, manifest.composeFile ?? null, manifest.appService ?? null,
JSON.stringify(manifest.configuration), configurationReady],
);
}
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.database.query('DELETE FROM modules WHERE id = $1', [id]); await this.database.query('DELETE FROM modules WHERE id = $1', [id]);
} }
@@ -140,6 +156,15 @@ export class ModuleRepository {
updatedAt: row.updated_at, updatedAt: row.updated_at,
composeFile: row.compose_file, composeFile: row.compose_file,
appService: row.app_service, appService: row.app_service,
configuration: row.configuration_schema,
configurationReady: row.configuration_ready,
}; };
} }
async updateConfigurationReady(id: string, ready: boolean): Promise<void> {
await this.database.query(
'UPDATE modules SET configuration_ready = $2, updated_at = now() WHERE id = $1',
[id, ready],
);
}
} }

View File

@@ -25,6 +25,7 @@ import type { AuthenticatedRequest } from '../auth/authenticated-request';
import type { AuthUser } from '../users/user.types'; import type { AuthUser } from '../users/user.types';
import type { ModuleRecord, ModuleStatus } from './manifest.types'; import type { ModuleRecord, ModuleStatus } from './manifest.types';
import { ModulesService } from './modules.service'; import { ModulesService } from './modules.service';
import type { ModuleConfigurationState } from './module-configuration.service';
/** Modul-Daten in API-Antworten. */ /** Modul-Daten in API-Antworten. */
interface ModuleResponse { interface ModuleResponse {
@@ -40,6 +41,8 @@ interface ModuleResponse {
healthcheckUrl: string; healthcheckUrl: string;
enabled: boolean; enabled: boolean;
createdAt: string; createdAt: string;
configuration: ModuleRecord['configuration'];
configurationReady: boolean;
} }
function toModuleResponse(module: ModuleRecord): ModuleResponse { function toModuleResponse(module: ModuleRecord): ModuleResponse {
@@ -56,6 +59,8 @@ function toModuleResponse(module: ModuleRecord): ModuleResponse {
healthcheckUrl: module.healthcheckUrl, healthcheckUrl: module.healthcheckUrl,
enabled: module.enabled, enabled: module.enabled,
createdAt: module.createdAt.toISOString(), createdAt: module.createdAt.toISOString(),
configuration: module.configuration,
configurationReady: module.configurationReady,
}; };
} }
@@ -81,6 +86,21 @@ export class ModulesController {
return { module: toModuleResponse(module) }; return { module: toModuleResponse(module) };
} }
@Get(':id/configuration')
async getConfiguration(@Param('id', ParseUUIDPipe) id: string): Promise<ModuleConfigurationState> {
return this.modulesService.getConfiguration(id);
}
@Patch(':id/configuration')
async saveConfiguration(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { values?: unknown; clearKeys?: unknown },
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<ModuleConfigurationState> {
return this.modulesService.saveConfiguration(id, body, actor, request.ip ?? null);
}
@Post('install') @Post('install')
@UseInterceptors(FileInterceptor('package')) @UseInterceptors(FileInterceptor('package'))
async install( async install(
@@ -147,8 +167,8 @@ export class ModulesController {
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthUser, @CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request, @Req() request: AuthenticatedRequest & Request,
): Promise<{ success: true }> { ): Promise<{ success: true; cleanupWarning?: string }> {
await this.modulesService.remove(id, actor, request.ip ?? null); const result = await this.modulesService.remove(id, actor, request.ip ?? null);
return { success: true }; return { success: true, ...result };
} }
} }

View File

@@ -25,6 +25,7 @@ import { ModuleIdentityService } from './module-identity.service';
import { MarketplaceController } from './marketplace.controller'; import { MarketplaceController } from './marketplace.controller';
import { MarketplaceService } from './marketplace.service'; import { MarketplaceService } from './marketplace.service';
import { ModuleContainerManager } from './module-container-manager'; import { ModuleContainerManager } from './module-container-manager';
import { ModuleConfigurationService } from './module-configuration.service';
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */ /** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */
@Module({ @Module({
@@ -36,6 +37,7 @@ import { ModuleContainerManager } from './module-container-manager';
ModuleProcessManager, ModuleProcessManager,
ModuleIdentityService, ModuleIdentityService,
ModuleContainerManager, ModuleContainerManager,
ModuleConfigurationService,
ModuleHealthChecker, ModuleHealthChecker,
ModulesService, ModulesService,
SessionService, SessionService,

View File

@@ -23,6 +23,7 @@ function createManifest(overrides: Partial<ModuleManifest> = {}): ModuleManifest
port: 41001, port: 41001,
healthcheck: '/health', healthcheck: '/health',
apiVersion: 'v1', apiVersion: 'v1',
configuration: [],
...overrides, ...overrides,
}; };
} }
@@ -45,6 +46,8 @@ function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord
enabled: true, enabled: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides, ...overrides,
}; };
} }
@@ -190,8 +193,9 @@ const TEST_CONFIG: AppConfig = {
loginRateLimitWindowMinutes: 5, loginRateLimitWindowMinutes: 5,
}, },
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
modulePublicOrigin: 'http://localhost:8081',
}; };
describe('ModulesService', () => { describe('ModulesService', () => {
@@ -214,6 +218,7 @@ describe('ModulesService', () => {
processManager as unknown as ModuleProcessManager, processManager as unknown as ModuleProcessManager,
healthChecker as unknown as ModuleHealthChecker, healthChecker as unknown as ModuleHealthChecker,
auditService as unknown as AuditService, auditService as unknown as AuditService,
{ resolvedValues: async () => ({}), state: async () => ({ ready: true, fields: [] }), save: async () => ({ state: { ready: true, fields: [] }, changedKeys: [] }) } as never,
TEST_CONFIG, TEST_CONFIG,
); );
}); });

View File

@@ -2,7 +2,9 @@ import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Inject, Inject,
InternalServerErrorException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
@@ -11,8 +13,11 @@ import type { ActingUser } from '../users/users.service';
import { ModuleHealthChecker } from './module-health-checker'; import { ModuleHealthChecker } from './module-health-checker';
import { ModuleInstaller } from './module-installer'; import { ModuleInstaller } from './module-installer';
import { ModuleProcessManager } from './module-process-manager'; import { ModuleProcessManager } from './module-process-manager';
import { ModuleCommandError } from './module-container-manager';
import { ModuleRepository } from './module.repository'; import { ModuleRepository } from './module.repository';
import type { ModuleRecord } from './manifest.types'; import type { ModuleManifest, ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service';
import { ModuleOperationLock } from './module-operation-lock';
/** /**
* Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module. * Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module.
@@ -27,12 +32,16 @@ import type { ModuleRecord } from './manifest.types';
*/ */
@Injectable() @Injectable()
export class ModulesService { export class ModulesService {
private readonly logger = new Logger(ModulesService.name);
private readonly operationLock = new ModuleOperationLock();
constructor( constructor(
private readonly moduleRepository: ModuleRepository, private readonly moduleRepository: ModuleRepository,
private readonly installer: ModuleInstaller, private readonly installer: ModuleInstaller,
private readonly processManager: ModuleProcessManager, private readonly processManager: ModuleProcessManager,
private readonly healthChecker: ModuleHealthChecker, private readonly healthChecker: ModuleHealthChecker,
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly configurationService: ModuleConfigurationService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
@@ -48,6 +57,42 @@ export class ModulesService {
return module; return module;
} }
async getConfiguration(id: string) {
return this.configurationService.state(await this.getById(id));
}
async saveConfiguration(
id: string,
input: { values?: unknown; clearKeys?: unknown },
actor: ActingUser,
ipAddress: string | null,
) {
return this.withModuleLock(id, () => this.saveConfigurationUnlocked(id, input, actor, ipAddress));
}
private async saveConfigurationUnlocked(
id: string,
input: { values?: unknown; clearKeys?: unknown },
actor: ActingUser,
ipAddress: string | null,
) {
const module = await this.getById(id);
const result = await this.configurationService.save(module, input);
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.MODULE_CONFIG_UPDATED,
details: { moduleId: module.moduleId, keys: result.changedKeys },
ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Konfiguration von ${module.moduleId} gespeichert, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
});
if (result.changedKeys.length && module.status === 'RUNNING') {
await this.restartUnlocked(id, actor, ipAddress);
}
return this.configurationService.state(await this.getById(id));
}
/** Installiert ein Modul-Paket (ZIP) und registriert es. */ /** Installiert ein Modul-Paket (ZIP) und registriert es. */
async install( async install(
packageBuffer: Buffer, packageBuffer: Buffer,
@@ -55,7 +100,15 @@ export class ModulesService {
ipAddress: string | null, ipAddress: string | null,
): Promise<ModuleRecord> { ): Promise<ModuleRecord> {
const manifest = await this.installer.validatePackage(packageBuffer); const manifest = await this.installer.validatePackage(packageBuffer);
return this.operationLock.run(manifest.id, () => this.installUnlocked(packageBuffer, manifest, actor, ipAddress));
}
private async installUnlocked(
packageBuffer: Buffer,
manifest: ModuleManifest,
actor: ActingUser,
ipAddress: string | null,
): Promise<ModuleRecord> {
const [existingId, existingSlug, existingPort] = await Promise.all([ const [existingId, existingSlug, existingPort] = await Promise.all([
this.moduleRepository.findByModuleId(manifest.id), this.moduleRepository.findByModuleId(manifest.id),
this.moduleRepository.findBySlug(manifest.slug), this.moduleRepository.findBySlug(manifest.slug),
@@ -76,7 +129,27 @@ export class ModulesService {
manifest, manifest,
this.config.runtime.modulesDir, this.config.runtime.modulesDir,
); );
const module = await this.moduleRepository.create(manifest, directory); let module: ModuleRecord;
try {
module = await this.moduleRepository.create(manifest, directory);
} catch {
let cleanupFailed = false;
try {
await this.installer.remove(this.config.runtime.modulesDir, manifest.id);
} catch {
cleanupFailed = true;
this.logger.error(`Temporäre Dateien für Modul ${manifest.id} konnten nach fehlgeschlagener Registrierung nicht entfernt werden`);
}
throw new InternalServerErrorException({
statusCode: 500,
error: 'Internal Server Error',
code: 'MODULE_INSTALL_REGISTRATION_FAILED',
message: `Modul „${manifest.name}“ konnte nicht in MPM registriert werden.`,
diagnostic: cleanupFailed
? 'Die Registrierung ist fehlgeschlagen und die temporären Dateien konnten nicht bereinigt werden. Bitte Plattform-Logs prüfen.'
: 'Die Registrierung in der Datenbank ist fehlgeschlagen; die entpackten Dateien wurden zurückgerollt.',
});
}
await this.auditService.record({ await this.auditService.record({
userId: actor.id, userId: actor.id,
@@ -84,6 +157,8 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_INSTALLED, action: AUDIT_ACTIONS.MODULE_INSTALLED,
details: { moduleId: manifest.id, version: manifest.version, slug: manifest.slug }, details: { moduleId: manifest.id, version: manifest.version, slug: manifest.slug },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Installation von ${manifest.id} erfolgreich, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
return module; return module;
} }
@@ -92,23 +167,124 @@ export class ModulesService {
return this.installer.validatePackage(packageBuffer); return this.installer.validatePackage(packageBuffer);
} }
async updateFromMarketplace(
id: string,
packageBuffer: Buffer,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
afterApplied?: () => Promise<void>,
): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.updateFromMarketplaceUnlocked(id, packageBuffer, actor, ipAddress, onProgress, afterApplied));
}
private async updateFromMarketplaceUnlocked(
id: string,
packageBuffer: Buffer,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
afterApplied?: () => Promise<void>,
): Promise<ModuleRecord> {
onProgress?.('validation', 'Update-Paket wird geprüft', 42);
const current = await this.getById(id);
if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) {
throw new ConflictException('Das Modul muss einen stabilen Status haben, bevor ein Update gestartet werden kann');
}
const manifest = await this.installer.validatePackage(packageBuffer);
const previousManifest = await this.installer.readInstalledManifest(current.path);
if (!previousManifest) throw new InternalServerErrorException('Installiertes Modulmanifest kann vor dem Update nicht gelesen werden');
if (manifest.id !== current.moduleId || manifest.slug !== current.slug || manifest.port !== current.internalPort ||
manifest.composeFile !== current.composeFile || manifest.appService !== current.appService) {
throw new BadRequestException('Das Update muss Modul-ID, URL-Slug, Port und Compose-Service beibehalten');
}
const wasRunning = current.status === 'RUNNING';
if (wasRunning) {
onProgress?.('stopping', 'Laufendes Modul wird gestoppt', 55);
await this.stopUnlocked(id, actor, ipAddress);
}
let replacement: { directory: string; backupDirectory: string } | undefined;
try {
onProgress?.('replacing', 'Moduldateien werden aktualisiert', 66);
replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir);
const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description,
author: manifest.author, configuration: manifest.configuration };
const configuration = await this.configurationService.state(candidate);
await this.moduleRepository.updateManifest(id, manifest, configuration.ready);
if (wasRunning) await this.startUnlocked(id, actor, ipAddress, onProgress);
const updated = await this.getById(id);
// Source metadata belongs to the same serialized operation. A failed
// metadata write still has a backup available for the rollback below.
await afterApplied?.();
await this.installer.finalizeReplacement(replacement.backupDirectory).catch((cleanupError: unknown) => {
this.logger.warn(`Alte Moduldateien für ${current.moduleId} konnten nicht bereinigt werden: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
});
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.MODULE_UPDATED,
details: { moduleId: current.moduleId, fromVersion: current.version, toVersion: manifest.version },
ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Update von ${current.moduleId} erfolgreich, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
});
return updated;
} catch (error) {
if (replacement) {
try {
if (wasRunning) {
try { await this.processManager.stop(current.moduleId); } catch { /* Continue restoring the previous package. */ }
}
await this.installer.rollbackReplacement(replacement.directory, replacement.backupDirectory);
await this.moduleRepository.updateManifest(id, previousManifest, current.configurationReady);
await this.moduleRepository.updateStatus(id, wasRunning ? 'STOPPED' : current.status);
if (wasRunning) await this.startUnlocked(id, actor, ipAddress);
} catch (rollbackError) {
this.logger.error(`Rollback des Modulupdates für ${current.moduleId} fehlgeschlagen: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
throw new InternalServerErrorException('Update fehlgeschlagen; die vorherige Modulversion konnte nicht vollständig wiederhergestellt werden. Plattform-Logs prüfen.');
}
} else if (wasRunning) {
try { await this.startUnlocked(id, actor, ipAddress); } catch { /* Preserve the original update error. */ }
}
throw error;
}
}
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> { async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
return this.moduleRepository.findByModuleId(moduleId); return this.moduleRepository.findByModuleId(moduleId);
} }
/** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */ /** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */
async start(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> { async start(
id: string,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.startUnlocked(id, actor, ipAddress, onProgress));
}
private async startUnlocked(
id: string,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
this.assertEnabled(module); this.assertEnabled(module);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
return module; return module;
} }
await this.configurationService.resolvedValues(module);
await this.moduleRepository.updateStatus(id, 'STARTING'); await this.moduleRepository.updateStatus(id, 'STARTING');
try { try {
onProgress?.('starting', 'Modulcontainer werden gestartet', 80);
await this.processManager.start(module); await this.processManager.start(module);
// Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten. // Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten.
// Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt. // Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt.
onProgress?.('healthcheck', 'Healthcheck läuft', 91);
const health = await this.waitForHealthy(module); const health = await this.waitForHealthy(module);
if (!health.healthy) { if (!health.healthy) {
throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`); throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`);
@@ -116,11 +292,21 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'RUNNING'); await this.moduleRepository.updateStatus(id, 'RUNNING');
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress); await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress);
} catch (error) { } catch (error) {
let statusUpdateFailed = false;
try {
await this.moduleRepository.updateStatus(id, 'ERROR'); await this.moduleRepository.updateStatus(id, 'ERROR');
} catch {
statusUpdateFailed = true;
this.logger.error(`Fehlerstatus für Modul ${module.moduleId} konnte nicht gespeichert werden`);
}
try {
await this.processManager.stop(module.moduleId); await this.processManager.stop(module.moduleId);
throw new BadRequestException( } catch {
`Modul konnte nicht gestartet werden: ${error instanceof Error ? error.message : String(error)}`, // Erhalte den ursprünglichen Startfehler; ein fehlgeschlagener Cleanup
); // darf ihn nicht durch eine zweite Ausnahme ersetzen.
this.logger.error(`Cleanup nach fehlgeschlagenem Start von ${module.moduleId} ist fehlgeschlagen`);
}
throw this.lifecycleFailure('start', module, error, statusUpdateFailed);
} }
return (await this.moduleRepository.findById(id)) ?? module; return (await this.moduleRepository.findById(id)) ?? module;
} }
@@ -150,6 +336,10 @@ export class ModulesService {
/** Stoppt ein Modul (RUNNING → STOPPING → STOPPED). */ /** Stoppt ein Modul (RUNNING → STOPPING → STOPPED). */
async stop(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> { async stop(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.stopUnlocked(id, actor, ipAddress));
}
private async stopUnlocked(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.status === 'STOPPED' || module.status === 'STOPPING') { if (module.status === 'STOPPED' || module.status === 'STOPPING') {
return module; return module;
@@ -161,21 +351,29 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'STOPPED'); await this.moduleRepository.updateStatus(id, 'STOPPED');
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress); await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress);
} catch (error) { } catch (error) {
let statusUpdateFailed = false;
try {
await this.moduleRepository.updateStatus(id, 'ERROR'); await this.moduleRepository.updateStatus(id, 'ERROR');
throw new BadRequestException( } catch {
`Modul konnte nicht gestoppt werden: ${error instanceof Error ? error.message : String(error)}`, statusUpdateFailed = true;
); this.logger.error(`Fehlerstatus für Modul ${module.moduleId} konnte nicht gespeichert werden`);
}
throw this.lifecycleFailure('stop', module, error, statusUpdateFailed);
} }
return (await this.moduleRepository.findById(id)) ?? module; return (await this.moduleRepository.findById(id)) ?? module;
} }
/** Startet ein Modul neu (Stop + Start). */ /** Startet ein Modul neu (Stop + Start). */
async restart(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> { async restart(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.restartUnlocked(id, actor, ipAddress));
}
private async restartUnlocked(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
await this.stop(id, actor, ipAddress); await this.stopUnlocked(id, actor, ipAddress);
} }
return this.start(id, actor, ipAddress); return this.startUnlocked(id, actor, ipAddress);
} }
/** Aktiviert oder deaktiviert ein Modul (DISABLED-Zustand). */ /** Aktiviert oder deaktiviert ein Modul (DISABLED-Zustand). */
@@ -184,6 +382,15 @@ export class ModulesService {
enabled: boolean, enabled: boolean,
actor: ActingUser, actor: ActingUser,
ipAddress: string | null, ipAddress: string | null,
): Promise<ModuleRecord> {
return this.withModuleLock(id, () => this.setEnabledUnlocked(id, enabled, actor, ipAddress));
}
private async setEnabledUnlocked(
id: string,
enabled: boolean,
actor: ActingUser,
ipAddress: string | null,
): Promise<ModuleRecord> { ): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.enabled === enabled) { if (module.enabled === enabled) {
@@ -191,7 +398,7 @@ export class ModulesService {
} }
if (!enabled && (module.status === 'RUNNING' || module.status === 'STARTING')) { if (!enabled && (module.status === 'RUNNING' || module.status === 'STARTING')) {
await this.stop(id, actor, ipAddress); await this.stopUnlocked(id, actor, ipAddress);
} }
await this.moduleRepository.updateEnabled(id, enabled); await this.moduleRepository.updateEnabled(id, enabled);
@@ -206,22 +413,42 @@ export class ModulesService {
action: enabled ? AUDIT_ACTIONS.MODULE_ENABLED : AUDIT_ACTIONS.MODULE_DISABLED, action: enabled ? AUDIT_ACTIONS.MODULE_ENABLED : AUDIT_ACTIONS.MODULE_DISABLED,
details: { moduleId: module.moduleId }, details: { moduleId: module.moduleId },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Status von ${module.moduleId} geändert, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
return (await this.moduleRepository.findById(id)) ?? module; return (await this.moduleRepository.findById(id)) ?? module;
} }
/** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */ /** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> { async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> {
return this.withModuleLock(id, () => this.removeUnlocked(id, actor, ipAddress));
}
private async removeUnlocked(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
await this.stop(id, actor, ipAddress); await this.stopUnlocked(id, actor, ipAddress);
} }
try {
await this.processManager.remove(module); await this.processManager.remove(module);
} catch (error) {
throw this.lifecycleFailure('remove', module, error);
}
try {
await this.moduleRepository.delete(id); await this.moduleRepository.delete(id);
} catch (error) {
throw this.lifecycleFailure('remove', module, error);
}
let cleanupWarning: string | undefined;
try {
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId); await this.installer.remove(this.config.runtime.modulesDir, module.moduleId);
} catch {
cleanupWarning = 'Das Modul wurde aus MPM entfernt, aber seine Dateien konnten nicht vollständig gelöscht werden.';
this.logger.error(`Dateien von Modul ${module.moduleId} konnten nach dem Entfernen nicht bereinigt werden`);
}
await this.auditService.record({ await this.auditService.record({
userId: actor.id, userId: actor.id,
@@ -229,7 +456,10 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_REMOVED, action: AUDIT_ACTIONS.MODULE_REMOVED,
details: { moduleId: module.moduleId }, details: { moduleId: module.moduleId },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Modul ${module.moduleId} entfernt, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
return cleanupWarning ? { cleanupWarning } : {};
} }
/** Führt einen Healthcheck für ein Modul aus (ohne Statusänderung). */ /** Führt einen Healthcheck für ein Modul aus (ohne Statusänderung). */
@@ -245,6 +475,36 @@ export class ModulesService {
} }
} }
private async withModuleLock<T>(id: string, operation: () => Promise<T>): Promise<T> {
const module = await this.getById(id);
return this.operationLock.run(module.moduleId, operation);
}
private lifecycleFailure(
action: 'start' | 'stop' | 'remove',
module: ModuleRecord,
error: unknown,
statusUpdateFailed = false,
): BadRequestException {
const actionText = { start: 'gestartet', stop: 'gestoppt', remove: 'entfernt' }[action];
const commandDiagnostic = error instanceof ModuleCommandError
? error.diagnostic
: error instanceof Error && error.message.startsWith('Healthcheck fehlgeschlagen:')
? error.message
: 'Die technische Ursache steht im Plattform-Log.';
const diagnostic = statusUpdateFailed
? `${commandDiagnostic} MPM konnte den Fehlerstatus nicht speichern; bitte Status erneut laden.`
: commandDiagnostic;
return new BadRequestException({
statusCode: 400,
error: 'Bad Request',
code: `MODULE_${action.toUpperCase()}_FAILED`,
message: `Modul „${module.name}“ konnte nicht ${actionText} werden.`,
diagnostic,
});
}
private async auditLifecycle( private async auditLifecycle(
module: ModuleRecord, module: ModuleRecord,
action: AuditAction, action: AuditAction,
@@ -257,6 +517,8 @@ export class ModulesService {
action, action,
details: { moduleId: module.moduleId, version: module.version }, details: { moduleId: module.moduleId, version: module.version },
ipAddress, ipAddress,
}).catch((auditError: unknown) => {
this.logger.error(`Modulaktion ${action} für ${module.moduleId} erfolgreich, aber Audit konnte nicht gespeichert werden: ${auditError instanceof Error ? auditError.message : String(auditError)}`);
}); });
} }
} }

View File

@@ -1,6 +1,5 @@
import { UnauthorizedException } from '@nestjs/common'; import { UnauthorizedException } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher'; import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository'; import { UserRepository } from './user.repository';
import type { UserRecord } from './user.types'; import type { UserRecord } from './user.types';
@@ -28,23 +27,14 @@ function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
/** Mock des UserRepository. */ /** Mock des UserRepository. */
class MockUserRepository { class MockUserRepository {
public user: UserRecord | null = createUserRecord(); public user: UserRecord | null = createUserRecord();
public updatedPasswords: Array<{ id: string; hash: string }> = []; public updatedPasswords: Array<{ id: string; hash: string; exceptSessionId?: string; expectedHash?: string }> = [];
async findById(id: string): Promise<UserRecord | null> { async findById(id: string): Promise<UserRecord | null> {
return this.user && this.user.id === id ? this.user : null; return this.user && this.user.id === id ? this.user : null;
} }
async updatePassword(id: string, hash: string): Promise<void> { async updatePassword(id: string, hash: string, exceptSessionId?: string, expectedHash?: string): Promise<void> {
this.updatedPasswords.push({ id, hash }); this.updatedPasswords.push({ id, hash, exceptSessionId, expectedHash });
}
}
/** 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 });
} }
} }
@@ -59,20 +49,17 @@ class MockAuditService {
describe('ProfileService', () => { describe('ProfileService', () => {
let userRepository: MockUserRepository; let userRepository: MockUserRepository;
let sessionService: MockSessionService;
let auditService: MockAuditService; let auditService: MockAuditService;
let profileService: ProfileService; let profileService: ProfileService;
let passwordHasher: PasswordHasher; let passwordHasher: PasswordHasher;
beforeEach(async () => { beforeEach(async () => {
userRepository = new MockUserRepository(); userRepository = new MockUserRepository();
sessionService = new MockSessionService();
auditService = new MockAuditService(); auditService = new MockAuditService();
passwordHasher = new PasswordHasher(); passwordHasher = new PasswordHasher();
profileService = new ProfileService( profileService = new ProfileService(
userRepository as unknown as UserRepository, userRepository as unknown as UserRepository,
passwordHasher, passwordHasher,
sessionService as unknown as SessionService,
auditService as unknown as AuditService, auditService as unknown as AuditService,
); );
@@ -101,9 +88,8 @@ describe('ProfileService', () => {
); );
expect(userRepository.updatedPasswords).toHaveLength(1); expect(userRepository.updatedPasswords).toHaveLength(1);
expect(sessionService.deletedForUser).toEqual([ expect(userRepository.updatedPasswords[0].exceptSessionId).toBe('session-1');
{ userId: 'user-1', exceptSessionId: 'session-1' }, expect(userRepository.updatedPasswords[0].expectedHash).toBe(userRepository.user?.passwordHash);
]);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_CHANGED);
}); });

View File

@@ -1,6 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher'; import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository'; import { UserRepository } from './user.repository';
import type { ChangePasswordDto, UserRecord } from './user.types'; import type { ChangePasswordDto, UserRecord } from './user.types';
@@ -16,7 +15,6 @@ export class ProfileService {
constructor( constructor(
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher, private readonly passwordHasher: PasswordHasher,
private readonly sessionService: SessionService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
) {} ) {}
@@ -48,8 +46,7 @@ export class ProfileService {
} }
const newPasswordHash = await this.passwordHasher.hash(input.newPassword); const newPasswordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(userId, newPasswordHash); await this.userRepository.updatePassword(userId, newPasswordHash, currentSessionId, user.passwordHash);
await this.sessionService.deleteAllForUser(userId, currentSessionId);
await this.auditService.record({ await this.auditService.record({
userId, userId,

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { PasswordHasher } from './password-hasher'; import { PasswordHasher } from './password-hasher';
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types'; import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
@@ -146,11 +146,31 @@ export class UserRepository {
} }
/** Setzt einen neuen Passwort-Hash. */ /** Setzt einen neuen Passwort-Hash. */
async updatePassword(id: string, passwordHash: string): Promise<void> { async updatePassword(
await this.database.query( id: string,
'UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1', passwordHash: string,
[id, passwordHash], exceptSessionId?: string,
expectedPasswordHash?: string,
): Promise<void> {
await this.database.transaction(async (client) => {
// Login hält dieselbe Benutzerzeile bis zur Session-Anlage gesperrt.
const updated = await client.query(
`UPDATE users SET password_hash = $2, updated_at = now()
WHERE id = $1 AND ($3::text IS NULL OR password_hash = $3)`,
[id, passwordHash, expectedPasswordHash ?? null],
); );
if (updated.rowCount !== 1) {
throw new UnauthorizedException('Passwort wurde zwischenzeitlich geändert');
}
await client.query(
exceptSessionId
? 'DELETE FROM sessions WHERE user_id = $1 AND id <> $2'
: 'DELETE FROM sessions WHERE user_id = $1',
exceptSessionId ? [id, exceptSessionId] : [id],
);
await client.query('DELETE FROM module_sessions WHERE user_id = $1', [id]);
await client.query('DELETE FROM module_access_tickets WHERE user_id = $1', [id]);
});
} }
/** Setzt Fehlversuchs-Zähler und Sperre zurück (bei Aktivierung). */ /** Setzt Fehlversuchs-Zähler und Sperre zurück (bei Aktivierung). */

View File

@@ -225,7 +225,7 @@ describe('UsersService', () => {
null, null,
); );
expect(userRepository.updatedPasswords).toHaveLength(1); expect(userRepository.updatedPasswords).toHaveLength(1);
expect(sessionService.deletedSessionsForUser).toEqual(['user-1']); expect(sessionService.deletedSessionsForUser).toEqual([]);
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.USER_PASSWORD_RESET);
}); });
}); });

View File

@@ -135,7 +135,6 @@ export class UsersService {
const user = await this.findById(id); const user = await this.findById(id);
const passwordHash = await this.passwordHasher.hash(input.newPassword); const passwordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(id, passwordHash); await this.userRepository.updatePassword(id, passwordHash);
await this.sessionService.deleteAllForUser(id);
await this.auditService.record({ await this.auditService.record({
userId: actor.id, userId: actor.id,
username: actor.username, username: actor.username,

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
"aliases": { "components": "@/components", "ui": "@/components/ui", "utils": "@/lib/utils", "lib": "@/lib", "hooks": "@/hooks" }
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,14 +12,22 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.53.0",
"radix-ui": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.0", "react-router-dom": "^7.1.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tw-animate-css": "^1.4.0",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.0.0", "@eslint/js": "^9.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^26.6.4",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",

View File

@@ -1,10 +1,13 @@
import { type ReactNode, useEffect, useState } from 'react'; import { type ReactNode, useEffect, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../../features/auth/auth-context'; import { useAuth } from '../../features/auth/auth-context';
import { ProfilePage } from '../../features/profile/profile-page'; import { ProfilePage } from '../../features/profile/profile-page';
import { ChevronsUpDown } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { Icon, type IconName } from '../ui/icon'; import { Icon, type IconName } from '../ui/icon';
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
import { Switch } from '../ui/switch';
/** Ein Navigationspunkt der Sidebar. */
interface NavItem { interface NavItem {
to: string; to: string;
label: string; label: string;
@@ -24,10 +27,19 @@ function initialDarkMode(): boolean {
return document.documentElement.dataset.theme === 'dark'; return document.documentElement.dataset.theme === 'dark';
} }
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */ /** MPM-Shell mit shadcn Sidebar; bestehende Breiten, Höhen und Innenabstände bleiben erhalten. */
export function AppLayout(): ReactNode { export function AppLayout(): ReactNode {
return (
<SidebarProvider defaultOpen>
<AppLayoutContent />
</SidebarProvider>
);
}
function AppLayoutContent(): ReactNode {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const { setOpenMobile, state: sidebarState } = useSidebar();
const location = useLocation();
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [darkMode, setDarkMode] = useState(initialDarkMode); const [darkMode, setDarkMode] = useState(initialDarkMode);
@@ -36,116 +48,117 @@ export function AppLayout(): ReactNode {
localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light'); localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light');
}, [darkMode]); }, [darkMode]);
const visibleItems = NAV_ITEMS.filter( const visibleItems = NAV_ITEMS.filter((item) => !item.adminOnly || user?.role === 'ADMIN');
(item) => !item.adminOnly || user?.role === 'ADMIN',
);
return ( return (
<div className="flex h-full"> <>
{/* Mobile: Overlay-Hintergrund */} <Sidebar className="border-r border-slate-200 bg-white" collapsible="icon" variant="sidebar">
{mobileMenuOpen && ( <SidebarHeader
<div className="group/sidebar-header relative h-16 flex-row items-center justify-between gap-2 border-b border-slate-200 px-6 py-0"
className="fixed inset-0 z-30 bg-slate-900/50 lg:hidden" style={sidebarState === 'collapsed'
onClick={() => setMobileMenuOpen(false)} ? { display: 'grid', gridTemplateColumns: '1fr', placeItems: 'center', paddingInline: 0 }
aria-hidden="true" : undefined}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-slate-200 bg-white transition-transform lg:static lg:translate-x-0
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}
aria-label="Hauptnavigation"
> >
<div className="flex h-16 items-center gap-2 border-b border-slate-200 px-6"> <div
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-sm font-bold text-white"> className="flex min-w-0 items-center gap-2 group-data-[collapsible=icon]:hidden"
M style={sidebarState === 'collapsed' ? { display: 'none' } : undefined}
</span> >
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-sm font-bold text-white">M</span>
<span className="text-base font-semibold text-slate-900">MPM</span> <span className="text-base font-semibold text-slate-900">MPM</span>
</div> </div>
<div
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4"> style={sidebarState === 'collapsed'
{visibleItems.map((item) => ( ? { display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'center' }
<NavLink : { display: 'contents' }}
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => setMobileMenuOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
${
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`
}
> >
<Icon name={item.icon} className="h-[18px] w-[18px]" /> <SidebarTrigger
{item.label} className="hidden h-9 w-9 shrink-0 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:flex"
</NavLink> aria-label="Sidebar einklappen oder ausklappen"
))}
</nav>
<div className="border-t border-slate-200 p-4">
<button
type="button"
onClick={() => setProfileOpen(true)}
className="group flex w-full min-w-0 items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
aria-haspopup="dialog"
aria-label={`Profileinstellungen für ${user?.displayName ?? 'Benutzer'} öffnen`}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600">
<Icon name="user" className="h-[18px] w-[18px]" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-900 group-hover:text-brand-700">
{user?.displayName}
</span>
<Icon name="arrow" className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" />
</button>
<button
onClick={() => void logout()}
className="mt-3 w-full rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Abmelden
</button>
</div>
</aside>
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Topbar (mobil: Menü-Button) */}
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 lg:px-6">
<button
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100 lg:hidden"
onClick={() => setMobileMenuOpen((open) => !open)}
aria-label="Menü öffnen"
aria-expanded={mobileMenuOpen}
> >
<div className="flex h-full w-full items-center justify-center">
<Icon name="menu" className="h-5 w-5" /> <Icon name="menu" className="h-5 w-5" />
</button> </div>
<span className="hidden text-sm text-slate-500 lg:block">Management-Plattform</span> </SidebarTrigger>
<span className="text-sm text-slate-500 lg:hidden">MPM</span> </div>
<button </SidebarHeader>
type="button"
role="switch" <SidebarContent className="flex-1 overflow-y-auto px-3 py-4 group-data-[collapsible=icon]:px-0">
aria-checked={darkMode} <SidebarGroup className="p-0">
aria-label="Darkmode" <SidebarMenu className="gap-1">
onClick={() => setDarkMode((current) => !current)} {visibleItems.map((item) => {
className="inline-flex h-9 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition-colors hover:bg-slate-100" const isActive = item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to);
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
asChild
isActive={isActive}
className={`h-auto gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors data-[active=true]:bg-brand-50 data-[active=true]:text-brand-700 group-data-[collapsible=icon]:mx-auto ${isActive ? '' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}`}
> >
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" /> <NavLink to={item.to} end={item.to === '/'} onClick={() => setOpenMobile(false)}>
<span>{darkMode ? 'Hell' : 'Dunkel'}</span> <Icon name={item.icon} className="h-[18px] w-[18px]" />
<span className={`relative h-5 w-9 rounded-full transition-colors ${darkMode ? 'bg-brand-600' : 'bg-slate-300'}`}> <span>{item.label}</span>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${darkMode ? 'translate-x-4' : 'translate-x-0.5'}`} /> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-slate-200 p-3 group-data-[collapsible=icon]:p-1">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="h-auto min-h-16 gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 hover:border-brand-300 hover:bg-brand-50 data-[state=open]:bg-brand-50 group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:min-h-10! group-data-[collapsible=icon]:p-1!"
aria-label="Benutzermenü öffnen"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-600 text-sm font-semibold text-white">
{(user?.displayName ?? user?.username ?? 'U').trim().charAt(0).toUpperCase()}
</span> </span>
</button> <span className="grid min-w-0 flex-1 text-left leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-medium text-slate-900">{user?.displayName ?? user?.username}</span>
<span className="truncate text-xs text-slate-500">{user?.email}</span>
</span>
<ChevronsUpDown className="ml-auto size-4 shrink-0 text-slate-500 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-56">
<DropdownMenuItem onSelect={() => setProfileOpen(true)}>
<Icon name="user" className="size-4" />
Profileinstellungen
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void logout()}>
<Icon name="arrow" className="size-4" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset className="h-full min-w-0 flex-1 flex-col bg-transparent">
<header className="flex h-16 items-center border-b border-slate-200 bg-white px-4 lg:px-6">
<SidebarTrigger className="mr-auto h-9 w-9 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:hidden" aria-label="Menü öffnen">
<Icon name="menu" className="h-5 w-5" />
</SidebarTrigger>
<div className="ml-auto inline-flex h-9 items-center gap-3">
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" />
<Switch checked={darkMode} onCheckedChange={setDarkMode} aria-label="Darkmode" />
</div>
</header> </header>
<main className="flex-1 overflow-y-auto p-4 lg:p-8"> <main className="flex-1 overflow-y-auto p-4 lg:p-8">
<Outlet /> <Outlet />
</main> </main>
</div> </SidebarInset>
{profileOpen && <ProfilePage onClose={() => setProfileOpen(false)} />} {profileOpen && <ProfilePage onClose={() => setProfileOpen(false)} />}
</div> </>
); );
} }

View File

@@ -1,29 +1,26 @@
import { type ReactNode } from 'react'; import { cva, type VariantProps } from 'class-variance-authority';
import { type HTMLAttributes, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
/** Farbschemata für Badges (Statusanzeigen). */ const badgeVariants = cva('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset', {
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'neutral' | 'info'; variants: {
variant: {
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20', success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
warning: 'bg-amber-50 text-amber-700 ring-amber-600/20', warning: 'bg-amber-50 text-amber-700 ring-amber-600/20',
danger: 'bg-red-50 text-red-700 ring-red-600/20', danger: 'bg-red-50 text-red-700 ring-red-600/20',
neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20', neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20',
info: 'bg-brand-50 text-brand-700 ring-brand-600/20', info: 'bg-brand-50 text-brand-700 ring-brand-600/20',
}; },
},
defaultVariants: { variant: 'neutral' },
});
export interface BadgeProps { export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
variant?: BadgeVariant; export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> { children: ReactNode }
children: ReactNode;
/** shadcn/ui Badge mit den bisherigen MPM-Statusvarianten. */
export function Badge({ variant = 'neutral', children, className, ...rest }: BadgeProps): ReactNode {
return <span className={cn(badgeVariants({ variant }), className)} {...rest}>{children}</span>;
} }
/** Kleines Status-Label (Design-System). */ export { badgeVariants };
export function Badge({ variant = 'neutral', children }: BadgeProps): ReactNode {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${VARIANT_CLASSES[variant]}`}
>
{children}
</span>
);
}

View File

@@ -1,58 +1,52 @@
import { Slot } from 'radix-ui';
import { cva, type VariantProps } from 'class-variance-authority';
import { type ButtonHTMLAttributes, type ReactNode } from 'react'; import { type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
/** Varianten des Buttons (Design-System). */ const buttonVariants = cva(
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
export type ButtonSize = 'sm' | 'md' | 'lg'; {
variants: {
const VARIANT_CLASSES: Record<ButtonVariant, string> = { variant: {
primary: default: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300', primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300',
secondary: secondary: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400',
'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400', outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 active:bg-slate-100',
danger: destructive: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300', danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300',
ghost: ghost: 'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400',
'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400', link: 'text-brand-600 underline-offset-4 hover:underline',
}; },
size: {
const SIZE_CLASSES: Record<ButtonSize, string> = { xs: 'h-6 rounded-md px-2 text-xs',
sm: 'h-8 px-3 text-sm', sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm', md: 'h-10 px-4 text-sm',
default: 'h-9 px-4 text-sm',
lg: 'h-11 px-5 text-base', lg: 'h-11 px-5 text-base',
}; icon: 'size-9 p-0',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
},
);
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>['variant']>;
variant?: ButtonVariant; export type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>['size']>;
size?: ButtonSize; export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
loading?: boolean; loading?: boolean;
asChild?: boolean;
children: ReactNode; children: ReactNode;
} }
/** Standard-Button des Design-Systems. */ /** shadcn/ui Button mit MPM-Varianten und bestehenden Abmessungen. */
export function Button({ export function Button({ variant = 'primary', size = 'md', loading = false, asChild = false, className, disabled, children, ...rest }: ButtonProps): ReactNode {
variant = 'primary', const Comp = asChild ? Slot.Root : 'button';
size = 'md',
loading = false,
className = '',
disabled,
children,
...rest
}: ButtonProps): ReactNode {
return ( return (
<button <Comp data-slot="button" className={cn(buttonVariants({ variant, size }), className)} disabled={disabled || loading} {...rest}>
className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors {loading && <span aria-hidden="true" className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />}
focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed
${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
disabled={disabled || loading}
{...rest}
>
{loading && (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
)}
{children} {children}
</button> </Comp>
); );
} }
export { buttonVariants };

View File

@@ -1,46 +1,33 @@
import { type ReactNode } from 'react'; import { type ComponentProps, type ReactNode } from 'react';
import { Slot } from 'radix-ui';
import { cn } from '../../lib/utils';
export interface CardProps { /** shadcn/ui Card primitives; MPM classes preserve the established card geometry. */
children: ReactNode; export function Card({ asChild = false, className, ...props }: ComponentProps<'div'> & { asChild?: boolean }): ReactNode {
className?: string; const Comp = asChild ? Slot.Root : 'div';
return <Comp data-slot="card" className={cn('flex flex-col rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-sm', className)} {...props} />;
} }
/** Karten-Container des Design-Systems. */ export function CardHeader({ className, ...props }: ComponentProps<'div'>): ReactNode {
export function Card({ children, className = '' }: CardProps): ReactNode { return <div data-slot="card-header" className={cn('flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4', className)} {...props} />;
return (
<div
className={`rounded-2xl border border-slate-200 bg-white shadow-sm ${className}`}
>
{children}
</div>
);
} }
export interface CardHeaderProps { export function CardTitle({ className, ...props }: ComponentProps<'h2'>): ReactNode {
title: string; return <h2 data-slot="card-title" className={cn('text-base font-semibold text-slate-900', className)} {...props} />;
description?: string;
children?: ReactNode;
} }
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */ export function CardDescription({ className, ...props }: ComponentProps<'p'>): ReactNode {
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode { return <p data-slot="card-description" className={cn('mt-0.5 text-sm text-slate-500', className)} {...props} />;
return (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div>
<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>
{children}
</div>
);
} }
export interface CardBodyProps { export function CardAction({ className, ...props }: ComponentProps<'div'>): ReactNode {
children: ReactNode; return <div data-slot="card-action" className={cn('shrink-0', className)} {...props} />;
className?: string;
} }
/** Karten-Inhalt. */ export function CardContent({ className, ...props }: ComponentProps<'div'>): ReactNode {
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode { return <div data-slot="card-content" className={cn('px-5 py-5', className)} {...props} />;
return <div className={`px-5 py-5 ${className}`}>{children}</div>; }
export function CardFooter({ className, ...props }: ComponentProps<'div'>): ReactNode {
return <div data-slot="card-footer" className={cn('flex items-center border-t border-slate-200 px-5 py-4', className)} {...props} />;
} }

View File

@@ -0,0 +1,18 @@
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import { Check } from 'lucide-react';
import { type ComponentProps, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export type CheckboxProps = ComponentProps<typeof CheckboxPrimitive.Root>;
export function Checkbox({ className, ...props }: CheckboxProps): ReactNode {
return (
<CheckboxPrimitive.Root
className={cn('peer h-4 w-4 shrink-0 rounded-[4px] border border-slate-300 bg-white text-white shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-brand-600 data-[state=checked]:bg-brand-600', className)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="h-3 w-3" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className="bg-slate-900/50" />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from '../../lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-900 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-slate-200', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger };

View File

@@ -1,36 +1,40 @@
import {
ArrowRight,
Boxes,
Check,
ClipboardList,
Clock3,
LayoutDashboard,
Menu,
Moon,
Plus,
Sun,
UserRound,
UsersRound,
X,
type LucideIcon,
} from 'lucide-react';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check'; export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check';
const ICONS: Record<IconName, LucideIcon> = {
const PATHS: Record<IconName, ReactNode> = { dashboard: LayoutDashboard,
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></>, users: UsersRound,
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="10" cy="7" r="4" /><path d="M20 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" /></>, modules: Boxes,
modules: <><rect x="3" y="3" width="8" height="8" rx="2" /><rect x="13" y="3" width="8" height="5" rx="2" /><rect x="13" y="10" width="8" height="11" rx="2" /><rect x="3" y="13" width="8" height="8" rx="2" /></>, system: Clock3,
system: <><path d="M12 8v4l2.5 2.5" /><circle cx="12" cy="12" r="9" /><path d="M12 3v2m9 7h-2M5 12H3m9 9v-2" /></>, audit: ClipboardList,
audit: <><path d="M8 4h10a2 2 0 0 1 2 2v14H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2Z" /><path d="M8 2v4m0 4h8m-8 4h8m-8 4h5" /></>, moon: Moon,
moon: <path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z" />, sun: Sun,
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42" /></>, user: UserRound,
user: <><circle cx="12" cy="8" r="4" /><path d="M5 21a7 7 0 0 1 14 0" /></>, arrow: ArrowRight,
arrow: <><path d="M5 12h14m-6-6 6 6-6 6" /></>, close: X,
close: <><path d="m18 6-12 12M6 6l12 12" /></>, menu: Menu,
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>, plus: Plus,
plus: <path d="M12 5v14M5 12h14" />, check: Check,
check: <path d="m5 12 4 4L19 6" />,
}; };
/** Lucide-Symbole im bestehenden MPM-Icon-API. */
export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode { export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode {
return ( const Component = ICONS[name];
<svg return <Component aria-hidden="true" className={className} strokeWidth={1.8} />;
aria-hidden="true"
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
{PATHS[name]}
</svg>
);
} }

View File

@@ -1,4 +1,6 @@
import { type InputHTMLAttributes, type ReactNode } from 'react'; import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { Label } from './label';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string; label: string;
@@ -6,46 +8,42 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
hint?: string; hint?: string;
} }
/** Text-Input mit Label, Fehler- und Hinweistext (Design-System). */ /** Basis-Input für Formulare, die ihr Label separat rendern. */
export function Input({ export const InputControl = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
label, function InputControl({ className, ...rest }, ref) {
error, return (
hint, <input
className = '', ref={ref}
id, data-slot="input"
...rest className={cn(
}: InputProps): ReactNode { 'h-10 w-full min-w-0 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm transition-[color,box-shadow] outline-none placeholder:text-slate-400 selection:bg-brand-600 selection:text-white focus-visible:border-brand-500 focus-visible:ring-2 focus-visible:ring-brand-500/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...rest}
/>
);
},
);
/** shadcn/ui Input mit MPM-Label und Fehlertext. */
export function Input({ label, error, hint, className, id, ...rest }: InputProps): ReactNode {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`; const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined; const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined;
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor={inputId} className="text-sm font-medium text-slate-700"> <Label htmlFor={inputId}>{label}</Label>
{label} <InputControl
</label>
<input
id={inputId} id={inputId}
className={`h-10 rounded-lg border px-3 text-sm transition-colors className={cn(
${ error ? 'border-red-400 focus:border-red-500' : 'border-slate-300 focus:border-brand-500',
error className,
? 'border-red-400 focus:border-red-500' )}
: 'border-slate-300 focus:border-brand-500'
}
${className}`}
aria-invalid={error ? true : undefined} aria-invalid={error ? true : undefined}
aria-describedby={describedBy} aria-describedby={describedBy}
{...rest} {...rest}
/> />
{hint && !error && ( {hint && !error && <p id={`${inputId}-hint`} className="text-xs text-slate-500">{hint}</p>}
<p id={`${inputId}-hint`} className="text-xs text-slate-500"> {error && <p id={`${inputId}-error`} role="alert" className="text-xs text-red-600">{error}</p>}
{hint}
</p>
)}
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-600">
{error}
</p>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,7 @@
import { Label as LabelPrimitive } from 'radix-ui';
import { type ComponentProps, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export function Label({ className, ...props }: ComponentProps<typeof LabelPrimitive.Root>): ReactNode {
return <LabelPrimitive.Root className={cn('text-sm font-medium text-slate-700', className)} {...props} />;
}

View File

@@ -1,4 +1,8 @@
import { type ReactNode, useEffect } from 'react'; import { X } from 'lucide-react';
import { type ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { Button } from './button';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from './dialog';
export interface ModalProps { export interface ModalProps {
open: boolean; open: boolean;
@@ -11,56 +15,35 @@ export interface ModalProps {
hideHeader?: boolean; hideHeader?: boolean;
} }
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */ /** Kompatibler MPM-Wrapper auf den shadcn/ui Dialog-Komponenten. */
export function Modal({ export function Modal({ open, title, description, onClose, children, footer, panelClassName = 'max-w-md', hideHeader = false }: ModalProps): ReactNode {
open,
title,
description,
onClose,
children,
footer,
panelClassName = 'max-w-md',
hideHeader = false,
}: 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 ( return (
<div <Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4" <DialogContent
onClick={onClose} showCloseButton={false}
role="presentation" className={cn('w-[calc(100%-2rem)] max-w-none gap-0 rounded-xl border-0 bg-white p-0 shadow-xl', panelClassName)}
> >
<div {hideHeader ? (
role="dialog" <>
aria-modal="true" <DialogTitle className="sr-only">{title}</DialogTitle>
aria-label={title} {description && <DialogDescription className="sr-only">{description}</DialogDescription>}
className={`w-full rounded-xl bg-white shadow-xl ${panelClassName}`} </>
onClick={(event) => event.stopPropagation()} ) : (
> <div className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-4">
{!hideHeader && ( <div className="min-w-0">
<div className="border-b border-slate-200 px-6 py-4"> <DialogTitle className="text-base font-semibold text-slate-900">{title}</DialogTitle>
<h2 className="text-base font-semibold text-slate-900">{title}</h2> {description && <DialogDescription className="mt-0.5 text-sm text-slate-500">{description}</DialogDescription>}
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>} </div>
<DialogClose asChild>
<Button variant="ghost" size="icon" aria-label="Dialog schließen" className="-mr-2 -mt-1 h-8 w-8 rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-900">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</DialogClose>
</div> </div>
)} )}
<div className="px-6 py-4">{children}</div> <div className="px-6 py-4">{children}</div>
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>} {footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
</div> </DialogContent>
</div> </Dialog>
); );
} }

View File

@@ -1,50 +1,62 @@
import { type ReactNode, type SelectHTMLAttributes } from 'react'; import { Select as SelectPrimitive } from 'radix-ui';
import { Check, ChevronDown } from 'lucide-react';
import { type ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { Label } from './label';
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { export interface SelectProps {
label: string; label: string;
error?: string; error?: string;
options: ReadonlyArray<{ value: string; label: string }>; options: ReadonlyArray<{ value: string; label: string }>;
placeholder?: string;
id?: string;
name?: string;
required?: boolean;
disabled?: boolean;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
export type SelectControlProps = Omit<SelectProps, 'label' | 'error' | 'id'> & { id?: string; className?: string; invalid?: boolean };
export function SelectControl({ options, placeholder, id, className, invalid, ...rootProps }: SelectControlProps): ReactNode {
return (
<SelectPrimitive.Root {...rootProps}>
<SelectPrimitive.Trigger
id={id}
aria-invalid={invalid ? true : undefined}
className={cn(
'flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-left text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:cursor-not-allowed disabled:opacity-50',
invalid ? 'border-red-400' : 'border-slate-300',
className,
)}
>
<SelectPrimitive.Value placeholder={placeholder} />
<SelectPrimitive.Icon asChild><ChevronDown className="h-4 w-4 opacity-50" /></SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content position="popper" sideOffset={4} className="z-[60] max-h-60 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 text-slate-900 shadow-md">
<SelectPrimitive.Viewport>
{options.map((option) => (
<SelectPrimitive.Item key={option.value} value={option.value} className="relative flex cursor-default select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<span className="absolute left-2 flex h-4 w-4 items-center justify-center"><SelectPrimitive.ItemIndicator><Check className="h-4 w-4" /></SelectPrimitive.ItemIndicator></span>
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
} }
/** Select-Dropdown mit Label (Design-System). */ export function Select({ label, error, options, placeholder, id, ...props }: SelectProps): ReactNode {
export function Select({
label,
error,
options,
className = '',
id,
...rest
}: SelectProps): ReactNode {
const selectId = id ?? `select-${label.toLowerCase().replace(/\s+/g, '-')}`; const selectId = id ?? `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor={selectId} className="text-sm font-medium text-slate-700"> <Label htmlFor={selectId}>{label}</Label>
{label} <SelectControl id={selectId} options={options} placeholder={placeholder} invalid={Boolean(error)} {...props} />
</label> {error && <p role="alert" className="text-xs text-red-600">{error}</p>}
<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> </div>
); );
} }

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,142 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-slate-900/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,727 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { InputControl as Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "16rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
children,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
{children ?? <PanelLeftIcon />}
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -22,15 +22,17 @@ export interface EmptyStateProps {
title: string; title: string;
description?: string; description?: string;
icon?: ReactNode; icon?: ReactNode;
action?: ReactNode;
} }
/** Anzeige für leere Zustände (Design-System). */ /** Anzeige für leere Zustände (Design-System). */
export function EmptyState({ title, description, icon }: EmptyStateProps): ReactNode { export function EmptyState({ title, description, icon, action }: EmptyStateProps): ReactNode {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{icon && <div className="text-slate-300">{icon}</div>} {icon && <div className="text-slate-300">{icon}</div>}
<h3 className="text-sm font-semibold text-slate-900">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>} {description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
{action}
</div> </div>
); );
} }
@@ -38,12 +40,14 @@ export function EmptyState({ title, description, icon }: EmptyStateProps): React
export interface ErrorStateProps { export interface ErrorStateProps {
title?: string; title?: string;
message?: string; message?: string;
action?: ReactNode;
} }
/** Anzeige für Fehlerzustände (Design-System). */ /** Anzeige für Fehlerzustände (Design-System). */
export function ErrorState({ export function ErrorState({
title = 'Ein Fehler ist aufgetreten', title = 'Ein Fehler ist aufgetreten',
message = 'Bitte versuchen Sie es später erneut.', message = 'Bitte versuchen Sie es später erneut.',
action,
}: ErrorStateProps): ReactNode { }: ErrorStateProps): ReactNode {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert">
@@ -52,6 +56,7 @@ export function ErrorState({
</div> </div>
<h3 className="text-sm font-semibold text-slate-900">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="max-w-sm text-sm text-slate-500">{message}</p> <p className="max-w-sm text-sm text-slate-500">{message}</p>
{action}
</div> </div>
); );
} }

View File

@@ -0,0 +1,25 @@
import { Switch as SwitchPrimitive } from 'radix-ui';
import { type ComponentProps, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export type SwitchProps = ComponentProps<typeof SwitchPrimitive.Root> & { size?: 'sm' | 'default' };
/** shadcn/ui Switch, einschließlich unveränderter shadcn-Standardmaße und Zustandsfarben. */
export function Switch({ className, size = 'default', ...props }: SwitchProps): ReactNode {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
'peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}

View File

@@ -0,0 +1,21 @@
import { type ComponentProps, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export function Table({ className, ...props }: ComponentProps<'table'>): ReactNode {
return <table className={cn('w-full caption-bottom text-sm', className)} {...props} />;
}
export function TableHeader({ className, ...props }: ComponentProps<'thead'>): ReactNode {
return <thead className={cn('[&_tr]:border-b', className)} {...props} />;
}
export function TableBody({ className, ...props }: ComponentProps<'tbody'>): ReactNode {
return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
}
export function TableRow({ className, ...props }: ComponentProps<'tr'>): ReactNode {
return <tr className={cn('border-b transition-colors', className)} {...props} />;
}
export function TableHead({ className, ...props }: ComponentProps<'th'>): ReactNode {
return <th className={cn('h-10 px-2 text-left align-middle font-medium text-slate-500', className)} {...props} />;
}
export function TableCell({ className, ...props }: ComponentProps<'td'>): ReactNode {
return <td className={cn('p-2 align-middle', className)} {...props} />;
}

View File

@@ -0,0 +1,16 @@
import { Tabs as TabsPrimitive } from 'radix-ui';
import { type ComponentProps, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export function Tabs(props: ComponentProps<typeof TabsPrimitive.Root>): ReactNode {
return <TabsPrimitive.Root {...props} />;
}
export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>): ReactNode {
return <TabsPrimitive.List className={cn('inline-flex items-center', className)} {...props} />;
}
export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>): ReactNode {
return <TabsPrimitive.Trigger className={cn('inline-flex items-center justify-center whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-50', className)} {...props} />;
}
export function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>): ReactNode {
return <TabsPrimitive.Content className={cn('outline-none', className)} {...props} />;
}

View File

@@ -1,84 +1,30 @@
import { import { type ReactNode, createContext, useCallback, useContext, useMemo } from 'react';
type ReactNode, import { Toaster, toast } from 'sonner';
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
/** Toast-Varianten. */ /** Toast-Varianten. */
export type ToastVariant = 'success' | 'error'; export type ToastVariant = 'success' | 'error';
interface ToastContextValue { showToast: (variant: ToastVariant, message: string, description?: ReactNode) => void }
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 ToastContext = createContext<ToastContextValue | null>(null);
const VARIANT_CLASSES: Record<ToastVariant, string> = { /** shadcn/ui Sonner-Toasts an derselben Position und mit derselben Dauer. */
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 { export function ToastProvider({ children }: { children: ReactNode }): ReactNode {
const [toasts, setToasts] = useState<ToastEntry[]>([]); const showToast = useCallback((variant: ToastVariant, message: string, description?: ReactNode) => {
const nextId = useRef(1); const className = 'rounded-lg border-0 px-4 py-3 text-sm font-medium shadow-lg';
const options = { description, duration: description ? 12_000 : 4_000 };
const showToast = useCallback((variant: ToastVariant, message: string) => { if (variant === 'success') toast.success(message, { ...options, className: `${className} !bg-emerald-600 !text-white` });
const id = nextId.current; else toast.error(message, { ...options, className: `${className} !bg-red-600 !text-white` });
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]); const value = useMemo<ToastContextValue>(() => ({ showToast }), [showToast]);
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div <Toaster position="bottom-right" duration={4000} closeButton={false} richColors={false} />
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> </ToastContext.Provider>
); );
} }
/** Zeigt eine Toast-Meldung an (wirft außerhalb des Providers). */
export function useToast(): ToastContextValue { export function useToast(): ToastContextValue {
const context = useContext(ToastContext); const context = useContext(ToastContext);
if (!context) { if (!context) throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
}
return context; return context;
} }

View File

@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -2,10 +2,11 @@ import { type FormEvent, type ReactNode, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '../../lib/api-client'; import { apiRequest } from '../../lib/api-client';
import { z } from 'zod'; import { z } from 'zod';
import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { ErrorState, Spinner } from '../../components/ui/states'; import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
/** Audit-Eintrag (API-Vertrag /api/v1/audit). */ /** Audit-Eintrag (API-Vertrag /api/v1/audit). */
const auditEntrySchema = z.object({ const auditEntrySchema = z.object({
@@ -86,7 +87,7 @@ export function AuditPage(): ReactNode {
{/* Filter */} {/* Filter */}
<Card> <Card>
<CardBody> <CardContent>
<form onSubmit={handleFilterSubmit} className="flex flex-wrap items-end gap-3" noValidate> <form onSubmit={handleFilterSubmit} className="flex flex-wrap items-end gap-3" noValidate>
<div className="w-48"> <div className="w-48">
<Input <Input
@@ -108,55 +109,52 @@ export function AuditPage(): ReactNode {
Filtern Filtern
</Button> </Button>
</form> </form>
</CardBody> </CardContent>
</Card> </Card>
{/* Einträge */} {/* Einträge */}
<Card> <Card>
<CardHeader <CardHeader>
title="Ereignisse" <div><CardTitle>Ereignisse</CardTitle>{auditQuery.data && <CardDescription>{auditQuery.data.total} Einträge insgesamt</CardDescription>}</div>
description={auditQuery.data ? `${auditQuery.data.total} Einträge insgesamt` : undefined} </CardHeader>
/> <CardContent>
<CardBody>
{auditQuery.isLoading && <Spinner label="Audit-Log wird geladen…" />} {auditQuery.isLoading && <Spinner label="Audit-Log wird geladen…" />}
{auditQuery.isError && <ErrorState message="Audit-Log konnte nicht geladen werden." />} {auditQuery.isError && <ErrorState title="Audit-Log konnte nicht geladen werden" message="Prüfe die Verbindung und lade die Einträge erneut." action={<Button variant="secondary" onClick={() => void auditQuery.refetch()}>Erneut versuchen</Button>} />}
{auditQuery.data && auditQuery.data.entries.length === 0 && ( {auditQuery.data && auditQuery.data.entries.length === 0 && (
<p className="py-8 text-center text-sm text-slate-500"> <EmptyState title="Keine Audit-Einträge gefunden" description="Passe die Filter an oder entferne sie, um weitere Einträge zu sehen." />
Keine Einträge für die gewählten Filter.
</p>
)} )}
{auditQuery.data && auditQuery.data.entries.length > 0 && ( {auditQuery.data && auditQuery.data.entries.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm"> <Table className="w-full min-w-[640px] text-sm">
<thead> <TableHeader>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500"> <TableRow className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-3 py-2 font-semibold">Zeit</th> <TableHead className="px-3 py-2 font-semibold">Zeit</TableHead>
<th className="px-3 py-2 font-semibold">Benutzer</th> <TableHead className="px-3 py-2 font-semibold">Benutzer</TableHead>
<th className="px-3 py-2 font-semibold">Aktion</th> <TableHead className="px-3 py-2 font-semibold">Aktion</TableHead>
<th className="px-3 py-2 font-semibold">Details</th> <TableHead className="px-3 py-2 font-semibold">Details</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{auditQuery.data.entries.map((entry) => ( {auditQuery.data.entries.map((entry) => (
<tr key={entry.id} className="border-b border-slate-100 last:border-0"> <TableRow key={entry.id} className="border-b border-slate-100 last:border-0">
<td className="whitespace-nowrap px-3 py-2 text-slate-500"> <TableCell className="whitespace-nowrap px-3 py-2 text-slate-500">
{formatTimestamp(entry.createdAt)} {formatTimestamp(entry.createdAt)}
</td> </TableCell>
<td className="px-3 py-2 font-medium text-slate-900">{entry.username}</td> <TableCell className="px-3 py-2 font-medium text-slate-900">{entry.username}</TableCell>
<td className="px-3 py-2"> <TableCell className="px-3 py-2">
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700"> <code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
{entry.action} {entry.action}
</code> </code>
</td> </TableCell>
<td className="px-3 py-2 text-xs text-slate-500"> <TableCell className="px-3 py-2 text-xs text-slate-500">
{Object.keys(entry.details).length > 0 {Object.keys(entry.details).length > 0
? JSON.stringify(entry.details) ? JSON.stringify(entry.details)
: '–'} : '–'}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
@@ -184,7 +182,7 @@ export function AuditPage(): ReactNode {
</Button> </Button>
</div> </div>
)} )}
</CardBody> </CardContent>
</Card> </Card>
</div> </div>
); );

View File

@@ -1,8 +1,18 @@
import { type ReactNode, useRef, useState } from 'react'; import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Card } from '../../components/ui/card';
import { Label } from '../../components/ui/label';
import { Icon } from '../../components/ui/icon'; import { Icon } from '../../components/ui/icon';
import { InputControl } from '../../components/ui/input';
import { Modal } from '../../components/ui/modal'; import { Modal } from '../../components/ui/modal';
import { SelectControl } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { Skeleton } from '../../components/ui/skeleton';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../../components/ui/dropdown-menu';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
@@ -12,14 +22,20 @@ import {
fetchMarketplaceRepositories, fetchMarketplaceRepositories,
fetchMarketplaceProviders, fetchMarketplaceProviders,
fetchModules, fetchModules,
fetchModuleConfiguration,
installModule, installModule,
installMarketplaceRepository, installMarketplaceRepository,
fetchMarketplaceUpdates,
fetchMarketplaceOperationProgress,
updateMarketplaceModule,
removeModule, removeModule,
restartModule, restartModule,
setModuleEnabled, setModuleEnabled,
saveModuleConfiguration,
startModule, startModule,
stopModule, stopModule,
type MarketplaceProvider, type MarketplaceProvider,
type MarketplaceUpdateState,
} from '../../lib/modules-api'; } from '../../lib/modules-api';
import type { Module, ModuleStatus } from '../../lib/schemas'; import type { Module, ModuleStatus } from '../../lib/schemas';
@@ -40,22 +56,49 @@ function statusVariant(status: ModuleStatus): 'success' | 'warning' | 'danger' |
} }
} }
function operationDiagnostic(error: unknown): string | undefined {
if (!(error instanceof ApiError)) return undefined;
const lines = [error.code ? `Fehlercode: ${error.code}` : null, error.diagnostic ?? null]
.filter((line): line is string => Boolean(line));
return lines.length > 0 ? lines.join('\n') : undefined;
}
function operationToastDetails(error: unknown): ReactNode {
const diagnostic = operationDiagnostic(error);
if (!diagnostic) return undefined;
return (
<details className="text-xs">
<summary className="cursor-pointer font-medium">Technische Details</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono">{diagnostic}</pre>
</details>
);
}
/** Bestätigungsdialog: Modul entfernen. */ /** Bestätigungsdialog: Modul entfernen. */
function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode { function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode {
const { showToast } = useToast(); const { showToast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [formDiagnostic, setFormDiagnostic] = useState<string | null>(null);
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: () => removeModule(module.id), mutationFn: () => removeModule(module.id),
onSuccess: () => { onSuccess: async (result) => {
void queryClient.invalidateQueries({ queryKey: ['modules'] }); await Promise.all([
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }); queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }),
]);
if (result.cleanupWarning) {
showToast('error', `Modul "${module.name}" wurde entfernt, Dateien blieben zurück`, result.cleanupWarning);
} else {
showToast('success', `Modul "${module.name}" wurde entfernt`); showToast('success', `Modul "${module.name}" wurde entfernt`);
}
onClose(); onClose();
}, },
onError: (error) => { onError: async (error) => {
setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen'); setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen');
setFormDiagnostic(operationDiagnostic(error) ?? null);
await queryClient.invalidateQueries({ queryKey: ['modules'] });
}, },
}); });
@@ -63,13 +106,14 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
<Modal <Modal
open open
title="Modul entfernen" title="Modul entfernen"
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer sowie Dateien und Registrierung werden gelöscht. Datenvolumes bleiben für eine mögliche Neuinstallation erhalten.`} description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer, alle zugehörigen Datenvolumes, Dateien und Registrierung werden endgültig gelöscht. Gespeicherte Modul- und Kalenderdaten können danach nicht wiederhergestellt werden.`}
onClose={onClose} onClose={onClose}
> >
{formError && ( {formError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700"> <div role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError} <p>{formError}</p>
</p> {formDiagnostic && <details className="mt-2 text-xs"><summary className="cursor-pointer font-medium">Technische Details</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono">{formDiagnostic}</pre></details>}
</div>
)} )}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
@@ -87,6 +131,125 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
); );
} }
function ModuleConfigurationModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [values, setValues] = useState<Record<string, string>>({});
const [clearKeys, setClearKeys] = useState<string[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const configQuery = useQuery({
queryKey: ['module-configuration', module.id],
queryFn: () => fetchModuleConfiguration(module.id),
});
useEffect(() => {
if (configQuery.data) {
setValues(Object.fromEntries(configQuery.data.fields.map((field) => [field.key, field.value ?? ''])));
setClearKeys([]);
}
}, [configQuery.data]);
const saveMutation = useMutation({
mutationFn: () => saveModuleConfiguration(module.id, { values, clearKeys }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['modules'] });
await queryClient.invalidateQueries({ queryKey: ['module-configuration', module.id] });
showToast('success', `Konfiguration für „${module.name}“ gespeichert`);
onClose();
},
onError: (error) => setFormError(error instanceof ApiError ? error.message : 'Konfiguration konnte nicht gespeichert werden'),
});
const inputClass = 'mt-1 h-auto w-full rounded-lg border border-slate-300 px-3 py-2 text-sm';
return (
<Modal open title={`Konfiguration: ${module.name}`} description="Werte werden verschlüsselt gespeichert." onClose={onClose} panelClassName="max-w-2xl">
{configQuery.isLoading && <Spinner label="Konfiguration wird geladen…" className="py-8" />}
{configQuery.isError && <ErrorState title="Konfiguration konnte nicht geladen werden" message="Prüfe die Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void configQuery.refetch()}>Erneut versuchen</Button>} />}
{configQuery.data && <form onSubmit={(event) => { event.preventDefault(); setFormError(null); saveMutation.mutate(); }}>
{module.status === 'RUNNING' && <p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">Das laufende Modul wird nach dem Speichern neu gestartet.</p>}
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
{configQuery.data.fields.map((field) => <div key={field.key}>
<Label htmlFor={`config-${field.key}`} className="block text-sm font-medium text-slate-800">{field.label}{field.required && <span className="text-red-600"> *</span>}</Label>
{field.description && <p className="mt-0.5 text-xs text-slate-500">{field.description}</p>}
{field.type === 'boolean' ? <SelectControl id={`config-${field.key}`} className={inputClass} required={field.required} value={values[field.key] ?? ''} placeholder="Bitte auswählen" options={[{ value: 'true', label: 'Ja' }, { value: 'false', label: 'Nein' }]} onValueChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))} /> : <InputControl id={`config-${field.key}`} className={inputClass} type={field.secret ? 'password' : field.type === 'url' ? 'url' : 'text'} required={field.required && (!field.secret || !field.isSet || clearKeys.includes(field.key))} value={values[field.key] ?? ''} placeholder={field.secret && field.isSet ? 'Gespeicherter Wert bleibt erhalten' : ''} autoComplete="new-password" onChange={(event) => { setValues((current) => ({ ...current, [field.key]: event.target.value })); setClearKeys((current) => current.filter((key) => key !== field.key)); }} />}
{field.secret && field.isSet && <Button variant="ghost" size="sm" type="button" className="mt-1 h-auto px-0 py-0 text-xs text-red-700 hover:bg-transparent hover:underline" onClick={() => setClearKeys((current) => current.includes(field.key) ? current.filter((key) => key !== field.key) : [...current, field.key])}>{clearKeys.includes(field.key) ? 'Löschen rückgängig machen' : 'Gespeicherten Wert löschen'}</Button>}
{clearKeys.includes(field.key) && <p className="text-xs text-red-700">Wird beim Speichern gelöscht.</p>}
</div>)}
</div>
{formError && <p role="alert" className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
<div className="mt-5 flex justify-end gap-2"><Button type="button" variant="secondary" onClick={onClose}>Abbrechen</Button><Button type="submit" loading={saveMutation.isPending}>Speichern</Button></div>
</form>}
</Modal>
);
}
function ModuleUpdateModal({ module, state, onClose }: { module: Module; state: MarketplaceUpdateState; onClose: () => void }): ReactNode {
const queryClient = useQueryClient();
const [branch, setBranch] = useState(state.branches[0]?.name ?? '');
const [formError, setFormError] = useState<string | null>(null);
const [completed, setCompleted] = useState<{ module: Module; branch: string; commit: string | null } | null>(null);
const [operationId] = useState(() => crypto.randomUUID());
const updateMutation = useMutation({
mutationFn: () => updateMarketplaceModule(module.id, branch, operationId),
onSuccess: async (updatedModule) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['module-updates', module.id] }),
]);
setCompleted({ module: updatedModule, branch, commit: state.branches.find((item) => item.name === branch)?.commit ?? null });
},
onError: (error) => setFormError(error instanceof ApiError ? error.message : 'Modulupdate fehlgeschlagen'),
});
const progressQuery = useQuery({
queryKey: ['marketplace-operation', operationId],
queryFn: () => fetchMarketplaceOperationProgress(operationId),
enabled: updateMutation.isPending,
refetchInterval: (query) => query.state.data?.status === 'running' ? 750 : false,
retry: false,
});
const selected = state.branches.find((item) => item.name === branch);
const progress = progressQuery.data?.progress ?? 3;
const handleClose = () => { if (!updateMutation.isPending) onClose(); };
return (
<Modal open title={completed ? 'Update erfolgreich' : `Update verfügbar: ${module.name}`}
description={completed ? `${completed.module.name} wurde erfolgreich aktualisiert.` : 'Wähle die getestete Branch für das Update aus.'}
onClose={handleClose}>
{completed ? <div className="space-y-5">
<div role="status" className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-emerald-900">
<Icon name="check" className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Installation abgeschlossen</p>
<p className="mt-1 text-sm">Branch <span className="font-medium">{completed.branch}</span> ist jetzt installiert.</p>
{completed.commit && <p className="mt-1 break-all text-xs text-emerald-800">Commit {completed.commit}</p>}
</div>
</div>
<div className="flex justify-end"><Button onClick={handleClose}>Schließen</Button></div>
</div> : <div className="space-y-4">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
<p><span className="font-medium">Installierte Branch:</span> {state.installedBranch}</p>
{state.installedCommit && <p className="mt-1 text-xs text-slate-500">Commit {state.installedCommit.slice(0, 12)}</p>}
</div>
<div>
<Label htmlFor="module-update-branch">Neue Branch</Label>
<SelectControl id="module-update-branch" className="mt-1" disabled={updateMutation.isPending} value={branch} onValueChange={(value) => { setBranch(value); setFormError(null); }}
options={state.branches.map((item) => ({ value: item.name, label: item.name }))} />
{selected && <p className="mt-1 text-xs text-slate-500">Commit {selected.commit.slice(0, 12)}</p>}
</div>
<p className="text-xs text-slate-500">Die Konfiguration und persistenten Daten bleiben erhalten. Bei einem fehlgeschlagenen Start stellt MPM die vorherige Modulversion wieder her.</p>
{updateMutation.isPending && <div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3" role="status" aria-live="polite">
<div role="progressbar" aria-label="Modulupdate läuft" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress} aria-valuetext={progressQuery.data?.message ?? 'Update wird vorbereitet'} className="h-2.5 overflow-hidden rounded-full bg-slate-200">
<div className="h-full rounded-full bg-brand-600 transition-[width] duration-500" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm font-medium text-slate-800">{progressQuery.data?.message ?? `Update auf „${branch}“ wird vorbereitet`}</p>
<p className="text-xs text-slate-500">Schritt {progressQuery.data?.phase ?? 'starting'} · Das kann je nach Build einige Minuten dauern.</p>
</div>}
{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">
<Button variant="secondary" disabled={updateMutation.isPending} onClick={handleClose}>Abbrechen</Button>
<Button disabled={!branch} loading={updateMutation.isPending} onClick={() => { setFormError(null); updateMutation.mutate(); }}>Update installieren</Button>
</div>
</div>}
</Modal>
);
}
/** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */ /** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */
export function ModulesPage(): ReactNode { export function ModulesPage(): ReactNode {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -94,6 +257,8 @@ export function ModulesPage(): ReactNode {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [removeTarget, setRemoveTarget] = useState<Module | null>(null); const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
const [configurationTarget, setConfigurationTarget] = useState<Module | null>(null);
const [updateTarget, setUpdateTarget] = useState<Module | null>(null);
const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null); const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null);
const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers'); const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers');
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({}); const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
@@ -101,12 +266,20 @@ export function ModulesPage(): ReactNode {
const modulesQuery = useQuery({ const modulesQuery = useQuery({
queryKey: ['modules'], queryKey: ['modules'],
queryFn: fetchModules, queryFn: fetchModules,
refetchInterval: 15_000, refetchInterval: (query) => query.state.data?.some((module) => module.status === 'STARTING' || module.status === 'STOPPING') ? 2_000 : 15_000,
}); });
const marketplaceQuery = useQuery({ const marketplaceQuery = useQuery({
queryKey: ['marketplace-providers'], queryKey: ['marketplace-providers'],
queryFn: fetchMarketplaceProviders, queryFn: fetchMarketplaceProviders,
}); });
const moduleUpdateQueries = useQueries({
queries: (modulesQuery.data ?? []).map((module) => ({
queryKey: ['module-updates', module.id],
queryFn: () => fetchMarketplaceUpdates(module.id),
refetchInterval: 60 * 1000,
retry: false,
})),
});
const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? []; const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? [];
const repositoriesQuery = useQuery({ const repositoriesQuery = useQuery({
queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)], queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)],
@@ -135,30 +308,36 @@ export function ModulesPage(): ReactNode {
const marketplaceInstallMutation = useMutation({ const marketplaceInstallMutation = useMutation({
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) => mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
installMarketplaceRepository(input.provider, input.owner, input.repository), installMarketplaceRepository(input.provider, input.owner, input.repository),
onSuccess: (module) => { onSuccess: async (module) => {
void queryClient.invalidateQueries({ queryKey: ['modules'] }); await Promise.all([
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }); queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }),
]);
showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`); showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`);
}, },
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen'), onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen', operationToastDetails(error));
await invalidate();
},
}); });
const invalidate = (): void => { const invalidate = async (): Promise<void> => {
void queryClient.invalidateQueries({ queryKey: ['modules'] }); await queryClient.invalidateQueries({ queryKey: ['modules'] });
}; };
const installMutation = useMutation({ const installMutation = useMutation({
mutationFn: (file: File) => installModule(file), mutationFn: (file: File) => installModule(file),
onSuccess: (module) => { onSuccess: async (module) => {
showToast('success', `Modul "${module.name}" wurde installiert`); showToast('success', `Modul "${module.name}" wurde installiert`);
setSelectedFile(null); setSelectedFile(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
invalidate(); await invalidate();
}, },
onError: (error) => { onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen'); showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen', operationToastDetails(error));
await invalidate();
}, },
}); });
@@ -177,13 +356,13 @@ export function ModulesPage(): ReactNode {
return setModuleEnabled(input.module.id, false); return setModuleEnabled(input.module.id, false);
} }
}, },
onSuccess: (module, variables) => { onSuccess: async (module, variables) => {
showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`); showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`);
invalidate(); await invalidate();
}, },
onError: (error) => { onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen'); showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen', operationToastDetails(error));
invalidate(); await invalidate();
}, },
}); });
@@ -201,6 +380,41 @@ export function ModulesPage(): ReactNode {
}, },
}); });
const renderModuleUpdates = (module: Module): ReactNode => {
const index = (modulesQuery.data ?? []).findIndex((item) => item.id === module.id);
const updateQuery = moduleUpdateQueries[index];
const updateState = updateQuery?.data;
if (updateState?.branches.length) {
return <Button size="sm" variant="secondary" className="whitespace-nowrap" title="Neue getestete Branch installieren" onClick={() => setUpdateTarget(module)}>Update verfügbar</Button>;
}
if (updateState?.installedBranch) return <span className="text-xs text-slate-500">Keine Updates</span>;
if (updateQuery?.isLoading) return <Skeleton className="my-1 h-5 w-24" aria-label="Updates werden geprüft" />;
return <span className="text-xs text-slate-500" title="Kein Marketplace-Repository verknüpft">Kein Update</span>;
};
const renderModuleActions = (module: Module, compact = false): ReactNode => {
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
const action = lifecycleMutation.variables?.action;
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" disabled={actionPending} className={`focus-visible:outline-none focus-visible:ring-0 ${compact ? 'w-full justify-center' : ''}`} aria-label={`Aktionen auswählen für ${module.name}`}>
<span>Aktionen auswählen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{module.status !== 'RUNNING' && module.enabled && <DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</DropdownMenuItem>}
{(module.status === 'RUNNING' || module.status === 'STARTING') && <DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</DropdownMenuItem>}
<DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</DropdownMenuItem>
<DropdownMenuItem disabled={actionPending || healthMutation.isPending} onSelect={() => healthMutation.mutate(module)}>{healthMutation.isPending && healthMutation.variables?.id === module.id ? 'Health läuft…' : 'Health prüfen'}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={actionPending} className="text-red-600 focus:bg-red-50 focus:text-red-700" onSelect={() => setRemoveTarget(module)}>Remove</DropdownMenuItem>
</DropdownMenuContent>
{actionPending && action && <span className="sr-only" role="status">{action} läuft</span>}
</DropdownMenu>;
};
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void { function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void {
setSelectedFile(event.target.files?.[0] ?? null); setSelectedFile(event.target.files?.[0] ?? null);
} }
@@ -242,18 +456,18 @@ export function ModulesPage(): ReactNode {
<div className="space-y-6"> <div className="space-y-6">
{/* Compact provider connectors */} {/* Compact provider connectors */}
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"> <Card className="p-5">
<h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2> <h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2>
<div role="tablist" aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200"> <Tabs value={connectionTab} onValueChange={(value) => setConnectionTab(value as 'providers' | 'manual')}>
<button type="button" role="tab" aria-selected={connectionTab === 'providers'} onClick={() => setConnectionTab('providers')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'providers' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}> <TabsList aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
<TabsTrigger value="providers" className="border-b-2 px-3 py-2 text-sm font-medium data-[state=active]:border-brand-600 data-[state=active]:text-brand-700 data-[state=inactive]:border-transparent data-[state=inactive]:text-slate-500 data-[state=inactive]:hover:text-slate-800">
Verbindungen Verbindungen
</button> </TabsTrigger>
<button type="button" role="tab" aria-selected={connectionTab === 'manual'} onClick={() => setConnectionTab('manual')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'manual' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}> <TabsTrigger value="manual" className="border-b-2 px-3 py-2 text-sm font-medium data-[state=active]:border-brand-600 data-[state=active]:text-brand-700 data-[state=inactive]:border-transparent data-[state=inactive]:text-slate-500 data-[state=inactive]:hover:text-slate-800">
Manuelle Installation Manuelle Installation
</button> </TabsTrigger>
</div> </TabsList>
{connectionTab === 'providers' ? ( <TabsContent value="providers" className="grid grid-cols-1">
<div className="grid grid-cols-1">
{(['github', 'gitea', 'forgejo'] as const).map((provider) => { {(['github', 'gitea', 'forgejo'] as const).map((provider) => {
const connection = marketplaceQuery.data?.find((item) => item.provider === provider); const connection = marketplaceQuery.data?.find((item) => item.provider === provider);
const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo'); const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo');
@@ -301,87 +515,106 @@ export function ModulesPage(): ReactNode {
</div> </div>
); );
})} })}
</div> </TabsContent>
) : ( <TabsContent value="manual" className="flex flex-wrap items-center gap-3">
<div role="tabpanel" className="flex flex-wrap items-center gap-3"> <InputControl
<input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".zip" accept=".zip"
onChange={handleFileChange} onChange={handleFileChange}
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100" className="h-auto w-auto border-0 bg-transparent px-0 py-0 text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
aria-label="Modul-Paket (ZIP) auswählen" aria-label="Modul-Paket (ZIP) auswählen"
/> />
<Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}> <Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}>
Installieren Installieren
</Button> </Button>
<p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p> <p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p>
</div> </TabsContent>
)} </Tabs>
</section> </Card>
{/* Modul-Liste */} {/* Modul-Liste */}
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"> <Card className="p-5">
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2> <h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2>
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200"> {modulesQuery.isLoading && <div className="space-y-3" role="status" aria-label="Module werden geladen">
<table className="w-full min-w-[900px] table-fixed text-sm"> {[0, 1, 2].map((item) => <div key={item} className="space-y-2 rounded-lg border border-slate-200 p-4"><Skeleton className="h-5 w-1/3" /><Skeleton className="h-4 w-2/3" /><Skeleton className="h-9 w-full" /></div>)}
<thead className="sticky top-0 z-10"> </div>}
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"> {modulesQuery.isError && <ErrorState title="Module konnten nicht geladen werden" message="Prüfe die Verbindung und lade die Liste erneut." action={<Button variant="secondary" onClick={() => void modulesQuery.refetch()}>Erneut versuchen</Button>} />}
<th className="w-[34%] px-4 py-3 font-semibold">Modul</th> {modulesQuery.data && modulesQuery.data.length === 0 && <EmptyState title="Noch keine Module installiert" description="Verbinde ein Forge-Konto oder installiere ein Modul als ZIP." />}
<th className="w-[13%] px-4 py-3 font-semibold">Status</th> {modulesQuery.data && modulesQuery.data.length > 0 && <>
<th className="w-[15%] px-4 py-3 font-semibold">URL</th> <div className="space-y-3 xl:hidden">
<th className="w-[38%] px-4 py-3 font-semibold">Aktionen</th> {modulesQuery.data.map((module) => {
</tr> const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
</thead> return <article key={module.id} className="space-y-4 rounded-xl border border-slate-200 p-4">
<tbody> <div className="flex min-w-0 items-start justify-between gap-3">
{modulesQuery.isLoading && <tr><td colSpan={4} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</td></tr>} <div className="min-w-0">
{modulesQuery.isError && <tr><td colSpan={4} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</td></tr>} <h3 className="truncate font-semibold text-slate-900">{module.name}</h3>
{modulesQuery.data?.map((module) => ( <p className="break-words text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</p>
<tr key={module.id} className="border-b border-slate-100 last:border-0"> </div>
<td className="px-4 py-3"> <Badge variant={statusVariant(module.status)}>{module.status}</Badge>
</div>
{module.description && <p className="text-sm text-slate-500">{module.description}</p>}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="min-w-0"><p className="text-xs font-medium uppercase text-slate-500">URL</p><code className="mt-1 inline-block max-w-full break-all rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div>
{module.configuration.length > 0 && <div className="min-w-0"><p className="text-xs font-medium uppercase text-slate-500">Konfiguration</p><p className={`mt-1 text-xs font-medium ${module.configurationReady ? 'text-emerald-700' : 'text-amber-700'}`}>{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}</p><Button size="sm" variant="ghost" disabled={actionPending} className="mt-1 px-0" onClick={() => setConfigurationTarget(module)}>{module.configurationReady ? 'Ändern' : 'Konfigurieren'}</Button></div>}
</div>
{healthResults[module.id] && <p className="text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</p>}
<div className="grid grid-cols-1 gap-3 border-t border-slate-200 pt-3 sm:grid-cols-2">
<div className="space-y-2"><p className="text-xs font-medium uppercase tracking-wide text-slate-500">Updates</p>{renderModuleUpdates(module)}</div>
<div className="space-y-2"><p className="text-xs font-medium uppercase tracking-wide text-slate-500">Aktionen</p>{renderModuleActions(module, true)}</div>
</div>
</article>;
})}
</div>
<div className="hidden overflow-auto rounded-xl border border-slate-200 xl:block">
<Table className="w-full min-w-[1040px] table-fixed text-sm">
<TableHeader className="sticky top-0 z-10">
<TableRow className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<TableHead className="w-[25%] px-4 py-3 font-semibold">Modul</TableHead>
<TableHead className="w-[10%] px-4 py-3 text-center font-semibold">Status</TableHead>
<TableHead className="w-[11%] px-4 py-3 text-center font-semibold">URL</TableHead>
<TableHead className="w-[19%] px-4 py-3 text-center font-semibold">Konfiguration</TableHead>
<TableHead className="w-[17%] px-4 py-3 text-center font-semibold">Updates</TableHead>
<TableHead className="w-[18%] px-4 py-3 text-center font-semibold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modulesQuery.data.map((module) => {
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
return <TableRow key={module.id} className="border-b border-slate-100 last:border-0">
<TableCell className="align-top px-4 py-3">
<div className="font-semibold text-slate-900">{module.name}</div> <div className="font-semibold text-slate-900">{module.name}</div>
<div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div> <div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div>
{module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>} {module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>}
</td> </TableCell>
<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 ${statusVariant(module.status) === 'success' ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20' : statusVariant(module.status) === 'warning' ? 'bg-amber-50 text-amber-700 ring-amber-600/20' : statusVariant(module.status) === 'danger' ? 'bg-red-50 text-red-700 ring-red-600/20' : statusVariant(module.status) === 'neutral' ? 'bg-slate-100 text-slate-600 ring-slate-500/20' : 'bg-brand-50 text-brand-700 ring-brand-600/20'}`}>{module.status}</span> <TableCell className="align-top px-4 py-3"><div className="flex w-full justify-center"><Badge variant={statusVariant(module.status)}>{module.status}</Badge></div>{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}</TableCell>
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>} <TableCell className="align-top px-4 py-3"><div className="flex justify-center"><code className="whitespace-nowrap rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div></TableCell>
</td> <TableCell className="align-top px-4 py-3">{module.configuration.length > 0 && <div className="flex flex-col items-center gap-1 text-center"><div className={`text-xs font-medium ${module.configurationReady ? 'text-emerald-700' : 'text-amber-700'}`}>{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}</div><Button size="sm" variant="ghost" disabled={actionPending} onClick={() => setConfigurationTarget(module)}>{module.configurationReady ? 'Konfiguration ändern' : 'Konfigurieren'}</Button></div>}</TableCell>
<td className="px-4 py-3"><code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></td> <TableCell className="align-top px-4 py-3 text-center">{renderModuleUpdates(module)}</TableCell>
<td className="px-4 py-3"><div className="flex flex-wrap gap-1"> <TableCell className="align-top px-4 py-3"><div className="flex justify-center">{renderModuleActions(module)}</div></TableCell>
{module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>} </TableRow>;
{(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>} })}
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button> </TableBody>
<Button size="sm" variant="ghost" onClick={() => healthMutation.mutate(module)}>Health</Button> </Table>
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && (lifecycleMutation.variables?.action === 'enable' || lifecycleMutation.variables?.action === 'disable')} onClick={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</Button>
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>Remove</Button>
</div></td>
</tr>
))}
</tbody>
</table>
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
</div> </div>
</section> </>}
</Card>
</div> </div>
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"> <Card className="p-5">
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Marketplace</h2> <h2 className="text-lg font-semibold text-slate-900">Marketplace</h2>
<p className="mt-1 text-base text-slate-500"> <p className="mt-1 text-base text-slate-500">
&Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;ft das Modulmanifest vor der Installation. &Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;ft das Modulmanifest vor der Installation.
</p> </p>
</div> </div>
{connectedProviders.length === 0 && ( {connectedProviders.length === 0 && <EmptyState title="Kein Forge-Konto verbunden" description="Verbinde zuerst GitHub, Gitea oder Forgejo, um Repositories zu installieren." />}
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500"> {repositoriesQuery.isLoading && <Spinner label="Repositories werden geladen…" className="py-8" />}
Verbinde zuerst GitHub, Gitea oder Forgejo. {repositoriesQuery.isError && <ErrorState title="Repositories konnten nicht geladen werden" message="Prüfe die Forge-Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void repositoriesQuery.refetch()}>Erneut versuchen</Button>} />}
</p>
)}
{repositoriesQuery.isLoading && <p className="py-4 text-sm text-slate-500">Repositories werden geladen...</p>}
{repositoriesQuery.isError && <p role="alert" className="py-4 text-sm text-red-600">Repositories konnten nicht geladen werden.</p>}
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2"> <div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
{repositoriesQuery.data?.map((provider) => ( {repositoriesQuery.data?.map((provider) => (
<div key={provider.provider}> <div key={provider.provider}>
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3> <h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
{provider.repositories.length === 0 ? ( {provider.repositories.length === 0 ? (
<p className="text-sm text-slate-500">Keine &ouml;ffentlichen Repositories gefunden.</p> <EmptyState title="Keine öffentlichen Repositories gefunden" description="In diesem Forge-Konto sind derzeit keine installierbaren öffentlichen Repositories verfügbar." />
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{provider.repositories.map((repo) => ( {provider.repositories.map((repo) => (
@@ -413,10 +646,16 @@ export function ModulesPage(): ReactNode {
</div> </div>
))} ))}
</div> </div>
</section> </Card>
{removeTarget && ( {removeTarget && (
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} /> <RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
)} )}
{configurationTarget && <ModuleConfigurationModal module={configurationTarget} onClose={() => setConfigurationTarget(null)} />}
{updateTarget && (() => {
const index = (modulesQuery.data ?? []).findIndex((item) => item.id === updateTarget.id);
const updateState = moduleUpdateQueries[index]?.data;
return updateState?.branches.length ? <ModuleUpdateModal module={updateTarget} state={updateState} onClose={() => setUpdateTarget(null)} /> : null;
})()}
{connectTarget && ( {connectTarget && (
<ConnectProviderModal <ConnectProviderModal
provider={connectTarget} provider={connectTarget}
@@ -471,14 +710,15 @@ function ConnectProviderModal({
panelClassName="max-w-xl overflow-hidden rounded-[28px]" panelClassName="max-w-xl overflow-hidden rounded-[28px]"
> >
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2"> <div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
<button <Button
variant="ghost"
type="button" type="button"
aria-label="Dialog schließen" aria-label="Dialog schließen"
onClick={onClose} onClick={onClose}
className="absolute right-1 top-0 rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900" className="absolute right-1 top-0 h-auto rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
> >
<Icon name="close" className="h-4 w-4" /> <Icon name="close" className="h-4 w-4" />
</button> </Button>
<div className="flex items-center justify-center gap-4 pb-5 pt-2"> <div className="flex items-center justify-center gap-4 pb-5 pt-2">
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white"> <span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white">

View File

@@ -2,9 +2,10 @@ import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '../../lib/api-client'; import { apiRequest } from '../../lib/api-client';
import { z } from 'zod'; import { z } from 'zod';
import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { ErrorState, Spinner } from '../../components/ui/states'; import { Button } from '../../components/ui/button';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
/** Erweiterter Systemstatus (API-Vertrag /api/v1/system/status). */ /** Erweiterter Systemstatus (API-Vertrag /api/v1/system/status). */
const systemStatusSchema = z.object({ const systemStatusSchema = z.object({
@@ -54,13 +55,12 @@ export function SystemStatusPage(): ReactNode {
</div> </div>
<Card> <Card>
<CardHeader title="Gesamtstatus" /> <CardHeader><CardTitle>Gesamtstatus</CardTitle></CardHeader>
<CardBody> <CardContent>
{statusQuery.isLoading && <Spinner />} {statusQuery.isLoading && <Spinner />}
{statusQuery.isError && ( {statusQuery.isError && <ErrorState title="Systemstatus nicht erreichbar" message="Prüfe die Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void statusQuery.refetch()}>Erneut versuchen</Button>} />}
<ErrorState message="Der Systemstatus ist nicht erreichbar." /> {statusQuery.data && statusQuery.data.components.length === 0 && <EmptyState title="Keine Komponenten gemeldet" description="Sobald Plattformdienste registriert sind, erscheinen sie hier." />}
)} {statusQuery.data && statusQuery.data.components.length > 0 && (
{statusQuery.data && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge <Badge
variant={ variant={
@@ -78,12 +78,12 @@ export function SystemStatusPage(): ReactNode {
</span> </span>
</div> </div>
)} )}
</CardBody> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader title="Komponenten" /> <CardHeader><CardTitle>Komponenten</CardTitle></CardHeader>
<CardBody> <CardContent>
{statusQuery.data && ( {statusQuery.data && (
<div className="space-y-3"> <div className="space-y-3">
{statusQuery.data.components.map((component) => ( {statusQuery.data.components.map((component) => (
@@ -100,7 +100,7 @@ export function SystemStatusPage(): ReactNode {
))} ))}
</div> </div>
)} )}
</CardBody> </CardContent>
</Card> </Card>
</div> </div>
); );

View File

@@ -1,6 +1,8 @@
import { type ReactNode, useEffect, useState } from 'react'; import { type ReactNode, useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Modal } from '../../components/ui/modal'; import { Modal } from '../../components/ui/modal';
import { Checkbox } from '../../components/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
@@ -102,14 +104,14 @@ export function UserPermissionsModal({
</p> </p>
) : ( ) : (
<div className="overflow-x-auto rounded-lg border border-slate-200"> <div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full text-sm"> <Table className="w-full text-sm">
<thead> <TableHeader>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"> <TableRow className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<th scope="col" className="px-4 py-3 font-semibold">Modul</th> <TableHead scope="col" className="px-4 py-3 font-semibold">Modul</TableHead>
<th scope="col" className="w-32 px-4 py-3 text-center font-semibold">Freigeben</th> <TableHead scope="col" className="w-32 px-4 py-3 text-center font-semibold">Freigeben</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{modulesQuery.data?.map((module) => { {modulesQuery.data?.map((module) => {
const isAdmin = user.role === 'ADMIN'; const isAdmin = user.role === 'ADMIN';
const isGranted = isAdmin || grantedModuleIds.has(module.id); const isGranted = isAdmin || grantedModuleIds.has(module.id);
@@ -117,29 +119,28 @@ export function UserPermissionsModal({
toggleMutation.isPending && toggleMutation.variables?.module.id === module.id; toggleMutation.isPending && toggleMutation.variables?.module.id === module.id;
return ( return (
<tr key={module.id} className="border-b border-slate-100 last:border-0"> <TableRow key={module.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3"> <TableCell className="px-4 py-3">
<p className="font-medium text-slate-900">{module.name}</p> <p className="font-medium text-slate-900">{module.name}</p>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
/{module.slug} · Version {module.version} /{module.slug} · Version {module.version}
</p> </p>
</td> </TableCell>
<td className="px-4 py-3 text-center"> <TableCell className="px-4 py-3 text-center">
<input <Checkbox
type="checkbox"
checked={isGranted} checked={isGranted}
disabled={isAdmin || isSaving} disabled={isAdmin || isSaving}
aria-label={`${module.name} für ${user.displayName} freigeben`} aria-label={`${module.name} für ${user.displayName} freigeben`}
title={isAdmin ? 'Administratoren haben automatisch Zugriff' : undefined} title={isAdmin ? 'Administratoren haben automatisch Zugriff' : undefined}
className="h-4 w-4 cursor-pointer accent-brand-600 disabled:cursor-not-allowed disabled:opacity-60" className="cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
onChange={() => handleToggle(module)} onCheckedChange={() => handleToggle(module)}
/> />
</td> </TableCell>
</tr> </TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
</Modal> </Modal>

View File

@@ -2,10 +2,14 @@ import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Card } from '../../components/ui/card';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Modal } from '../../components/ui/modal'; import { Modal } from '../../components/ui/modal';
import { Select } from '../../components/ui/select'; import { Select } from '../../components/ui/select';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
createUser, createUser,
@@ -401,64 +405,47 @@ export function UsersPage(): ReactNode {
<Button onClick={() => setCreateOpen(true)}>+ Benutzer anlegen</Button> <Button onClick={() => setCreateOpen(true)}>+ Benutzer anlegen</Button>
</div> </div>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm"> <Card className="overflow-x-auto rounded-xl">
<table className="w-full min-w-[720px] text-sm"> <Table className="w-full min-w-[720px] text-sm">
<thead> <TableHeader>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"> <TableRow 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> <TableHead className="px-4 py-3 font-semibold">Benutzer</TableHead>
<th className="px-4 py-3 font-semibold">Rolle</th> <TableHead className="px-4 py-3 font-semibold">Rolle</TableHead>
<th className="px-4 py-3 font-semibold">Status</th> <TableHead className="px-4 py-3 font-semibold">Status</TableHead>
<th className="px-4 py-3 font-semibold">Letzter Login</th> <TableHead className="px-4 py-3 font-semibold">Letzter Login</TableHead>
<th className="px-4 py-3 font-semibold">Aktionen</th> <TableHead className="px-4 py-3 font-semibold">Aktionen</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{usersQuery.isLoading && ( {usersQuery.isLoading && (
<tr> <TableRow>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500"> <TableCell colSpan={5}><Spinner label="Benutzer werden geladen…" className="py-8" /></TableCell>
Benutzer werden geladen… </TableRow>
</td>
</tr>
)} )}
{usersQuery.isError && ( {usersQuery.isError && (
<tr> <TableRow>
<td colSpan={5} className="px-4 py-8 text-center text-red-600"> <TableCell colSpan={5}><ErrorState title="Benutzer konnten nicht geladen werden" message="Prüfe die Verbindung und lade die Liste erneut." action={<Button variant="secondary" onClick={() => void usersQuery.refetch()}>Erneut versuchen</Button>} /></TableCell>
Benutzer konnten nicht geladen werden. </TableRow>
</td>
</tr>
)} )}
{usersQuery.data?.length === 0 && <TableRow><TableCell colSpan={5}><EmptyState title="Noch keine Benutzer angelegt" description="Lege den ersten Benutzer über die Schaltfläche oben an." /></TableCell></TableRow>}
{usersQuery.data?.map((user) => ( {usersQuery.data?.map((user) => (
<tr key={user.id} className="border-b border-slate-100 last:border-0"> <TableRow key={user.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3"> <TableCell className="px-4 py-3">
<div className="font-medium text-slate-900">{user.displayName}</div> <div className="font-medium text-slate-900">{user.displayName}</div>
<div className="text-xs text-slate-500">@{user.username}</div> <div className="text-xs text-slate-500">@{user.username}</div>
</td> </TableCell>
<td className="px-4 py-3"> <TableCell className="px-4 py-3">
<span <Badge variant={user.role === 'ADMIN' ? 'info' : 'neutral'}>
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' ? 'Admin' : 'Benutzer'} {user.role === 'ADMIN' ? 'Admin' : 'Benutzer'}
</span> </Badge>
</td> </TableCell>
<td className="px-4 py-3"> <TableCell className="px-4 py-3">
<span <Badge variant={user.isActive ? 'success' : 'danger'}>
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'} {user.isActive ? 'Aktiv' : 'Deaktiviert'}
</span> </Badge>
</td> </TableCell>
<td className="px-4 py-3 text-slate-500">{formatDate(user.lastLoginAt)}</td> <TableCell className="px-4 py-3 text-slate-500">{formatDate(user.lastLoginAt)}</TableCell>
<td className="px-4 py-3"> <TableCell className="px-4 py-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Button size="sm" variant="ghost" onClick={() => setEditUser(user)}> <Button size="sm" variant="ghost" onClick={() => setEditUser(user)}>
Bearbeiten Bearbeiten
@@ -488,17 +475,17 @@ export function UsersPage(): ReactNode {
</> </>
)} )}
</div> </div>
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
{usersQuery.data?.length === 0 && ( {usersQuery.data?.length === 0 && (
<p className="px-4 py-8 text-center text-slate-500"> <p className="px-4 py-8 text-center text-slate-500">
Noch keine Benutzer vorhanden. Noch keine Benutzer vorhanden.
</p> </p>
)} )}
</div> </Card>
{createOpen && ( {createOpen && (
<CreateUserModal <CreateUserModal

View File

@@ -2,6 +2,7 @@ import { type FormEvent, type ReactNode, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './auth-context'; import { useAuth } from './auth-context';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Card } from '../../components/ui/card';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { loginSchema } from '../../lib/schemas'; import { loginSchema } from '../../lib/schemas';
@@ -64,9 +65,10 @@ export function LoginPage(): ReactNode {
</p> </p>
</div> </div>
<Card asChild className="rounded-xl p-6">
<form <form
onSubmit={(event) => void handleSubmit(event)} onSubmit={(event) => void handleSubmit(event)}
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm" className=""
noValidate noValidate
> >
<div className="space-y-4"> <div className="space-y-4">
@@ -98,6 +100,7 @@ export function LoginPage(): ReactNode {
Anmelden Anmelden
</Button> </Button>
</form> </form>
</Card>
</div> </div>
</div> </div>
); );

View File

@@ -4,7 +4,7 @@ import { useAuth } from '../auth/auth-context';
import { apiRequest } from '../../lib/api-client'; import { apiRequest } from '../../lib/api-client';
import { fetchAccessibleModules } from '../../lib/modules-api'; import { fetchAccessibleModules } from '../../lib/modules-api';
import { healthSchema, type Health } from '../../lib/schemas'; import { healthSchema, type Health } from '../../lib/schemas';
import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states'; import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { Icon } from '../../components/ui/icon'; import { Icon } from '../../components/ui/icon';
@@ -41,11 +41,10 @@ export function DashboardPage(): ReactNode {
{/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */} {/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */}
<Card> <Card>
<CardHeader <CardHeader>
title="Meine Anwendungen" <div><CardTitle>Meine Anwendungen</CardTitle><CardDescription>Freigegebene Module der Plattform</CardDescription></div>
description="Freigegebene Module der Plattform" </CardHeader>
/> <CardContent>
<CardBody>
{modulesQuery.isLoading && <Spinner label="Anwendungen werden geladen…" />} {modulesQuery.isLoading && <Spinner label="Anwendungen werden geladen…" />}
{modulesQuery.isError && ( {modulesQuery.isError && (
<ErrorState <ErrorState
@@ -65,7 +64,9 @@ export function DashboardPage(): ReactNode {
{modulesQuery.data.map((module) => ( {modulesQuery.data.map((module) => (
<a <a
key={module.id} key={module.id}
href={`/${module.slug}`} href={`/api/v1/auth/module-open/${encodeURIComponent(module.slug)}`}
target="_blank"
rel="noopener noreferrer"
className="mpm-module-tile group rounded-xl border border-slate-200 p-5 transition-all duration-150 hover:-translate-y-0.5 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md" className="mpm-module-tile group rounded-xl border border-slate-200 p-5 transition-all duration-150 hover:-translate-y-0.5 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -88,17 +89,16 @@ export function DashboardPage(): ReactNode {
))} ))}
</div> </div>
)} )}
</CardBody> </CardContent>
</Card> </Card>
{/* Systemstatus (nur für Admins) */} {/* Systemstatus (nur für Admins) */}
{isAdmin && ( {isAdmin && (
<Card> <Card>
<CardHeader <CardHeader>
title="Systemstatus" <div><CardTitle>Systemstatus</CardTitle><CardDescription>Live-Status der Plattform-Komponenten</CardDescription></div>
description="Live-Status der Plattform-Komponenten" </CardHeader>
/> <CardContent>
<CardBody>
{healthQuery.isLoading && <Spinner label="Status wird geladen…" />} {healthQuery.isLoading && <Spinner label="Status wird geladen…" />}
{healthQuery.isError && ( {healthQuery.isError && (
<ErrorState <ErrorState
@@ -128,7 +128,7 @@ export function DashboardPage(): ReactNode {
/> />
</div> </div>
)} )}
</CardBody> </CardContent>
</Card> </Card>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { type FormEvent, type ReactNode, useState } from 'react'; import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Modal } from '../../components/ui/modal'; import { Modal } from '../../components/ui/modal';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
@@ -77,8 +77,8 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
> >
<div className="space-y-5"> <div className="space-y-5">
<Card> <Card>
<CardHeader title="Persönliche Daten" /> <CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardBody> <CardContent>
{profileQuery.isLoading && <p className="text-sm text-slate-500">Wird geladen…</p>} {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.isError && <p className="text-sm text-red-600">Profil konnte nicht geladen werden.</p>}
{profile && ( {profile && (
@@ -91,12 +91,12 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
<ProfileField label="Mitglied seit" value={formatDate(profile.createdAt)} /> <ProfileField label="Mitglied seit" value={formatDate(profile.createdAt)} />
</dl> </dl>
)} )}
</CardBody> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader title="Passwort ändern" description="Andere aktive Sitzungen werden danach abgemeldet." /> <CardHeader><div><CardTitle>Passwort ändern</CardTitle><CardDescription>Andere aktive Sitzungen werden danach abgemeldet.</CardDescription></div></CardHeader>
<CardBody> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate> <form onSubmit={handleSubmit} className="space-y-4" noValidate>
<Input label="Aktuelles Passwort" name="currentPassword" type="password" autoComplete="current-password" error={fieldErrors.currentPassword} /> <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" name="newPassword" type="password" autoComplete="new-password" hint="Mindestens 10 Zeichen" error={fieldErrors.newPassword} />
@@ -106,7 +106,7 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
<Button type="submit" loading={changePasswordMutation.isPending}>Passwort ändern</Button> <Button type="submit" loading={changePasswordMutation.isPending}>Passwort ändern</Button>
</div> </div>
</form> </form>
</CardBody> </CardContent>
</Card> </Card>
</div> </div>
</Modal> </Modal>

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,4 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *));
/* ============================================================= /* =============================================================
MPM Design-System – zentrale Design-Tokens (Tailwind v4) MPM Design-System – zentrale Design-Tokens (Tailwind v4)
@@ -19,6 +22,72 @@
--color-brand-950: oklch(0.24 0.08 265); --color-brand-950: oklch(0.24 0.08 265);
} }
/* shadcn/ui semantic palette; legacy MPM utilities keep their existing colors. */
:root {
--background: #ffffff;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: var(--color-brand-600);
--primary-foreground: #ffffff;
--secondary: #f1f5f9;
--secondary-foreground: #334155;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #f1f5f9;
--accent-foreground: #0f172a;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #cbd5e1;
--ring: var(--color-brand-500);
--radius: 0.5rem;
--sidebar: #ffffff;
--sidebar-foreground: #475569;
--sidebar-primary: var(--color-brand-600);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f5f9;
--sidebar-accent-foreground: #0f172a;
--sidebar-border: #e2e8f0;
--sidebar-ring: var(--color-brand-500);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
}
/* Fokus-Stil für Tastaturnavigation (Barrierefreiheit) */ /* Fokus-Stil für Tastaturnavigation (Barrierefreiheit) */
@layer base { @layer base {
:focus-visible { :focus-visible {
@@ -31,6 +100,33 @@
Markenakzente werden neutral statt blau. */ Markenakzente werden neutral statt blau. */
html[data-theme='dark'] { html[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--sidebar: #303337;
--sidebar-foreground: #c2c6cb;
--sidebar-primary: #565b61;
--sidebar-primary-foreground: #f3f4f6;
--sidebar-accent: #3c4044;
--sidebar-accent-foreground: #e5e7eb;
--sidebar-border: #474b50;
--sidebar-ring: #92979d;
--background: #272a2d;
--foreground: #e5e7eb;
--card: #303337;
--card-foreground: #e5e7eb;
--popover: #303337;
--popover-foreground: #e5e7eb;
--primary: #565b61;
--primary-foreground: #f3f4f6;
--secondary: #3c4044;
--secondary-foreground: #e5e7eb;
--muted: #3c4044;
--muted-foreground: #aeb3b9;
--accent: #3c4044;
--accent-foreground: #e5e7eb;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #474b50;
--input: #565b61;
--ring: #92979d;
--color-slate-50: #272a2d; --color-slate-50: #272a2d;
--color-slate-100: #3c4044; --color-slate-100: #3c4044;
--color-slate-200: #474b50; --color-slate-200: #474b50;

View File

@@ -11,6 +11,8 @@ export class ApiError extends Error {
public readonly status: number, public readonly status: number,
message: string, message: string,
public readonly details?: Record<string, string | string[]>, public readonly details?: Record<string, string | string[]>,
public readonly code?: string,
public readonly diagnostic?: string,
) { ) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
@@ -22,6 +24,9 @@ function readCsrfToken(): string | null {
let csrfToken: string | null = null; let csrfToken: string | null = null;
for (const part of document.cookie.split(';')) { for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('='); const [name, ...value] = part.trim().split('=');
if (name === '__Host-mpm_csrf') {
return decodeURIComponent(value.join('='));
}
if (name === 'mpm_csrf') { if (name === 'mpm_csrf') {
csrfToken = decodeURIComponent(value.join('=')); csrfToken = decodeURIComponent(value.join('='));
} }
@@ -78,10 +83,14 @@ export async function apiRequest<TResponse>(
if (!response.ok) { if (!response.ok) {
let message = 'Ein unerwarteter Fehler ist aufgetreten.'; let message = 'Ein unerwarteter Fehler ist aufgetreten.';
let details: Record<string, string[]> | undefined; let details: Record<string, string[]> | undefined;
let code: string | undefined;
let diagnostic: string | undefined;
try { try {
const errorBody = (await response.json()) as { const errorBody = (await response.json()) as {
message?: string | string[]; message?: string | string[];
details?: Record<string, string[]>; details?: Record<string, string[]>;
code?: string;
diagnostic?: string;
}; };
if (typeof errorBody.message === 'string') { if (typeof errorBody.message === 'string') {
message = errorBody.message; message = errorBody.message;
@@ -89,10 +98,12 @@ export async function apiRequest<TResponse>(
message = errorBody.message.join(', '); message = errorBody.message.join(', ');
} }
details = errorBody.details; details = errorBody.details;
code = errorBody.code;
diagnostic = errorBody.diagnostic;
} catch { } catch {
// Antwort enthält kein JSON – Standardmeldung verwenden. // Antwort enthält kein JSON – Standardmeldung verwenden.
} }
throw new ApiError(response.status, message, details); throw new ApiError(response.status, message, details, code, diagnostic);
} }
return (await response.json()) as TResponse; return (await response.json()) as TResponse;

View File

@@ -3,9 +3,11 @@ import {
accessibleModuleSchema, accessibleModuleSchema,
modulePermissionSchema, modulePermissionSchema,
moduleSchema, moduleSchema,
moduleConfigurationSchema,
type AccessibleModule, type AccessibleModule,
type Module, type Module,
type ModulePermission, type ModulePermission,
type ModuleConfiguration,
} from './schemas'; } from './schemas';
/** Typsichere API-Funktionen für die Modul-Verwaltung. */ /** Typsichere API-Funktionen für die Modul-Verwaltung. */
@@ -35,10 +37,14 @@ export async function installModule(file: File): Promise<Module> {
if (!response.ok) { if (!response.ok) {
let message = 'Installation fehlgeschlagen'; let message = 'Installation fehlgeschlagen';
let details: Record<string, string | string[]> | undefined; let details: Record<string, string | string[]> | undefined;
let code: string | undefined;
let diagnostic: string | undefined;
try { try {
const errorBody = (await response.json()) as { const errorBody = (await response.json()) as {
message?: string | string[]; message?: string | string[];
details?: Record<string, string | string[]>; details?: Record<string, string | string[]>;
code?: string;
diagnostic?: string;
}; };
if (typeof errorBody.message === 'string') { if (typeof errorBody.message === 'string') {
message = errorBody.message; message = errorBody.message;
@@ -46,10 +52,12 @@ export async function installModule(file: File): Promise<Module> {
message = errorBody.message.join(', '); message = errorBody.message.join(', ');
} }
details = errorBody.details; details = errorBody.details;
code = errorBody.code;
diagnostic = errorBody.diagnostic;
} catch { } catch {
// Kein JSON in der Antwort – Standardmeldung verwenden. // Kein JSON in der Antwort – Standardmeldung verwenden.
} }
throw new ApiError(response.status, message, details); throw new ApiError(response.status, message, details, code, diagnostic);
} }
const response_ = (await response.json()) as { module: unknown }; const response_ = (await response.json()) as { module: unknown };
@@ -85,8 +93,21 @@ export async function setModuleEnabled(id: string, enabled: boolean): Promise<Mo
return moduleSchema.parse(response.module); return moduleSchema.parse(response.module);
} }
export async function removeModule(id: string): Promise<void> { export async function removeModule(id: string): Promise<{ cleanupWarning?: string }> {
await apiRequest(`/api/v1/modules/${id}`, { method: 'DELETE' }); return apiRequest<{ cleanupWarning?: string }>(`/api/v1/modules/${id}`, { method: 'DELETE' });
}
export async function fetchModuleConfiguration(id: string): Promise<ModuleConfiguration> {
return moduleConfigurationSchema.parse(await apiRequest(`/api/v1/modules/${id}/configuration`));
}
export async function saveModuleConfiguration(
id: string,
input: { values: Record<string, string>; clearKeys: string[] },
): Promise<ModuleConfiguration> {
return moduleConfigurationSchema.parse(await apiRequest(`/api/v1/modules/${id}/configuration`, {
method: 'PATCH', body: input,
}));
} }
export async function checkModuleHealth( export async function checkModuleHealth(
@@ -124,6 +145,9 @@ function readCsrfToken(): string | null {
let csrfToken: string | null = null; let csrfToken: string | null = null;
for (const part of document.cookie.split(';')) { for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('='); const [name, ...value] = part.trim().split('=');
if (name === '__Host-mpm_csrf') {
return decodeURIComponent(value.join('='));
}
if (name === 'mpm_csrf') { if (name === 'mpm_csrf') {
csrfToken = decodeURIComponent(value.join('=')); csrfToken = decodeURIComponent(value.join('='));
} }
@@ -166,6 +190,35 @@ export async function installMarketplaceRepository(
return moduleSchema.parse(response.module); return moduleSchema.parse(response.module);
} }
export interface MarketplaceUpdateState {
installedBranch: string;
installedCommit: string | null;
branches: Array<{ name: string; commit: string }>;
}
export interface MarketplaceOperationProgress {
status: 'running' | 'completed' | 'failed';
phase: string;
message: string;
progress: number;
}
export async function fetchMarketplaceUpdates(moduleId: string): Promise<MarketplaceUpdateState> {
return apiRequest<MarketplaceUpdateState>(`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/updates`);
}
export async function fetchMarketplaceOperationProgress(operationId: string): Promise<MarketplaceOperationProgress> {
return apiRequest<MarketplaceOperationProgress>(`/api/v1/marketplace/operations/${encodeURIComponent(operationId)}`);
}
export async function updateMarketplaceModule(moduleId: string, branch: string, operationId?: string): Promise<Module> {
const response = await apiRequest<{ module: unknown }>(
`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/update`,
{ method: 'POST', body: { branch, operationId } },
);
return moduleSchema.parse(response.module);
}
export async function fetchMarketplaceProviders(): Promise<MarketplaceProvider[]> { export async function fetchMarketplaceProviders(): Promise<MarketplaceProvider[]> {
return apiRequest<MarketplaceProvider[]>('/api/v1/marketplace/providers'); return apiRequest<MarketplaceProvider[]>('/api/v1/marketplace/providers');
} }

View File

@@ -134,9 +134,25 @@ export const moduleSchema = z.object({
healthcheckUrl: z.string(), healthcheckUrl: z.string(),
enabled: z.boolean(), enabled: z.boolean(),
createdAt: z.string(), createdAt: z.string(),
configuration: z.array(z.object({
key: z.string(), label: z.string(), description: z.string(),
type: z.enum(['text', 'url', 'boolean']), secret: z.boolean(), required: z.boolean(),
defaultValue: z.string().optional(), services: z.array(z.string()),
})),
configurationReady: z.boolean(),
}); });
export type Module = z.infer<typeof moduleSchema>; export type Module = z.infer<typeof moduleSchema>;
export const moduleConfigurationSchema = z.object({
ready: z.boolean(),
fields: z.array(z.object({
key: z.string(), label: z.string(), description: z.string(),
type: z.enum(['text', 'url', 'boolean']), secret: z.boolean(), required: z.boolean(),
services: z.array(z.string()), isSet: z.boolean(), value: z.string().optional(),
})),
});
export type ModuleConfiguration = z.infer<typeof moduleConfigurationSchema>;
/** Modul-Kachel für das Dashboard (/api/v1/profile/modules). */ /** Modul-Kachel für das Dashboard (/api/v1/profile/modules). */
export const accessibleModuleSchema = z.object({ export const accessibleModuleSchema = z.object({
id: z.string(), id: z.string(),

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -16,7 +16,9 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,10 +1,17 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(fileURLToPath(new URL('.', import.meta.url)), 'src'),
},
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {

View File

@@ -45,10 +45,12 @@ services:
DOCKER_SOCKET_GID: ${DOCKER_SOCKET_GID:-0} DOCKER_SOCKET_GID: ${DOCKER_SOCKET_GID:-0}
MODULES_DIR: /app/data/modules MODULES_DIR: /app/data/modules
LOGS_DIR: /app/data/logs LOGS_DIR: /app/data/logs
MODULE_CONFIG_ENCRYPTION_KEY: ${MODULE_CONFIG_ENCRYPTION_KEY:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen} ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen} ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen}
MARKETPLACE_PUBLIC_URL: ${MARKETPLACE_PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-8080}} MARKETPLACE_PUBLIC_URL: ${MARKETPLACE_PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-8080}}
MODULE_PUBLIC_ORIGIN: ${MODULE_PUBLIC_ORIGIN:-}
MARKETPLACE_TOKEN_ENCRYPTION_KEY: ${MARKETPLACE_TOKEN_ENCRYPTION_KEY:-} MARKETPLACE_TOKEN_ENCRYPTION_KEY: ${MARKETPLACE_TOKEN_ENCRYPTION_KEY:-}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}

View File

@@ -3,7 +3,8 @@
# Master has restricted capabilities; workers run as unprivileged user app. # Master has restricted capabilities; workers run as unprivileged user app.
# - / -> Management-Frontend (SPA, statische Dateien) # - / -> Management-Frontend (SPA, statische Dateien)
# - /api/ -> Management-Backend (127.0.0.1:3000) # - /api/ -> Management-Backend (127.0.0.1:3000)
# Ab Phase 4 werden hier dynamisch Modul-Routen (/slug) ergänzt. # Moduloberflächen liegen auf einem eigenen Host; der Hostname wird beim Start
# aus MODULE_PUBLIC_ORIGIN in diese Konfiguration eingesetzt.
# ============================================================= # =============================================================
worker_processes auto; worker_processes auto;
@@ -43,7 +44,7 @@ http {
} }
server { server {
listen 8080; listen 8080 default_server;
server_name _; server_name _;
root /app/public; root /app/public;
@@ -62,30 +63,50 @@ http {
} }
# Management-API ans Backend proxien # Management-API ans Backend proxien
# Modulstarts können durch Docker-Builds deutlich länger als 30 Sekunden dauern.
# Der Client muss auf die Lifecycle-Antwort warten können.
location /api/v1/modules/ {
proxy_pass http://platform_backend;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
# Marketplace-Updates klonen Branches, bauen Images und starten Compose-Stacks.
# Diese Lifecycle-Antworten können länger als das normale API-Limit dauern.
location /api/v1/marketplace/modules/ {
proxy_pass http://platform_backend;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
location /api/ { location /api/ {
proxy_pass http://platform_backend; proxy_pass http://platform_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s; proxy_read_timeout 30s;
} }
# Modul-Routing (Phase 4): /slug/* wird intern an den # Alte Modul-Links auf dem Plattformhost führen über das Einmal-Ticket
# Modul-Gateway des Backends übergeben (/api/v1/gateway/slug/*). # zum getrennten Modulhost. Hier wird kein Modul-JavaScript ausgeliefert.
# Der Gateway prüft Session, Modul-Status und Berechtigung,
# bevor der Request an den Modul-Prozess proxied wird.
# WICHTIG: Plattform-Pfade (api, assets, login, …) sind ausgeschlossen,
# damit nur echte Modul-Slugs (3–100 Zeichen) weitergeleitet werden.
location ~ "^/(?!api/|assets/|login|profile|admin|403|404)(?<module_slug>[a-z0-9][a-z0-9-]{2,100})(?<module_path>/.*)?$" { location ~ "^/(?!api/|assets/|login|profile|admin|403|404)(?<module_slug>[a-z0-9][a-z0-9-]{2,100})(?<module_path>/.*)?$" {
proxy_pass http://platform_backend/api/v1/gateway/$module_slug$module_path; proxy_pass http://platform_backend/api/v1/auth/module-open/$module_slug;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s; proxy_read_timeout 30s;
} }
# SPA-Fallback für React Router # SPA-Fallback für React Router
@@ -93,4 +114,51 @@ http {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
} }
# Dieser Host liefert ausschließlich Modulpfade und den Ticket-Übergang.
# Management-API, Login, Admin-Frontend und Plattform-Cookies sind hier
# nicht erreichbar. Der Name wird beim Containerstart validiert eingesetzt.
server {
listen 8080;
server_name __MODULE_HOSTNAME__;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; worker-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# Auf lokalen Macs kann dieser Host zuvor für MPM verwendet worden sein.
# Bekannte Plattform-Einstiege führen zum konfigurierten Plattformhost.
location = / { return 302 __PLATFORM_ORIGIN__/; }
location ~ "^/(login|admin|profile|assets|403|404)(/|$)" {
return 302 __PLATFORM_ORIGIN__$request_uri;
}
location = /__mpm_module_handoff {
access_log off;
proxy_pass http://platform_backend/api/v1/auth/module-handoff$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
location ^~ /api/ { return 404; }
location ~ "^/(?<module_slug>[a-z0-9][a-z0-9-]{2,100})(?<module_path>/.*)?$" {
proxy_pass http://platform_backend/api/v1/gateway/$module_slug$module_path$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Service-Worker-Allowed;
}
location / { return 404; }
}
} }

View File

@@ -0,0 +1,14 @@
const fs = require('node:fs');
const { loadConfiguration } = require('/app/platform-backend/dist/config/configuration.js');
const configuration = loadConfiguration();
const { hostname } = new URL(configuration.modulePublicOrigin);
const platformOrigin = new URL(configuration.marketplace.publicUrl).origin;
if (!/^[a-z0-9.-]+$/i.test(hostname)) {
throw new Error('Ungültiger Modul-Hostname');
}
const template = fs.readFileSync('/etc/nginx/nginx.conf.template', 'utf8');
fs.writeFileSync(
'/tmp/mpm-nginx.conf',
template.replaceAll('__MODULE_HOSTNAME__', hostname).replaceAll('__PLATFORM_ORIGIN__', platformOrigin),
);

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
node /usr/local/lib/mpm/render-nginx-config.cjs
exec /usr/sbin/nginx -g 'daemon off;' -c /tmp/mpm-nginx.conf

View File

@@ -30,7 +30,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
[program:nginx] [program:nginx]
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf command=/usr/local/bin/start-mpm-nginx
autorestart=true autorestart=true
startretries=5 startretries=5
stopsignal=QUIT stopsignal=QUIT

View File

@@ -84,14 +84,15 @@ Browser ──▶ Nginx ──▶ SessionGuard (Session gültig? User aktiv?)
### Modul-Routing (ab Phase 4, geplant) ### Modul-Routing (ab Phase 4, geplant)
``` ```
Browser ──▶ Nginx (/slug) ──▶ Management-Gateway Browser ──▶ MPM-Host (/api/v1/auth/module-open/slug)
└── Einmal-Ticket ──▶ eigener Modul-Host (/<slug>) ──▶ Management-Gateway
├── User identifizieren (Session) ├── User identifizieren (Session)
├── Permission Check (user_module_permissions) ├── Permission Check (user_module_permissions)
├── DENIED → 403 ├── DENIED → 403
└── ALLOWED → Modul-Gateway → Modulprozess └── ALLOWED → Modul-Gateway → Modulprozess
``` ```
Ein Modul vertraut **niemals** allein auf die URL; die Plattform übergibt die Identität sicher an das Modul (Modul-API-Vertrag, Phase 6). Ein Modul vertraut **niemals** allein auf die URL; die Plattform übergibt die Identität sicher an das Modul (Modul-API-Vertrag, Phase 6). Der Modulhost bedient keine Management-API. Ein kurzlebiges, einmalig verwendbares Ticket stellt dort eine an die Plattformsession gebundene Modulsession aus. Modulpfade für Assets und API-Aufrufe müssen unter `/<slug>/` liegen; root-relative `/api/` ist auf dem Modulhost gesperrt. In Produktion verwenden Plattform-Cookies den `__Host-`-Präfix.
## 5. Datenmodell (Phase 1) ## 5. Datenmodell (Phase 1)

View File

@@ -20,6 +20,7 @@ OAuth-Verbindung -> Repository auswählen -> Release-Katalog -> Paket prüfen ->
- Der Katalog lädt öffentliche Repositories verbundener Forge-Konten. Installiert wird der aktuelle Stand des jeweiligen Standard-Branches. - Der Katalog lädt öffentliche Repositories verbundener Forge-Konten. Installiert wird der aktuelle Stand des jeweiligen Standard-Branches.
- MPM lädt das vom Forge erzeugte Quellarchiv serverseitig, entfernt den Archiv-Stammordner und erwartet `module.json` im Repository-Stamm. - MPM lädt das vom Forge erzeugte Quellarchiv serverseitig, entfernt den Archiv-Stammordner und erwartet `module.json` im Repository-Stamm.
- Nach der Branch-Prüfung lädt MPM das Archiv über die ermittelte Commit-ID. ZIP-Dateien dürfen komprimiert höchstens 10 MiB, entpackt höchstens 50 MiB und insgesamt höchstens 2000 Einträge enthalten.
## Container-Vertrag für Module ## Container-Vertrag für Module
@@ -34,7 +35,9 @@ Installierbare Module müssen neben `module.json` eine Compose-Datei und einen A
Der App-Service muss den Manifest-Port im Container bereitstellen (`expose`, kein `ports`) und auf `0.0.0.0` lauschen. Datenbanken gehören als weitere Services in dieselbe Compose-Datei. Die Dienste teilen ein privates Compose-Netz; nur der App-Service wird zusätzlich an das MPM-Gateway angeschlossen. Für SQLite kann der App-Service `/var/lib/mpm-module` als persistenten Speicher unter `MPM_MODULE_DATA_DIR` verwenden. Datenbankcontainer definieren eigene projektlokale named volumes. Der App-Service muss den Manifest-Port im Container bereitstellen (`expose`, kein `ports`) und auf `0.0.0.0` lauschen. Datenbanken gehören als weitere Services in dieselbe Compose-Datei. Die Dienste teilen ein privates Compose-Netz; nur der App-Service wird zusätzlich an das MPM-Gateway angeschlossen. Für SQLite kann der App-Service `/var/lib/mpm-module` als persistenten Speicher unter `MPM_MODULE_DATA_DIR` verwenden. Datenbankcontainer definieren eigene projektlokale named volumes.
MPM startet/stoppt den gesamten Stack gemeinsam. Beim Entfernen löscht Compose alle App- und Datenbankcontainer samt Projekt-Netzwerk; benannte Datenvolumes bleiben standardmäßig erhalten, damit ein Entfernen der App keine Daten vernichtet. Pakete dürfen keine Host-Ports, Host-Verzeichnisse, externen Docker-Ressourcen, privilegierten Optionen oder Docker-Socket-Mounts anfordern. Die Modulverwaltung benötigt Zugriff auf den Docker-Socket des Hosts; deshalb dürfen nur vertrauenswürdige Administratoren Module installieren. Ein Compose-Stack darf höchstens acht Services enthalten. MPM erzwingt für jeden Service `no-new-privileges`, 512 MiB Arbeitsspeicher, eine CPU und höchstens 256 Prozesse. Build-Kontext und Dockerfile müssen feste relative Pfade innerhalb des Modulpakets sein; Variablenersetzung in diesen Pfaden und zusätzliche Build-Zugriffe auf Hostdateien sind nicht erlaubt. Ein Build/Start darf höchstens 15 Minuten dauern, Stoppen und Entfernen höchstens zwei Minuten pro Compose-Befehl.
MPM startet/stoppt den gesamten Stack gemeinsam. Beim Entfernen löscht Compose alle App- und Datenbankcontainer, das Projekt-Netzwerk und sämtliche projektbezogenen Datenvolumes. Eine spätere Neuinstallation beginnt dadurch ohne die vorherigen Modul-Daten. Pakete dürfen keine Host-Ports, Host-Verzeichnisse, externen Docker-Ressourcen, privilegierten Optionen oder Docker-Socket-Mounts anfordern. Die Modulverwaltung benötigt Zugriff auf den Docker-Socket des Hosts; deshalb dürfen nur vertrauenswürdige Administratoren Module installieren.
- Vor der Installation prüft MPM Downloadgröße, Archivpfade, Symlinks, Manifest und Modul-ID. Die bestehende `ModuleInstaller`-Validierung bleibt die letzte Instanz. - Vor der Installation prüft MPM Downloadgröße, Archivpfade, Symlinks, Manifest und Modul-ID. Die bestehende `ModuleInstaller`-Validierung bleibt die letzte Instanz.
- Der Browser übermittelt keine Download-URL; MPM erstellt sie aus Anbieter, Besitzer, Repository und Standard-Branch. - Der Browser übermittelt keine Download-URL; MPM erstellt sie aus Anbieter, Besitzer, Repository und Standard-Branch.
- Die Installation registriert das Modul. Das Starten bleibt ein separater Lifecycle-Schritt und erfolgt erst nach Bestätigung durch den Administrator. - Die Installation registriert das Modul. Das Starten bleibt ein separater Lifecycle-Schritt und erfolgt erst nach Bestätigung durch den Administrator.
@@ -55,9 +58,17 @@ Gitea und Forgejo verwenden kompatible Release- und Repository-APIs, aber jede s
5. Repository-Archive vom Standard-Branch laden und vor dem Installieren sicher normalisieren. 5. Repository-Archive vom Standard-Branch laden und vor dem Installieren sicher normalisieren.
6. Installation in die bestehende Modulregistrierung integrieren, auditieren und im UI anzeigen. Module starten nach der Installation nicht automatisch. 6. Installation in die bestehende Modulregistrierung integrieren, auditieren und im UI anzeigen. Module starten nach der Installation nicht automatisch.
## Branch-basierte Modulupdates
Marketplace-Installationen speichern Repository, installierte Branch und Commit-ID. MPM prüft das Repository jede Minute und bietet in der Modulverwaltung über **Update verfügbar** jede neuere Versions-Branch als mögliche getestete Version an. Der Administrator wählt im Dialog eine Branch aus. Es wird nichts automatisch installiert.
Das Update wird anhand des gewählten Branch-Commits geladen und durchläuft dieselbe Archiv- und Manifestprüfung wie eine Neuinstallation. Modul-ID, URL-Slug, Port und Compose-App-Service müssen stabil bleiben. MPM tauscht den Modulcode mit einer temporären Sicherung aus, behält die persistenten Daten und verschlüsselte Modulkonfiguration und startet zuvor laufende Module anschließend erneut. Schlägt der Start fehl, stellt MPM den vorherigen Code und das Datenbankmanifest wieder her. Datenbankinhalte in Modulvolumes werden nicht automatisch zurückgerollt; Modulmigrationen müssen daher rückwärtskompatibel sein oder eigene Sicherungs-/Wiederherstellungsverfahren bieten.
Die installierte Branch wird nicht als Update angeboten. Auch bei einer Neuinstallation bleiben alternative Branches auswählbar, selbst wenn sie bereits vor der Installation im Repository vorhanden waren.
## Aktueller Umfang ## Aktueller Umfang
Der Marketplace in der Modulverwaltung unterstützt öffentliche Repositories von GitHub, Gitea und Forgejo. Private Repositories, Suche über fremde Katalogserver und Updates installierter Module sind noch nicht enthalten. Der Marketplace in der Modulverwaltung unterstützt öffentliche Repositories von GitHub, Gitea und Forgejo. Private Repositories und Suche über fremde Katalogserver sind noch nicht enthalten.
## Voraussetzungen für den Betrieb ## Voraussetzungen für den Betrieb