MPM marketplace and UI updates
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -66,4 +68,4 @@ export class AuditService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {} },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -253,4 +255,4 @@ describe('ModuleGatewayMiddleware', () => {
|
|||||||
|
|
||||||
proxySpy.mockRestore();
|
proxySpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,4 +126,4 @@ export class ModulePermissionRepository {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -219,4 +221,4 @@ describe('ModulePermissionsService', () => {
|
|||||||
expect(modules[0].moduleId).toBe('demo');
|
expect(modules[0].moduleId).toBe('demo');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
8
apps/platform-frontend/components.json
Normal file
8
apps/platform-frontend/components.json
Normal 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" }
|
||||||
|
}
|
||||||
1862
apps/platform-frontend/package-lock.json
generated
1862
apps/platform-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
@@ -32,4 +40,4 @@
|
|||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/platform-frontend/src/components/ui/checkbox.tsx
Normal file
18
apps/platform-frontend/src/components/ui/checkbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
apps/platform-frontend/src/components/ui/dialog.tsx
Normal file
156
apps/platform-frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
53
apps/platform-frontend/src/components/ui/dropdown-menu.tsx
Normal file
53
apps/platform-frontend/src/components/ui/dropdown-menu.tsx
Normal 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 };
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/platform-frontend/src/components/ui/label.tsx
Normal file
7
apps/platform-frontend/src/components/ui/label.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
apps/platform-frontend/src/components/ui/separator.tsx
Normal file
25
apps/platform-frontend/src/components/ui/separator.tsx
Normal 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 }
|
||||||
142
apps/platform-frontend/src/components/ui/sheet.tsx
Normal file
142
apps/platform-frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||||
|
}
|
||||||
727
apps/platform-frontend/src/components/ui/sidebar.tsx
Normal file
727
apps/platform-frontend/src/components/ui/sidebar.tsx
Normal 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,
|
||||||
|
}
|
||||||
13
apps/platform-frontend/src/components/ui/skeleton.tsx
Normal file
13
apps/platform-frontend/src/components/ui/skeleton.tsx
Normal 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 }
|
||||||
25
apps/platform-frontend/src/components/ui/switch.tsx
Normal file
25
apps/platform-frontend/src/components/ui/switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/platform-frontend/src/components/ui/table.tsx
Normal file
21
apps/platform-frontend/src/components/ui/table.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
16
apps/platform-frontend/src/components/ui/tabs.tsx
Normal file
16
apps/platform-frontend/src/components/ui/tabs.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
54
apps/platform-frontend/src/components/ui/tooltip.tsx
Normal file
54
apps/platform-frontend/src/components/ui/tooltip.tsx
Normal 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 }
|
||||||
@@ -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,8 +184,8 @@ export function AuditPage(): ReactNode {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,8 +100,8 @@ export function SystemStatusPage(): ReactNode {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +100,8 @@ export function LoginPage(): ReactNode {
|
|||||||
Anmelden
|
Anmelden
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
19
apps/platform-frontend/src/hooks/use-mobile.ts
Normal file
19
apps/platform-frontend/src/hooks/use-mobile.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -155,4 +171,4 @@ export const modulePermissionSchema = z.object({
|
|||||||
moduleName: z.string(),
|
moduleName: z.string(),
|
||||||
permission: z.enum(['GRANTED', 'DENIED']),
|
permission: z.enum(['GRANTED', 'DENIED']),
|
||||||
});
|
});
|
||||||
export type ModulePermission = z.infer<typeof modulePermissionSchema>;
|
export type ModulePermission = z.infer<typeof modulePermissionSchema>;
|
||||||
|
|||||||
6
apps/platform-frontend/src/lib/utils.ts
Normal file
6
apps/platform-frontend/src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -19,4 +26,4 @@ export default defineConfig({
|
|||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user