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
# --- Plattform-Container ---------------------------------------
NODE_ENV=production
NODE_ENV=development
PORT=3000
# Host-Port, unter dem die Plattform erreichbar ist
APP_PORT=8080
@@ -22,7 +22,7 @@ DATABASE_URL=postgresql://mpm:<sicheres-passwort>@postgres:5432/mpm
# Session-Gültigkeit in Minuten (kurz halten)
SESSION_TTL_MINUTES=120
# "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
BEHIND_PROXY=true
@@ -30,6 +30,10 @@ BEHIND_PROXY=true
# MPM braucht den Socket, um eigene Modul-Container zu verwalten.
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).
# MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten.
DOCKER_SOCKET_GID=0
@@ -42,6 +46,9 @@ ADMIN_PASSWORD=<mindestens-10-zeichen>
# --- Marketplace OAuth (optional) --------------------------------
# Lokal: Host-Adresse einschließlich APP_PORT; Callback-Pfad wird von MPM ergänzt.
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.
MARKETPLACE_TOKEN_ENCRYPTION_KEY=
# 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 \
&& 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 --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist
@@ -56,6 +59,6 @@ USER root
EXPOSE 8080
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"]

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.
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.
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.
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
```
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:
@@ -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.
- UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten.
- 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.
## Häufige Arbeitsplatzprobleme

View File

@@ -41,9 +41,9 @@ Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Phasen: [`docs/PHASES
docker compose up --build -d
# 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
# 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 ✓
@@ -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.
### 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
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_CHANGED: 'USER_PASSWORD_CHANGED',
MODULE_INSTALLED: 'MODULE_INSTALLED',
MODULE_UPDATED: 'MODULE_UPDATED',
MODULE_REMOVED: 'MODULE_REMOVED',
MODULE_STARTED: 'MODULE_STARTED',
MODULE_STOPPED: 'MODULE_STOPPED',
MODULE_RESTARTED: 'MODULE_RESTARTED',
MODULE_CONFIG_UPDATED: 'MODULE_CONFIG_UPDATED',
MODULE_ENABLED: 'MODULE_ENABLED',
MODULE_DISABLED: 'MODULE_DISABLED',
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 { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -8,6 +8,8 @@ import type { AuthenticatedRequest } from './authenticated-request';
import { AuthService } from './auth.service';
import { CsrfGuard } from './guards/csrf.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 type { AuthUser } from '../users/user.types';
@@ -38,6 +40,7 @@ function toAuthUserResponse(user: AuthUser): AuthUserResponse {
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly sessionService: SessionService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@@ -49,6 +52,7 @@ export class AuthController {
@Req() request: AuthenticatedRequest & Request,
@Res({ passthrough: true }) response: Response,
): Promise<{ user: AuthUserResponse }> {
requireSameOrigin(request, this.config.marketplace.publicUrl);
const result = await this.authService.login({
username: body.username,
password: body.password,
@@ -57,14 +61,14 @@ export class AuthController {
// Express expects cookie maxAge in milliseconds (the DB TTL is in minutes).
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,
secure: this.config.security.cookieSecure,
sameSite: 'lax',
path: '/',
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,
secure: this.config.security.cookieSecure,
sameSite: 'lax',
@@ -75,6 +79,63 @@ export class AuthController {
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)
@Post('logout')
@HttpCode(200)
@@ -88,6 +149,8 @@ export class AuthController {
}
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 });
return { success: true };
}

View File

@@ -25,8 +25,9 @@ function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig
...overrides,
},
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: {} },
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;
}

View File

