From fd9de823ea8c19d57f71b5dc71089118c8c98dd3 Mon Sep 17 00:00:00 2001 From: leon Date: Thu, 8 Oct 2026 21:13:57 +0200 Subject: [PATCH] MPM marketplace and UI updates --- .env.example | 4 + KI_SETUP.md | 3 +- .../src/audit/audit.service.ts | 4 +- .../src/auth/auth.service.spec.ts | 2 +- .../src/config/configuration.ts | 3 + .../src/database/migrations/index.ts | 4 + .../src/modules/manifest.types.ts | 45 + .../src/modules/marketplace.controller.ts | 32 +- .../src/modules/marketplace.service.ts | 234 ++- .../migrations/011-module-configuration.ts | 22 + .../012-marketplace-branch-updates.ts | 16 + .../modules/module-configuration.service.ts | 208 ++ .../src/modules/module-container-manager.ts | 204 +- .../modules/module-gateway.middleware.spec.ts | 4 +- .../src/modules/module-installer.ts | 69 +- .../modules/module-permission.repository.ts | 7 +- .../module-permissions.service.spec.ts | 4 +- .../src/modules/module-process-manager.ts | 13 +- .../src/modules/module.repository.ts | 33 +- .../src/modules/modules.controller.ts | 28 +- .../src/modules/modules.module.ts | 2 + .../src/modules/modules.service.spec.ts | 6 +- .../src/modules/modules.service.ts | 195 +- apps/platform-frontend/components.json | 8 + apps/platform-frontend/package-lock.json | 1862 ++++++++++++++++- apps/platform-frontend/package.json | 10 +- .../src/components/layout/app-layout.tsx | 216 +- .../src/components/ui/badge.tsx | 45 +- .../src/components/ui/button.tsx | 90 +- .../src/components/ui/card.tsx | 55 +- .../src/components/ui/checkbox.tsx | 18 + .../src/components/ui/dialog.tsx | 156 ++ .../src/components/ui/dropdown-menu.tsx | 53 + .../src/components/ui/icon.tsx | 62 +- .../src/components/ui/input.tsx | 64 +- .../src/components/ui/label.tsx | 7 + .../src/components/ui/modal.tsx | 75 +- .../src/components/ui/select.tsx | 90 +- .../src/components/ui/separator.tsx | 25 + .../src/components/ui/sheet.tsx | 142 ++ .../src/components/ui/sidebar.tsx | 727 +++++++ .../src/components/ui/skeleton.tsx | 13 + .../src/components/ui/switch.tsx | 25 + .../src/components/ui/table.tsx | 21 + .../src/components/ui/tabs.tsx | 16 + .../src/components/ui/toast.tsx | 78 +- .../src/components/ui/tooltip.tsx | 54 + .../src/features/admin/audit-page.tsx | 62 +- .../src/features/admin/modules-page.tsx | 347 ++- .../src/features/admin/system-status-page.tsx | 16 +- .../features/admin/user-permissions-modal.tsx | 41 +- .../src/features/admin/users-page.tsx | 93 +- .../src/features/auth/login-page.tsx | 7 +- .../src/features/dashboard/dashboard-page.tsx | 26 +- .../src/features/profile/profile-page.tsx | 14 +- .../platform-frontend/src/hooks/use-mobile.ts | 19 + apps/platform-frontend/src/index.css | 105 + apps/platform-frontend/src/lib/api-client.ts | 10 +- apps/platform-frontend/src/lib/modules-api.ts | 45 +- apps/platform-frontend/src/lib/schemas.ts | 18 +- apps/platform-frontend/src/lib/utils.ts | 6 + apps/platform-frontend/tsconfig.app.json | 6 +- apps/platform-frontend/vite.config.ts | 9 +- docker-compose.yml | 1 + docker/nginx/nginx.conf | 45 + docs/MODULE-MARKETPLACE.md | 12 +- 66 files changed, 5187 insertions(+), 749 deletions(-) create mode 100644 apps/platform-backend/src/modules/migrations/011-module-configuration.ts create mode 100644 apps/platform-backend/src/modules/migrations/012-marketplace-branch-updates.ts create mode 100644 apps/platform-backend/src/modules/module-configuration.service.ts create mode 100644 apps/platform-frontend/components.json create mode 100644 apps/platform-frontend/src/components/ui/checkbox.tsx create mode 100644 apps/platform-frontend/src/components/ui/dialog.tsx create mode 100644 apps/platform-frontend/src/components/ui/dropdown-menu.tsx create mode 100644 apps/platform-frontend/src/components/ui/label.tsx create mode 100644 apps/platform-frontend/src/components/ui/separator.tsx create mode 100644 apps/platform-frontend/src/components/ui/sheet.tsx create mode 100644 apps/platform-frontend/src/components/ui/sidebar.tsx create mode 100644 apps/platform-frontend/src/components/ui/skeleton.tsx create mode 100644 apps/platform-frontend/src/components/ui/switch.tsx create mode 100644 apps/platform-frontend/src/components/ui/table.tsx create mode 100644 apps/platform-frontend/src/components/ui/tabs.tsx create mode 100644 apps/platform-frontend/src/components/ui/tooltip.tsx create mode 100644 apps/platform-frontend/src/hooks/use-mobile.ts create mode 100644 apps/platform-frontend/src/lib/utils.ts diff --git a/.env.example b/.env.example index fe89faf..f229d2d 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ BEHIND_PROXY=true # MPM braucht den Socket, um eigene Modul-Container zu verwalten. DOCKER_SOCKET_GID=0 +# Schlüssel für verschlüsselte Modulkonfigurationen (mindestens 32 Zeichen). +# Lokal dauerhaft generieren und geheim halten. +MODULE_CONFIG_ENCRYPTION_KEY= + # GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0). # MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten. DOCKER_SOCKET_GID=0 diff --git a/KI_SETUP.md b/KI_SETUP.md index ea8dc07..697bc7b 100644 --- a/KI_SETUP.md +++ b/KI_SETUP.md @@ -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. 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. + 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`. 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. - UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten. - Abhängigkeiten nur bei Bedarf ändern und Lockfiles konsistent halten. -- Keine Builds, Tests, Deployments, Commits oder Pushes ausführen, wenn der Nutzer das nicht angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben. +- Nach Codeänderungen, die den lokalen Plattformcontainer betreffen, diesen neu bauen und starten, damit der Container die aktuellen Änderungen erhält. Tests, externe Deployments, Commits und Pushes nur ausführen, wenn der Nutzer sie angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben. - Bei einem gewünschten Push Ziel-Remote und Branch verifizieren, den kompletten Commit-Diff auf Secrets prüfen und keine Force-Pushes ausführen, außer der Nutzer weist sie ausdrücklich an. ## Häufige Arbeitsplatzprobleme diff --git a/apps/platform-backend/src/audit/audit.service.ts b/apps/platform-backend/src/audit/audit.service.ts index 48a84ac..d70e323 100644 --- a/apps/platform-backend/src/audit/audit.service.ts +++ b/apps/platform-backend/src/audit/audit.service.ts @@ -15,10 +15,12 @@ export const AUDIT_ACTIONS = { USER_PASSWORD_RESET: 'USER_PASSWORD_RESET', USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED', MODULE_INSTALLED: 'MODULE_INSTALLED', + MODULE_UPDATED: 'MODULE_UPDATED', MODULE_REMOVED: 'MODULE_REMOVED', MODULE_STARTED: 'MODULE_STARTED', MODULE_STOPPED: 'MODULE_STOPPED', MODULE_RESTARTED: 'MODULE_RESTARTED', + MODULE_CONFIG_UPDATED: 'MODULE_CONFIG_UPDATED', MODULE_ENABLED: 'MODULE_ENABLED', MODULE_DISABLED: 'MODULE_DISABLED', PERMISSION_GRANTED: 'PERMISSION_GRANTED', @@ -66,4 +68,4 @@ export class AuditService { ); } } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/auth/auth.service.spec.ts b/apps/platform-backend/src/auth/auth.service.spec.ts index 8e1caed..a1854f6 100644 --- a/apps/platform-backend/src/auth/auth.service.spec.ts +++ b/apps/platform-backend/src/auth/auth.service.spec.ts @@ -25,7 +25,7 @@ function createConfig(overrides: Partial = {}): AppConfig ...overrides, }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, - runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, + runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, }; } diff --git a/apps/platform-backend/src/config/configuration.ts b/apps/platform-backend/src/config/configuration.ts index b9a1858..9e78087 100644 --- a/apps/platform-backend/src/config/configuration.ts +++ b/apps/platform-backend/src/config/configuration.ts @@ -35,6 +35,7 @@ export interface RuntimeConfig { readonly modulesDir: string; readonly logsDir: string; readonly moduleUidBase?: number; + readonly moduleConfigurationEncryptionKey: string; } export interface MarketplaceProviderConfig { @@ -85,6 +86,7 @@ const environmentSchema = z.object({ MODULES_DIR: z.string().min(1).default('./data/modules'), MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'), LOGS_DIR: z.string().min(1).default('./data/logs'), + MODULE_CONFIG_ENCRYPTION_KEY: z.string().default(''), MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(), MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'), MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''), @@ -184,6 +186,7 @@ export function loadConfiguration(): AppConfig { runtime: { modulesDir: path.resolve(environment.MODULES_DIR), logsDir: path.resolve(environment.LOGS_DIR), + moduleConfigurationEncryptionKey: environment.MODULE_CONFIG_ENCRYPTION_KEY, ...(environment.MODULE_UID_BASE !== undefined ? { moduleUidBase: environment.MODULE_UID_BASE } : {}), diff --git a/apps/platform-backend/src/database/migrations/index.ts b/apps/platform-backend/src/database/migrations/index.ts index a711a10..2ef3a01 100644 --- a/apps/platform-backend/src/database/migrations/index.ts +++ b/apps/platform-backend/src/database/migrations/index.ts @@ -8,6 +8,8 @@ import { migration007MarketplaceCatalog } from '../../modules/migrations/007-mar import { migration008MarketplaceSourceBranch } from '../../modules/migrations/008-marketplace-source-branch'; import { migration009MarketplaceInstallations } from '../../modules/migrations/009-marketplace-installations'; import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers'; +import { migration011ModuleConfiguration } from '../../modules/migrations/011-module-configuration'; +import { migration012MarketplaceBranchUpdates } from '../../modules/migrations/012-marketplace-branch-updates'; /** Registrierte Migrationen in aufsteigender Reihenfolge. */ export const MIGRATIONS = [ @@ -21,4 +23,6 @@ export const MIGRATIONS = [ migration008MarketplaceSourceBranch, migration009MarketplaceInstallations, migration010ModuleContainers, + migration011ModuleConfiguration, + migration012MarketplaceBranchUpdates, ]; diff --git a/apps/platform-backend/src/modules/manifest.types.ts b/apps/platform-backend/src/modules/manifest.types.ts index b2a1a3d..99cea50 100644 --- a/apps/platform-backend/src/modules/manifest.types.ts +++ b/apps/platform-backend/src/modules/manifest.types.ts @@ -16,6 +16,40 @@ export type ModuleStatus = (typeof MODULE_STATUSES)[number]; export const MODULE_PORT_MIN = 41000; export const MODULE_PORT_MAX = 41999; +/** Vom Modul deklarierte, durch Admins setzbare Compose-Umgebungsvariable. */ +export const moduleConfigurationFieldSchema = z.object({ + key: z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/), + label: z.string().trim().min(1).max(100), + description: z.string().max(500).default(''), + type: z.enum(['text', 'url', 'boolean']).default('text'), + secret: z.boolean().default(false), + required: z.boolean().default(true), + defaultValue: z.string().max(8192).optional(), + services: z.array(z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/)).min(1).max(20), +}).superRefine((field, context) => { + if (field.secret && field.defaultValue !== undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Geheimnisse dürfen keinen Manifest-Standardwert haben' }); + } + if (field.required && field.defaultValue !== undefined && !field.defaultValue.trim()) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Pflichtfelder benötigen einen nicht leeren Standardwert' }); + } + if (field.type === 'boolean' && field.defaultValue !== undefined && !['true', 'false'].includes(field.defaultValue)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Boolean-Standardwerte müssen true oder false sein' }); + } + if (field.type === 'url' && field.defaultValue !== undefined) { + try { + if (!['http:', 'https:'].includes(new URL(field.defaultValue).protocol)) throw new Error('protocol'); + } catch { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'URL-Standardwerte müssen HTTP oder HTTPS verwenden' }); + } + } + const processEnvironmentKey = /^(PATH|HOME|TMPDIR|PORT|NODE_ENV|MPM_MODULE_DATA_DIR|MPM_MODULE_IDENTITY_KEY|DOCKER_.*|COMPOSE_.*|NODE_.*|NPM_.*|PYTHON.*|BASH_.*|LD_.*|DYLD_.*|RUBY.*|PERL.*|GIT_.*|SSH_AUTH_SOCK)$/; + if (processEnvironmentKey.test(field.key)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['key'], message: 'Dieser Umgebungsvariablenname ist für MPM reserviert' }); + } +}); +export type ModuleConfigurationField = z.infer; + /** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */ const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/; @@ -54,6 +88,15 @@ export const moduleManifestSchema = z.object({ apiVersion: z.literal('v1'), composeFile: z.string().min(1).max(200).optional(), appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(), + configuration: z.array(moduleConfigurationFieldSchema).max(50).default([]), +}).superRefine((manifest, context) => { + const keys = new Set(); + 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; @@ -75,4 +118,6 @@ export interface ModuleRecord { readonly updatedAt: Date; readonly composeFile?: string | null; readonly appService?: string | null; + readonly configuration: readonly ModuleConfigurationField[]; + readonly configurationReady: boolean; } diff --git a/apps/platform-backend/src/modules/marketplace.controller.ts b/apps/platform-backend/src/modules/marketplace.controller.ts index 601d22c..bfa5f0d 100644 --- a/apps/platform-backend/src/modules/marketplace.controller.ts +++ b/apps/platform-backend/src/modules/marketplace.controller.ts @@ -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 { ApiTags } from '@nestjs/swagger'; 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 { MarketplaceService } from './marketplace.service'; import { ModulesService } from './modules.service'; +import type { ModuleRecord } from './manifest.types'; @ApiTags('Marketplace') @Controller({ path: 'api/v1/marketplace' }) @@ -42,12 +43,14 @@ export class MarketplaceController { ): Promise<{ module: { id: string; moduleId: string; name: string; slug: string; version: string; description: string; author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string; + configuration: ModuleRecord['configuration']; configurationReady: boolean; } }> { - const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository); + const branch = await this.marketplaceService.defaultBranch(provider, owner, repository); + const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository, branch); const manifest = await this.modulesService.validatePackage(archive); const module = await this.modulesService.findByModuleId(manifest.id) ?? await this.modulesService.install(archive, actor, request.ip ?? null); - await this.marketplaceService.recordInstallation(provider, owner, repository, module.id); + await this.marketplaceService.recordInstallation(provider, owner, repository, module.id, branch); return { module: { id: module.id, @@ -62,10 +65,33 @@ export class MarketplaceController { healthcheckUrl: module.healthcheckUrl, enabled: module.enabled, createdAt: module.createdAt.toISOString(), + configuration: module.configuration, + configurationReady: module.configurationReady, }, }; } + @Get('modules/:moduleId/updates') + @Roles('ADMIN') + updates(@Param('moduleId') moduleId: string) { + return this.marketplaceService.availableUpdates(moduleId); + } + + @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') @Roles('ADMIN') async startConnection( diff --git a/apps/platform-backend/src/modules/marketplace.service.ts b/apps/platform-backend/src/modules/marketplace.service.ts index ba91d8e..ec91f16 100644 --- a/apps/platform-backend/src/modules/marketplace.service.ts +++ b/apps/platform-backend/src/modules/marketplace.service.ts @@ -3,8 +3,11 @@ import { BadRequestException, Injectable, InternalServerErrorException, + Logger, NotFoundException, UnauthorizedException, + type OnModuleDestroy, + type OnModuleInit, } from '@nestjs/common'; import { createCipheriv, createHash, randomBytes } from 'node:crypto'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; @@ -37,6 +40,31 @@ interface ProviderIdentity { readonly username?: string; } +export interface MarketplaceBranch { + name: string; + commit: string; +} + +export interface MarketplaceUpdatePackage { + archive: Buffer; + commit: string; + provider: MarketplaceProvider; + owner: string; + repository: string; + branch: string; +} + +interface MarketplaceInstallationRow { + module_id: string; + provider: MarketplaceProvider; + owner: string; + repository: string; + installed_branch: string; + installed_commit: string | null; + available_branches: MarketplaceBranch[]; + observed_branches: MarketplaceBranch[]; +} + const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024; function isProvider(value: string): value is MarketplaceProvider { @@ -54,13 +82,47 @@ function safeBaseUrl(value: string): string { return url.toString().replace(/\/$/, ''); } +function branchVersion(branch: string): number[] | null { + const match = branch.match(/(?:^|[-_/.])v?(\d+(?:\.\d+)+)$/i); + return match ? match[1].split('.').map(Number) : null; +} + +function isNewerVersionBranch(candidate: string, installed: string): boolean { + const candidateVersion = branchVersion(candidate); + const installedVersion = branchVersion(installed); + // Preserve support for repositories that use non-versioned release branch + // names. When both names carry versions, only offer a strictly newer one. + if (!candidateVersion || !installedVersion) return true; + const length = Math.max(candidateVersion.length, installedVersion.length); + for (let index = 0; index < length; index += 1) { + const candidatePart = candidateVersion[index] ?? 0; + const installedPart = installedVersion[index] ?? 0; + if (candidatePart !== installedPart) return candidatePart > installedPart; + } + return false; +} + @Injectable() -export class MarketplaceService { +export class MarketplaceService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MarketplaceService.name); + private branchCheckTimer: NodeJS.Timeout | undefined; + constructor( private readonly database: DatabaseService, @Inject(APP_CONFIG) private readonly config: AppConfig, ) {} + onModuleInit(): void { + const initialCheck = setTimeout(() => void this.refreshAllBranchSnapshots(), 10_000); + initialCheck.unref(); + this.branchCheckTimer = setInterval(() => void this.refreshAllBranchSnapshots(), 60 * 1000); + this.branchCheckTimer.unref(); + } + + onModuleDestroy(): void { + if (this.branchCheckTimer) clearInterval(this.branchCheckTimer); + } + async providers(): Promise { const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>( '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 { + async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string, branch: string): Promise { 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( - `INSERT INTO marketplace_module_installations (provider, owner, repository, module_id) - VALUES ($1, $2, $3, $4) - ON CONFLICT (provider, owner, repository) DO UPDATE SET module_id = EXCLUDED.module_id`, - [provider, owner, repository, moduleId], + `INSERT INTO marketplace_module_installations + (provider, owner, repository, module_id, installed_branch, installed_commit, available_branches, observed_branches, branches_checked_at) + VALUES ($1, $2, $3, $4, $5, $6, '[]'::jsonb, $7::jsonb, now()) + ON CONFLICT (provider, owner, repository) DO UPDATE SET + module_id = EXCLUDED.module_id, installed_branch = EXCLUDED.installed_branch, + installed_commit = EXCLUDED.installed_commit, available_branches = '[]'::jsonb, + observed_branches = EXCLUDED.observed_branches, branches_checked_at = now()`, + [provider, owner, repository, moduleId, branch, branchInfo.commit, JSON.stringify(branches)], ); } - async downloadRepositoryArchive(providerParam: string, owner: string, repository: string): Promise { + async defaultBranch(providerParam: string, owner: string, repository: string): Promise { + 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>(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( + `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 { const provider = this.requireProvider(providerParam); if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) { throw new BadRequestException('Repository-Angabe ist ungueltig'); @@ -228,11 +329,12 @@ export class MarketplaceService { this.repositoryApiPath(provider, owner, repository), ); if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt'); - const defaultBranch = String(repo.default_branch ?? 'main'); - if (!defaultBranch || defaultBranch.length > 200) throw new BadRequestException('Standard-Branch ist ungueltig'); + const selectedBranch = branch ?? String(repo.default_branch ?? 'main'); + if (!selectedBranch || selectedBranch.length > 200 || selectedBranch.includes('\0')) throw new BadRequestException('Branch ist ungueltig'); + if (branch) await this.getBranch(provider, owner, repository, branch); const archivePath = provider === 'github' - ? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(defaultBranch) - : new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(defaultBranch) + '.zip'; + ? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(selectedBranch) + : new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(selectedBranch) + '.zip'; const archiveUrl = provider === 'github' ? new URL(archivePath, 'https://api.github.com').toString() : new URL(archivePath, providerConfig.baseUrl).toString(); @@ -242,6 +344,116 @@ export class MarketplaceService { return this.normalizeRepositoryArchive(archive); } + async updateInstalledBranch(moduleId: string, branch: string): Promise { + const result = await this.database.query( + `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 { + 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 { + 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>(provider, this.config.marketplace.providers[provider]!.baseUrl, null, basePath); + const commit = value.commit as Record | 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 { + 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 { + const result = await this.database.query( + `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 { + 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>>(provider, providerConfig.baseUrl, null, path); + branches.push(...values.flatMap((value) => { + const name = String(value.name ?? ''); + const commit = value.commit as Record | 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 { const AdmZip = (await import('adm-zip')).default; const input = new AdmZip(archive); diff --git a/apps/platform-backend/src/modules/migrations/011-module-configuration.ts b/apps/platform-backend/src/modules/migrations/011-module-configuration.ts new file mode 100644 index 0000000..3f14db4 --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/011-module-configuration.ts @@ -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() + ) + `); + }, +}; diff --git a/apps/platform-backend/src/modules/migrations/012-marketplace-branch-updates.ts b/apps/platform-backend/src/modules/migrations/012-marketplace-branch-updates.ts new file mode 100644 index 0000000..b9d1902 --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/012-marketplace-branch-updates.ts @@ -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 + `); + }, +}; diff --git a/apps/platform-backend/src/modules/module-configuration.service.ts b/apps/platform-backend/src/modules/module-configuration.service.ts new file mode 100644 index 0000000..0cb9258 --- /dev/null +++ b/apps/platform-backend/src/modules/module-configuration.service.ts @@ -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 { + 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> { + 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), + ...(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(); + for (const [key, rawValue] of Object.entries(valuesInput as Record)) { + 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> { + 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> { + const result = await this.database.query( + '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; + } 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'); + } + } +} diff --git a/apps/platform-backend/src/modules/module-container-manager.ts b/apps/platform-backend/src/modules/module-container-manager.ts index 711c3e3..6845022 100644 --- a/apps/platform-backend/src/modules/module-container-manager.ts +++ b/apps/platform-backend/src/modules/module-container-manager.ts @@ -1,11 +1,12 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { spawn } from 'node:child_process'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { stringify, parseDocument } from 'yaml'; import { ModuleIdentityService } from './module-identity.service'; import type { ModuleRecord } from './manifest.types'; +import { ModuleConfigurationService } from './module-configuration.service'; const SAFE_SERVICE_KEYS = new Set([ 'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes', @@ -14,6 +15,17 @@ const SAFE_SERVICE_KEYS = new Set([ '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. */ @Injectable() 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 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 { - const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); - // Recreate stopped containers and project networks before each start. This - // prevents Compose v1 from trying to reconcile stale Docker Desktop network - // defaults after a stop; named data volumes are deliberately left untouched. - await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']); - if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); - await this.runDocker(['network', 'rm', gatewayNetwork], true); - await this.runDocker(['network', 'create', gatewayNetwork]); - await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans']); - if (this.mpmContainer) { - await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); - await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]); + const { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues } = await this.prepare(module); + try { + // Recreate stopped containers and project networks before each start. This + // prevents Compose v1 from reconciling stale Docker Desktop network defaults; + // named data volumes are deliberately left untouched. + await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans'], cleanupValues); + if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + await this.runDocker(['network', 'rm', gatewayNetwork], true); + await this.runDocker(['network', 'create', gatewayNetwork]); + await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans'], moduleValues); + if (this.mpmContainer) { + await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]); + } + this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`); + } finally { + await rm(overridePath, { force: true }); } - this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`); } async stop(module: ModuleRecord): Promise { - const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); - await this.runCompose(module.path, projectName, composePath, overridePath, ['stop']); - if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); - this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`); + const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module); + try { + await this.runCompose(module.path, projectName, composePath, overridePath, ['stop'], cleanupValues); + if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`); + } finally { + await rm(overridePath, { force: true }); + } } async remove(module: ModuleRecord): Promise { - const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); - if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); - // Compose down removes every app/database container and its networks. Named - // volumes remain, so uninstalling code does not silently destroy database data. - await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']); - await this.runDocker(['network', 'rm', gatewayNetwork], true); - await rm(overridePath, { force: true }); - this.logger.log(`Container für "${module.moduleId}" entfernt; Datenvolumes bleiben erhalten`); + const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module); + try { + if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + // Removing a module is an explicit delete: remove its project-scoped data volumes too. + await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--volumes', '--remove-orphans'], cleanupValues); + await this.runDocker(['network', 'rm', gatewayNetwork], true); + this.logger.log(`Container und Datenvolumes für "${module.moduleId}" entfernt`); + } finally { + await rm(overridePath, { force: true }); + } } private async prepare(module: ModuleRecord): Promise<{ @@ -63,6 +88,8 @@ export class ModuleContainerManager { overridePath: string; projectName: string; gatewayNetwork: string; + moduleValues: Record; + cleanupValues: Record; }> { if (!module.composeFile || !module.appService) { 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'); const compose = document.toJS() as Record; this.validateCompose(compose, module); + const serviceMap = compose.services as Record; + 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(/(?> = { + [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; + overrideServices[serviceName] = { + ...serviceOverride, + environment: { ...environment, [field.key]: value }, + }; + } + } const override = { version: '3.8', - services: { - [module.appService]: { - container_name: `mpm-${module.moduleId}-app`, - environment: { - PORT: String(module.internalPort), - NODE_ENV: process.env.NODE_ENV ?? 'production', - MPM_MODULE_DATA_DIR: '/var/lib/mpm-module', - MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId), - }, - volumes: ['mpm-runtime-data:/var/lib/mpm-module'], - networks: { - default: {}, - 'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] }, - }, - security_opt: ['no-new-privileges:true'], - }, - }, + services: overrideServices, volumes: { 'mpm-runtime-data': {} }, networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } }, }; await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 }); await writeFile(overridePath, stringify(override), { mode: 0o600 }); - return { composePath, overridePath, projectName, gatewayNetwork }; + await chmod(overridePath, 0o600); + return { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues }; } private validateCompose(compose: Record, module: ModuleRecord): void { @@ -204,41 +264,79 @@ export class ModuleContainerManager { } } - private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[]): Promise { - return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd); + private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[], config: Record): Promise { + return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd, false, config); } private runDocker(args: string[], ignoreFailure = false): Promise { return this.run('docker', args, process.cwd(), ignoreFailure); } - private run(command: string, args: string[], cwd: string, ignoreFailure = false): Promise { + private run(command: string, args: string[], cwd: string, ignoreFailure = false, extraEnv: Record = {}): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env: { + ...extraEnv, PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', // Do not let a root-owned /root/.docker configuration affect a child // command started by the unprivileged backend user. HOME: '/tmp', DOCKER_HOST: this.dockerHost, }, - stdio: ['ignore', 'ignore', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'], }); + let stdout = ''; let stderr = ''; + const keepTail = (current: string, chunk: string): string => (current + chunk).slice(-12_000); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout = keepTail(stdout, chunk); }); child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-2000); }); + child.stderr.on('data', (chunk: string) => { stderr = keepTail(stderr, chunk); }); + let processStartFailed = false; child.once('error', (error) => { 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) => { + if (processStartFailed) return; if (code === 0 || ignoreFailure) resolve(); else { - this.logger.error(`${command} ${args[args.length - 1]} schlug mit Status ${code} fehl: ${stderr.trim()}`); - reject(new Error(`${command} schlug mit Status ${code} fehl`)); + const diagnostic = this.sanitizeDiagnostic(`${stdout}\n${stderr}`, extraEnv); + const exitCode = code ?? 1; + this.logger.error(`${command} schlug mit Status ${exitCode} fehl: ${diagnostic}`); + reject(new ModuleCommandError(exitCode, diagnostic)); } }); }); } + + /** Entfernt Umgebungswerte, ANSI-Codes und wahrscheinliche Secrets aus CLI-Ausgaben. */ + private sanitizeDiagnostic(output: string, environment: Record): string { + 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); + } } diff --git a/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts b/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts index 2fbb538..e1f428a 100644 --- a/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts +++ b/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts @@ -25,6 +25,8 @@ function createModuleRecord(overrides: Partial = {}): ModuleRecord enabled: true, createdAt: new Date(), updatedAt: new Date(), + configuration: [], + configurationReady: true, ...overrides, }; } @@ -253,4 +255,4 @@ describe('ModuleGatewayMiddleware', () => { proxySpy.mockRestore(); }); -}); \ No newline at end of file +}); diff --git a/apps/platform-backend/src/modules/module-installer.ts b/apps/platform-backend/src/modules/module-installer.ts index 669bb81..fc8ce18 100644 --- a/apps/platform-backend/src/modules/module-installer.ts +++ b/apps/platform-backend/src/modules/module-installer.ts @@ -1,8 +1,10 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { moduleManifestSchema, type ModuleManifest } from './manifest.types'; import { secureModuleDirectory } from './module-filesystem'; +import { parseDocument } from 'yaml'; /** Maximale Größe eines Modul-Pakets (10 MB). */ const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024; @@ -76,6 +78,21 @@ export class ModuleInstaller { if (!zip.getEntry(manifest.composeFile)) { 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 } | 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; } @@ -125,6 +142,56 @@ export class ModuleInstaller { return { directory, manifest }; } + /** Stages and atomically swaps an installed module directory, retaining a rollback copy. */ + async replace( + buffer: Buffer, + manifest: ModuleManifest, + modulesDir: string, + ): Promise<{ directory: string; backupDirectory: string }> { + 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 { + await rm(directory, { recursive: true, force: true }); + await rename(backupDirectory, directory); + } + + async finalizeReplacement(backupDirectory: string): Promise { + await rm(backupDirectory, { recursive: true, force: true }); + } + /** Entfernt eine Modul-Installation vom Dateisystem. */ async remove(modulesDir: string, moduleId: string): Promise { const directory = path.join(modulesDir, moduleId); diff --git a/apps/platform-backend/src/modules/module-permission.repository.ts b/apps/platform-backend/src/modules/module-permission.repository.ts index 77ee7d5..4ef3b63 100644 --- a/apps/platform-backend/src/modules/module-permission.repository.ts +++ b/apps/platform-backend/src/modules/module-permission.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import type { ModuleRecord } from './manifest.types'; +import type { ModuleConfigurationField } from './manifest.types'; /** Modul-Berechtigung eines Benutzers. */ export interface ModulePermissionRecord { @@ -89,7 +90,7 @@ export class ModulePermissionRepository { const result = await this.database.query( `SELECT m.id, m.module_id, m.name, m.slug, m.version, m.description, m.author, m.path, m.status, m.internal_port, m.healthcheck_url, m.enabled, - m.created_at, m.updated_at + m.created_at, m.updated_at, m.configuration_schema, m.configuration_ready FROM user_module_permissions p JOIN modules m ON m.id = p.module_id WHERE p.user_id = $1 AND p.permission = 'GRANTED' AND m.enabled @@ -111,6 +112,8 @@ export class ModulePermissionRepository { enabled: row.enabled, createdAt: row.created_at, updatedAt: row.updated_at, + configuration: row.configuration_schema as ModuleConfigurationField[], + configurationReady: row.configuration_ready as boolean, })); } @@ -123,4 +126,4 @@ export class ModulePermissionRepository { createdAt: row.created_at, }; } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/module-permissions.service.spec.ts b/apps/platform-backend/src/modules/module-permissions.service.spec.ts index 953728f..509ef1a 100644 --- a/apps/platform-backend/src/modules/module-permissions.service.spec.ts +++ b/apps/platform-backend/src/modules/module-permissions.service.spec.ts @@ -28,6 +28,8 @@ function createModuleRecord(overrides: Partial = {}): ModuleRecord enabled: true, createdAt: new Date(), updatedAt: new Date(), + configuration: [], + configurationReady: true, ...overrides, }; } @@ -219,4 +221,4 @@ describe('ModulePermissionsService', () => { expect(modules[0].moduleId).toBe('demo'); }); }); -}); \ No newline at end of file +}); diff --git a/apps/platform-backend/src/modules/module-process-manager.ts b/apps/platform-backend/src/modules/module-process-manager.ts index 0890cd9..1a2f97b 100644 --- a/apps/platform-backend/src/modules/module-process-manager.ts +++ b/apps/platform-backend/src/modules/module-process-manager.ts @@ -46,7 +46,18 @@ export class ModuleProcessManager implements OnModuleDestroy { if (module.composeFile && module.appService) { await secureModuleDirectory(module.path); - await this.containerManager.start(module); + try { + await this.containerManager.start(module); + } catch (error) { + // Compose kann beim Build oder beim Start teilweise Container angelegt + // haben. Bereinige den Stack, bevor der ursprüngliche Fehler zurückgeht. + try { + await this.containerManager.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); return; } diff --git a/apps/platform-backend/src/modules/module.repository.ts b/apps/platform-backend/src/modules/module.repository.ts index 74351b9..38aff7a 100644 --- a/apps/platform-backend/src/modules/module.repository.ts +++ b/apps/platform-backend/src/modules/module.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; -import type { ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types'; +import type { ModuleConfigurationField, ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types'; interface ModuleRow { id: string; @@ -19,11 +19,13 @@ interface ModuleRow { updated_at: Date; compose_file: string | null; app_service: string | null; + configuration_schema: ModuleConfigurationField[]; + configuration_ready: boolean; } const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url, enabled, created_at, updated_at, - compose_file, app_service`; + compose_file, app_service, configuration_schema, configuration_ready`; /** * Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry. @@ -84,8 +86,8 @@ export class ModuleRepository { const result = await this.database.query( `INSERT INTO modules (module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url, - compose_file, app_service) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11) + compose_file, app_service, configuration_schema, configuration_ready) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11, $12::jsonb, $13) RETURNING ${MODULE_COLUMNS}`, [ manifest.id, @@ -99,6 +101,8 @@ export class ModuleRepository { manifest.healthcheck, manifest.composeFile ?? null, manifest.appService ?? null, + JSON.stringify(manifest.configuration), + manifest.configuration.every((field) => !field.required || field.defaultValue !== undefined), ], ); return this.mapRow(result.rows[0]); @@ -118,6 +122,18 @@ export class ModuleRepository { ); } + async updateManifest(id: string, manifest: ModuleManifest, configurationReady: boolean): Promise { + 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 { await this.database.query('DELETE FROM modules WHERE id = $1', [id]); } @@ -140,6 +156,15 @@ export class ModuleRepository { updatedAt: row.updated_at, composeFile: row.compose_file, appService: row.app_service, + configuration: row.configuration_schema, + configurationReady: row.configuration_ready, }; } + + async updateConfigurationReady(id: string, ready: boolean): Promise { + await this.database.query( + 'UPDATE modules SET configuration_ready = $2, updated_at = now() WHERE id = $1', + [id, ready], + ); + } } diff --git a/apps/platform-backend/src/modules/modules.controller.ts b/apps/platform-backend/src/modules/modules.controller.ts index b849938..6655540 100644 --- a/apps/platform-backend/src/modules/modules.controller.ts +++ b/apps/platform-backend/src/modules/modules.controller.ts @@ -25,6 +25,7 @@ import type { AuthenticatedRequest } from '../auth/authenticated-request'; import type { AuthUser } from '../users/user.types'; import type { ModuleRecord, ModuleStatus } from './manifest.types'; import { ModulesService } from './modules.service'; +import type { ModuleConfigurationState } from './module-configuration.service'; /** Modul-Daten in API-Antworten. */ interface ModuleResponse { @@ -40,6 +41,8 @@ interface ModuleResponse { healthcheckUrl: string; enabled: boolean; createdAt: string; + configuration: ModuleRecord['configuration']; + configurationReady: boolean; } function toModuleResponse(module: ModuleRecord): ModuleResponse { @@ -56,6 +59,8 @@ function toModuleResponse(module: ModuleRecord): ModuleResponse { healthcheckUrl: module.healthcheckUrl, enabled: module.enabled, createdAt: module.createdAt.toISOString(), + configuration: module.configuration, + configurationReady: module.configurationReady, }; } @@ -81,6 +86,21 @@ export class ModulesController { return { module: toModuleResponse(module) }; } + @Get(':id/configuration') + async getConfiguration(@Param('id', ParseUUIDPipe) id: string): Promise { + 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 { + return this.modulesService.saveConfiguration(id, body, actor, request.ip ?? null); + } + @Post('install') @UseInterceptors(FileInterceptor('package')) async install( @@ -147,8 +167,8 @@ export class ModulesController { @Param('id', ParseUUIDPipe) id: string, @CurrentUser() actor: AuthUser, @Req() request: AuthenticatedRequest & Request, - ): Promise<{ success: true }> { - await this.modulesService.remove(id, actor, request.ip ?? null); - return { success: true }; + ): Promise<{ success: true; cleanupWarning?: string }> { + const result = await this.modulesService.remove(id, actor, request.ip ?? null); + return { success: true, ...result }; } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/modules.module.ts b/apps/platform-backend/src/modules/modules.module.ts index 7f30cd8..25c423f 100644 --- a/apps/platform-backend/src/modules/modules.module.ts +++ b/apps/platform-backend/src/modules/modules.module.ts @@ -25,6 +25,7 @@ import { ModuleIdentityService } from './module-identity.service'; import { MarketplaceController } from './marketplace.controller'; import { MarketplaceService } from './marketplace.service'; import { ModuleContainerManager } from './module-container-manager'; +import { ModuleConfigurationService } from './module-configuration.service'; /** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */ @Module({ @@ -36,6 +37,7 @@ import { ModuleContainerManager } from './module-container-manager'; ModuleProcessManager, ModuleIdentityService, ModuleContainerManager, + ModuleConfigurationService, ModuleHealthChecker, ModulesService, SessionService, diff --git a/apps/platform-backend/src/modules/modules.service.spec.ts b/apps/platform-backend/src/modules/modules.service.spec.ts index 3dbc71a..121dda9 100644 --- a/apps/platform-backend/src/modules/modules.service.spec.ts +++ b/apps/platform-backend/src/modules/modules.service.spec.ts @@ -23,6 +23,7 @@ function createManifest(overrides: Partial = {}): ModuleManifest port: 41001, healthcheck: '/health', apiVersion: 'v1', + configuration: [], ...overrides, }; } @@ -45,6 +46,8 @@ function createModuleRecord(overrides: Partial = {}): ModuleRecord enabled: true, createdAt: new Date(), updatedAt: new Date(), + configuration: [], + configurationReady: true, ...overrides, }; } @@ -190,7 +193,7 @@ const TEST_CONFIG: AppConfig = { loginRateLimitWindowMinutes: 5, }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, - runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, + runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, }; @@ -214,6 +217,7 @@ describe('ModulesService', () => { processManager as unknown as ModuleProcessManager, healthChecker as unknown as ModuleHealthChecker, auditService as unknown as AuditService, + { resolvedValues: async () => ({}), state: async () => ({ ready: true, fields: [] }), save: async () => ({ state: { ready: true, fields: [] }, changedKeys: [] }) } as never, TEST_CONFIG, ); }); diff --git a/apps/platform-backend/src/modules/modules.service.ts b/apps/platform-backend/src/modules/modules.service.ts index a100888..dd8a327 100644 --- a/apps/platform-backend/src/modules/modules.service.ts +++ b/apps/platform-backend/src/modules/modules.service.ts @@ -2,7 +2,9 @@ import { BadRequestException, ConflictException, Inject, + InternalServerErrorException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; @@ -11,8 +13,10 @@ import type { ActingUser } from '../users/users.service'; import { ModuleHealthChecker } from './module-health-checker'; import { ModuleInstaller } from './module-installer'; import { ModuleProcessManager } from './module-process-manager'; +import { ModuleCommandError } from './module-container-manager'; import { ModuleRepository } from './module.repository'; import type { ModuleRecord } from './manifest.types'; +import { ModuleConfigurationService } from './module-configuration.service'; /** * Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module. @@ -27,12 +31,15 @@ import type { ModuleRecord } from './manifest.types'; */ @Injectable() export class ModulesService { + private readonly logger = new Logger(ModulesService.name); + constructor( private readonly moduleRepository: ModuleRepository, private readonly installer: ModuleInstaller, private readonly processManager: ModuleProcessManager, private readonly healthChecker: ModuleHealthChecker, private readonly auditService: AuditService, + private readonly configurationService: ModuleConfigurationService, @Inject(APP_CONFIG) private readonly config: AppConfig, ) {} @@ -48,6 +55,31 @@ export class ModulesService { return module; } + async getConfiguration(id: string) { + return this.configurationService.state(await this.getById(id)); + } + + async saveConfiguration( + id: string, + input: { values?: unknown; clearKeys?: unknown }, + actor: ActingUser, + ipAddress: string | null, + ) { + 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. */ async install( packageBuffer: Buffer, @@ -76,7 +108,27 @@ export class ModulesService { manifest, this.config.runtime.modulesDir, ); - const module = await this.moduleRepository.create(manifest, directory); + let module: ModuleRecord; + try { + module = await this.moduleRepository.create(manifest, directory); + } catch { + let cleanupFailed = false; + try { + await this.installer.remove(this.config.runtime.modulesDir, manifest.id); + } catch { + cleanupFailed = true; + this.logger.error(`Temporäre Dateien für Modul ${manifest.id} konnten nach fehlgeschlagener Registrierung nicht entfernt werden`); + } + throw new InternalServerErrorException({ + statusCode: 500, + error: 'Internal Server Error', + code: 'MODULE_INSTALL_REGISTRATION_FAILED', + message: `Modul „${manifest.name}“ konnte nicht in MPM registriert werden.`, + diagnostic: cleanupFailed + ? 'Die Registrierung ist fehlgeschlagen und die temporären Dateien konnten nicht bereinigt werden. Bitte Plattform-Logs prüfen.' + : 'Die Registrierung in der Datenbank ist fehlgeschlagen; die entpackten Dateien wurden zurückgerollt.', + }); + } await this.auditService.record({ userId: actor.id, @@ -92,6 +144,66 @@ export class ModulesService { return this.installer.validatePackage(packageBuffer); } + async updateFromMarketplace( + id: string, + packageBuffer: Buffer, + actor: ActingUser, + ipAddress: string | null, + ): Promise { + 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 { return this.moduleRepository.findByModuleId(moduleId); } @@ -103,6 +215,7 @@ export class ModulesService { if (module.status === 'RUNNING' || module.status === 'STARTING') { return module; } + await this.configurationService.resolvedValues(module); await this.moduleRepository.updateStatus(id, 'STARTING'); try { @@ -116,11 +229,21 @@ export class ModulesService { await this.moduleRepository.updateStatus(id, 'RUNNING'); await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress); } catch (error) { - await this.moduleRepository.updateStatus(id, 'ERROR'); - await this.processManager.stop(module.moduleId); - throw new BadRequestException( - `Modul konnte nicht gestartet werden: ${error instanceof Error ? error.message : String(error)}`, - ); + let statusUpdateFailed = false; + try { + await this.moduleRepository.updateStatus(id, 'ERROR'); + } catch { + statusUpdateFailed = true; + this.logger.error(`Fehlerstatus für Modul ${module.moduleId} konnte nicht gespeichert werden`); + } + try { + await this.processManager.stop(module.moduleId); + } catch { + // Erhalte den ursprünglichen Startfehler; ein fehlgeschlagener Cleanup + // darf ihn nicht durch eine zweite Ausnahme ersetzen. + this.logger.error(`Cleanup nach fehlgeschlagenem Start von ${module.moduleId} ist fehlgeschlagen`); + } + throw this.lifecycleFailure('start', module, error, statusUpdateFailed); } return (await this.moduleRepository.findById(id)) ?? module; } @@ -161,10 +284,14 @@ export class ModulesService { await this.moduleRepository.updateStatus(id, 'STOPPED'); await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress); } catch (error) { - await this.moduleRepository.updateStatus(id, 'ERROR'); - throw new BadRequestException( - `Modul konnte nicht gestoppt werden: ${error instanceof Error ? error.message : String(error)}`, - ); + let statusUpdateFailed = false; + try { + await this.moduleRepository.updateStatus(id, 'ERROR'); + } catch { + statusUpdateFailed = true; + this.logger.error(`Fehlerstatus für Modul ${module.moduleId} konnte nicht gespeichert werden`); + } + throw this.lifecycleFailure('stop', module, error, statusUpdateFailed); } return (await this.moduleRepository.findById(id)) ?? module; } @@ -211,17 +338,31 @@ export class ModulesService { } /** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */ - async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise { + async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> { const module = await this.getById(id); if (module.status === 'RUNNING' || module.status === 'STARTING') { await this.stop(id, actor, ipAddress); } - await this.processManager.remove(module); + try { + await this.processManager.remove(module); + } catch (error) { + throw this.lifecycleFailure('remove', module, error); + } - await this.moduleRepository.delete(id); - await this.installer.remove(this.config.runtime.modulesDir, module.moduleId); + try { + await this.moduleRepository.delete(id); + } catch (error) { + throw this.lifecycleFailure('remove', module, error); + } + let cleanupWarning: string | undefined; + try { + await this.installer.remove(this.config.runtime.modulesDir, module.moduleId); + } catch { + cleanupWarning = 'Das Modul wurde aus MPM entfernt, aber seine Dateien konnten nicht vollständig gelöscht werden.'; + this.logger.error(`Dateien von Modul ${module.moduleId} konnten nach dem Entfernen nicht bereinigt werden`); + } await this.auditService.record({ userId: actor.id, @@ -230,6 +371,7 @@ export class ModulesService { details: { moduleId: module.moduleId }, ipAddress, }); + return cleanupWarning ? { cleanupWarning } : {}; } /** 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( module: ModuleRecord, action: AuditAction, diff --git a/apps/platform-frontend/components.json b/apps/platform-frontend/components.json new file mode 100644 index 0000000..09365cb --- /dev/null +++ b/apps/platform-frontend/components.json @@ -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" } +} diff --git a/apps/platform-frontend/package-lock.json b/apps/platform-frontend/package-lock.json index f209669..2d0f870 100644 --- a/apps/platform-frontend/package-lock.json +++ b/apps/platform-frontend/package-lock.json @@ -9,14 +9,22 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.62.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.53.0", + "radix-ui": "^1.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.0", + "sonner": "^2.0.8", + "tailwind-merge": "^3.7.0", + "tw-animate-css": "^1.4.0", "zod": "^3.24.0" }, "devDependencies": { "@eslint/js": "^9.0.0", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^26.6.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", @@ -911,6 +919,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1047,6 +1093,1506 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.16.tgz", + "integrity": "sha512-I+T/Hc6SY3WbFVZ9Ne1o0DJbpjQpTpLIBZcVzYiHCQ9z1yGUd/G88u5SKyM6eJ223txAOS7bp7bb9k0Ocfkjwg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.21.tgz", + "integrity": "sha512-2fmc6COuKLq2k3P7JZrOt9Zf8HT9nTQhHN5V+A3ih7ftqoyDcoEUrPoLq8ngr7QwTd7hpQ2xoUKWtvy1Xw9RAQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.21", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.24.tgz", + "integrity": "sha512-dRvB59HhcjIWcCPKzK5Px1yQ8pIULQQrxqgyepCqin7tNXi/xvmC86WkyMOKhuCbjz3+v9Q6oE7/tUN5NQ9dCg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.2.0", + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.16.tgz", + "integrity": "sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.16.tgz", + "integrity": "sha512-w2u6oiTzb7WkWwV3ZClX/llYYlTbilUOkHIKkwb+PWWytQ/IdEVRNLIChjbcXy/mkqcKKM3wEhrOilJW+uPHDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.7.tgz", + "integrity": "sha512-6AizCpbU3Zngojp9vQak8Lt4AXwdKJ2lkRYAfpoETraT+qKVNeRqytiADzneHetFEB0Wu4+qeSfmZEq0PiVDmQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.12.tgz", + "integrity": "sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.21.tgz", + "integrity": "sha512-IzkusZHCZBnED5skW+b+syl8d+e3+zelvxZ2Ed9lfawqEBfa5osPj53sTJ+q/uNmGAj9e7wMYgyzwPu/luWJ4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.16.tgz", + "integrity": "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.8.tgz", + "integrity": "sha512-s8ABBkEuAOyhDub0Y99f8lVRlVsd28mUnOHtuqGg8LuHnefkmPN5rYzA2g8MAPLjAZMisnDHhN4dBHUM7ZF7Rg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.2.0.tgz", + "integrity": "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.5.tgz", + "integrity": "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.20.tgz", + "integrity": "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.25.tgz", + "integrity": "sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.2.0.tgz", + "integrity": "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.2.0.tgz", + "integrity": "sha512-5ELeC+bQUHXEjU51svw11DynjGFlaUjo5QOUCr5hptvLLtBiqWtva2O3IWdjrFWQwitw6k6KpHHEyI61/kCVeg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.16", + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.24.tgz", + "integrity": "sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.16.tgz", + "integrity": "sha512-2vTQj72YFmeoTxWKJEMbO/9uQt6zzLgbzi0+nWBffq49oo6KhIDP/D+fA2im0wsxBCAX7QfaF3xgsYBVdDbNFQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.25.tgz", + "integrity": "sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.25.tgz", + "integrity": "sha512-JIQiKcXsgl6BtwcMe7BMLvCGH2p9Czgcmxl6Ie9Zoi+MW1mmitkcm5We01m/ndLYVI8PpRJnM/RcLV0eOCxaJQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.3.0.tgz", + "integrity": "sha512-hZqTqDwc8Z5aI4rGIyPqCSJ2JoSNorqX/F1/gqNHkZzWJO6JuYzL7hn5sWOZJjpkfT+cbxWWRbKVU/IeiOM9ag==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.17.tgz", + "integrity": "sha512-+6jmOqjSNBr7F1uSc7HSDqD3XL54A2qA99I9rIH4JoOD/jDScw8tUmDz6HCAkjytbrE/cdL66i+QkeDyZ0WNyw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.12.tgz", + "integrity": "sha512-+SWSgf3HRpE4zUira+p6t5KUR473PSqewtQgj9qzl3znZPRWPCGycwXNTfHwkiRTD3tEXwNIXJg0i1NTteiULw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.2.0.tgz", + "integrity": "sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.8.tgz", + "integrity": "sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.18.tgz", + "integrity": "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz", + "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.17.tgz", + "integrity": "sha512-A+RfMARd9u+UaVbLQDmysGlAGuaFQrF+uxF3v+7+xU8tkKoYxtCSs+6HHnIKJVgam2sGZ7c0hLRPWSnmnFyGJQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.8.tgz", + "integrity": "sha512-MaOOZ1TlaYoOeSDnHeVIONss0rp5ngOTY5HJY+Xkq16PAopnj21iU9r9m0O/77cBYu8Ksaq3BcjlaOdpbfbzzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.20.tgz", + "integrity": "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.3.0.tgz", + "integrity": "sha512-1WSuAXaahfeLmFX/dfYo/ftg1filMfaWjVye2d40VlgtWMMq/NOVocfukZEXkG+dwrAoNI+qpo9bjhNSJ/XC8g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.8.tgz", + "integrity": "sha512-Tz9xOpV9Rbca5WTk7pzmfAECV3zb++uScT9mP3Px7ziAhH7SlK53b0uwMw8VubPhRK4x7uDKCy5fwevT8I0fAg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.16.tgz", + "integrity": "sha512-vugPjGQBXbJVVkftKqv/BVeZ108SyRGT3FR1oa14az6qyLgX8jPhnZYHgdSB7Gw8UJusqwb9eaK6mluOnsn/Vg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.5.0.tgz", + "integrity": "sha512-ZOBLK7KZvGL02V+4VwVbj8NYM4vPrAtyZdrgHSv+AMS9VL+5yAdnQF3DZT4Y8cbCp1Yn4xdp92orvez8fJa77w==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz", + "integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.8.tgz", + "integrity": "sha512-rr+zuoAeHWK3m6H4HgjOUiu3VsrQ/9JkjwY/uMnTgHJGPe9WNzDP/MOuIV7S+65p42DIxjGL0hAHktqBq82+2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.22.tgz", + "integrity": "sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.24.tgz", + "integrity": "sha512-XR9pDEz9CH0ez98zPXQZOpF46anvbarkoOpjXpPSX7gm21vEl5Tzoe1PSQd3EY1OTOHac4lNq+TPx/2Z7jObdQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.19.tgz", + "integrity": "sha512-l7FQHtuZjUJ0vKLdhKu5LzZ8Jw6K60QEC1tC22QNLuXN2PkydSoqEhKTCqwxBFZJ52NTCjzudDvl9sUsZeUbPw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.20.tgz", + "integrity": "sha512-O+Sjiv64DfnByHYtnKEXmn63uUskU/X61vjf69X1v8JFn/ZvuNNLdJTBfYurVTSDv4/RJnJGtGgb/uvuS/rRJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-toggle": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.20.tgz", + "integrity": "sha512-5gyzL6B5/zjBwU3ATBWAVZ4xXQC5F8E9CB4BmYC1UvZ08pN2OJQoooeIwJtZZ/2rz4sTiQmbGfdDvmU73wRJCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-separator": "1.1.16", + "@radix-ui/react-toggle-group": "1.1.20" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.3.0.tgz", + "integrity": "sha512-4HM4b1Cft1CBXCGDRDG3S/fh8GedBuVOMNh145wMIfTbVqcuv+tyj03nZqYgFevWJMYo58H0s+8P6s6NaUHWtA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.12.tgz", + "integrity": "sha512-/iPLVHRBzKWvekN+5xaDmXw80f+nleYYceomVORrGjuJe0LF2SCbSH4f/LG4hu32q92QSuPZx4hmSACYBVBfzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1812,11 +3358,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1826,7 +3382,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" @@ -2211,6 +3767,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2324,6 +3892,27 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2390,7 +3979,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2428,6 +4017,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.445", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.445.tgz", @@ -2807,6 +4402,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3334,6 +4938,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.53.0.tgz", + "integrity": "sha512-OxJ7elNku+iqVzOolk5caxq2LM8Mck6lhA6uZx3g2PlZ53+FvBnT6PlJxGCukCa6oF5wQwgSatn9cH1b4olWmQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3552,6 +5171,83 @@ "node": ">=6" } }, + "node_modules/radix-ui": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.7.0.tgz", + "integrity": "sha512-x+z5TV2v6u0QpAQ4dQfNMcvtIdSQ1Op2oqBEyfDXjNldRZbMTP+hcjr5Iie31bep81Axeioa+70Ww7pZDzTEQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.16", + "@radix-ui/react-accordion": "1.2.21", + "@radix-ui/react-alert-dialog": "1.1.24", + "@radix-ui/react-arrow": "1.1.16", + "@radix-ui/react-aspect-ratio": "1.1.16", + "@radix-ui/react-avatar": "1.2.7", + "@radix-ui/react-checkbox": "1.3.12", + "@radix-ui/react-collapsible": "1.1.21", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.8", + "@radix-ui/react-dialog": "1.2.0", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-dropdown-menu": "2.1.25", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-form": "0.2.0", + "@radix-ui/react-hover-card": "1.1.24", + "@radix-ui/react-label": "2.1.16", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-menubar": "1.1.25", + "@radix-ui/react-navigation-menu": "1.3.0", + "@radix-ui/react-one-time-password-field": "0.1.17", + "@radix-ui/react-password-toggle-field": "0.1.12", + "@radix-ui/react-popover": "1.2.0", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-progress": "1.1.17", + "@radix-ui/react-radio-group": "1.4.8", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-scroll-area": "1.3.0", + "@radix-ui/react-select": "2.3.8", + "@radix-ui/react-separator": "1.1.16", + "@radix-ui/react-slider": "1.5.0", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-switch": "1.3.8", + "@radix-ui/react-tabs": "1.1.22", + "@radix-ui/react-toast": "1.2.24", + "@radix-ui/react-toggle": "1.1.19", + "@radix-ui/react-toggle-group": "1.1.20", + "@radix-ui/react-toolbar": "1.1.20", + "@radix-ui/react-tooltip": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", + "@radix-ui/react-visually-hidden": "1.2.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -3583,6 +5279,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.18.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", @@ -3621,6 +5364,28 @@ "react-dom": ">=18" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3722,6 +5487,22 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -3758,6 +5539,16 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -3809,6 +5600,21 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3860,6 +5666,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -3901,6 +5714,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", diff --git a/apps/platform-frontend/package.json b/apps/platform-frontend/package.json index 511b778..aeb4188 100644 --- a/apps/platform-frontend/package.json +++ b/apps/platform-frontend/package.json @@ -12,14 +12,22 @@ }, "dependencies": { "@tanstack/react-query": "^5.62.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.53.0", + "radix-ui": "^1.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.0", + "sonner": "^2.0.8", + "tailwind-merge": "^3.7.0", + "tw-animate-css": "^1.4.0", "zod": "^3.24.0" }, "devDependencies": { "@eslint/js": "^9.0.0", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^26.6.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", @@ -32,4 +40,4 @@ "typescript-eslint": "^8.0.0", "vite": "^6.0.0" } -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/components/layout/app-layout.tsx b/apps/platform-frontend/src/components/layout/app-layout.tsx index d064f10..d005170 100644 --- a/apps/platform-frontend/src/components/layout/app-layout.tsx +++ b/apps/platform-frontend/src/components/layout/app-layout.tsx @@ -1,10 +1,13 @@ import { type ReactNode, useEffect, useState } from 'react'; -import { NavLink, Outlet } from 'react-router-dom'; +import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from '../../features/auth/auth-context'; import { ProfilePage } from '../../features/profile/profile-page'; +import { ChevronsUpDown } from 'lucide-react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { Icon, type IconName } from '../ui/icon'; +import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar'; +import { Switch } from '../ui/switch'; -/** Ein Navigationspunkt der Sidebar. */ interface NavItem { to: string; label: string; @@ -24,10 +27,19 @@ function initialDarkMode(): boolean { return document.documentElement.dataset.theme === 'dark'; } -/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */ +/** MPM-Shell mit shadcn Sidebar; bestehende Breiten, Höhen und Innenabstände bleiben erhalten. */ export function AppLayout(): ReactNode { + return ( + + + + ); +} + +function AppLayoutContent(): ReactNode { const { user, logout } = useAuth(); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { setOpenMobile, state: sidebarState } = useSidebar(); + const location = useLocation(); const [profileOpen, setProfileOpen] = useState(false); const [darkMode, setDarkMode] = useState(initialDarkMode); @@ -36,116 +48,118 @@ export function AppLayout(): ReactNode { localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light'); }, [darkMode]); - const visibleItems = NAV_ITEMS.filter( - (item) => !item.adminOnly || user?.role === 'ADMIN', - ); + const visibleItems = NAV_ITEMS.filter((item) => !item.adminOnly || user?.role === 'ADMIN'); return ( -
- {/* Mobile: Overlay-Hintergrund */} - {mobileMenuOpen && ( -
setMobileMenuOpen(false)} - aria-hidden="true" - /> - )} - - {/* Sidebar */} -
+ -
- - -
- + + + + {visibleItems.map((item) => { + const isActive = item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to); + return ( + + + setOpenMobile(false)}> + + {item.label} + + + + ); + })} + + + - {/* Hauptbereich */} -
- {/* Topbar (mobil: Menü-Button) */} -
- - Management-Plattform - MPM - + +
-
+ {profileOpen && setProfileOpen(false)} />} - + ); } diff --git a/apps/platform-frontend/src/components/ui/badge.tsx b/apps/platform-frontend/src/components/ui/badge.tsx index 13e6bec..0f8c553 100644 --- a/apps/platform-frontend/src/components/ui/badge.tsx +++ b/apps/platform-frontend/src/components/ui/badge.tsx @@ -1,29 +1,26 @@ -import { type ReactNode } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { type HTMLAttributes, type ReactNode } from 'react'; +import { cn } from '../../lib/utils'; -/** Farbschemata für Badges (Statusanzeigen). */ -export type BadgeVariant = 'success' | 'warning' | 'danger' | 'neutral' | 'info'; +const badgeVariants = cva('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset', { + variants: { + variant: { + success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20', + warning: 'bg-amber-50 text-amber-700 ring-amber-600/20', + danger: 'bg-red-50 text-red-700 ring-red-600/20', + neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20', + info: 'bg-brand-50 text-brand-700 ring-brand-600/20', + }, + }, + defaultVariants: { variant: 'neutral' }, +}); -const VARIANT_CLASSES: Record = { - success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20', - warning: 'bg-amber-50 text-amber-700 ring-amber-600/20', - danger: 'bg-red-50 text-red-700 ring-red-600/20', - neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20', - info: 'bg-brand-50 text-brand-700 ring-brand-600/20', -}; +export type BadgeVariant = NonNullable['variant']>; +export interface BadgeProps extends HTMLAttributes, VariantProps { children: ReactNode } -export interface BadgeProps { - variant?: BadgeVariant; - children: ReactNode; +/** shadcn/ui Badge mit den bisherigen MPM-Statusvarianten. */ +export function Badge({ variant = 'neutral', children, className, ...rest }: BadgeProps): ReactNode { + return {children}; } -/** Kleines Status-Label (Design-System). */ -export function Badge({ variant = 'neutral', children }: BadgeProps): ReactNode { - return ( - - {children} - - ); -} \ No newline at end of file +export { badgeVariants }; diff --git a/apps/platform-frontend/src/components/ui/button.tsx b/apps/platform-frontend/src/components/ui/button.tsx index 1471db1..a7265e0 100644 --- a/apps/platform-frontend/src/components/ui/button.tsx +++ b/apps/platform-frontend/src/components/ui/button.tsx @@ -1,58 +1,52 @@ +import { Slot } from 'radix-ui'; +import { cva, type VariantProps } from 'class-variance-authority'; import { type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { cn } from '../../lib/utils'; -/** Varianten des Buttons (Design-System). */ -export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; -export type ButtonSize = 'sm' | 'md' | 'lg'; +const buttonVariants = cva( + 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800', + primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300', + secondary: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400', + outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 active:bg-slate-100', + destructive: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800', + danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300', + ghost: 'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400', + link: 'text-brand-600 underline-offset-4 hover:underline', + }, + size: { + xs: 'h-6 rounded-md px-2 text-xs', + sm: 'h-8 px-3 text-sm', + md: 'h-10 px-4 text-sm', + default: 'h-9 px-4 text-sm', + lg: 'h-11 px-5 text-base', + icon: 'size-9 p-0', + }, + }, + defaultVariants: { variant: 'primary', size: 'md' }, + }, +); -const VARIANT_CLASSES: Record = { - primary: - 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300', - secondary: - 'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400', - danger: - 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300', - ghost: - 'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400', -}; - -const SIZE_CLASSES: Record = { - 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 { - variant?: ButtonVariant; - size?: ButtonSize; +export type ButtonVariant = NonNullable['variant']>; +export type ButtonSize = NonNullable['size']>; +export interface ButtonProps extends ButtonHTMLAttributes, VariantProps { loading?: boolean; + asChild?: boolean; children: ReactNode; } -/** Standard-Button des Design-Systems. */ -export function Button({ - variant = 'primary', - size = 'md', - loading = false, - className = '', - disabled, - children, - ...rest -}: ButtonProps): ReactNode { +/** shadcn/ui Button mit MPM-Varianten und bestehenden Abmessungen. */ +export function Button({ variant = 'primary', size = 'md', loading = false, asChild = false, className, disabled, children, ...rest }: ButtonProps): ReactNode { + const Comp = asChild ? Slot.Root : 'button'; return ( - + ); -} \ No newline at end of file +} + +export { buttonVariants }; diff --git a/apps/platform-frontend/src/components/ui/card.tsx b/apps/platform-frontend/src/components/ui/card.tsx index c872f65..306fd94 100644 --- a/apps/platform-frontend/src/components/ui/card.tsx +++ b/apps/platform-frontend/src/components/ui/card.tsx @@ -1,46 +1,33 @@ -import { type ReactNode } from 'react'; +import { type ComponentProps, type ReactNode } from 'react'; +import { Slot } from 'radix-ui'; +import { cn } from '../../lib/utils'; -export interface CardProps { - children: ReactNode; - className?: string; +/** shadcn/ui Card primitives; MPM classes preserve the established card geometry. */ +export function Card({ asChild = false, className, ...props }: ComponentProps<'div'> & { asChild?: boolean }): ReactNode { + const Comp = asChild ? Slot.Root : 'div'; + return ; } -/** Karten-Container des Design-Systems. */ -export function Card({ children, className = '' }: CardProps): ReactNode { - return ( -
- {children} -
- ); +export function CardHeader({ className, ...props }: ComponentProps<'div'>): ReactNode { + return
; } -export interface CardHeaderProps { - title: string; - description?: string; - children?: ReactNode; +export function CardTitle({ className, ...props }: ComponentProps<'h2'>): ReactNode { + return

; } -/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */ -export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode { - return ( -
-
-

{title}

- {description &&

{description}

} -
- {children} -
- ); +export function CardDescription({ className, ...props }: ComponentProps<'p'>): ReactNode { + return

; } -export interface CardBodyProps { - children: ReactNode; - className?: string; +export function CardAction({ className, ...props }: ComponentProps<'div'>): ReactNode { + return

; } -/** Karten-Inhalt. */ -export function CardBody({ children, className = '' }: CardBodyProps): ReactNode { - return
{children}
; +export function CardContent({ className, ...props }: ComponentProps<'div'>): ReactNode { + return
; +} + +export function CardFooter({ className, ...props }: ComponentProps<'div'>): ReactNode { + return
; } diff --git a/apps/platform-frontend/src/components/ui/checkbox.tsx b/apps/platform-frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..b432999 --- /dev/null +++ b/apps/platform-frontend/src/components/ui/checkbox.tsx @@ -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; +export function Checkbox({ className, ...props }: CheckboxProps): ReactNode { + return ( + + + + + + ); +} diff --git a/apps/platform-frontend/src/components/ui/dialog.tsx b/apps/platform-frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..9c3d816 --- /dev/null +++ b/apps/platform-frontend/src/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/apps/platform-frontend/src/components/ui/dropdown-menu.tsx b/apps/platform-frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..520ba47 --- /dev/null +++ b/apps/platform-frontend/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger }; diff --git a/apps/platform-frontend/src/components/ui/icon.tsx b/apps/platform-frontend/src/components/ui/icon.tsx index 4229aee..29e1ace 100644 --- a/apps/platform-frontend/src/components/ui/icon.tsx +++ b/apps/platform-frontend/src/components/ui/icon.tsx @@ -1,36 +1,40 @@ +import { + ArrowRight, + Boxes, + Check, + ClipboardList, + Clock3, + LayoutDashboard, + Menu, + Moon, + Plus, + Sun, + UserRound, + UsersRound, + X, + type LucideIcon, +} from 'lucide-react'; import { type ReactNode } from 'react'; export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check'; - -const PATHS: Record = { - dashboard: <>, - users: <>, - modules: <>, - system: <>, - audit: <>, - moon: , - sun: <>, - user: <>, - arrow: <>, - close: <>, - menu: <>, - plus: , - check: , +const ICONS: Record = { + dashboard: LayoutDashboard, + users: UsersRound, + modules: Boxes, + system: Clock3, + audit: ClipboardList, + moon: Moon, + sun: Sun, + user: UserRound, + arrow: ArrowRight, + close: X, + menu: Menu, + plus: Plus, + check: Check, }; +/** Lucide-Symbole im bestehenden MPM-Icon-API. */ export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode { - return ( - - ); + const Component = ICONS[name]; + return
); -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx b/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx index d945ee2..d8b330c 100644 --- a/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx +++ b/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx @@ -1,6 +1,8 @@ import { type ReactNode, useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Modal } from '../../components/ui/modal'; +import { Checkbox } from '../../components/ui/checkbox'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; import { useToast } from '../../components/ui/toast'; import { ApiError } from '../../lib/api-client'; import { @@ -102,14 +104,14 @@ export function UserPermissionsModal({

) : (
- - - - - - - - +
ModulFreigeben
+ + + Modul + Freigeben + + + {modulesQuery.data?.map((module) => { const isAdmin = user.role === 'ADMIN'; const isGranted = isAdmin || grantedModuleIds.has(module.id); @@ -117,29 +119,28 @@ export function UserPermissionsModal({ toggleMutation.isPending && toggleMutation.variables?.module.id === module.id; return ( - - - - + + ); })} - -
+ +

{module.name}

/{module.slug} · Version {module.version}

-
- + + handleToggle(module)} + className="cursor-pointer disabled:cursor-not-allowed disabled:opacity-60" + onCheckedChange={() => handleToggle(module)} /> -
+ +
)} diff --git a/apps/platform-frontend/src/features/admin/users-page.tsx b/apps/platform-frontend/src/features/admin/users-page.tsx index b8a0aa0..8276bf0 100644 --- a/apps/platform-frontend/src/features/admin/users-page.tsx +++ b/apps/platform-frontend/src/features/admin/users-page.tsx @@ -2,10 +2,13 @@ import { type FormEvent, type ReactNode, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useAuth } from '../auth/auth-context'; import { Button } from '../../components/ui/button'; +import { Badge } from '../../components/ui/badge'; +import { Card } from '../../components/ui/card'; import { Input } from '../../components/ui/input'; import { Modal } from '../../components/ui/modal'; import { Select } from '../../components/ui/select'; import { useToast } from '../../components/ui/toast'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; import { ApiError } from '../../lib/api-client'; import { createUser, @@ -401,64 +404,50 @@ export function UsersPage(): ReactNode {
-
- - - - - - - - - - - + +
BenutzerRolleStatusLetzter LoginAktionen
+ + + Benutzer + Rolle + Status + Letzter Login + Aktionen + + + {usersQuery.isLoading && ( - - - + + )} {usersQuery.isError && ( - - - + + )} {usersQuery.data?.map((user) => ( - - - - - - - + + ))} - -
+ + Benutzer werden geladen… -
+ + Benutzer konnten nicht geladen werden. -
+ +
{user.displayName}
@{user.username}
-
- + + + {user.role === 'ADMIN' ? 'Admin' : 'Benutzer'} - - - + + + + {user.isActive ? 'Aktiv' : 'Deaktiviert'} - - {formatDate(user.lastLoginAt)} + + + {formatDate(user.lastLoginAt)} +
-
+ + {usersQuery.data?.length === 0 && (

Noch keine Benutzer vorhanden.

)} -
+ {createOpen && (
+
void handleSubmit(event)} - className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm" + className="" noValidate >
@@ -98,7 +100,8 @@ export function LoginPage(): ReactNode { Anmelden +
); -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx b/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx index 51d3666..6547bd7 100644 --- a/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx +++ b/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx @@ -4,7 +4,7 @@ import { useAuth } from '../auth/auth-context'; import { apiRequest } from '../../lib/api-client'; import { fetchAccessibleModules } from '../../lib/modules-api'; import { healthSchema, type Health } from '../../lib/schemas'; -import { Card, CardBody, CardHeader } from '../../components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; import { Badge } from '../../components/ui/badge'; import { EmptyState, ErrorState, Spinner } from '../../components/ui/states'; import { Icon } from '../../components/ui/icon'; @@ -41,11 +41,10 @@ export function DashboardPage(): ReactNode { {/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */} - - + +
Meine AnwendungenFreigegebene Module der Plattform
+
+ {modulesQuery.isLoading && } {modulesQuery.isError && (
@@ -88,17 +89,16 @@ export function DashboardPage(): ReactNode { ))}
)} -
+
{/* Systemstatus (nur für Admins) */} {isAdmin && ( - - + +
SystemstatusLive-Status der Plattform-Komponenten
+
+ {healthQuery.isLoading && } {healthQuery.isError && (

)} - + )} diff --git a/apps/platform-frontend/src/features/profile/profile-page.tsx b/apps/platform-frontend/src/features/profile/profile-page.tsx index cc23ca8..6846332 100644 --- a/apps/platform-frontend/src/features/profile/profile-page.tsx +++ b/apps/platform-frontend/src/features/profile/profile-page.tsx @@ -1,7 +1,7 @@ import { type FormEvent, type ReactNode, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { Button } from '../../components/ui/button'; -import { Card, CardBody, CardHeader } from '../../components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; import { Input } from '../../components/ui/input'; import { Modal } from '../../components/ui/modal'; import { useToast } from '../../components/ui/toast'; @@ -77,8 +77,8 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode { >
- - + Persönliche Daten + {profileQuery.isLoading &&

Wird geladen…

} {profileQuery.isError &&

Profil konnte nicht geladen werden.

} {profile && ( @@ -91,12 +91,12 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode { )} -
+
- - +
Passwort ändernAndere aktive Sitzungen werden danach abgemeldet.
+
@@ -106,7 +106,7 @@ export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
- + diff --git a/apps/platform-frontend/src/hooks/use-mobile.ts b/apps/platform-frontend/src/hooks/use-mobile.ts new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/apps/platform-frontend/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(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 +} diff --git a/apps/platform-frontend/src/index.css b/apps/platform-frontend/src/index.css index 933ae19..633cde3 100644 --- a/apps/platform-frontend/src/index.css +++ b/apps/platform-frontend/src/index.css @@ -1,4 +1,16 @@ @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) @@ -19,6 +31,72 @@ --color-brand-950: oklch(0.24 0.08 265); } +/* shadcn/ui semantic palette; legacy MPM utilities keep their existing colors. */ +:root { + --background: #ffffff; + --foreground: #0f172a; + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: var(--color-brand-600); + --primary-foreground: #ffffff; + --secondary: #f1f5f9; + --secondary-foreground: #334155; + --muted: #f1f5f9; + --muted-foreground: #64748b; + --accent: #f1f5f9; + --accent-foreground: #0f172a; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #e2e8f0; + --input: #cbd5e1; + --ring: var(--color-brand-500); + --radius: 0.5rem; + --sidebar: #ffffff; + --sidebar-foreground: #475569; + --sidebar-primary: var(--color-brand-600); + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f1f5f9; + --sidebar-accent-foreground: #0f172a; + --sidebar-border: #e2e8f0; + --sidebar-ring: var(--color-brand-500); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); +} + /* Fokus-Stil für Tastaturnavigation (Barrierefreiheit) */ @layer base { :focus-visible { @@ -31,6 +109,33 @@ Markenakzente werden neutral statt blau. */ html[data-theme='dark'] { color-scheme: dark; + --sidebar: #303337; + --sidebar-foreground: #c2c6cb; + --sidebar-primary: #565b61; + --sidebar-primary-foreground: #f3f4f6; + --sidebar-accent: #3c4044; + --sidebar-accent-foreground: #e5e7eb; + --sidebar-border: #474b50; + --sidebar-ring: #92979d; + --background: #272a2d; + --foreground: #e5e7eb; + --card: #303337; + --card-foreground: #e5e7eb; + --popover: #303337; + --popover-foreground: #e5e7eb; + --primary: #565b61; + --primary-foreground: #f3f4f6; + --secondary: #3c4044; + --secondary-foreground: #e5e7eb; + --muted: #3c4044; + --muted-foreground: #aeb3b9; + --accent: #3c4044; + --accent-foreground: #e5e7eb; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #474b50; + --input: #565b61; + --ring: #92979d; --color-slate-50: #272a2d; --color-slate-100: #3c4044; --color-slate-200: #474b50; diff --git a/apps/platform-frontend/src/lib/api-client.ts b/apps/platform-frontend/src/lib/api-client.ts index 827a6bd..9f609fe 100644 --- a/apps/platform-frontend/src/lib/api-client.ts +++ b/apps/platform-frontend/src/lib/api-client.ts @@ -11,6 +11,8 @@ export class ApiError extends Error { public readonly status: number, message: string, public readonly details?: Record, + public readonly code?: string, + public readonly diagnostic?: string, ) { super(message); this.name = 'ApiError'; @@ -78,10 +80,14 @@ export async function apiRequest( if (!response.ok) { let message = 'Ein unerwarteter Fehler ist aufgetreten.'; let details: Record | undefined; + let code: string | undefined; + let diagnostic: string | undefined; try { const errorBody = (await response.json()) as { message?: string | string[]; details?: Record; + code?: string; + diagnostic?: string; }; if (typeof errorBody.message === 'string') { message = errorBody.message; @@ -89,10 +95,12 @@ export async function apiRequest( message = errorBody.message.join(', '); } details = errorBody.details; + code = errorBody.code; + diagnostic = errorBody.diagnostic; } catch { // Antwort enthält kein JSON – Standardmeldung verwenden. } - throw new ApiError(response.status, message, details); + throw new ApiError(response.status, message, details, code, diagnostic); } return (await response.json()) as TResponse; diff --git a/apps/platform-frontend/src/lib/modules-api.ts b/apps/platform-frontend/src/lib/modules-api.ts index cf523d5..4a08cb0 100644 --- a/apps/platform-frontend/src/lib/modules-api.ts +++ b/apps/platform-frontend/src/lib/modules-api.ts @@ -3,9 +3,11 @@ import { accessibleModuleSchema, modulePermissionSchema, moduleSchema, + moduleConfigurationSchema, type AccessibleModule, type Module, type ModulePermission, + type ModuleConfiguration, } from './schemas'; /** Typsichere API-Funktionen für die Modul-Verwaltung. */ @@ -35,10 +37,14 @@ export async function installModule(file: File): Promise { if (!response.ok) { let message = 'Installation fehlgeschlagen'; let details: Record | undefined; + let code: string | undefined; + let diagnostic: string | undefined; try { const errorBody = (await response.json()) as { message?: string | string[]; details?: Record; + code?: string; + diagnostic?: string; }; if (typeof errorBody.message === 'string') { message = errorBody.message; @@ -46,10 +52,12 @@ export async function installModule(file: File): Promise { message = errorBody.message.join(', '); } details = errorBody.details; + code = errorBody.code; + diagnostic = errorBody.diagnostic; } catch { // Kein JSON in der Antwort – Standardmeldung verwenden. } - throw new ApiError(response.status, message, details); + throw new ApiError(response.status, message, details, code, diagnostic); } const response_ = (await response.json()) as { module: unknown }; @@ -85,8 +93,21 @@ export async function setModuleEnabled(id: string, enabled: boolean): Promise { - await apiRequest(`/api/v1/modules/${id}`, { method: 'DELETE' }); +export async function removeModule(id: string): Promise<{ cleanupWarning?: string }> { + return apiRequest<{ cleanupWarning?: string }>(`/api/v1/modules/${id}`, { method: 'DELETE' }); +} + +export async function fetchModuleConfiguration(id: string): Promise { + return moduleConfigurationSchema.parse(await apiRequest(`/api/v1/modules/${id}/configuration`)); +} + +export async function saveModuleConfiguration( + id: string, + input: { values: Record; clearKeys: string[] }, +): Promise { + return moduleConfigurationSchema.parse(await apiRequest(`/api/v1/modules/${id}/configuration`, { + method: 'PATCH', body: input, + })); } export async function checkModuleHealth( @@ -166,6 +187,24 @@ export async function installMarketplaceRepository( 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 { + return apiRequest(`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/updates`); +} + +export async function updateMarketplaceModule(moduleId: string, branch: string): Promise { + 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 { return apiRequest('/api/v1/marketplace/providers'); } diff --git a/apps/platform-frontend/src/lib/schemas.ts b/apps/platform-frontend/src/lib/schemas.ts index af9e2e2..dc53017 100644 --- a/apps/platform-frontend/src/lib/schemas.ts +++ b/apps/platform-frontend/src/lib/schemas.ts @@ -134,9 +134,25 @@ export const moduleSchema = z.object({ healthcheckUrl: z.string(), enabled: z.boolean(), createdAt: z.string(), + configuration: z.array(z.object({ + key: z.string(), label: z.string(), description: z.string(), + type: z.enum(['text', 'url', 'boolean']), secret: z.boolean(), required: z.boolean(), + defaultValue: z.string().optional(), services: z.array(z.string()), + })), + configurationReady: z.boolean(), }); export type Module = z.infer; +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; + /** Modul-Kachel für das Dashboard (/api/v1/profile/modules). */ export const accessibleModuleSchema = z.object({ id: z.string(), @@ -155,4 +171,4 @@ export const modulePermissionSchema = z.object({ moduleName: z.string(), permission: z.enum(['GRANTED', 'DENIED']), }); -export type ModulePermission = z.infer; \ No newline at end of file +export type ModulePermission = z.infer; diff --git a/apps/platform-frontend/src/lib/utils.ts b/apps/platform-frontend/src/lib/utils.ts new file mode 100644 index 0000000..a7c2663 --- /dev/null +++ b/apps/platform-frontend/src/lib/utils.ts @@ -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)); +} diff --git a/apps/platform-frontend/tsconfig.app.json b/apps/platform-frontend/tsconfig.app.json index ba46317..086434b 100644 --- a/apps/platform-frontend/tsconfig.app.json +++ b/apps/platform-frontend/tsconfig.app.json @@ -16,7 +16,9 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } }, "include": ["src"] -} \ No newline at end of file +} diff --git a/apps/platform-frontend/vite.config.ts b/apps/platform-frontend/vite.config.ts index 6c0a59c..8e54fe9 100644 --- a/apps/platform-frontend/vite.config.ts +++ b/apps/platform-frontend/vite.config.ts @@ -1,10 +1,17 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(fileURLToPath(new URL('.', import.meta.url)), 'src'), + }, + }, server: { port: 5173, proxy: { @@ -19,4 +26,4 @@ export default defineConfig({ outDir: 'dist', sourcemap: false, }, -}); \ No newline at end of file +}); diff --git a/docker-compose.yml b/docker-compose.yml index b2af4b2..5813ddb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,7 @@ services: DOCKER_SOCKET_GID: ${DOCKER_SOCKET_GID:-0} MODULES_DIR: /app/data/modules LOGS_DIR: /app/data/logs + MODULE_CONFIG_ENCRYPTION_KEY: ${MODULE_CONFIG_ENCRYPTION_KEY:-} ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen} ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen} diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 85b711f..6a3edb0 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -62,6 +62,30 @@ http { } # Management-API ans Backend proxien + # Modulstarts können durch Docker-Builds deutlich länger als 30 Sekunden dauern. + # Der Client muss auf die Lifecycle-Antwort warten können. + location /api/v1/modules/ { + proxy_pass http://platform_backend; + proxy_http_version 1.1; + proxy_set_header Host $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/ { proxy_pass http://platform_backend; proxy_http_version 1.1; @@ -72,6 +96,27 @@ http { 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(?/.*)?$" { + 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-Gateway des Backends übergeben (/api/v1/gateway/slug/*). # Der Gateway prüft Session, Modul-Status und Berechtigung, diff --git a/docs/MODULE-MARKETPLACE.md b/docs/MODULE-MARKETPLACE.md index f453cd0..86e7114 100644 --- a/docs/MODULE-MARKETPLACE.md +++ b/docs/MODULE-MARKETPLACE.md @@ -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. -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. - Der Browser übermittelt keine Download-URL; MPM erstellt sie aus Anbieter, Besitzer, Repository und Standard-Branch. - Die Installation registriert das Modul. Das Starten bleibt ein separater Lifecycle-Schritt und erfolgt erst nach Bestätigung durch den Administrator. @@ -55,9 +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. 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 -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