1 Commits

Author SHA1 Message Date
leon
fd9de823ea MPM marketplace and UI updates 2026-10-08 21:13:57 +02:00
66 changed files with 5187 additions and 749 deletions

View File

@@ -30,6 +30,10 @@ BEHIND_PROXY=true
# MPM braucht den Socket, um eigene Modul-Container zu verwalten. # MPM braucht den Socket, um eigene Modul-Container zu verwalten.
DOCKER_SOCKET_GID=0 DOCKER_SOCKET_GID=0
# Schlüssel für verschlüsselte Modulkonfigurationen (mindestens 32 Zeichen).
# Lokal dauerhaft generieren und geheim halten.
MODULE_CONFIG_ENCRYPTION_KEY=
# GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0). # GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0).
# MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten. # MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten.
DOCKER_SOCKET_GID=0 DOCKER_SOCKET_GID=0

View File

@@ -36,6 +36,7 @@ Für lokale Frontend-/Backend-Entwicklung außerhalb von Docker zusätzlich Node
5. Secrets dieses Arbeitsplatzes getrennt halten. Keine Passwörter, OAuth-Secrets, Zugriffstokens, Cookies oder privaten Schlüssel in Quellcode, Dokumentation, Kommandoausgaben, Commits oder Issues übernehmen. Beispielwerte in `.env.example` sind Platzhalter. 5. Secrets dieses Arbeitsplatzes getrennt halten. Keine Passwörter, OAuth-Secrets, Zugriffstokens, Cookies oder privaten Schlüssel in Quellcode, Dokumentation, Kommandoausgaben, Commits oder Issues übernehmen. Beispielwerte in `.env.example` sind Platzhalter.
6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden. 6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden.
7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten. 7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten.
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`. 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`.
9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen. 9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen.
@@ -96,7 +97,7 @@ Der Backendprozess benötigt gültige Variablen aus `.env`; beim lokalen Start m
- Datenbankänderungen als neue Migration ergänzen; bestehende Migrationen nicht nachträglich umschreiben, wenn sie schon angewendet sein könnten. - Datenbankänderungen als neue Migration ergänzen; bestehende Migrationen nicht nachträglich umschreiben, wenn sie schon angewendet sein könnten.
- UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten. - UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten.
- Abhängigkeiten nur bei Bedarf ändern und Lockfiles konsistent halten. - Abhängigkeiten nur bei Bedarf ändern und Lockfiles konsistent halten.
- Keine Builds, Tests, Deployments, Commits oder Pushes ausführen, wenn der Nutzer das nicht angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben. - Nach Codeänderungen, die den lokalen Plattformcontainer betreffen, diesen neu bauen und starten, damit der Container die aktuellen Änderungen erhält. Tests, externe Deployments, Commits und Pushes nur ausführen, wenn der Nutzer sie angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben.
- Bei einem gewünschten Push Ziel-Remote und Branch verifizieren, den kompletten Commit-Diff auf Secrets prüfen und keine Force-Pushes ausführen, außer der Nutzer weist sie ausdrücklich an. - Bei einem gewünschten Push Ziel-Remote und Branch verifizieren, den kompletten Commit-Diff auf Secrets prüfen und keine Force-Pushes ausführen, außer der Nutzer weist sie ausdrücklich an.
## Häufige Arbeitsplatzprobleme ## Häufige Arbeitsplatzprobleme

View File

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

View File

@@ -25,7 +25,7 @@ function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig
...overrides, ...overrides,
}, },
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
}; };
} }

View File