@@ -25,6 +25,10 @@ const INVALID_CREDENTIALS_MESSAGE = 'Benutzername oder Passwort ist falsch';
*/
@Injectable()
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(
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher,
@@ -32,7 +36,9 @@ export class AuthService {
private readonly rateLimiter: RateLimiterService,
private readonly auditService: AuditService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
) {
this.dummyPasswordHash = this.passwordHasher.hash('mpm-invalid-user-placeholder');
}
async login(input: {
username: string;
@@ -62,6 +68,7 @@ export class AuthService {
// Gleiches Verhalten für "unbekannter Benutzer" und "falsches Passwort"
// (keine User-Enumeration).
if (!user) {
await this.passwordHasher.verify(await this.dummyPasswordHash, input.password);
await this.auditService.record({
userId: null,
username: input.username,
@@ -96,12 +103,23 @@ export class AuthService {
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
}
await this.userRepository.updateLoginSuccess(user.id);
const { token, data } = await this.sessionService.create(
const createdSession = await this.sessionService.createForVerifiedPassword(
user.id,
user.passwordHash,
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({
userId: user.id,

View File

@@ -4,6 +4,7 @@ import { UserRepository } from '../../users/user.repository';
import type { UserRecord } from '../../users/user.types';
import { SessionService } from '../session.service';
import { SessionGuard } from './session.guard';
import type { AppConfig } from '../../config/config.tokens';
/** Erzeugt einen Benutzer-Datensatz für Tests. */
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
@@ -74,6 +75,7 @@ describe('SessionGuard', () => {
sessionService as unknown as SessionService,
userRepository as unknown as UserRepository,
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 { APP_CONFIG, type AppConfig } from '../../config/config.tokens';
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
import { UserRepository } from '../../users/user.repository';
import type { AuthenticatedRequest } from '../authenticated-request';
@@ -11,7 +12,7 @@ interface RequestWithCookieHeader {
}
/** 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;
if (!cookieHeader) {
return null;
@@ -19,7 +20,7 @@ export function extractSessionToken(request: RequestWithCookieHeader): string |
let sessionToken: string | null = null;
for (const part of cookieHeader.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
// before the current Path=/ cookie. The last value is the root cookie.
sessionToken = decodeURIComponent(value.join('='));
@@ -40,6 +41,7 @@ export class SessionGuard implements CanActivate {
private readonly sessionService: SessionService,
private readonly userRepository: UserRepository,
private readonly reflector: Reflector,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -52,7 +54,7 @@ export class SessionGuard implements CanActivate {
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = extractSessionToken(request);
const token = extractSessionToken(request, this.config.security.cookieSecure);
if (!token) {
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 { DatabaseService } from '../database/database.service';
@@ -17,6 +17,12 @@ interface SessionRow {
expires_at: Date;
}
interface ModuleAccessRow {
user_id: string;
module_slug: string;
platform_session_id: string;
}
/**
* Serverseitige Session-Verwaltung (Infrastructure):
* - 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]) };
}
/**
* 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. */
async findValid(token: string): Promise<SessionData | null> {
const result = await this.database.query<SessionRow>(
@@ -75,6 +196,8 @@ export class SessionService {
/** Löscht alle Sessions eines Benutzers (Deaktivierung, Passwort-Reset). */
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) {
await this.database.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [
userId,
@@ -87,6 +210,8 @@ export class SessionService {
/** Löscht alle abgelaufenen Sessions (Aufräumjob, später via Cron). */
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()');
}

View File

@@ -35,6 +35,7 @@ export interface RuntimeConfig {
readonly modulesDir: string;
readonly logsDir: string;
readonly moduleUidBase?: number;
readonly moduleConfigurationEncryptionKey: string;
}
export interface MarketplaceProviderConfig {
@@ -61,6 +62,22 @@ export interface AppConfig {
readonly adminSeed: AdminSeedConfig;
readonly runtime: RuntimeConfig;
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
@@ -85,8 +102,10 @@ const environmentSchema = z.object({
MODULES_DIR: z.string().min(1).default('./data/modules'),
MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'),
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(),
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(''),
GITHUB_OAUTH_CLIENT_ID: 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_SECRET: z.string().default(''),
}).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) {
context.addIssue({
code: z.ZodIssueCode.custom,
@@ -184,6 +223,7 @@ export function loadConfiguration(): AppConfig {
runtime: {
modulesDir: path.resolve(environment.MODULES_DIR),
logsDir: path.resolve(environment.LOGS_DIR),
moduleConfigurationEncryptionKey: environment.MODULE_CONFIG_ENCRYPTION_KEY,
...(environment.MODULE_UID_BASE !== undefined
? { 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 { migration009MarketplaceInstallations } from '../../modules/migrations/009-marketplace-installations';
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. */
export const MIGRATIONS = [
@@ -21,4 +24,7 @@ export const MIGRATIONS = [
migration008MarketplaceSourceBranch,
migration009MarketplaceInstallations,
migration010ModuleContainers,
migration011ModuleConfiguration,
migration012MarketplaceBranchUpdates,
migration013ModuleBrowserSessions,
];

View File

@@ -42,7 +42,7 @@ async function bootstrap(): Promise<void> {
.setTitle('MPM Management API')
.setDescription('Zentrale Management-API der MPM-Plattform (Auth, RBAC, Health)')
.setVersion('0.1.0')
.addCookieAuth('mpm_session')
.addCookieAuth(config.security.cookieSecure ? '__Host-mpm_session' : 'mpm_session')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
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_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. */
const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/;
/** URL-Slugs für das spätere Routing (/slug). */
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). */
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'),
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'),
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(''),
author: z.string().max(200).default(''),
runtime: z.literal('node'),
@@ -54,6 +91,15 @@ export const moduleManifestSchema = z.object({
apiVersion: z.literal('v1'),
composeFile: z.string().min(1).max(200).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>;
@@ -75,4 +121,6 @@ export interface ModuleRecord {
readonly updatedAt: Date;
readonly composeFile?: 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 { ApiTags } from '@nestjs/swagger';
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 type { AuthUser } from '../users/user.types';
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 { MarketplaceService } from './marketplace.service';
import { ModulesService } from './modules.service';
import type { ModuleRecord } from './manifest.types';
@ApiTags('Marketplace')
@Controller({ path: 'api/v1/marketplace' })
@@ -17,6 +20,7 @@ export class MarketplaceController {
private readonly marketplaceService: MarketplaceService,
private readonly sessionService: SessionService,
private readonly modulesService: ModulesService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@Get('providers')
@@ -42,12 +46,16 @@ export class MarketplaceController {
): Promise<{ module: {
id: string; moduleId: string; name: string; slug: string; version: string; description: 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 module = await this.modulesService.findByModuleId(manifest.id) ??
await this.modulesService.install(archive, actor, request.ip ?? null);
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id);
if (await this.modulesService.findByModuleId(manifest.id)) {
throw new BadRequestException('Ein Modul mit dieser ID ist bereits installiert');
}
const module = await this.modulesService.install(archive, actor, request.ip ?? null);
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id, branch, commit);
return {
module: {
id: module.id,
@@ -62,10 +70,62 @@ export class MarketplaceController {
healthcheckUrl: module.healthcheckUrl,
enabled: module.enabled,
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')
@Roles('ADMIN')
async startConnection(
@@ -102,7 +162,7 @@ export class MarketplaceController {
destination.searchParams.set('reason', 'callback');
} else {
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;
if (!session) {
destination.searchParams.set('marketplace', 'error');

View File

@@ -1,10 +1,14 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
UnauthorizedException,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
@@ -37,8 +41,51 @@ interface ProviderIdentity {
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;
function isCommitSha(value: string): boolean {
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
}
function isProvider(value: string): value is MarketplaceProvider {
return MARKETPLACE_PROVIDERS.includes(value as MarketplaceProvider);
}
@@ -54,13 +101,79 @@ function safeBaseUrl(value: string): string {
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()
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(
private readonly database: DatabaseService,
@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[]> {
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
'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);
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(
`INSERT INTO marketplace_module_installations (provider, owner, repository, module_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, owner, repository) DO UPDATE SET module_id = EXCLUDED.module_id`,
[provider, owner, repository, moduleId],
`INSERT INTO marketplace_module_installations
(provider, owner, repository, module_id, installed_branch, installed_commit, available_branches, observed_branches, branches_checked_at)
VALUES ($1, $2, $3, $4, $5, $6, '[]'::jsonb, $7::jsonb, now())
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);
if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) {
throw new BadRequestException('Repository-Angabe ist ungueltig');
@@ -228,18 +391,158 @@ export class MarketplaceService {
this.repositoryApiPath(provider, owner, repository),
);
if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt');
const defaultBranch = String(repo.default_branch ?? 'main');
if (!defaultBranch || defaultBranch.length > 200) throw new BadRequestException('Standard-Branch ist ungueltig');
const selectedBranch = branch ?? String(repo.default_branch ?? 'main');
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'
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(defaultBranch)
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(defaultBranch) + '.zip';
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + commit
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + commit + '.zip';
const archiveUrl = provider === 'github'
? new URL(archivePath, 'https://api.github.com').toString()
: new URL(archivePath, providerConfig.baseUrl).toString();
const allowedHosts = this.downloadHosts(providerConfig.baseUrl, provider);
const headers: Record<string, string> = provider === 'github' ? { 'User-Agent': 'MPM-Module-Marketplace' } : {};
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> {

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 { 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 path from 'node:path';
import { stringify, parseDocument } from 'yaml';
import { ModuleIdentityService } from './module-identity.service';
import type { ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service';
const SAFE_SERVICE_KEYS = new Set([
'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',
]);
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. */
@Injectable()
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 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> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
// Recreate stopped containers and project networks before each start. This
// prevents Compose v1 from trying to reconcile stale Docker Desktop network
// defaults after a stop; named data volumes are deliberately left untouched.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
await this.runDocker(['network', 'create', gatewayNetwork]);
await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans']);
if (this.mpmContainer) {
await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]);
const { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues } = await this.prepare(module);
try {
// Recreate stopped containers and project networks before each start. This
// prevents Compose v1 from reconciling stale Docker Desktop network defaults;
// named data volumes are deliberately left untouched.
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);
await this.runDocker(['network', 'create', gatewayNetwork]);
await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans'], moduleValues);
if (this.mpmContainer) {
await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]);
}
this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`);
} finally {
await rm(overridePath, { force: true });
}
this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`);
}
async stop(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop']);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`);
const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
try {
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop'], cleanupValues);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`);
} finally {
await rm(overridePath, { force: true });
}
}
async remove(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
// Compose down removes every app/database container and its networks. Named
// volumes remain, so uninstalling code does not silently destroy database data.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
await rm(overridePath, { force: true });
this.logger.log(`Container für "${module.moduleId}" entfernt; Datenvolumes bleiben erhalten`);
const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
try {
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
// Removing a module is an explicit delete: remove its project-scoped data volumes too.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--volumes', '--remove-orphans'], cleanupValues);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
this.logger.log(`Container und Datenvolumes für "${module.moduleId}" entfernt`);
} finally {
await rm(overridePath, { force: true });
}
}
/** 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<{
@@ -63,6 +113,8 @@ export class ModuleContainerManager {
overridePath: string;
projectName: string;
gatewayNetwork: string;
moduleValues: Record<string, string>;
cleanupValues: Record<string, string>;
}> {
if (!module.composeFile || !module.appService) {
throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration');
@@ -70,47 +122,97 @@ export class ModuleContainerManager {
const root = path.resolve(module.path);
const composePath = path.resolve(root, module.composeFile);
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`);
});
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 });
if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML');
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 gatewayNetwork = `mpm-module-${module.moduleId}-gateway`;
// Keep generated secrets outside the package/build context so Dockerfiles
// cannot accidentally copy them into an application image.
const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`);
const overrideServices: Record<string, Record<string, unknown>> = {};
for (const serviceName of Object.keys(serviceMap)) {
overrideServices[serviceName] = {
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`,
environment: {
PORT: String(module.internalPort),
NODE_ENV: process.env.NODE_ENV ?? 'production',
MPM_MODULE_DATA_DIR: '/var/lib/mpm-module',
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
},
volumes: ['mpm-runtime-data:/var/lib/mpm-module'],
networks: {
default: {},
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
},
};
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: {
[module.appService]: {
container_name: `mpm-${module.moduleId}-app`,
environment: {
PORT: String(module.internalPort),
NODE_ENV: process.env.NODE_ENV ?? 'production',
MPM_MODULE_DATA_DIR: '/var/lib/mpm-module',
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
},
volumes: ['mpm-runtime-data:/var/lib/mpm-module'],
networks: {
default: {},
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
},
security_opt: ['no-new-privileges:true'],
},
},
services: overrideServices,
volumes: { 'mpm-runtime-data': {} },
networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } },
};
await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 });
await writeFile(overridePath, stringify(override), { mode: 0o600 });
return { composePath, overridePath, projectName, gatewayNetwork };
try {
await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 });
await writeFile(overridePath, stringify(override), { mode: 0o600 });
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)) {
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');
}
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!)) {
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) {
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);
service.security_opt = ['no-new-privileges:true'];
}
for (const [name, volume] of Object.entries(definedVolumes)) {
if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null &&
@@ -175,18 +279,64 @@ export class ModuleContainerManager {
return value as Record<string, unknown>;
}
private validateBuild(build: unknown, modulePath: string): void {
const context = typeof build === 'string'
? build
private async validateBuild(build: unknown, root: string, composeDir: string): Promise<void> {
const options: Record<string, unknown> | null = typeof build === 'string'
? { context: build }
: build && typeof build === 'object' && !Array.isArray(build)
? String((build as Record<string, unknown>).context ?? '.')
: '';
if (!context || path.isAbsolute(context) || context.split(/[\\/]/).includes('..')) {
throw new BadRequestException('Build-Kontext muss innerhalb des Modulpakets liegen');
? build as Record<string, unknown>
: null;
if (!options || Object.keys(options).some((key) => !SAFE_BUILD_KEYS.has(key))) {
throw new BadRequestException('Compose-Build enthält nicht erlaubte Optionen');
}
const resolved = path.resolve(modulePath, context);
if (resolved !== modulePath && !resolved.startsWith(modulePath + path.sep)) {
throw new BadRequestException('Build-Kontext liegt außerhalb des Modulpakets');
const context = options.context ?? '.';
if (typeof context !== 'string' || !this.isStaticRelativePath(context)) {
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> {
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd);
private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[], config: Record<string, string>): Promise<void> {
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> {
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) => {
const child = spawn(command, args, {
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: {
...extraEnv,
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
// command started by the unprivileged backend user.
HOME: '/tmp',
DOCKER_HOST: this.dockerHost,
},
stdio: ['ignore', 'ignore', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
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.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) => {
if (timedOut) return;
clearTimeout(timeout);
if (terminationTimer) clearTimeout(terminationTimer);
if (settled) return;
settled = true;
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) => {
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();
else {
this.logger.error(`${command} ${args[args.length - 1]} schlug mit Status ${code} fehl: ${stderr.trim()}`);
reject(new Error(`${command} schlug mit Status ${code} fehl`));
const diagnostic = this.sanitizeDiagnostic(`${stdout}\n${stderr}`, extraEnv);
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,
createdAt: new Date(),
updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides,
};
}
@@ -52,7 +54,11 @@ function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
function createRequest(url: string, cookie?: string): Request {
return {
url,
headers: cookie ? { cookie } : {},
method: 'GET',
headers: {
host: 'localhost:8081',
...(cookie ? { cookie } : {}),
},
} as unknown as Request;
}
@@ -77,8 +83,8 @@ function createResponse(): Response & { sentStatus: number; sentBody: unknown }
class MockSessionService {
public session: SessionData | null = null;
async findValid(): Promise<SessionData | null> {
return this.session;
async findValidModuleSession(): Promise<string | null> {
return this.session?.userId ?? null;
}
}
@@ -133,6 +139,7 @@ describe('ModuleGatewayMiddleware', () => {
sessionService as unknown as SessionService,
userRepository as unknown as UserRepository,
permissionsService as unknown as ModulePermissionsService,
{ modulePublicOrigin: 'http://localhost:8081' } as never,
);
sessionService.session = {
id: 'session-1',
@@ -161,7 +168,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 401 bei ungültiger Session', async () => {
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();
await middleware.use(request, response, makeNext());
@@ -170,7 +177,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 401 bei deaktiviertem Benutzer', async () => {
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();
await middleware.use(request, response, makeNext());
@@ -179,7 +186,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 404 bei unbekanntem Modul-Slug', async () => {
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();
await middleware.use(request, response, makeNext());
@@ -188,7 +195,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 503 bei gestopptem Modul', async () => {
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();
await middleware.use(request, response, makeNext());
@@ -197,7 +204,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 503 bei deaktiviertem Modul', async () => {
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();
await middleware.use(request, response, makeNext());
@@ -206,7 +213,7 @@ describe('ModuleGatewayMiddleware', () => {
it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => {
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();
await middleware.use(request, response, makeNext());
@@ -215,7 +222,7 @@ describe('ModuleGatewayMiddleware', () => {
it('leitet USER-Requests mit GRANTED-Berechtigung an den Proxy weiter', async () => {
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 proxySpy = jest
@@ -233,7 +240,7 @@ describe('ModuleGatewayMiddleware', () => {
it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => {
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();
// 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 httpProxy from 'http-proxy';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
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 { ModuleRepository } from './module.repository';
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. */
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.
@@ -35,6 +53,7 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
private readonly sessionService: SessionService,
private readonly userRepository: UserRepository,
private readonly permissionsService: ModulePermissionsService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
private readonly identityService: ModuleIdentityService = new ModuleIdentityService(),
) {
this.proxy = httpProxy.createProxyServer({
@@ -69,6 +88,21 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
proxyRequest.setHeader('Content-Length', Buffer.byteLength(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> {
@@ -77,26 +111,45 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
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>
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 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
const token = extractSessionToken(request);
const token = moduleCookie(request);
if (!token) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
const session = await this.sessionService.findValid(token);
if (!session) {
const userId = await this.sessionService.findValidModuleSession(token, slug);
if (!userId) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
const user = await this.userRepository.findById(session.userId);
const user = await this.userRepository.findById(userId);
if (!user || !user.isActive) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
@@ -139,8 +192,11 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
request.headers['x-user-role'] = user.role;
request.headers['x-mpm-identity-timestamp'] = signedIdentity.timestamp;
request.headers['x-mpm-identity-signature'] = signedIdentity.signature;
// Session-Cookie niemals an das Modul weiterleiten
delete request.headers.cookie;
(request as Request & { mpmModuleSlug?: string }).mpmModuleSlug = slug;
// 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, {
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 { 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 { moduleManifestSchema, type ModuleManifest } from './manifest.types';
import { secureModuleDirectory } from './module-filesystem';
import { parseDocument } from 'yaml';
/** Maximale Größe eines Modul-Pakets (10 MB). */
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. */
const REQUIRED_MANIFEST_FILE = 'module.json';
@@ -26,20 +32,19 @@ export class ModuleInstaller {
/** Validiert ein hochgeladenes Paket und gibt das Manifest zurück. */
async validatePackage(buffer: Buffer): Promise<ModuleManifest> {
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)');
}
this.assertCompressedSize(buffer);
const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer);
this.assertSafeArchive(zip.getEntries(), path.resolve('/module-package'));
const manifestEntry = zip.getEntry(REQUIRED_MANIFEST_FILE);
if (!manifestEntry) {
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;
try {
@@ -73,9 +78,28 @@ export class ModuleInstaller {
manifest.composeFile.split('/').includes('..')) {
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`);
}
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;
}
@@ -89,23 +113,14 @@ export class ModuleInstaller {
manifest: ModuleManifest,
modulesDir: string,
): Promise<{ directory: string; manifest: ModuleManifest }> {
this.assertCompressedSize(buffer);
const directory = path.join(modulesDir, manifest.id);
// Zip-Slip-Schutz: Alle Einträge müssen innerhalb des Zielverzeichnisses liegen.
const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer);
const resolvedDirectory = path.resolve(directory);
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}`);
}
}
this.assertSafeArchive(zip.getEntries(), resolvedDirectory);
// Bestehende Installation entfernen (Update-Szenario).
await rm(directory, { recursive: true, force: true });
@@ -125,6 +140,50 @@ export class ModuleInstaller {
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. */
async remove(modulesDir: string, moduleId: string): Promise<void> {
const directory = path.join(modulesDir, moduleId);
@@ -141,4 +200,48 @@ export class ModuleInstaller {
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 { DatabaseService } from '../database/database.service';
import type { ModuleRecord } from './manifest.types';
import type { ModuleConfigurationField } from './manifest.types';
/** Modul-Berechtigung eines Benutzers. */
export interface ModulePermissionRecord {
@@ -89,7 +90,7 @@ export class ModulePermissionRepository {
const result = await this.database.query(
`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.created_at, m.updated_at
m.created_at, m.updated_at, m.configuration_schema, m.configuration_ready
FROM user_module_permissions p
JOIN modules m ON m.id = p.module_id
WHERE p.user_id = $1 AND p.permission = 'GRANTED' AND m.enabled
@@ -111,6 +112,8 @@ export class ModulePermissionRepository {
enabled: row.enabled,
createdAt: row.created_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,
createdAt: new Date(),
updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides,
};
}

View File

@@ -46,7 +46,18 @@ export class ModuleProcessManager implements OnModuleDestroy {
if (module.composeFile && module.appService) {
await secureModuleDirectory(module.path);
await this.containerManager.start(module);
try {
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);
return;
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
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 {
id: string;
@@ -19,11 +19,13 @@ interface ModuleRow {
updated_at: Date;
compose_file: string | null;
app_service: string | null;
configuration_schema: ModuleConfigurationField[];
configuration_ready: boolean;
}
const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path,
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.
@@ -84,8 +86,8 @@ export class ModuleRepository {
const result = await this.database.query<ModuleRow>(
`INSERT INTO modules
(module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url,
compose_file, app_service)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11)
compose_file, app_service, configuration_schema, configuration_ready)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11, $12::jsonb, $13)
RETURNING ${MODULE_COLUMNS}`,
[
manifest.id,
@@ -99,6 +101,8 @@ export class ModuleRepository {
manifest.healthcheck,
manifest.composeFile ?? null,
manifest.appService ?? null,
JSON.stringify(manifest.configuration),
manifest.configuration.every((field) => !field.required || field.defaultValue !== undefined),
],
);
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> {
await this.database.query('DELETE FROM modules WHERE id = $1', [id]);
}
@@ -140,6 +156,15 @@ export class ModuleRepository {
updatedAt: row.updated_at,
composeFile: row.compose_file,
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 { ModuleRecord, ModuleStatus } from './manifest.types';
import { ModulesService } from './modules.service';
import type { ModuleConfigurationState } from './module-configuration.service';
/** Modul-Daten in API-Antworten. */
interface ModuleResponse {
@@ -40,6 +41,8 @@ interface ModuleResponse {
healthcheckUrl: string;
enabled: boolean;
createdAt: string;
configuration: ModuleRecord['configuration'];
configurationReady: boolean;
}
function toModuleResponse(module: ModuleRecord): ModuleResponse {
@@ -56,6 +59,8 @@ function toModuleResponse(module: ModuleRecord): ModuleResponse {
healthcheckUrl: module.healthcheckUrl,
enabled: module.enabled,
createdAt: module.createdAt.toISOString(),
configuration: module.configuration,
configurationReady: module.configurationReady,
};
}
@@ -81,6 +86,21 @@ export class ModulesController {
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')
@UseInterceptors(FileInterceptor('package'))
async install(
@@ -147,8 +167,8 @@ export class ModulesController {
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<{ success: true }> {
await this.modulesService.remove(id, actor, request.ip ?? null);
return { success: true };
): Promise<{ success: true; cleanupWarning?: string }> {
const result = await this.modulesService.remove(id, actor, request.ip ?? null);
return { success: true, ...result };
}
}

View File

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

View File

@@ -23,6 +23,7 @@ function createManifest(overrides: Partial<ModuleManifest> = {}): ModuleManifest
port: 41001,
healthcheck: '/health',
apiVersion: 'v1',
configuration: [],
...overrides,
};
}
@@ -45,6 +46,8 @@ function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides,
};
}
@@ -190,8 +193,9 @@ const TEST_CONFIG: AppConfig = {
loginRateLimitWindowMinutes: 5,
},
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: {} },
modulePublicOrigin: 'http://localhost:8081',
};
describe('ModulesService', () => {
@@ -214,6 +218,7 @@ describe('ModulesService', () => {
processManager as unknown as ModuleProcessManager,
healthChecker as unknown as ModuleHealthChecker,
auditService as unknown as AuditService,
{ resolvedValues: async () => ({}), state: async () => ({ ready: true, fields: [] }), save: async () => ({ state: { ready: true, fields: [] }, changedKeys: [] }) } as never,
TEST_CONFIG,
);
});

View File

@@ -2,7 +2,9 @@ import {
BadRequestException,
ConflictException,
Inject,
InternalServerErrorException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
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 { ModuleInstaller } from './module-installer';
import { ModuleProcessManager } from './module-process-manager';
import { ModuleCommandError } from './module-container-manager';
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.
@@ -27,12 +32,16 @@ import type { ModuleRecord } from './manifest.types';
*/
@Injectable()
export class ModulesService {
private readonly logger = new Logger(ModulesService.name);
private readonly operationLock = new ModuleOperationLock();
constructor(
private readonly moduleRepository: ModuleRepository,
private readonly installer: ModuleInstaller,
private readonly processManager: ModuleProcessManager,
private readonly healthChecker: ModuleHealthChecker,
private readonly auditService: AuditService,
private readonly configurationService: ModuleConfigurationService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@@ -48,6 +57,42 @@ export class ModulesService {
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. */
async install(
packageBuffer: Buffer,
@@ -55,7 +100,15 @@ export class ModulesService {
ipAddress: string | null,
): Promise<ModuleRecord> {
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([
this.moduleRepository.findByModuleId(manifest.id),
this.moduleRepository.findBySlug(manifest.slug),
@@ -76,7 +129,27 @@ export class ModulesService {
manifest,
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({
userId: actor.id,
@@ -84,6 +157,8 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_INSTALLED,
details: { moduleId: manifest.id, version: manifest.version, slug: manifest.slug },
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;
}
@@ -92,23 +167,124 @@ export class ModulesService {
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> {
return this.moduleRepository.findByModuleId(moduleId);
}
/** 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);
this.assertEnabled(module);
if (module.status === 'RUNNING' || module.status === 'STARTING') {
return module;
}
await this.configurationService.resolvedValues(module);
await this.moduleRepository.updateStatus(id, 'STARTING');
try {
onProgress?.('starting', 'Modulcontainer werden gestartet', 80);
await this.processManager.start(module);
// Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten.
// Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt.
onProgress?.('healthcheck', 'Healthcheck läuft', 91);
const health = await this.waitForHealthy(module);
if (!health.healthy) {
throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`);
@@ -116,11 +292,21 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'RUNNING');
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress);
} catch (error) {
await this.moduleRepository.updateStatus(id, 'ERROR');
await this.processManager.stop(module.moduleId);
throw new BadRequestException(
`Modul konnte nicht gestartet werden: ${error instanceof Error ? error.message : String(error)}`,
);
let statusUpdateFailed = false;
try {
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);
} catch {
// 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;
}
@@ -150,6 +336,10 @@ export class ModulesService {
/** Stoppt ein Modul (RUNNING → STOPPING → STOPPED). */
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);
if (module.status === 'STOPPED' || module.status === 'STOPPING') {
return module;
@@ -161,21 +351,29 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'STOPPED');
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress);
} catch (error) {
await this.moduleRepository.updateStatus(id, 'ERROR');
throw new BadRequestException(
`Modul konnte nicht gestoppt werden: ${error instanceof Error ? error.message : String(error)}`,
);
let statusUpdateFailed = false;
try {
await this.moduleRepository.updateStatus(id, 'ERROR');
} catch {
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;
}
/** Startet ein Modul neu (Stop + Start). */
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);
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). */
@@ -184,6 +382,15 @@ export class ModulesService {
enabled: boolean,
actor: ActingUser,
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> {
const module = await this.getById(id);
if (module.enabled === enabled) {
@@ -191,7 +398,7 @@ export class ModulesService {
}
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);
@@ -206,22 +413,42 @@ export class ModulesService {
action: enabled ? AUDIT_ACTIONS.MODULE_ENABLED : AUDIT_ACTIONS.MODULE_DISABLED,
details: { moduleId: module.moduleId },
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;
}
/** 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);
if (module.status === 'RUNNING' || module.status === 'STARTING') {
await this.stop(id, actor, ipAddress);
await this.stopUnlocked(id, actor, ipAddress);
}
await this.processManager.remove(module);
try {
await this.processManager.remove(module);
} catch (error) {
throw this.lifecycleFailure('remove', module, error);
}
await this.moduleRepository.delete(id);
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId);
try {
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);
} 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({
userId: actor.id,
@@ -229,7 +456,10 @@ export class ModulesService {
action: AUDIT_ACTIONS.MODULE_REMOVED,
details: { moduleId: module.moduleId },
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). */
@@ -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(
module: ModuleRecord,
action: AuditAction,
@@ -257,6 +517,8 @@ export class ModulesService {
action,
details: { moduleId: module.moduleId, version: module.version },
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 { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository';
import type { UserRecord } from './user.types';
@@ -28,23 +27,14 @@ function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
/** Mock des UserRepository. */
class MockUserRepository {
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> {
return this.user && this.user.id === id ? this.user : null;
}
async updatePassword(id: string, hash: string): Promise<void> {
this.updatedPasswords.push({ id, hash });
}
}
/** 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 });
async updatePassword(id: string, hash: string, exceptSessionId?: string, expectedHash?: string): Promise<void> {
this.updatedPasswords.push({ id, hash, exceptSessionId, expectedHash });
}
}
@@ -59,20 +49,17 @@ class MockAuditService {
describe('ProfileService', () => {
let userRepository: MockUserRepository;
let sessionService: MockSessionService;
let auditService: MockAuditService;
let profileService: ProfileService;
let passwordHasher: PasswordHasher;
beforeEach(async () => {
userRepository = new MockUserRepository();
sessionService = new MockSessionService();
auditService = new MockAuditService();
passwordHasher = new PasswordHasher();
profileService = new ProfileService(
userRepository as unknown as UserRepository,
passwordHasher,
sessionService as unknown as SessionService,
auditService as unknown as AuditService,
);
@@ -101,9 +88,8 @@ describe('ProfileService', () => {
);
expect(userRepository.updatedPasswords).toHaveLength(1);
expect(sessionService.deletedForUser).toEqual([
{ userId: 'user-1', exceptSessionId: 'session-1' },
]);
expect(userRepository.updatedPasswords[0].exceptSessionId).toBe('session-1');
expect(userRepository.updatedPasswords[0].expectedHash).toBe(userRepository.user?.passwordHash);
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 { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from './password-hasher';
import { UserRepository } from './user.repository';
import type { ChangePasswordDto, UserRecord } from './user.types';
@@ -16,7 +15,6 @@ export class ProfileService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher,
private readonly sessionService: SessionService,
private readonly auditService: AuditService,
) {}
@@ -48,8 +46,7 @@ export class ProfileService {
}
const newPasswordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(userId, newPasswordHash);
await this.sessionService.deleteAllForUser(userId, currentSessionId);
await this.userRepository.updatePassword(userId, newPasswordHash, currentSessionId, user.passwordHash);
await this.auditService.record({
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 { PasswordHasher } from './password-hasher';
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
@@ -146,11 +146,31 @@ export class UserRepository {
}
/** Setzt einen neuen Passwort-Hash. */
async updatePassword(id: string, passwordHash: string): Promise<void> {
await this.database.query(
'UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1',
[id, passwordHash],
);
async updatePassword(
id: string,
passwordHash: string,
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). */

View File

@@ -225,7 +225,7 @@ describe('UsersService', () => {
null,
);
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);
});
});

View File

@@ -135,7 +135,6 @@ export class UsersService {
const user = await this.findById(id);
const passwordHash = await this.passwordHasher.hash(input.newPassword);
await this.userRepository.updatePassword(id, passwordHash);
await this.sessionService.deleteAllForUser(id);
await this.auditService.record({
userId: actor.id,
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": {
"@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-dom": "^19.0.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"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^26.6.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",

View File

@@ -1,10 +1,13 @@
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 { 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 { 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 {
to: string;
label: string;
@@ -24,10 +27,19 @@ function initialDarkMode(): boolean {
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 {
return (
<SidebarProvider defaultOpen>
<AppLayoutContent />
</SidebarProvider>
);
}
function AppLayoutContent(): ReactNode {
const { user, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { setOpenMobile, state: sidebarState } = useSidebar();
const location = useLocation();
const [profileOpen, setProfileOpen] = useState(false);
const [darkMode, setDarkMode] = useState(initialDarkMode);
@@ -36,116 +48,117 @@ export function AppLayout(): ReactNode {
localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
const visibleItems = NAV_ITEMS.filter(
(item) => !item.adminOnly || user?.role === 'ADMIN',
);
const visibleItems = NAV_ITEMS.filter((item) => !item.adminOnly || user?.role === 'ADMIN');
return (
<div className="flex h-full">
{/* Mobile: Overlay-Hintergrund */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-slate-900/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
aria-hidden="true"
/>
)}
{/* 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">
<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>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{visibleItems.map((item) => (
<NavLink
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'
}`
}
<>
<Sidebar className="border-r border-slate-200 bg-white" collapsible="icon" variant="sidebar">
<SidebarHeader
className="group/sidebar-header relative h-16 flex-row items-center justify-between gap-2 border-b border-slate-200 px-6 py-0"
style={sidebarState === 'collapsed'
? { display: 'grid', gridTemplateColumns: '1fr', placeItems: 'center', paddingInline: 0 }
: undefined}
>
<div
className="flex min-w-0 items-center gap-2 group-data-[collapsible=icon]:hidden"
style={sidebarState === 'collapsed' ? { display: 'none' } : undefined}
>
<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>
</div>
<div
style={sidebarState === 'collapsed'
? { display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'center' }
: { display: 'contents' }}
>
<SidebarTrigger
className="hidden h-9 w-9 shrink-0 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:flex"
aria-label="Sidebar einklappen oder ausklappen"
>
<Icon name={item.icon} className="h-[18px] w-[18px]" />
{item.label}
</NavLink>
))}
</nav>
<div className="flex h-full w-full items-center justify-center">
<Icon name="menu" className="h-5 w-5" />
</div>
</SidebarTrigger>
</div>
</SidebarHeader>
<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>
<SidebarContent className="flex-1 overflow-y-auto px-3 py-4 group-data-[collapsible=icon]:px-0">
<SidebarGroup className="p-0">
<SidebarMenu className="gap-1">
{visibleItems.map((item) => {
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'}`}
>
<NavLink to={item.to} end={item.to === '/'} onClick={() => setOpenMobile(false)}>
<Icon name={item.icon} className="h-[18px] w-[18px]" />
<span>{item.label}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
{/* 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}
>
<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 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" />
</button>
<span className="hidden text-sm text-slate-500 lg:block">Management-Plattform</span>
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
<button
type="button"
role="switch"
aria-checked={darkMode}
aria-label="Darkmode"
onClick={() => setDarkMode((current) => !current)}
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"
>
</SidebarTrigger>
<div className="ml-auto inline-flex h-9 items-center gap-3">
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" />
<span>{darkMode ? 'Hell' : 'Dunkel'}</span>
<span className={`relative h-5 w-9 rounded-full transition-colors ${darkMode ? 'bg-brand-600' : 'bg-slate-300'}`}>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${darkMode ? 'translate-x-4' : 'translate-x-0.5'}`} />
</span>
</button>
<Switch checked={darkMode} onCheckedChange={setDarkMode} aria-label="Darkmode" />
</div>
</header>
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
<Outlet />
</main>
</div>
</SidebarInset>
{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). */
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'neutral' | 'info';
const badgeVariants = cva('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset', {
variants: {
variant: {
success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
warning: 'bg-amber-50 text-amber-700 ring-amber-600/20',
danger: 'bg-red-50 text-red-700 ring-red-600/20',
neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20',
info: 'bg-brand-50 text-brand-700 ring-brand-600/20',
},
},
defaultVariants: { variant: 'neutral' },
});
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
warning: 'bg-amber-50 text-amber-700 ring-amber-600/20',
danger: 'bg-red-50 text-red-700 ring-red-600/20',
neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20',
info: 'bg-brand-50 text-brand-700 ring-brand-600/20',
};
export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> { children: ReactNode }
export interface BadgeProps {
variant?: BadgeVariant;
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 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>
);
}
export { badgeVariants };

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 { cn } from '../../lib/utils';
/** Varianten des Buttons (Design-System). */
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
const buttonVariants = cva(
'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',
{
variants: {
variant: {
default: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300',
secondary: 'border border-slate-300 bg-white text-slate-700 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',
destructive: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300',
ghost: '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: {
xs: 'h-6 rounded-md px-2 text-xs',
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
default: 'h-9 px-4 text-sm',
lg: 'h-11 px-5 text-base',
icon: 'size-9 p-0',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
},
);
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300',
secondary:
'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400',
danger:
'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300',
ghost:
'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400',
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-5 text-base',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>['variant']>;
export type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>['size']>;
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
loading?: boolean;
asChild?: boolean;
children: ReactNode;
}
/** Standard-Button des Design-Systems. */
export function Button({
variant = 'primary',
size = 'md',
loading = false,
className = '',
disabled,
children,
...rest
}: ButtonProps): ReactNode {
/** shadcn/ui Button mit MPM-Varianten und bestehenden Abmessungen. */
export function Button({ variant = 'primary', size = 'md', loading = false, asChild = false, className, disabled, children, ...rest }: ButtonProps): ReactNode {
const Comp = asChild ? Slot.Root : 'button';
return (
<button
className={`inline-flex 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
${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"
/>
)}
<Comp data-slot="button" className={cn(buttonVariants({ variant, 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}
</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 {
children: ReactNode;
className?: string;
/** shadcn/ui Card primitives; MPM classes preserve the established card geometry. */
export function Card({ asChild = false, className, ...props }: ComponentProps<'div'> & { asChild?: boolean }): ReactNode {
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 Card({ children, className = '' }: CardProps): ReactNode {
return (
<div
className={`rounded-2xl border border-slate-200 bg-white shadow-sm ${className}`}
>
{children}
</div>
);
export function CardHeader({ className, ...props }: ComponentProps<'div'>): 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} />;
}
export interface CardHeaderProps {
title: string;
description?: string;
children?: ReactNode;
export function CardTitle({ className, ...props }: ComponentProps<'h2'>): ReactNode {
return <h2 data-slot="card-title" className={cn('text-base font-semibold text-slate-900', className)} {...props} />;
}
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode {
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 function CardDescription({ className, ...props }: ComponentProps<'p'>): ReactNode {
return <p data-slot="card-description" className={cn('mt-0.5 text-sm text-slate-500', className)} {...props} />;
}
export interface CardBodyProps {
children: ReactNode;
className?: string;
export function CardAction({ className, ...props }: ComponentProps<'div'>): ReactNode {
return <div data-slot="card-action" className={cn('shrink-0', className)} {...props} />;
}
/** Karten-Inhalt. */
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
return <div className={`px-5 py-5 ${className}`}>{children}</div>;
export function CardContent({ className, ...props }: ComponentProps<'div'>): ReactNode {
return <div data-slot="card-content" className={cn('px-5 py-5', className)} {...props} />;
}
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';
export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check';
const PATHS: Record<IconName, ReactNode> = {
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: <><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: <><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: <><path d="M12 8v4l2.5 2.5" /><circle cx="12" cy="12" r="9" /><path d="M12 3v2m9 7h-2M5 12H3m9 9v-2" /></>,
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: <path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z" />,
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: <><circle cx="12" cy="8" r="4" /><path d="M5 21a7 7 0 0 1 14 0" /></>,
arrow: <><path d="M5 12h14m-6-6 6 6-6 6" /></>,
close: <><path d="m18 6-12 12M6 6l12 12" /></>,
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
plus: <path d="M12 5v14M5 12h14" />,
check: <path d="m5 12 4 4L19 6" />,
const ICONS: Record<IconName, LucideIcon> = {
dashboard: LayoutDashboard,
users: UsersRound,
modules: Boxes,
system: Clock3,
audit: ClipboardList,
moon: Moon,
sun: Sun,
user: UserRound,
arrow: ArrowRight,
close: X,
menu: Menu,
plus: Plus,
check: Check,
};
/** Lucide-Symbole im bestehenden MPM-Icon-API. */
export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
{PATHS[name]}
</svg>
);
const Component = ICONS[name];
return <Component aria-hidden="true" className={className} strokeWidth={1.8} />;
}

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> {
label: string;
@@ -6,46 +8,42 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
hint?: string;
}
/** Text-Input mit Label, Fehler- und Hinweistext (Design-System). */
export function Input({
label,
error,
hint,
className = '',
id,
...rest
}: InputProps): ReactNode {
/** Basis-Input für Formulare, die ihr Label separat rendern. */
export const InputControl = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function InputControl({ className, ...rest }, ref) {
return (
<input
ref={ref}
data-slot="input"
className={cn(
'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 describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={inputId} className="text-sm font-medium text-slate-700">
{label}
</label>
<input
<Label htmlFor={inputId}>{label}</Label>
<InputControl
id={inputId}
className={`h-10 rounded-lg border px-3 text-sm transition-colors
${
error
? 'border-red-400 focus:border-red-500'
: 'border-slate-300 focus:border-brand-500'
}
${className}`}
className={cn(
error ? 'border-red-400 focus:border-red-500' : 'border-slate-300 focus:border-brand-500',
className,
)}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...rest}
/>
{hint && !error && (
<p id={`${inputId}-hint`} className="text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-600">
{error}
</p>
)}
{hint && !error && <p id={`${inputId}-hint`} className="text-xs text-slate-500">{hint}</p>}
{error && <p id={`${inputId}-error`} role="alert" className="text-xs text-red-600">{error}</p>}
</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 {
open: boolean;
@@ -11,56 +15,35 @@ export interface ModalProps {
hideHeader?: boolean;
}
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
export function Modal({
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;
}
/** Kompatibler MPM-Wrapper auf den shadcn/ui Dialog-Komponenten. */
export function Modal({ open, title, description, onClose, children, footer, panelClassName = 'max-w-md', hideHeader = false }: ModalProps): ReactNode {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onClick={onClose}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={`w-full rounded-xl bg-white shadow-xl ${panelClassName}`}
onClick={(event) => event.stopPropagation()}
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<DialogContent
showCloseButton={false}
className={cn('w-[calc(100%-2rem)] max-w-none gap-0 rounded-xl border-0 bg-white p-0 shadow-xl', panelClassName)}
>
{!hideHeader && (
<div className="border-b border-slate-200 px-6 py-4">
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
{hideHeader ? (
<>
<DialogTitle className="sr-only">{title}</DialogTitle>
{description && <DialogDescription className="sr-only">{description}</DialogDescription>}
</>
) : (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-4">
<div className="min-w-0">
<DialogTitle className="text-base font-semibold text-slate-900">{title}</DialogTitle>
{description && <DialogDescription className="mt-0.5 text-sm text-slate-500">{description}</DialogDescription>}
</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 className="px-6 py-4">{children}</div>
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
</div>
</div>
</DialogContent>
</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;
error?: 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,
className = '',
id,
...rest
}: SelectProps): ReactNode {
export function Select({ label, error, options, placeholder, id, ...props }: SelectProps): ReactNode {
const selectId = id ?? `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={selectId} className="text-sm font-medium text-slate-700">
{label}
</label>
<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>
)}
<Label htmlFor={selectId}>{label}</Label>
<SelectControl id={selectId} options={options} placeholder={placeholder} invalid={Boolean(error)} {...props} />
{error && <p role="alert" className="text-xs text-red-600">{error}</p>}
</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;
description?: string;
icon?: ReactNode;
action?: ReactNode;
}
/** 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 (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{icon && <div className="text-slate-300">{icon}</div>}
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
{action}
</div>
);
}
@@ -38,12 +40,14 @@ export function EmptyState({ title, description, icon }: EmptyStateProps): React
export interface ErrorStateProps {
title?: string;
message?: string;
action?: ReactNode;
}
/** Anzeige für Fehlerzustände (Design-System). */
export function ErrorState({
title = 'Ein Fehler ist aufgetreten',
message = 'Bitte versuchen Sie es später erneut.',
action,
}: ErrorStateProps): ReactNode {
return (
<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>
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="max-w-sm text-sm text-slate-500">{message}</p>
{action}
</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 {
type ReactNode,
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import { type ReactNode, createContext, useCallback, useContext, useMemo } from 'react';
import { Toaster, toast } from 'sonner';
/** Toast-Varianten. */
export type ToastVariant = 'success' | 'error';
interface ToastEntry {
readonly id: number;
readonly variant: ToastVariant;
readonly message: string;
}
interface ToastContextValue {
showToast: (variant: ToastVariant, message: string) => void;
}
interface ToastContextValue { showToast: (variant: ToastVariant, message: string, description?: ReactNode) => void }
const ToastContext = createContext<ToastContextValue | null>(null);
const VARIANT_CLASSES: Record<ToastVariant, string> = {
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.
*/
/** shadcn/ui Sonner-Toasts an derselben Position und mit derselben Dauer. */
export function ToastProvider({ children }: { children: ReactNode }): ReactNode {
const [toasts, setToasts] = useState<ToastEntry[]>([]);
const nextId = useRef(1);
const showToast = useCallback((variant: ToastVariant, message: string) => {
const id = nextId.current;
nextId.current += 1;
setToasts((current) => [...current, { id, variant, message }]);
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 4_000);
const showToast = useCallback((variant: ToastVariant, message: string, description?: ReactNode) => {
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 };
if (variant === 'success') toast.success(message, { ...options, className: `${className} !bg-emerald-600 !text-white` });
else toast.error(message, { ...options, className: `${className} !bg-red-600 !text-white` });
}, []);
const value = useMemo<ToastContextValue>(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div
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>
<Toaster position="bottom-right" duration={4000} closeButton={false} richColors={false} />
</ToastContext.Provider>
);
}
/** Zeigt eine Toast-Meldung an (wirft außerhalb des Providers). */
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
}
if (!context) throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
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 { apiRequest } from '../../lib/api-client';
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 { 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). */
const auditEntrySchema = z.object({
@@ -86,7 +87,7 @@ export function AuditPage(): ReactNode {
{/* Filter */}
<Card>
<CardBody>
<CardContent>
<form onSubmit={handleFilterSubmit} className="flex flex-wrap items-end gap-3" noValidate>
<div className="w-48">
<Input
@@ -108,55 +109,52 @@ export function AuditPage(): ReactNode {
Filtern
</Button>
</form>
</CardBody>
</CardContent>
</Card>
{/* Einträge */}
<Card>
<CardHeader
title="Ereignisse"
description={auditQuery.data ? `${auditQuery.data.total} Einträge insgesamt` : undefined}
/>
<CardBody>
<CardHeader>
<div><CardTitle>Ereignisse</CardTitle>{auditQuery.data && <CardDescription>{auditQuery.data.total} Einträge insgesamt</CardDescription>}</div>
</CardHeader>
<CardContent>
{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 && (
<p className="py-8 text-center text-sm text-slate-500">
Keine Einträge für die gewählten Filter.
</p>
<EmptyState title="Keine Audit-Einträge gefunden" description="Passe die Filter an oder entferne sie, um weitere Einträge zu sehen." />
)}
{auditQuery.data && auditQuery.data.entries.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr 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>
<th className="px-3 py-2 font-semibold">Benutzer</th>
<th className="px-3 py-2 font-semibold">Aktion</th>
<th className="px-3 py-2 font-semibold">Details</th>
</tr>
</thead>
<tbody>
<Table className="w-full min-w-[640px] text-sm">
<TableHeader>
<TableRow className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<TableHead className="px-3 py-2 font-semibold">Zeit</TableHead>
<TableHead className="px-3 py-2 font-semibold">Benutzer</TableHead>
<TableHead className="px-3 py-2 font-semibold">Aktion</TableHead>
<TableHead className="px-3 py-2 font-semibold">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditQuery.data.entries.map((entry) => (
<tr key={entry.id} className="border-b border-slate-100 last:border-0">
<td className="whitespace-nowrap px-3 py-2 text-slate-500">
<TableRow key={entry.id} className="border-b border-slate-100 last:border-0">
<TableCell className="whitespace-nowrap px-3 py-2 text-slate-500">
{formatTimestamp(entry.createdAt)}
</td>
<td className="px-3 py-2 font-medium text-slate-900">{entry.username}</td>
<td className="px-3 py-2">
</TableCell>
<TableCell className="px-3 py-2 font-medium text-slate-900">{entry.username}</TableCell>
<TableCell className="px-3 py-2">
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
{entry.action}
</code>
</td>
<td className="px-3 py-2 text-xs text-slate-500">
</TableCell>
<TableCell className="px-3 py-2 text-xs text-slate-500">
{Object.keys(entry.details).length > 0
? JSON.stringify(entry.details)
: '–'}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
)}
@@ -184,7 +182,7 @@ export function AuditPage(): ReactNode {
</Button>
</div>
)}
</CardBody>
</CardContent>
</Card>
</div>
);

View File

@@ -1,8 +1,18 @@
import { type ReactNode, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
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 { InputControl } from '../../components/ui/input';
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 { ApiError } from '../../lib/api-client';
import {
@@ -12,14 +22,20 @@ import {
fetchMarketplaceRepositories,
fetchMarketplaceProviders,
fetchModules,
fetchModuleConfiguration,
installModule,
installMarketplaceRepository,
fetchMarketplaceUpdates,
fetchMarketplaceOperationProgress,
updateMarketplaceModule,
removeModule,
restartModule,
setModuleEnabled,
saveModuleConfiguration,
startModule,
stopModule,
type MarketplaceProvider,
type MarketplaceUpdateState,
} from '../../lib/modules-api';
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. */
function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null);
const [formDiagnostic, setFormDiagnostic] = useState<string | null>(null);
const removeMutation = useMutation({
mutationFn: () => removeModule(module.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['modules'] });
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
showToast('success', `Modul "${module.name}" wurde entfernt`);
onSuccess: async (result) => {
await Promise.all([
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`);
}
onClose();
},
onError: (error) => {
onError: async (error) => {
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
open
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}
>
{formError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
<div role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
<p>{formError}</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">
<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. */
export function ModulesPage(): ReactNode {
const { showToast } = useToast();
@@ -94,6 +257,8 @@ export function ModulesPage(): ReactNode {
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | 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 [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers');
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
@@ -101,12 +266,20 @@ export function ModulesPage(): ReactNode {
const modulesQuery = useQuery({
queryKey: ['modules'],
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({
queryKey: ['marketplace-providers'],
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 repositoriesQuery = useQuery({
queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)],
@@ -135,30 +308,36 @@ export function ModulesPage(): ReactNode {
const marketplaceInstallMutation = useMutation({
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
installMarketplaceRepository(input.provider, input.owner, input.repository),
onSuccess: (module) => {
void queryClient.invalidateQueries({ queryKey: ['modules'] });
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
onSuccess: async (module) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }),
]);
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 => {
void queryClient.invalidateQueries({ queryKey: ['modules'] });
const invalidate = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['modules'] });
};
const installMutation = useMutation({
mutationFn: (file: File) => installModule(file),
onSuccess: (module) => {
onSuccess: async (module) => {
showToast('success', `Modul "${module.name}" wurde installiert`);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
invalidate();
await invalidate();
},
onError: (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen');
onError: async (error) => {
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);
}
},
onSuccess: (module, variables) => {
onSuccess: async (module, variables) => {
showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`);
invalidate();
await invalidate();
},
onError: (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen');
invalidate();
onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen', operationToastDetails(error));
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 {
setSelectedFile(event.target.files?.[0] ?? null);
}
@@ -242,18 +456,18 @@ export function ModulesPage(): ReactNode {
<div className="space-y-6">
{/* 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>
<div role="tablist" aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
<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'}`}>
<Tabs value={connectionTab} onValueChange={(value) => setConnectionTab(value as 'providers' | 'manual')}>
<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
</button>
<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>
<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
</button>
</div>
{connectionTab === 'providers' ? (
<div className="grid grid-cols-1">
</TabsTrigger>
</TabsList>
<TabsContent value="providers" className="grid grid-cols-1">
{(['github', 'gitea', 'forgejo'] as const).map((provider) => {
const connection = marketplaceQuery.data?.find((item) => item.provider === provider);
const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo');
@@ -301,87 +515,106 @@ export function ModulesPage(): ReactNode {
</div>
);
})}
</div>
) : (
<div role="tabpanel" className="flex flex-wrap items-center gap-3">
<input
</TabsContent>
<TabsContent value="manual" className="flex flex-wrap items-center gap-3">
<InputControl
ref={fileInputRef}
type="file"
accept=".zip"
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"
/>
<Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}>
Installieren
</Button>
<p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p>
</div>
)}
</section>
</TabsContent>
</Tabs>
</Card>
{/* 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>
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200">
<table className="w-full min-w-[900px] table-fixed text-sm">
<thead className="sticky top-0 z-10">
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="w-[34%] px-4 py-3 font-semibold">Modul</th>
<th className="w-[13%] px-4 py-3 font-semibold">Status</th>
<th className="w-[15%] px-4 py-3 font-semibold">URL</th>
<th className="w-[38%] px-4 py-3 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{modulesQuery.isLoading && <tr><td colSpan={4} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</td></tr>}
{modulesQuery.isError && <tr><td colSpan={4} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</td></tr>}
{modulesQuery.data?.map((module) => (
<tr key={module.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<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>
{module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>}
</td>
<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>
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}
</td>
<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>
<td className="px-4 py-3"><div className="flex flex-wrap gap-1">
{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>}
{(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>
<Button size="sm" variant="ghost" onClick={() => healthMutation.mutate(module)}>Health</Button>
<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>
</section>
{modulesQuery.isLoading && <div className="space-y-3" role="status" aria-label="Module werden geladen">
{[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>)}
</div>}
{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>} />}
{modulesQuery.data && modulesQuery.data.length === 0 && <EmptyState title="Noch keine Module installiert" description="Verbinde ein Forge-Konto oder installiere ein Modul als ZIP." />}
{modulesQuery.data && modulesQuery.data.length > 0 && <>
<div className="space-y-3 xl:hidden">
{modulesQuery.data.map((module) => {
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
return <article key={module.id} className="space-y-4 rounded-xl border border-slate-200 p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate font-semibold text-slate-900">{module.name}</h3>
<p className="break-words text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</p>
</div>
<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="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>}
</TableCell>
<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>
<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>
<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>
<TableCell className="align-top px-4 py-3 text-center">{renderModuleUpdates(module)}</TableCell>
<TableCell className="align-top px-4 py-3"><div className="flex justify-center">{renderModuleActions(module)}</div></TableCell>
</TableRow>;
})}
</TableBody>
</Table>
</div>
</>}
</Card>
</div>
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<Card className="p-5">
<div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Marketplace</h2>
<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.
</p>
</div>
{connectedProviders.length === 0 && (
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
Verbinde zuerst GitHub, Gitea oder Forgejo.
</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>}
{connectedProviders.length === 0 && <EmptyState title="Kein Forge-Konto verbunden" description="Verbinde zuerst GitHub, Gitea oder Forgejo, um Repositories zu installieren." />}
{repositoriesQuery.isLoading && <Spinner label="Repositories werden geladen…" className="py-8" />}
{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>} />}
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
{repositoriesQuery.data?.map((provider) => (
<div key={provider.provider}>
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
{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">
{provider.repositories.map((repo) => (
@@ -413,10 +646,16 @@ export function ModulesPage(): ReactNode {
</div>
))}
</div>
</section>
</Card>
{removeTarget && (
<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 && (
<ConnectProviderModal
provider={connectTarget}
@@ -471,14 +710,15 @@ function ConnectProviderModal({
panelClassName="max-w-xl overflow-hidden rounded-[28px]"
>
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
<button
<Button
variant="ghost"
type="button"
aria-label="Dialog schließen"
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" />
</button>
</Button>
<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">

View File

@@ -2,9 +2,10 @@ import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '../../lib/api-client';
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 { 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). */
const systemStatusSchema = z.object({
@@ -54,13 +55,12 @@ export function SystemStatusPage(): ReactNode {
</div>
<Card>
<CardHeader title="Gesamtstatus" />
<CardBody>
<CardHeader><CardTitle>Gesamtstatus</CardTitle></CardHeader>
<CardContent>
{statusQuery.isLoading && <Spinner />}
{statusQuery.isError && (
<ErrorState message="Der Systemstatus ist nicht erreichbar." />
)}
{statusQuery.data && (
{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>} />}
{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 && (
<div className="flex items-center gap-3">
<Badge
variant={
@@ -78,12 +78,12 @@ export function SystemStatusPage(): ReactNode {
</span>
</div>
)}
</CardBody>
</CardContent>
</Card>
<Card>
<CardHeader title="Komponenten" />
<CardBody>
<CardHeader><CardTitle>Komponenten</CardTitle></CardHeader>
<CardContent>
{statusQuery.data && (
<div className="space-y-3">
{statusQuery.data.components.map((component) => (
@@ -100,7 +100,7 @@ export function SystemStatusPage(): ReactNode {
))}
</div>
)}
</CardBody>
</CardContent>
</Card>
</div>
);

View File

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

View File

@@ -2,10 +2,14 @@ import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context';
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 { Modal } from '../../components/ui/modal';
import { Select } from '../../components/ui/select';
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 {
createUser,
@@ -401,64 +405,47 @@ export function UsersPage(): ReactNode {
<Button onClick={() => setCreateOpen(true)}>+ Benutzer anlegen</Button>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr 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>
<th className="px-4 py-3 font-semibold">Rolle</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">Letzter Login</th>
<th className="px-4 py-3 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
<Card className="overflow-x-auto rounded-xl">
<Table className="w-full min-w-[720px] text-sm">
<TableHeader>
<TableRow className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<TableHead className="px-4 py-3 font-semibold">Benutzer</TableHead>
<TableHead className="px-4 py-3 font-semibold">Rolle</TableHead>
<TableHead className="px-4 py-3 font-semibold">Status</TableHead>
<TableHead className="px-4 py-3 font-semibold">Letzter Login</TableHead>
<TableHead className="px-4 py-3 font-semibold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.isLoading && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
Benutzer werden geladen…
</td>
</tr>
<TableRow>
<TableCell colSpan={5}><Spinner label="Benutzer werden geladen…" className="py-8" /></TableCell>
</TableRow>
)}
{usersQuery.isError && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-red-600">
Benutzer konnten nicht geladen werden.
</td>
</tr>
<TableRow>
<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>
</TableRow>
)}
{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) => (
<tr key={user.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<TableRow key={user.id} className="border-b border-slate-100 last:border-0">
<TableCell className="px-4 py-3">
<div className="font-medium text-slate-900">{user.displayName}</div>
<div className="text-xs text-slate-500">@{user.username}</div>
</td>
<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
${
user.role === 'ADMIN'
? 'bg-brand-50 text-brand-700 ring-brand-600/20'
: 'bg-slate-100 text-slate-600 ring-slate-500/20'
}`}
>
</TableCell>
<TableCell className="px-4 py-3">
<Badge variant={user.role === 'ADMIN' ? 'info' : 'neutral'}>
{user.role === 'ADMIN' ? 'Admin' : 'Benutzer'}
</span>
</td>
<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
${
user.isActive
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
: 'bg-red-50 text-red-700 ring-red-600/20'
}`}
>
</Badge>
</TableCell>
<TableCell className="px-4 py-3">
<Badge variant={user.isActive ? 'success' : 'danger'}>
{user.isActive ? 'Aktiv' : 'Deaktiviert'}
</span>
</td>
<td className="px-4 py-3 text-slate-500">{formatDate(user.lastLoginAt)}</td>
<td className="px-4 py-3">
</Badge>
</TableCell>
<TableCell className="px-4 py-3 text-slate-500">{formatDate(user.lastLoginAt)}</TableCell>
<TableCell className="px-4 py-3">
<div className="flex flex-wrap gap-1">
<Button size="sm" variant="ghost" onClick={() => setEditUser(user)}>
Bearbeiten
@@ -488,17 +475,17 @@ export function UsersPage(): ReactNode {
</>
)}
</div>
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
{usersQuery.data?.length === 0 && (
<p className="px-4 py-8 text-center text-slate-500">
Noch keine Benutzer vorhanden.
</p>
)}
</div>
</Card>
{createOpen && (
<CreateUserModal

View File

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

View File

@@ -4,7 +4,7 @@ import { useAuth } from '../auth/auth-context';
import { apiRequest } from '../../lib/api-client';
import { fetchAccessibleModules } from '../../lib/modules-api';
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 { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { Icon } from '../../components/ui/icon';
@@ -41,11 +41,10 @@ export function DashboardPage(): ReactNode {
{/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */}
<Card>
<CardHeader
title="Meine Anwendungen"
description="Freigegebene Module der Plattform"
/>
<CardBody>
<CardHeader>
<div><CardTitle>Meine Anwendungen</CardTitle><CardDescription>Freigegebene Module der Plattform</CardDescription></div>
</CardHeader>
<CardContent>
{modulesQuery.isLoading && <Spinner label="Anwendungen werden geladen…" />}
{modulesQuery.isError && (
<ErrorState
@@ -65,7 +64,9 @@ export function DashboardPage(): ReactNode {
{modulesQuery.data.map((module) => (
<a
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"
>
<div className="flex items-start justify-between">
@@ -88,17 +89,16 @@ export function DashboardPage(): ReactNode {
))}
</div>
)}
</CardBody>
</CardContent>
</Card>
{/* Systemstatus (nur für Admins) */}
{isAdmin && (
<Card>
<CardHeader
title="Systemstatus"
description="Live-Status der Plattform-Komponenten"
/>
<CardBody>
<CardHeader>
<div><CardTitle>Systemstatus</CardTitle><CardDescription>Live-Status der Plattform-Komponenten</CardDescription></div>
</CardHeader>
<CardContent>
{healthQuery.isLoading && <Spinner label="Status wird geladen…" />}
{healthQuery.isError && (
<ErrorState
@@ -128,7 +128,7 @@ export function DashboardPage(): ReactNode {
/>
</div>
)}
</CardBody>
</CardContent>
</Card>
)}
</div>

View File

@@ -1,7 +1,7 @@
import { type FormEvent, type ReactNode, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
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 { Modal } from '../../components/ui/modal';
import { useToast } from '../../components/ui/toast';
@@ -77,8 +77,8 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
>
<div className="space-y-5">
<Card>
<CardHeader title="Persönliche Daten" />
<CardBody>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardContent>
{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>}
{profile && (
@@ -91,12 +91,12 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
<ProfileField label="Mitglied seit" value={formatDate(profile.createdAt)} />
</dl>
)}
</CardBody>
</CardContent>
</Card>
<Card>
<CardHeader title="Passwort ändern" description="Andere aktive Sitzungen werden danach abgemeldet." />
<CardBody>
<CardHeader><div><CardTitle>Passwort ändern</CardTitle><CardDescription>Andere aktive Sitzungen werden danach abgemeldet.</CardDescription></div></CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<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} />
@@ -106,7 +106,7 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
<Button type="submit" loading={changePasswordMutation.isPending}>Passwort ändern</Button>
</div>
</form>
</CardBody>
</CardContent>
</Card>
</div>
</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 "tw-animate-css";
@custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *));
/* =============================================================
MPM Design-System – zentrale Design-Tokens (Tailwind v4)
@@ -19,6 +22,72 @@
--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) */
@layer base {
:focus-visible {
@@ -31,6 +100,33 @@
Markenakzente werden neutral statt blau. */
html[data-theme='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-100: #3c4044;
--color-slate-200: #474b50;

View File

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

View File

@@ -3,9 +3,11 @@ import {
accessibleModuleSchema,
modulePermissionSchema,
moduleSchema,
moduleConfigurationSchema,
type AccessibleModule,
type Module,
type ModulePermission,
type ModuleConfiguration,
} from './schemas';
/** Typsichere API-Funktionen für die Modul-Verwaltung. */
@@ -35,10 +37,14 @@ export async function installModule(file: File): Promise<Module> {
if (!response.ok) {
let message = 'Installation fehlgeschlagen';
let details: Record<string, string | string[]> | undefined;
let code: string | undefined;
let diagnostic: string | undefined;
try {
const errorBody = (await response.json()) as {
message?: string | string[];
details?: Record<string, string | string[]>;
code?: string;
diagnostic?: string;
};
if (typeof errorBody.message === 'string') {
message = errorBody.message;
@@ -46,10 +52,12 @@ export async function installModule(file: File): Promise<Module> {
message = errorBody.message.join(', ');
}
details = errorBody.details;
code = errorBody.code;
diagnostic = errorBody.diagnostic;
} catch {
// 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 };
@@ -85,8 +93,21 @@ export async function setModuleEnabled(id: string, enabled: boolean): Promise<Mo
return moduleSchema.parse(response.module);
}
export async function removeModule(id: string): Promise<void> {
await apiRequest(`/api/v1/modules/${id}`, { method: 'DELETE' });
export async function removeModule(id: string): Promise<{ cleanupWarning?: string }> {
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(
@@ -124,6 +145,9 @@ function readCsrfToken(): string | null {
let csrfToken: string | null = null;
for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('=');
if (name === '__Host-mpm_csrf') {
return decodeURIComponent(value.join('='));
}
if (name === 'mpm_csrf') {
csrfToken = decodeURIComponent(value.join('='));
}
@@ -166,6 +190,35 @@ export async function installMarketplaceRepository(
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[]> {
return apiRequest<MarketplaceProvider[]>('/api/v1/marketplace/providers');
}

View File

@@ -134,9 +134,25 @@ export const moduleSchema = z.object({
healthcheckUrl: z.string(),
enabled: z.boolean(),
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 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). */
export const accessibleModuleSchema = z.object({
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,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}

View File

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

View File

@@ -45,10 +45,12 @@ services:
DOCKER_SOCKET_GID: ${DOCKER_SOCKET_GID:-0}
MODULES_DIR: /app/data/modules
LOGS_DIR: /app/data/logs
MODULE_CONFIG_ENCRYPTION_KEY: ${MODULE_CONFIG_ENCRYPTION_KEY:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL 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}}
MODULE_PUBLIC_ORIGIN: ${MODULE_PUBLIC_ORIGIN:-}
MARKETPLACE_TOKEN_ENCRYPTION_KEY: ${MARKETPLACE_TOKEN_ENCRYPTION_KEY:-}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}

View File

@@ -3,7 +3,8 @@
# Master has restricted capabilities; workers run as unprivileged user app.
# - / -> Management-Frontend (SPA, statische Dateien)
# - /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;
@@ -43,7 +44,7 @@ http {
}
server {
listen 8080;
listen 8080 default_server;
server_name _;
root /app/public;
@@ -62,30 +63,50 @@ http {
}
# 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/ {
proxy_pass http://platform_backend;
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-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
# Modul-Routing (Phase 4): /slug/* wird intern an den
# Modul-Gateway des Backends übergeben (/api/v1/gateway/slug/*).
# 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.
# Alte Modul-Links auf dem Plattformhost führen über das Einmal-Ticket
# zum getrennten Modulhost. Hier wird kein Modul-JavaScript ausgeliefert.
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_set_header Host $host;
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_read_timeout 30s;
}
# SPA-Fallback für React Router
@@ -93,4 +114,51 @@ http {
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
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf
command=/usr/local/bin/start-mpm-nginx
autorestart=true
startretries=5
stopsignal=QUIT

View File

@@ -84,14 +84,15 @@ Browser ──▶ Nginx ──▶ SessionGuard (Session gültig? User aktiv?)
### 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)
├── Permission Check (user_module_permissions)
├── DENIED → 403
└── 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)

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.
- 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
@@ -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.
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.
- 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.
@@ -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.
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
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