@@ -35,6 +35,7 @@ export interface RuntimeConfig {
readonly modulesDir: string; readonly modulesDir: string;
readonly logsDir: string; readonly logsDir: string;
readonly moduleUidBase?: number; readonly moduleUidBase?: number;
readonly moduleConfigurationEncryptionKey: string;
} }
export interface MarketplaceProviderConfig { export interface MarketplaceProviderConfig {
@@ -85,6 +86,7 @@ const environmentSchema = z.object({
MODULES_DIR: z.string().min(1).default('./data/modules'), MODULES_DIR: z.string().min(1).default('./data/modules'),
MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'), MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'),
LOGS_DIR: z.string().min(1).default('./data/logs'), LOGS_DIR: z.string().min(1).default('./data/logs'),
MODULE_CONFIG_ENCRYPTION_KEY: z.string().default(''),
MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(), MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(),
MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'), MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'),
MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''), MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''),
@@ -184,6 +186,7 @@ export function loadConfiguration(): AppConfig {
runtime: { runtime: {
modulesDir: path.resolve(environment.MODULES_DIR), modulesDir: path.resolve(environment.MODULES_DIR),
logsDir: path.resolve(environment.LOGS_DIR), logsDir: path.resolve(environment.LOGS_DIR),
moduleConfigurationEncryptionKey: environment.MODULE_CONFIG_ENCRYPTION_KEY,
...(environment.MODULE_UID_BASE !== undefined ...(environment.MODULE_UID_BASE !== undefined
? { moduleUidBase: environment.MODULE_UID_BASE } ? { moduleUidBase: environment.MODULE_UID_BASE }
: {}), : {}),

View File

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

View File

@@ -16,6 +16,40 @@ export type ModuleStatus = (typeof MODULE_STATUSES)[number];
export const MODULE_PORT_MIN = 41000; export const MODULE_PORT_MIN = 41000;
export const MODULE_PORT_MAX = 41999; export const MODULE_PORT_MAX = 41999;
/** Vom Modul deklarierte, durch Admins setzbare Compose-Umgebungsvariable. */
export const moduleConfigurationFieldSchema = z.object({
key: z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/),
label: z.string().trim().min(1).max(100),
description: z.string().max(500).default(''),
type: z.enum(['text', 'url', 'boolean']).default('text'),
secret: z.boolean().default(false),
required: z.boolean().default(true),
defaultValue: z.string().max(8192).optional(),
services: z.array(z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/)).min(1).max(20),
}).superRefine((field, context) => {
if (field.secret && field.defaultValue !== undefined) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Geheimnisse dürfen keinen Manifest-Standardwert haben' });
}
if (field.required && field.defaultValue !== undefined && !field.defaultValue.trim()) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Pflichtfelder benötigen einen nicht leeren Standardwert' });
}
if (field.type === 'boolean' && field.defaultValue !== undefined && !['true', 'false'].includes(field.defaultValue)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Boolean-Standardwerte müssen true oder false sein' });
}
if (field.type === 'url' && field.defaultValue !== undefined) {
try {
if (!['http:', 'https:'].includes(new URL(field.defaultValue).protocol)) throw new Error('protocol');
} catch {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'URL-Standardwerte müssen HTTP oder HTTPS verwenden' });
}
}
const processEnvironmentKey = /^(PATH|HOME|TMPDIR|PORT|NODE_ENV|MPM_MODULE_DATA_DIR|MPM_MODULE_IDENTITY_KEY|DOCKER_.*|COMPOSE_.*|NODE_.*|NPM_.*|PYTHON.*|BASH_.*|LD_.*|DYLD_.*|RUBY.*|PERL.*|GIT_.*|SSH_AUTH_SOCK)$/;
if (processEnvironmentKey.test(field.key)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['key'], message: 'Dieser Umgebungsvariablenname ist für MPM reserviert' });
}
});
export type ModuleConfigurationField = z.infer<typeof moduleConfigurationFieldSchema>;
/** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */ /** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */
const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/; const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/;
@@ -54,6 +88,15 @@ export const moduleManifestSchema = z.object({
apiVersion: z.literal('v1'), apiVersion: z.literal('v1'),
composeFile: z.string().min(1).max(200).optional(), composeFile: z.string().min(1).max(200).optional(),
appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(), appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(),
configuration: z.array(moduleConfigurationFieldSchema).max(50).default([]),
}).superRefine((manifest, context) => {
const keys = new Set<string>();
manifest.configuration.forEach((field, index) => {
if (keys.has(field.key)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['configuration', index, 'key'], message: 'Konfigurationsschlüssel dürfen nicht doppelt vorkommen' });
}
keys.add(field.key);
});
}); });
export type ModuleManifest = z.infer<typeof moduleManifestSchema>; export type ModuleManifest = z.infer<typeof moduleManifestSchema>;
@@ -75,4 +118,6 @@ export interface ModuleRecord {
readonly updatedAt: Date; readonly updatedAt: Date;
readonly composeFile?: string | null; readonly composeFile?: string | null;
readonly appService?: string | null; readonly appService?: string | null;
readonly configuration: readonly ModuleConfigurationField[];
readonly configurationReady: boolean;
} }

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common'; import { BadRequestException, Body, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -9,6 +9,7 @@ import { SessionService } from '../auth/session.service';
import type { AuthenticatedRequest } from '../auth/authenticated-request'; import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { MarketplaceService } from './marketplace.service'; import { MarketplaceService } from './marketplace.service';
import { ModulesService } from './modules.service'; import { ModulesService } from './modules.service';
import type { ModuleRecord } from './manifest.types';
@ApiTags('Marketplace') @ApiTags('Marketplace')
@Controller({ path: 'api/v1/marketplace' }) @Controller({ path: 'api/v1/marketplace' })
@@ -42,12 +43,14 @@ export class MarketplaceController {
): Promise<{ module: { ): Promise<{ module: {
id: string; moduleId: string; name: string; slug: string; version: string; description: string; id: string; moduleId: string; name: string; slug: string; version: string; description: string;
author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string; author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string;
configuration: ModuleRecord['configuration']; configurationReady: boolean;
} }> { } }> {
const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository); const branch = await this.marketplaceService.defaultBranch(provider, owner, repository);
const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository, branch);
const manifest = await this.modulesService.validatePackage(archive); const manifest = await this.modulesService.validatePackage(archive);
const module = await this.modulesService.findByModuleId(manifest.id) ?? const module = await this.modulesService.findByModuleId(manifest.id) ??
await this.modulesService.install(archive, actor, request.ip ?? null); await this.modulesService.install(archive, actor, request.ip ?? null);
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id); await this.marketplaceService.recordInstallation(provider, owner, repository, module.id, branch);
return { return {
module: { module: {
id: module.id, id: module.id,
@@ -62,10 +65,33 @@ export class MarketplaceController {
healthcheckUrl: module.healthcheckUrl, healthcheckUrl: module.healthcheckUrl,
enabled: module.enabled, enabled: module.enabled,
createdAt: module.createdAt.toISOString(), createdAt: module.createdAt.toISOString(),
configuration: module.configuration,
configurationReady: module.configurationReady,
}, },
}; };
} }
@Get('modules/:moduleId/updates')
@Roles('ADMIN')
updates(@Param('moduleId') moduleId: string) {
return this.marketplaceService.availableUpdates(moduleId);
}
@Post('modules/:moduleId/update')
@Roles('ADMIN')
async updateModule(
@Param('moduleId') moduleId: string,
@Body() body: { branch?: 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 update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch);
const module = await this.modulesService.updateFromMarketplace(moduleId, update.archive, actor, request.ip ?? null);
await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit);
return { module };
}
@Post('connections/:provider/start') @Post('connections/:provider/start')
@Roles('ADMIN') @Roles('ADMIN')
async startConnection( async startConnection(

View File

@@ -3,8 +3,11 @@ import {
BadRequestException, BadRequestException,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
Logger,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { createCipheriv, createHash, randomBytes } from 'node:crypto'; import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
@@ -37,6 +40,31 @@ interface ProviderIdentity {
readonly username?: string; readonly username?: string;
} }
export interface MarketplaceBranch {
name: string;
commit: string;
}
export interface MarketplaceUpdatePackage {
archive: Buffer;
commit: string;
provider: MarketplaceProvider;
owner: string;
repository: string;
branch: string;
}
interface MarketplaceInstallationRow {
module_id: string;
provider: MarketplaceProvider;
owner: string;
repository: string;
installed_branch: string;
installed_commit: string | null;
available_branches: MarketplaceBranch[];
observed_branches: MarketplaceBranch[];
}
const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024; const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024;
function isProvider(value: string): value is MarketplaceProvider { function isProvider(value: string): value is MarketplaceProvider {
@@ -54,13 +82,47 @@ function safeBaseUrl(value: string): string {
return url.toString().replace(/\/$/, ''); return url.toString().replace(/\/$/, '');
} }
function branchVersion(branch: string): number[] | null {
const match = branch.match(/(?:^|[-_/.])v?(\d+(?:\.\d+)+)$/i);
return match ? match[1].split('.').map(Number) : null;
}
function isNewerVersionBranch(candidate: string, installed: string): boolean {
const candidateVersion = branchVersion(candidate);
const installedVersion = branchVersion(installed);
// Preserve support for repositories that use non-versioned release branch
// names. When both names carry versions, only offer a strictly newer one.
if (!candidateVersion || !installedVersion) return true;
const length = Math.max(candidateVersion.length, installedVersion.length);
for (let index = 0; index < length; index += 1) {
const candidatePart = candidateVersion[index] ?? 0;
const installedPart = installedVersion[index] ?? 0;
if (candidatePart !== installedPart) return candidatePart > installedPart;
}
return false;
}
@Injectable() @Injectable()
export class MarketplaceService { export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MarketplaceService.name);
private branchCheckTimer: NodeJS.Timeout | undefined;
constructor( constructor(
private readonly database: DatabaseService, private readonly database: DatabaseService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
onModuleInit(): void {
const initialCheck = setTimeout(() => void this.refreshAllBranchSnapshots(), 10_000);
initialCheck.unref();
this.branchCheckTimer = setInterval(() => void this.refreshAllBranchSnapshots(), 60 * 1000);
this.branchCheckTimer.unref();
}
onModuleDestroy(): void {
if (this.branchCheckTimer) clearInterval(this.branchCheckTimer);
}
async providers(): Promise<ProviderStatus[]> { async providers(): Promise<ProviderStatus[]> {
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>( const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
'SELECT provider, account_login FROM marketplace_connections', 'SELECT provider, account_login FROM marketplace_connections',
@@ -204,17 +266,56 @@ 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): Promise<void> {
const provider = this.requireProvider(providerParam); const provider = this.requireProvider(providerParam);
const branchInfo = await this.getBranch(provider, owner, repository, branch);
const branches = await this.fetchBranches(provider, owner, repository);
await this.database.query( await this.database.query(
`INSERT INTO marketplace_module_installations (provider, owner, repository, module_id) `INSERT INTO marketplace_module_installations
VALUES ($1, $2, $3, $4) (provider, owner, repository, module_id, installed_branch, installed_commit, available_branches, observed_branches, branches_checked_at)
ON CONFLICT (provider, owner, repository) DO UPDATE SET module_id = EXCLUDED.module_id`, VALUES ($1, $2, $3, $4, $5, $6, '[]'::jsonb, $7::jsonb, now())
[provider, owner, repository, moduleId], ON CONFLICT (provider, owner, repository) DO UPDATE SET
module_id = EXCLUDED.module_id, installed_branch = EXCLUDED.installed_branch,
installed_commit = EXCLUDED.installed_commit, available_branches = '[]'::jsonb,
observed_branches = EXCLUDED.observed_branches, branches_checked_at = now()`,
[provider, owner, repository, moduleId, branch, branchInfo.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): Promise<Buffer> {
const provider = this.requireProvider(providerParam); const provider = this.requireProvider(providerParam);
if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) { if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) {
throw new BadRequestException('Repository-Angabe ist ungueltig'); throw new BadRequestException('Repository-Angabe ist ungueltig');
@@ -228,11 +329,12 @@ export class MarketplaceService {
this.repositoryApiPath(provider, owner, repository), this.repositoryApiPath(provider, owner, repository),
); );
if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt'); if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt');
const defaultBranch = String(repo.default_branch ?? 'main'); const selectedBranch = branch ?? String(repo.default_branch ?? 'main');
if (!defaultBranch || defaultBranch.length > 200) throw new BadRequestException('Standard-Branch ist ungueltig'); if (!selectedBranch || selectedBranch.length > 200 || selectedBranch.includes('\0')) throw new BadRequestException('Branch ist ungueltig');
if (branch) await this.getBranch(provider, owner, repository, branch);
const archivePath = provider === 'github' const archivePath = provider === 'github'
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(defaultBranch) ? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(selectedBranch)
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(defaultBranch) + '.zip'; : new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(selectedBranch) + '.zip';
const archiveUrl = provider === 'github' const archiveUrl = provider === 'github'
? new URL(archivePath, 'https://api.github.com').toString() ? new URL(archivePath, 'https://api.github.com').toString()
: new URL(archivePath, providerConfig.baseUrl).toString(); : new URL(archivePath, providerConfig.baseUrl).toString();
@@ -242,6 +344,116 @@ export class MarketplaceService {
return this.normalizeRepositoryArchive(archive); return this.normalizeRepositoryArchive(archive);
} }
async updateInstalledBranch(moduleId: string, branch: string): 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');
}
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');
}
const archive = await this.downloadRepositoryArchive(installation.provider, installation.owner, installation.repository, branch);
// 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 };
}
async commitInstalledBranch(moduleId: string, provider: MarketplaceProvider, owner: string, repository: string, branch: string, commit: string): Promise<void> {
await this.database.query(
`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`,
[moduleId, branch, commit, provider, owner, repository],
);
await this.refreshInstallation(moduleId);
}
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 (!sha) throw new NotFoundException('Branch konnte beim Forge nicht gefunden werden');
return { name: String(value.name ?? branch), commit: sha };
}
private async refreshAllBranchSnapshots(): Promise<void> {
try {
const result = await this.database.query<{ module_id: string }>('SELECT module_id FROM marketplace_module_installations');
for (const row of result.rows) {
try { await this.refreshInstallation(row.module_id); }
catch (error) { this.logger.warn(`Branch-Prüfung für Modul ${row.module_id} fehlgeschlagen: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`); }
}
} catch (error) {
this.logger.warn(`Tägliche Marketplace-Branch-Prüfung nicht verfügbar: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`);
}
}
private async refreshInstallation(moduleId: string): Promise<void> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches, observed_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
);
const row = result.rows[0];
if (!row) return;
const providerConfig = this.config.marketplace.providers[row.provider];
if (!providerConfig) return;
const branches = await this.fetchBranches(row.provider, row.owner, row.repository);
// Every branch other than the installed one is a selectable tested version.
// Do not rely on whether it existed when this installation was first recorded:
// removing and reinstalling a module must not hide an available release branch.
const installedCommit = row.installed_commit?.toLowerCase();
const candidates = branches.filter((branch) => branch.name !== 'main'
&& branch.name !== row.installed_branch
&& isNewerVersionBranch(branch.name, row.installed_branch)
&& (!installedCommit || branch.commit.toLowerCase() !== installedCommit));
await this.database.query(
`UPDATE marketplace_module_installations SET available_branches = $2::jsonb,
observed_branches = $3::jsonb, branches_checked_at = now() WHERE module_id = $1`,
[moduleId, JSON.stringify(candidates), JSON.stringify(branches)],
);
}
private async fetchBranches(provider: MarketplaceProvider, owner: string, repository: string): Promise<MarketplaceBranch[]> {
const providerConfig = this.config.marketplace.providers[provider];
if (!providerConfig) return [];
const branches: MarketplaceBranch[] = [];
const pageSize = provider === 'github' ? 100 : 50;
for (let page = 1; page <= 100; page += 1) {
const path = provider === 'github'
? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?per_page=${pageSize}&page=${page}`
: `${new URL(providerConfig.baseUrl).pathname.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?limit=${pageSize}&page=${page}`;
const values = await this.forgeJson<Array<Record<string, unknown>>>(provider, providerConfig.baseUrl, null, path);
branches.push(...values.flatMap((value) => {
const name = String(value.name ?? '');
const commit = value.commit as Record<string, unknown> | undefined;
const sha = String(commit?.sha ?? commit?.id ?? '');
return name && sha ? [{ name, commit: sha }] : [];
}));
if (values.length < pageSize) break;
}
return branches;
}
private async normalizeRepositoryArchive(archive: Buffer): Promise<Buffer> { private async normalizeRepositoryArchive(archive: Buffer): Promise<Buffer> {
const AdmZip = (await import('adm-zip')).default; const AdmZip = (await import('adm-zip')).default;
const input = new AdmZip(archive); const input = new AdmZip(archive);

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { stringify, parseDocument } from 'yaml'; import { stringify, parseDocument } from 'yaml';
import { ModuleIdentityService } from './module-identity.service'; import { ModuleIdentityService } from './module-identity.service';
import type { ModuleRecord } from './manifest.types'; import type { ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service';
const SAFE_SERVICE_KEYS = new Set([ const SAFE_SERVICE_KEYS = new Set([
'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes', 'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes',
@@ -14,6 +15,17 @@ const SAFE_SERVICE_KEYS = new Set([
'stop_grace_period', 'read_only', 'tty', 'stdin_open', 'stop_grace_period', 'read_only', 'tty', 'stdin_open',
]); ]);
/** 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,
) {
super(exitCode === null ? 'Docker-Befehl konnte nicht gestartet werden' : `Docker-Befehl endete mit Status ${exitCode}`);
this.name = 'ModuleCommandError';
}
}
/** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */ /** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */
@Injectable() @Injectable()
export class ModuleContainerManager { export class ModuleContainerManager {
@@ -21,41 +33,54 @@ export class ModuleContainerManager {
private readonly dockerHost = process.env.MODULE_DOCKER_HOST ?? 'unix:///var/run/docker.sock'; private readonly dockerHost = process.env.MODULE_DOCKER_HOST ?? 'unix:///var/run/docker.sock';
private readonly mpmContainer = process.env.MPM_CONTAINER_NAME ?? ''; private readonly mpmContainer = process.env.MPM_CONTAINER_NAME ?? '';
constructor(private readonly identityService: ModuleIdentityService) {} constructor(
private readonly identityService: ModuleIdentityService,
private readonly configurationService: ModuleConfigurationService,
) {}
async start(module: ModuleRecord): Promise<void> { async start(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); const { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues } = await this.prepare(module);
// Recreate stopped containers and project networks before each start. This try {
// prevents Compose v1 from trying to reconcile stale Docker Desktop network // Recreate stopped containers and project networks before each start. This
// defaults after a stop; named data volumes are deliberately left untouched. // prevents Compose v1 from reconciling stale Docker Desktop network defaults;
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']); // named data volumes are deliberately left untouched.
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans'], cleanupValues);
await this.runDocker(['network', 'rm', gatewayNetwork], true); if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'create', gatewayNetwork]); await this.runDocker(['network', 'rm', gatewayNetwork], true);
await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans']); await this.runDocker(['network', 'create', gatewayNetwork]);
if (this.mpmContainer) { await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans'], moduleValues);
await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); if (this.mpmContainer) {
await this.runDocker(['network', 'connect', gatewayNetwork, 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> { async stop(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop']); try {
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); await this.runCompose(module.path, projectName, composePath, overridePath, ['stop'], cleanupValues);
this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`); 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> { async remove(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); try {
// Compose down removes every app/database container and its networks. Named if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
// volumes remain, so uninstalling code does not silently destroy database data. // Removing a module is an explicit delete: remove its project-scoped data volumes too.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']); await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--volumes', '--remove-orphans'], cleanupValues);
await this.runDocker(['network', 'rm', gatewayNetwork], true); await this.runDocker(['network', 'rm', gatewayNetwork], true);
await rm(overridePath, { force: true }); this.logger.log(`Container und Datenvolumes für "${module.moduleId}" entfernt`);
this.logger.log(`Container für "${module.moduleId}" entfernt; Datenvolumes bleiben erhalten`); } finally {
await rm(overridePath, { force: true });
}
} }
private async prepare(module: ModuleRecord): Promise<{ private async prepare(module: ModuleRecord): Promise<{
@@ -63,6 +88,8 @@ export class ModuleContainerManager {
overridePath: string; overridePath: string;
projectName: string; projectName: string;
gatewayNetwork: string; gatewayNetwork: string;
moduleValues: Record<string, string>;
cleanupValues: Record<string, string>;
}> { }> {
if (!module.composeFile || !module.appService) { if (!module.composeFile || !module.appService) {
throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration'); throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration');
@@ -77,37 +104,70 @@ export class ModuleContainerManager {
if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML'); if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML');
const compose = document.toJS() as Record<string, unknown>; const compose = document.toJS() as Record<string, unknown>;
this.validateCompose(compose, module); this.validateCompose(compose, module);
const serviceMap = compose.services as Record<string, unknown>;
const moduleValues = await this.configurationService.values(module);
// Compose validates required interpolations even for stop/down. Supply
// harmless placeholders only to cleanup commands so incomplete modules
// can still be stopped and removed. Never pass these placeholders to `up`.
const cleanupValues = { ...moduleValues };
for (const field of module.configuration) {
cleanupValues[field.key] ??= 'mpm-unset-configuration';
}
for (const match of source.matchAll(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)/g)) {
const key = match[1];
if (key && !Object.hasOwn(cleanupValues, key) &&
!/^(PATH|HOME|TMPDIR|NODE_OPTIONS|PYTHONPATH|DOCKER_HOST|DOCKER_CONTEXT)$/.test(key)) {
cleanupValues[key] = 'mpm-unset-configuration';
}
}
const projectName = `mpm-${module.moduleId}`; const projectName = `mpm-${module.moduleId}`;
const gatewayNetwork = `mpm-module-${module.moduleId}-gateway`; const gatewayNetwork = `mpm-module-${module.moduleId}-gateway`;
// Keep generated secrets outside the package/build context so Dockerfiles // Keep generated secrets outside the package/build context so Dockerfiles
// cannot accidentally copy them into an application image. // cannot accidentally copy them into an application image.
const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`); const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`);
const overrideServices: Record<string, Record<string, unknown>> = {
[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'],
},
};
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 = { const override = {
version: '3.8', version: '3.8',
services: { services: 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}`] },
},
security_opt: ['no-new-privileges:true'],
},
},
volumes: { 'mpm-runtime-data': {} }, volumes: { 'mpm-runtime-data': {} },
networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } }, networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } },
}; };
await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 }); await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 });
await writeFile(overridePath, stringify(override), { mode: 0o600 }); await writeFile(overridePath, stringify(override), { mode: 0o600 });
return { composePath, overridePath, projectName, gatewayNetwork }; await chmod(overridePath, 0o600);
return { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues };
} }
private validateCompose(compose: Record<string, unknown>, module: ModuleRecord): void { private validateCompose(compose: Record<string, unknown>, module: ModuleRecord): void {
@@ -204,41 +264,79 @@ export class ModuleContainerManager {
} }
} }
private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[]): Promise<void> { private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[], config: Record<string, string>): Promise<void> {
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd); return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd, false, config);
} }
private runDocker(args: string[], ignoreFailure = false): Promise<void> { private runDocker(args: string[], ignoreFailure = false): Promise<void> {
return this.run('docker', args, process.cwd(), ignoreFailure); return this.run('docker', args, process.cwd(), ignoreFailure);
} }
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> = {}): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(command, args, { const child = spawn(command, args, {
cwd, cwd,
env: { env: {
...extraEnv,
PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
// Do not let a root-owned /root/.docker configuration affect a child // Do not let a root-owned /root/.docker configuration affect a child
// command started by the unprivileged backend user. // command started by the unprivileged backend user.
HOME: '/tmp', HOME: '/tmp',
DOCKER_HOST: this.dockerHost, DOCKER_HOST: this.dockerHost,
}, },
stdio: ['ignore', 'ignore', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
let stdout = '';
let stderr = ''; let stderr = '';
const keepTail = (current: string, chunk: string): string => (current + chunk).slice(-12_000);
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => { stdout = keepTail(stdout, chunk); });
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-2000); }); child.stderr.on('data', (chunk: string) => { stderr = keepTail(stderr, chunk); });
let processStartFailed = false;
child.once('error', (error) => { child.once('error', (error) => {
if (ignoreFailure) resolve(); if (ignoreFailure) resolve();
else reject(new Error(`${command} konnte nicht gestartet werden: ${error.message}`)); else {
processStartFailed = true;
const errorCode = (error as NodeJS.ErrnoException).code ?? 'unbekannt';
this.logger.error(`${command} konnte nicht gestartet werden (${errorCode})`);
reject(new ModuleCommandError(null, `Der Befehl „${command}“ konnte nicht gestartet werden. Prüfe, ob Docker auf dem System verfügbar ist.`));
}
}); });
child.once('close', (code) => { child.once('close', (code) => {
if (processStartFailed) return;
if (code === 0 || ignoreFailure) resolve(); if (code === 0 || ignoreFailure) resolve();
else { else {
this.logger.error(`${command} ${args[args.length - 1]} schlug mit Status ${code} fehl: ${stderr.trim()}`); const diagnostic = this.sanitizeDiagnostic(`${stdout}\n${stderr}`, extraEnv);
reject(new Error(`${command} schlug mit Status ${code} fehl`)); const exitCode = code ?? 1;
this.logger.error(`${command} schlug mit Status ${exitCode} fehl: ${diagnostic}`);
reject(new ModuleCommandError(exitCode, diagnostic));
} }
}); });
}); });
} }
/** Entfernt Umgebungswerte, ANSI-Codes und wahrscheinliche Secrets aus CLI-Ausgaben. */
private sanitizeDiagnostic(output: string, environment: Record<string, string>): string {
let safe = output
.replace(/\u001b\[[0-9;]*m/g, '')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.replace(/\r/g, '')
.trim();
const configuredValues = Object.values(environment)
.filter((value) => value.length >= 4)
.sort((left, right) => right.length - left.length);
for (const value of configuredValues) {
safe = safe.split(value).join('[geschwärzt]');
}
safe = safe.replace(
/(["']?[A-Z0-9_-]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|AUTHORIZATION|COOKIE)[A-Z0-9_-]*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi,
'$1[geschwärzt]',
);
if (!safe) return 'Docker Compose ist ohne eine Fehlerbeschreibung fehlgeschlagen.';
return safe.slice(-2_500);
}
} }

View File

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

View File

@@ -1,8 +1,10 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { moduleManifestSchema, type ModuleManifest } from './manifest.types'; import { moduleManifestSchema, type ModuleManifest } from './manifest.types';
import { secureModuleDirectory } from './module-filesystem'; import { secureModuleDirectory } from './module-filesystem';
import { parseDocument } from 'yaml';
/** Maximale Größe eines Modul-Pakets (10 MB). */ /** Maximale Größe eines Modul-Pakets (10 MB). */
const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024; const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024;
@@ -76,6 +78,21 @@ export class ModuleInstaller {
if (!zip.getEntry(manifest.composeFile)) { if (!zip.getEntry(manifest.composeFile)) {
throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`); throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`);
} }
const composeDocument = parseDocument(zip.getEntry(manifest.composeFile)!.getData().toString('utf8'), { uniqueKeys: true });
if (composeDocument.errors.length) {
throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition');
}
const compose = composeDocument.toJS() as { services?: Record<string, unknown> } | null;
if (!compose?.services || typeof compose.services !== 'object' || Array.isArray(compose.services)) {
throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition');
}
for (const field of manifest.configuration) {
for (const service of field.services) {
if (!Object.hasOwn(compose.services, service)) {
throw new BadRequestException(`Konfiguration ${field.key} verweist auf fehlenden Compose-Service "${service}"`);
}
}
}
return manifest; return manifest;
} }
@@ -125,6 +142,56 @@ export class ModuleInstaller {
return { directory, manifest }; return { directory, manifest };
} }
/** Stages and atomically swaps an installed module directory, retaining a rollback copy. */
async replace(
buffer: Buffer,
manifest: ModuleManifest,
modulesDir: string,
): Promise<{ directory: string; backupDirectory: string }> {
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);
for (const entry of zip.getEntries()) {
const entryName = entry.entryName;
if (entryName.startsWith('/') || entryName.includes('..') || entryName.includes('\\') || /^[A-Za-z]:/.test(entryName)) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
const resolvedEntry = path.resolve(resolvedStage, entryName);
if (!resolvedEntry.startsWith(resolvedStage + path.sep)) throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
try {
await mkdir(stagingDirectory, { recursive: true });
zip.extractAllTo(resolvedStage, true);
await secureModuleDirectory(resolvedStage);
await writeFile(path.join(stagingDirectory, '.installed.json'),
JSON.stringify({ installedAt: new Date().toISOString(), manifest }, null, 2), 'utf8');
await rename(directory, backupDirectory);
try {
await rename(stagingDirectory, directory);
} catch (error) {
await rename(backupDirectory, directory);
throw error;
}
return { directory, backupDirectory };
} catch (error) {
await rm(stagingDirectory, { recursive: true, force: true });
throw error;
}
}
async rollbackReplacement(directory: string, backupDirectory: string): Promise<void> {
await rm(directory, { recursive: true, force: true });
await rename(backupDirectory, directory);
}
async finalizeReplacement(backupDirectory: string): Promise<void> {
await rm(backupDirectory, { recursive: true, force: true });
}
/** Entfernt eine Modul-Installation vom Dateisystem. */ /** Entfernt eine Modul-Installation vom Dateisystem. */
async remove(modulesDir: string, moduleId: string): Promise<void> { async remove(modulesDir: string, moduleId: string): Promise<void> {
const directory = path.join(modulesDir, moduleId); const directory = path.join(modulesDir, moduleId);

View File

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

View File

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

View File

@@ -46,7 +46,18 @@ export class ModuleProcessManager implements OnModuleDestroy {
if (module.composeFile && module.appService) { if (module.composeFile && module.appService) {
await secureModuleDirectory(module.path); await secureModuleDirectory(module.path);
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.remove(module);
} catch {
this.logger.error(`Teilweise gestarteter Container-Stack für "${module.moduleId}" konnte nicht bereinigt werden`);
}
throw error;
}
this.containerModules.set(module.moduleId, module); this.containerModules.set(module.moduleId, module);
return; return;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,9 @@ import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Inject, Inject,
InternalServerErrorException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
@@ -11,8 +13,10 @@ import type { ActingUser } from '../users/users.service';
import { ModuleHealthChecker } from './module-health-checker'; import { ModuleHealthChecker } from './module-health-checker';
import { ModuleInstaller } from './module-installer'; import { ModuleInstaller } from './module-installer';
import { ModuleProcessManager } from './module-process-manager'; import { ModuleProcessManager } from './module-process-manager';
import { ModuleCommandError } from './module-container-manager';
import { ModuleRepository } from './module.repository'; import { ModuleRepository } from './module.repository';
import type { ModuleRecord } from './manifest.types'; import type { ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service';
/** /**
* Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module. * Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module.
@@ -27,12 +31,15 @@ import type { ModuleRecord } from './manifest.types';
*/ */
@Injectable() @Injectable()
export class ModulesService { export class ModulesService {
private readonly logger = new Logger(ModulesService.name);
constructor( constructor(
private readonly moduleRepository: ModuleRepository, private readonly moduleRepository: ModuleRepository,
private readonly installer: ModuleInstaller, private readonly installer: ModuleInstaller,
private readonly processManager: ModuleProcessManager, private readonly processManager: ModuleProcessManager,
private readonly healthChecker: ModuleHealthChecker, private readonly healthChecker: ModuleHealthChecker,
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly configurationService: ModuleConfigurationService,
@Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(APP_CONFIG) private readonly config: AppConfig,
) {} ) {}
@@ -48,6 +55,31 @@ export class ModulesService {
return module; return module;
} }
async getConfiguration(id: string) {
return this.configurationService.state(await this.getById(id));
}
async saveConfiguration(
id: string,
input: { values?: unknown; clearKeys?: unknown },
actor: ActingUser,
ipAddress: string | null,
) {
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,
});
if (result.changedKeys.length && module.status === 'RUNNING') {
await this.restart(id, actor, ipAddress);
}
return this.configurationService.state(await this.getById(id));
}
/** Installiert ein Modul-Paket (ZIP) und registriert es. */ /** Installiert ein Modul-Paket (ZIP) und registriert es. */
async install( async install(
packageBuffer: Buffer, packageBuffer: Buffer,
@@ -76,7 +108,27 @@ export class ModulesService {
manifest, manifest,
this.config.runtime.modulesDir, this.config.runtime.modulesDir,
); );
const module = await this.moduleRepository.create(manifest, directory); let module: ModuleRecord;
try {
module = await this.moduleRepository.create(manifest, directory);
} catch {
let cleanupFailed = false;
try {
await this.installer.remove(this.config.runtime.modulesDir, manifest.id);
} catch {
cleanupFailed = true;
this.logger.error(`Temporäre Dateien für Modul ${manifest.id} konnten nach fehlgeschlagener Registrierung nicht entfernt werden`);
}
throw new InternalServerErrorException({
statusCode: 500,
error: 'Internal Server Error',
code: 'MODULE_INSTALL_REGISTRATION_FAILED',
message: `Modul „${manifest.name}“ konnte nicht in MPM registriert werden.`,
diagnostic: cleanupFailed
? 'Die Registrierung ist fehlgeschlagen und die temporären Dateien konnten nicht bereinigt werden. Bitte Plattform-Logs prüfen.'
: 'Die Registrierung in der Datenbank ist fehlgeschlagen; die entpackten Dateien wurden zurückgerollt.',
});
}
await this.auditService.record({ await this.auditService.record({
userId: actor.id, userId: actor.id,
@@ -92,6 +144,66 @@ export class ModulesService {
return this.installer.validatePackage(packageBuffer); return this.installer.validatePackage(packageBuffer);
} }
async updateFromMarketplace(
id: string,
packageBuffer: Buffer,
actor: ActingUser,
ipAddress: string | null,
): Promise<ModuleRecord> {
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) await this.stop(id, actor, ipAddress);
let replacement: { directory: string; backupDirectory: string } | undefined;
try {
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.start(id, actor, ipAddress);
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,
});
return await this.getById(id);
} 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.start(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.start(id, actor, ipAddress); } catch { /* Preserve the original update error. */ }
}
throw error;
}
}
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> { async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
return this.moduleRepository.findByModuleId(moduleId); return this.moduleRepository.findByModuleId(moduleId);
} }
@@ -103,6 +215,7 @@ export class ModulesService {
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
return module; return module;
} }
await this.configurationService.resolvedValues(module);
await this.moduleRepository.updateStatus(id, 'STARTING'); await this.moduleRepository.updateStatus(id, 'STARTING');
try { try {
@@ -116,11 +229,21 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'RUNNING'); await this.moduleRepository.updateStatus(id, 'RUNNING');
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress); await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress);
} catch (error) { } catch (error) {
await this.moduleRepository.updateStatus(id, 'ERROR'); let statusUpdateFailed = false;
await this.processManager.stop(module.moduleId); try {
throw new BadRequestException( await this.moduleRepository.updateStatus(id, 'ERROR');
`Modul konnte nicht gestartet werden: ${error instanceof Error ? error.message : String(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; return (await this.moduleRepository.findById(id)) ?? module;
} }
@@ -161,10 +284,14 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'STOPPED'); await this.moduleRepository.updateStatus(id, 'STOPPED');
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress); await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress);
} catch (error) { } catch (error) {
await this.moduleRepository.updateStatus(id, 'ERROR'); let statusUpdateFailed = false;
throw new BadRequestException( try {
`Modul konnte nicht gestoppt werden: ${error instanceof Error ? error.message : String(error)}`, 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; return (await this.moduleRepository.findById(id)) ?? module;
} }
@@ -211,17 +338,31 @@ export class ModulesService {
} }
/** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */ /** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> { async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> {
const module = await this.getById(id); const module = await this.getById(id);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
await this.stop(id, actor, ipAddress); await this.stop(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); try {
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId); 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({ await this.auditService.record({
userId: actor.id, userId: actor.id,
@@ -230,6 +371,7 @@ export class ModulesService {
details: { moduleId: module.moduleId }, details: { moduleId: module.moduleId },
ipAddress, ipAddress,
}); });
return cleanupWarning ? { cleanupWarning } : {};
} }
/** Führt einen Healthcheck für ein Modul aus (ohne Statusänderung). */ /** Führt einen Healthcheck für ein Modul aus (ohne Statusänderung). */
@@ -245,6 +387,31 @@ export class ModulesService {
} }
} }
private lifecycleFailure(
action: 'start' | 'stop' | 'remove',
module: ModuleRecord,
error: unknown,
statusUpdateFailed = false,
): BadRequestException {
const actionText = { start: 'gestartet', stop: 'gestoppt', remove: 'entfernt' }[action];
const commandDiagnostic = error instanceof ModuleCommandError
? error.diagnostic
: error instanceof Error && error.message.startsWith('Healthcheck fehlgeschlagen:')
? error.message
: 'Die technische Ursache steht im Plattform-Log.';
const diagnostic = statusUpdateFailed
? `${commandDiagnostic} MPM konnte den Fehlerstatus nicht speichern; bitte Status erneut laden.`
: commandDiagnostic;
return new BadRequestException({
statusCode: 400,
error: 'Bad Request',
code: `MODULE_${action.toUpperCase()}_FAILED`,
message: `Modul „${module.name}“ konnte nicht ${actionText} werden.`,
diagnostic,
});
}
private async auditLifecycle( private async auditLifecycle(
module: ModuleRecord, module: ModuleRecord,
action: AuditAction, action: AuditAction,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,84 +1,30 @@
import { import { type ReactNode, createContext, useCallback, useContext, useMemo } from 'react';
type ReactNode, import { Toaster, toast } from 'sonner';
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
/** Toast-Varianten. */ /** Toast-Varianten. */
export type ToastVariant = 'success' | 'error'; export type ToastVariant = 'success' | 'error';
interface ToastContextValue { showToast: (variant: ToastVariant, message: string, description?: ReactNode) => void }
interface ToastEntry {
readonly id: number;
readonly variant: ToastVariant;
readonly message: string;
}
interface ToastContextValue {
showToast: (variant: ToastVariant, message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null); const ToastContext = createContext<ToastContextValue | null>(null);
const VARIANT_CLASSES: Record<ToastVariant, string> = { /** shadcn/ui Sonner-Toasts an derselben Position und mit derselben Dauer. */
success: 'bg-emerald-600 text-white',
error: 'bg-red-600 text-white',
};
const VARIANT_ICONS: Record<ToastVariant, string> = {
success: '✓',
error: '✕',
};
/**
* Toast-Benachrichtigungen (Design-System).
* Meldungen verschwinden automatisch nach 4 Sekunden.
*/
export function ToastProvider({ children }: { children: ReactNode }): ReactNode { export function ToastProvider({ children }: { children: ReactNode }): ReactNode {
const [toasts, setToasts] = useState<ToastEntry[]>([]); const showToast = useCallback((variant: ToastVariant, message: string, description?: ReactNode) => {
const nextId = useRef(1); const className = 'rounded-lg border-0 px-4 py-3 text-sm font-medium shadow-lg';
const options = { description, duration: description ? 12_000 : 4_000 };
const showToast = useCallback((variant: ToastVariant, message: string) => { if (variant === 'success') toast.success(message, { ...options, className: `${className} !bg-emerald-600 !text-white` });
const id = nextId.current; else toast.error(message, { ...options, className: `${className} !bg-red-600 !text-white` });
nextId.current += 1;
setToasts((current) => [...current, { id, variant, message }]);
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 4_000);
}, []); }, []);
const value = useMemo<ToastContextValue>(() => ({ showToast }), [showToast]); const value = useMemo<ToastContextValue>(() => ({ showToast }), [showToast]);
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div <Toaster position="bottom-right" duration={4000} closeButton={false} richColors={false} />
className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2"
aria-live="polite"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg
${VARIANT_CLASSES[toast.variant]}`}
>
<span aria-hidden="true">{VARIANT_ICONS[toast.variant]}</span>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider> </ToastContext.Provider>
); );
} }
/** Zeigt eine Toast-Meldung an (wirft außerhalb des Providers). */
export function useToast(): ToastContextValue { export function useToast(): ToastContextValue {
const context = useContext(ToastContext); const context = useContext(ToastContext);
if (!context) { if (!context) throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
throw new Error('useToast muss innerhalb von ToastProvider verwendet werden');
}
return context; return context;
} }

View File

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

View File

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

View File

@@ -1,8 +1,15 @@
import { type ReactNode, useRef, useState } from 'react'; import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Card } from '../../components/ui/card';
import { Label } from '../../components/ui/label';
import { Icon } from '../../components/ui/icon'; import { Icon } from '../../components/ui/icon';
import { InputControl } from '../../components/ui/input';
import { Modal } from '../../components/ui/modal'; import { Modal } from '../../components/ui/modal';
import { SelectControl } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
@@ -12,14 +19,19 @@ import {
fetchMarketplaceRepositories, fetchMarketplaceRepositories,
fetchMarketplaceProviders, fetchMarketplaceProviders,
fetchModules, fetchModules,
fetchModuleConfiguration,
installModule, installModule,
installMarketplaceRepository, installMarketplaceRepository,
fetchMarketplaceUpdates,
updateMarketplaceModule,
removeModule, removeModule,
restartModule, restartModule,
setModuleEnabled, setModuleEnabled,
saveModuleConfiguration,
startModule, startModule,
stopModule, stopModule,
type MarketplaceProvider, type MarketplaceProvider,
type MarketplaceUpdateState,
} from '../../lib/modules-api'; } from '../../lib/modules-api';
import type { Module, ModuleStatus } from '../../lib/schemas'; import type { Module, ModuleStatus } from '../../lib/schemas';
@@ -40,22 +52,49 @@ function statusVariant(status: ModuleStatus): 'success' | 'warning' | 'danger' |
} }
} }
function operationDiagnostic(error: unknown): string | undefined {
if (!(error instanceof ApiError)) return undefined;
const lines = [error.code ? `Fehlercode: ${error.code}` : null, error.diagnostic ?? null]
.filter((line): line is string => Boolean(line));
return lines.length > 0 ? lines.join('\n') : undefined;
}
function operationToastDetails(error: unknown): ReactNode {
const diagnostic = operationDiagnostic(error);
if (!diagnostic) return undefined;
return (
<details className="text-xs">
<summary className="cursor-pointer font-medium">Technische Details</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono">{diagnostic}</pre>
</details>
);
}
/** Bestätigungsdialog: Modul entfernen. */ /** Bestätigungsdialog: Modul entfernen. */
function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode { function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode {
const { showToast } = useToast(); const { showToast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [formDiagnostic, setFormDiagnostic] = useState<string | null>(null);
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: () => removeModule(module.id), mutationFn: () => removeModule(module.id),
onSuccess: () => { onSuccess: async (result) => {
void queryClient.invalidateQueries({ queryKey: ['modules'] }); await Promise.all([
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }); queryClient.invalidateQueries({ queryKey: ['modules'] }),
showToast('success', `Modul "${module.name}" wurde entfernt`); 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(); onClose();
}, },
onError: (error) => { onError: async (error) => {
setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen'); setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen');
setFormDiagnostic(operationDiagnostic(error) ?? null);
await queryClient.invalidateQueries({ queryKey: ['modules'] });
}, },
}); });
@@ -63,13 +102,14 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
<Modal <Modal
open open
title="Modul entfernen" title="Modul entfernen"
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer sowie Dateien und Registrierung werden gelöscht. Datenvolumes bleiben für eine mögliche Neuinstallation erhalten.`} description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer, alle zugehörigen Datenvolumes, Dateien und Registrierung werden endgültig gelöscht. Gespeicherte Modul- und Kalenderdaten können danach nicht wiederhergestellt werden.`}
onClose={onClose} onClose={onClose}
> >
{formError && ( {formError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700"> <div role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError} <p>{formError}</p>
</p> {formDiagnostic && <details className="mt-2 text-xs"><summary className="cursor-pointer font-medium">Technische Details</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono">{formDiagnostic}</pre></details>}
</div>
)} )}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
@@ -87,6 +127,116 @@ 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 && <p className="text-sm text-slate-500">Konfiguration wird geladen…</p>}
{configQuery.isError && <p role="alert" className="text-sm text-red-600">Konfiguration konnte nicht geladen werden.</p>}
{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 updateMutation = useMutation({
mutationFn: () => updateMarketplaceModule(module.id, branch),
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 selected = state.branches.find((item) => item.name === branch);
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" role="status" aria-live="polite">
<div role="progressbar" aria-label="Modulupdate läuft" aria-valuemin={0} aria-valuemax={100} aria-valuetext="Installationsvorgang läuft" className="h-2 overflow-hidden rounded-full bg-brand-100">
<div className="mpm-indeterminate-progress h-full w-2/5 rounded-full bg-brand-600" />
</div>
<p className="text-sm font-medium text-slate-700">Update auf „{branch}“ wird installiert…</p>
<p className="text-xs text-slate-500">MPM aktualisiert das Modul und prüft anschließend den Start. Das kann einige Minuten dauern.</p>
</div>}
{formError && <p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" disabled={updateMutation.isPending} onClick={handleClose}>Abbrechen</Button>
<Button disabled={!branch} loading={updateMutation.isPending} onClick={() => { setFormError(null); updateMutation.mutate(); }}>Update installieren</Button>
</div>
</div>}
</Modal>
);
}
/** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */ /** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */
export function ModulesPage(): ReactNode { export function ModulesPage(): ReactNode {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -94,6 +244,8 @@ export function ModulesPage(): ReactNode {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [removeTarget, setRemoveTarget] = useState<Module | null>(null); const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
const [configurationTarget, setConfigurationTarget] = useState<Module | null>(null);
const [updateTarget, setUpdateTarget] = useState<Module | null>(null);
const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null); const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null);
const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers'); const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers');
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({}); const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
@@ -101,12 +253,20 @@ export function ModulesPage(): ReactNode {
const modulesQuery = useQuery({ const modulesQuery = useQuery({
queryKey: ['modules'], queryKey: ['modules'],
queryFn: fetchModules, queryFn: fetchModules,
refetchInterval: 15_000, refetchInterval: (query) => query.state.data?.some((module) => module.status === 'STARTING' || module.status === 'STOPPING') ? 2_000 : 15_000,
}); });
const marketplaceQuery = useQuery({ const marketplaceQuery = useQuery({
queryKey: ['marketplace-providers'], queryKey: ['marketplace-providers'],
queryFn: fetchMarketplaceProviders, queryFn: fetchMarketplaceProviders,
}); });
const moduleUpdateQueries = useQueries({
queries: (modulesQuery.data ?? []).map((module) => ({
queryKey: ['module-updates', module.id],
queryFn: () => fetchMarketplaceUpdates(module.id),
refetchInterval: 60 * 1000,
retry: false,
})),
});
const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? []; const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? [];
const repositoriesQuery = useQuery({ const repositoriesQuery = useQuery({
queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)], queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)],
@@ -135,30 +295,36 @@ export function ModulesPage(): ReactNode {
const marketplaceInstallMutation = useMutation({ const marketplaceInstallMutation = useMutation({
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) => mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
installMarketplaceRepository(input.provider, input.owner, input.repository), installMarketplaceRepository(input.provider, input.owner, input.repository),
onSuccess: (module) => { onSuccess: async (module) => {
void queryClient.invalidateQueries({ queryKey: ['modules'] }); await Promise.all([
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }); queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }),
]);
showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`); showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`);
}, },
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen'), onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen', operationToastDetails(error));
await invalidate();
},
}); });
const invalidate = (): void => { const invalidate = async (): Promise<void> => {
void queryClient.invalidateQueries({ queryKey: ['modules'] }); await queryClient.invalidateQueries({ queryKey: ['modules'] });
}; };
const installMutation = useMutation({ const installMutation = useMutation({
mutationFn: (file: File) => installModule(file), mutationFn: (file: File) => installModule(file),
onSuccess: (module) => { onSuccess: async (module) => {
showToast('success', `Modul "${module.name}" wurde installiert`); showToast('success', `Modul "${module.name}" wurde installiert`);
setSelectedFile(null); setSelectedFile(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
invalidate(); await invalidate();
}, },
onError: (error) => { onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen'); showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen', operationToastDetails(error));
await invalidate();
}, },
}); });
@@ -177,13 +343,13 @@ export function ModulesPage(): ReactNode {
return setModuleEnabled(input.module.id, false); return setModuleEnabled(input.module.id, false);
} }
}, },
onSuccess: (module, variables) => { onSuccess: async (module, variables) => {
showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`); showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`);
invalidate(); await invalidate();
}, },
onError: (error) => { onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen'); showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen', operationToastDetails(error));
invalidate(); await invalidate();
}, },
}); });
@@ -242,18 +408,18 @@ export function ModulesPage(): ReactNode {
<div className="space-y-6"> <div className="space-y-6">
{/* Compact provider connectors */} {/* Compact provider connectors */}
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"> <Card className="p-5">
<h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2> <h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2>
<div role="tablist" aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200"> <Tabs value={connectionTab} onValueChange={(value) => setConnectionTab(value as 'providers' | 'manual')}>
<button type="button" role="tab" aria-selected={connectionTab === 'providers'} onClick={() => setConnectionTab('providers')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'providers' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}> <TabsList aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
<TabsTrigger value="providers" className="border-b-2 px-3 py-2 text-sm font-medium data-[state=active]:border-brand-600 data-[state=active]:text-brand-700 data-[state=inactive]:border-transparent data-[state=inactive]:text-slate-500 data-[state=inactive]:hover:text-slate-800">
Verbindungen Verbindungen
</button> </TabsTrigger>
<button type="button" role="tab" aria-selected={connectionTab === 'manual'} onClick={() => setConnectionTab('manual')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'manual' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}> <TabsTrigger value="manual" className="border-b-2 px-3 py-2 text-sm font-medium data-[state=active]:border-brand-600 data-[state=active]:text-brand-700 data-[state=inactive]:border-transparent data-[state=inactive]:text-slate-500 data-[state=inactive]:hover:text-slate-800">
Manuelle Installation Manuelle Installation
</button> </TabsTrigger>
</div> </TabsList>
{connectionTab === 'providers' ? ( <TabsContent value="providers" className="grid grid-cols-1">
<div className="grid grid-cols-1">
{(['github', 'gitea', 'forgejo'] as const).map((provider) => { {(['github', 'gitea', 'forgejo'] as const).map((provider) => {
const connection = marketplaceQuery.data?.find((item) => item.provider === provider); const connection = marketplaceQuery.data?.find((item) => item.provider === provider);
const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo'); const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo');
@@ -301,68 +467,76 @@ export function ModulesPage(): ReactNode {
</div> </div>
); );
})} })}
</div> </TabsContent>
) : ( <TabsContent value="manual" className="flex flex-wrap items-center gap-3">
<div role="tabpanel" className="flex flex-wrap items-center gap-3"> <InputControl
<input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".zip" accept=".zip"
onChange={handleFileChange} onChange={handleFileChange}
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100" className="h-auto w-auto border-0 bg-transparent px-0 py-0 text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
aria-label="Modul-Paket (ZIP) auswählen" aria-label="Modul-Paket (ZIP) auswählen"
/> />
<Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}> <Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}>
Installieren Installieren
</Button> </Button>
<p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p> <p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p>
</div> </TabsContent>
)} </Tabs>
</section> </Card>
{/* Modul-Liste */} {/* Modul-Liste */}
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"> <Card className="p-5">
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2> <h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2>
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200"> <div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200">
<table className="w-full min-w-[900px] table-fixed text-sm"> <Table className="w-full min-w-[900px] table-fixed text-sm">
<thead className="sticky top-0 z-10"> <TableHeader 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"> <TableRow 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> <TableHead className="w-[29%] px-4 py-3 font-semibold">Modul</TableHead>
<th className="w-[13%] px-4 py-3 font-semibold">Status</th> <TableHead className="w-[12%] px-4 py-3 text-center font-semibold">Status</TableHead>
<th className="w-[15%] px-4 py-3 font-semibold">URL</th> <TableHead className="w-[13%] px-4 py-3 text-center font-semibold">URL</TableHead>
<th className="w-[38%] px-4 py-3 font-semibold">Aktionen</th> <TableHead className="w-[22%] px-4 py-3 text-center font-semibold">Konfiguration</TableHead>
</tr> <TableHead className="w-[24%] px-4 py-3 font-semibold">Aktionen</TableHead>
</thead> </TableRow>
<tbody> </TableHeader>
{modulesQuery.isLoading && <tr><td colSpan={4} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</td></tr>} <TableBody>
{modulesQuery.isError && <tr><td colSpan={4} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</td></tr>} {modulesQuery.isLoading && <TableRow><TableCell colSpan={5} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</TableCell></TableRow>}
{modulesQuery.data?.map((module) => ( {modulesQuery.isError && <TableRow><TableCell colSpan={5} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</TableCell></TableRow>}
<tr key={module.id} className="border-b border-slate-100 last:border-0"> {modulesQuery.data?.map((module) => {
<td className="px-4 py-3"> const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
return (
<TableRow key={module.id} className="border-b border-slate-100 last:border-0">
<TableCell className="align-top px-4 py-3">
<div className="font-semibold text-slate-900">{module.name}</div> <div className="font-semibold text-slate-900">{module.name}</div>
<div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div> <div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div>
{module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>} {module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>}
</td> </TableCell>
<td className="px-4 py-3"><span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${statusVariant(module.status) === 'success' ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20' : statusVariant(module.status) === 'warning' ? 'bg-amber-50 text-amber-700 ring-amber-600/20' : statusVariant(module.status) === 'danger' ? 'bg-red-50 text-red-700 ring-red-600/20' : statusVariant(module.status) === 'neutral' ? 'bg-slate-100 text-slate-600 ring-slate-500/20' : 'bg-brand-50 text-brand-700 ring-brand-600/20'}`}>{module.status}</span> <TableCell className="align-top px-4 py-3"><div className="flex w-full justify-center"><Badge variant={statusVariant(module.status)}>{module.status}</Badge></div>
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>} {healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}
</td> </TableCell>
<td className="px-4 py-3"><code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></td> <TableCell className="align-top px-4 py-3"><div className="flex justify-center"><code className="whitespace-nowrap rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div></TableCell>
<td className="px-4 py-3"><div className="flex flex-wrap gap-1"> <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>
{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>} <TableCell className="align-top px-4 py-3"><div className="flex flex-wrap gap-1">
{(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> const updateState = moduleUpdateQueries[(modulesQuery.data ?? []).findIndex((item) => item.id === module.id)]?.data;
<Button size="sm" variant="ghost" onClick={() => healthMutation.mutate(module)}>Health</Button> return <Button size="sm" variant="ghost" disabled={!updateState?.branches.length} title={updateState?.branches.length ? 'Neue getestete Branch installieren' : updateState?.installedBranch ? 'Keine neue Branch gefunden' : 'Kein Marketplace-Repository verknüpft'} onClick={() => updateState && setUpdateTarget(module)}>Update verfügbar</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> {module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>}
</div></td> {(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>}
</tr> <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button>
))} <Button size="sm" variant="ghost" disabled={actionPending} onClick={() => healthMutation.mutate(module)}>Health</Button>
</tbody> <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && (lifecycleMutation.variables?.action === 'enable' || lifecycleMutation.variables?.action === 'disable')} onClick={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</Button>
</table> <Button size="sm" variant="ghost" disabled={actionPending} onClick={() => setRemoveTarget(module)}>Remove</Button>
</div></TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>} {modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
</div> </div>
</section> </Card>
</div> </div>
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"> <Card className="p-5">
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Marketplace</h2> <h2 className="text-lg font-semibold text-slate-900">Marketplace</h2>
<p className="mt-1 text-base text-slate-500"> <p className="mt-1 text-base text-slate-500">
@@ -413,10 +587,16 @@ export function ModulesPage(): ReactNode {
</div> </div>
))} ))}
</div> </div>
</section> </Card>
{removeTarget && ( {removeTarget && (
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} /> <RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
)} )}
{configurationTarget && <ModuleConfigurationModal module={configurationTarget} onClose={() => setConfigurationTarget(null)} />}
{updateTarget && (() => {
const index = (modulesQuery.data ?? []).findIndex((item) => item.id === updateTarget.id);
const updateState = moduleUpdateQueries[index]?.data;
return updateState?.branches.length ? <ModuleUpdateModal module={updateTarget} state={updateState} onClose={() => setUpdateTarget(null)} /> : null;
})()}
{connectTarget && ( {connectTarget && (
<ConnectProviderModal <ConnectProviderModal
provider={connectTarget} provider={connectTarget}
@@ -471,14 +651,15 @@ function ConnectProviderModal({
panelClassName="max-w-xl overflow-hidden rounded-[28px]" panelClassName="max-w-xl overflow-hidden rounded-[28px]"
> >
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2"> <div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
<button <Button
variant="ghost"
type="button" type="button"
aria-label="Dialog schließen" aria-label="Dialog schließen"
onClick={onClose} onClick={onClose}
className="absolute right-1 top-0 rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900" className="absolute right-1 top-0 h-auto rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
> >
<Icon name="close" className="h-4 w-4" /> <Icon name="close" className="h-4 w-4" />
</button> </Button>
<div className="flex items-center justify-center gap-4 pb-5 pt-2"> <div className="flex items-center justify-center gap-4 pb-5 pt-2">
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white"> <span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white">

View File

@@ -2,7 +2,7 @@ import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '../../lib/api-client'; import { apiRequest } from '../../lib/api-client';
import { z } from 'zod'; import { z } from 'zod';
import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { ErrorState, Spinner } from '../../components/ui/states'; import { ErrorState, Spinner } from '../../components/ui/states';
@@ -54,8 +54,8 @@ export function SystemStatusPage(): ReactNode {
</div> </div>
<Card> <Card>
<CardHeader title="Gesamtstatus" /> <CardHeader><CardTitle>Gesamtstatus</CardTitle></CardHeader>
<CardBody> <CardContent>
{statusQuery.isLoading && <Spinner />} {statusQuery.isLoading && <Spinner />}
{statusQuery.isError && ( {statusQuery.isError && (
<ErrorState message="Der Systemstatus ist nicht erreichbar." /> <ErrorState message="Der Systemstatus ist nicht erreichbar." />
@@ -78,12 +78,12 @@ export function SystemStatusPage(): ReactNode {
</span> </span>
</div> </div>
)} )}
</CardBody> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader title="Komponenten" /> <CardHeader><CardTitle>Komponenten</CardTitle></CardHeader>
<CardBody> <CardContent>
{statusQuery.data && ( {statusQuery.data && (
<div className="space-y-3"> <div className="space-y-3">
{statusQuery.data.components.map((component) => ( {statusQuery.data.components.map((component) => (
@@ -100,7 +100,7 @@ export function SystemStatusPage(): ReactNode {
))} ))}
</div> </div>
)} )}
</CardBody> </CardContent>
</Card> </Card>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ export class ApiError extends Error {
public readonly status: number, public readonly status: number,
message: string, message: string,
public readonly details?: Record<string, string | string[]>, public readonly details?: Record<string, string | string[]>,
public readonly code?: string,
public readonly diagnostic?: string,
) { ) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
@@ -78,10 +80,14 @@ export async function apiRequest<TResponse>(
if (!response.ok) { if (!response.ok) {
let message = 'Ein unerwarteter Fehler ist aufgetreten.'; let message = 'Ein unerwarteter Fehler ist aufgetreten.';
let details: Record<string, string[]> | undefined; let details: Record<string, string[]> | undefined;
let code: string | undefined;
let diagnostic: string | undefined;
try { try {
const errorBody = (await response.json()) as { const errorBody = (await response.json()) as {
message?: string | string[]; message?: string | string[];
details?: Record<string, string[]>; details?: Record<string, string[]>;
code?: string;
diagnostic?: string;
}; };
if (typeof errorBody.message === 'string') { if (typeof errorBody.message === 'string') {
message = errorBody.message; message = errorBody.message;
@@ -89,10 +95,12 @@ export async function apiRequest<TResponse>(
message = errorBody.message.join(', '); message = errorBody.message.join(', ');
} }
details = errorBody.details; details = errorBody.details;
code = errorBody.code;
diagnostic = errorBody.diagnostic;
} catch { } catch {
// Antwort enthält kein JSON – Standardmeldung verwenden. // Antwort enthält kein JSON – Standardmeldung verwenden.
} }
throw new ApiError(response.status, message, details); throw new ApiError(response.status, message, details, code, diagnostic);
} }
return (await response.json()) as TResponse; return (await response.json()) as TResponse;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,7 @@ services:
DOCKER_SOCKET_GID: ${DOCKER_SOCKET_GID:-0} DOCKER_SOCKET_GID: ${DOCKER_SOCKET_GID:-0}
MODULES_DIR: /app/data/modules MODULES_DIR: /app/data/modules
LOGS_DIR: /app/data/logs LOGS_DIR: /app/data/logs
MODULE_CONFIG_ENCRYPTION_KEY: ${MODULE_CONFIG_ENCRYPTION_KEY:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen} ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen}
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen} ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen}

View File

@@ -62,6 +62,30 @@ http {
} }
# Management-API ans Backend proxien # Management-API ans Backend proxien
# Modulstarts können durch Docker-Builds deutlich länger als 30 Sekunden dauern.
# Der Client muss auf die Lifecycle-Antwort warten können.
location /api/v1/modules/ {
proxy_pass http://platform_backend;
proxy_http_version 1.1;
proxy_set_header Host $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 $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
location /api/ { location /api/ {
proxy_pass http://platform_backend; proxy_pass http://platform_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -72,6 +96,27 @@ http {
proxy_read_timeout 30s; proxy_read_timeout 30s;
} }
# Next.js benötigt Inline-Skripte für die React-Hydrierung. Die globale
# Plattform-CSP ohne 'unsafe-inline' würde trotz geladener JS-Dateien
# alle Client-Handler des Kalendertools blockieren.
location ~ "^/kalendartool(?<calendar_module_path>/.*)?$" {
proxy_pass http://platform_backend/api/v1/gateway/kalendartool$calendar_module_path;
proxy_http_version 1.1;
proxy_set_header Host $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;
# add_header überschreibt hier die globale Header-Liste; die übrigen
# Sicherheits-Header deshalb erneut setzen.
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'; 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;
}
# Modul-Routing (Phase 4): /slug/* wird intern an den # Modul-Routing (Phase 4): /slug/* wird intern an den
# Modul-Gateway des Backends übergeben (/api/v1/gateway/slug/*). # Modul-Gateway des Backends übergeben (/api/v1/gateway/slug/*).
# Der Gateway prüft Session, Modul-Status und Berechtigung, # Der Gateway prüft Session, Modul-Status und Berechtigung,

View File

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