From 289dfc8230c330f0d40451d822a26191dc9172c9 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 13 Sep 2026 09:44:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(marketplace):=20UI=20fuer=20Plugin-Marketp?= =?UTF-8?q?lace=20=E2=80=94=20Modul=205/16=20des=20UI-Backlogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERSTES Modul ueber die Phase-Q-Plugin-Architektur: Registrierung komplett ueber das Plugin-Manifest (page_routes + menu_items) — routes/index.tsx und Sidebar.tsx wurden NICHT angefasst. Der Komponenten-Map-Generator wired den lazy import automatisch (38 Komponenten). Backend existierte vollstaendig (listings mit search/tags/pagination, listing-detail, install mit Ed25519-Signatur-Verify, verify, categories; marketplace:read/admin + require_admin fuer Install), Frontend hatte 0% Abdeckung. - api/marketplace.ts: TanStack-Hooks (useMarketplaceListings mit search/tags/pagination, useMarketplaceListing, useMarketplaceCategories, useInstallFromMarketplace, useVerifyMarketplacePlugin) - pages/Marketplace.tsx: Suche, Tag-Filter-Chips, Listing-Karten (Name, Version, Author, Beschreibung, Tags, Download-Counter, Verified-Badge, Preis/Kostenlos), Install mit Confirm (Admin-only via is_system_admin), Signatur-Verify, Ergebnis-Banner, Pagination — No-Access-Card ohne marketplace:read - Manifest: page_route /marketplace + menu_item (Store-Icon, order 85, permission marketplace:read) - i18n marketplace.* + nav.marketplace de/en Verifikation: Vitest 10/10 (Rendering, No-Access, Empty/Error, Karten, Search+Tags, Admin-Gating, Install-Flow mit+ohne Confirm, Verify-Flow) · tsc exit 0 · production build exit 0 · Backend-Regressionen (route-order, m5-miniapps) 10/10 · compileall sauber · Manifest-Check: page_route + menu_item korrekt. --- app/plugins/builtins/marketplace/plugin.py | 24 +- .../src/__tests__/pages/Marketplace.test.tsx | 196 ++++++++++ frontend/src/api/marketplace.ts | 136 +++++++ .../generated/pluginComponents.generated.ts | 1 + frontend/src/i18n/locales/de.json | 20 +- frontend/src/i18n/locales/en.json | 20 +- frontend/src/pages/Marketplace.tsx | 349 ++++++++++++++++++ 7 files changed, 741 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/pages/Marketplace.test.tsx create mode 100644 frontend/src/api/marketplace.ts create mode 100644 frontend/src/pages/Marketplace.tsx diff --git a/app/plugins/builtins/marketplace/plugin.py b/app/plugins/builtins/marketplace/plugin.py index 8ac68f1..299f23e 100644 --- a/app/plugins/builtins/marketplace/plugin.py +++ b/app/plugins/builtins/marketplace/plugin.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef logger = logging.getLogger(__name__) @@ -29,8 +29,26 @@ class MarketplacePlugin(BasePlugin): events=[], migrations=["0001_initial.sql"], permissions=["marketplace:read", "marketplace:admin"], - menu_items=[], - page_routes=[], + # UI-Backlog Modul 5 (2026-09-13): marketplace browse/install page, + # registered via the manifest (Phase Q pattern). + menu_items=[ + FrontendMenuItem( + label_key="nav.marketplace", + label="Marketplace", + path="/marketplace", + icon="Store", + order=85, + permission="marketplace:read", + ), + ], + page_routes=[ + FrontendPageRoute( + path="/marketplace", + component="@/pages/Marketplace", + protected=True, + permission="marketplace:read", + ), + ], settings_pages=[], detail_tabs=[], author="LeoCRM", diff --git a/frontend/src/__tests__/pages/Marketplace.test.tsx b/frontend/src/__tests__/pages/Marketplace.test.tsx new file mode 100644 index 0000000..3c800fd --- /dev/null +++ b/frontend/src/__tests__/pages/Marketplace.test.tsx @@ -0,0 +1,196 @@ +/** + * Marketplace page tests — browse, search, install plugins (module 5/16). + * + * Covers: rendering, search input, tag filter buttons, listing cards with + * verified badge, install flow (admin only) with confirmation, verify + * signature flow, permission gating, empty and error states. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MarketplacePage } from '@/pages/Marketplace'; +import type { MarketplaceListing } from '@/api/marketplace'; + +const installMut = vi.fn().mockImplementation((_payload, opts) => { + opts?.onSuccess?.({ + success: true, name: 'demo-plugin', display_name: 'Demo Plugin', + version: '1.0.0', installed: true, activated: true, + message: 'ok', error: null, + }); + return Promise.resolve({}); +}); +const verifyMut = vi.fn().mockImplementation((_payload, opts) => { + opts?.onSuccess?.({ + name: 'demo-plugin', version: '1.0.0', + signature_valid: true, message: 'Signature valid', + }); + return Promise.resolve({}); +}); + +const makeListing = (overrides: Partial = {}): MarketplaceListing => ({ + id: 'mk-1', + name: 'demo-plugin', + display_name: 'Demo Plugin', + description: 'A demo plugin for testing', + version: '1.0.0', + author: 'LeoCRM', + homepage: '', + download_url: 'https://example.com/demo.zip', + icon: '', + screenshots: [], + tags: ['crm', 'demo'], + price: 0, + is_verified: true, + download_count: 42, + min_app_version: '1.0.0', + license: 'MIT', + created_at: null, + updated_at: null, + ...overrides, +}); + +let mockListings: MarketplaceListing[] = []; +let mockCategories: string[] = []; +let mockCanBrowse = true; +let mockIsAdmin = true; +let mockError = false; + +vi.mock('@/api/marketplace', () => ({ + useMarketplaceListings: () => ({ + data: { listings: mockListings, total: mockListings.length, page: 1, page_size: 12 }, + isLoading: false, + isError: mockError, + isFetching: false, + refetch: vi.fn(), + }), + useMarketplaceCategories: () => ({ + data: { categories: mockCategories, total: mockCategories.length }, + isLoading: false, + }), + useInstallFromMarketplace: () => ({ + mutate: installMut, + isPending: false, + }), + useVerifyMarketplacePlugin: () => ({ + mutate: verifyMut, + isPending: false, + }), +})); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ + hasPermission: (perm: string) => mockCanBrowse || perm !== 'marketplace:read', + }), +})); + +vi.mock('@/store/authStore', () => ({ + useAuthStore: (selector: (s: { user: { id: string; is_system_admin: boolean } }) => unknown) => + selector({ user: { id: 'u-1', is_system_admin: mockIsAdmin } }), +})); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockListings = []; + mockCategories = []; + mockCanBrowse = true; + mockIsAdmin = true; + mockError = false; +}); + +describe('MarketplacePage', () => { + it('renders the page with title', () => { + renderPage(); + expect(screen.getByTestId('marketplace-page')).toBeInTheDocument(); + }); + + it('shows no-access card without marketplace:read', () => { + mockCanBrowse = false; + renderPage(); + expect(screen.getByTestId('marketplace-no-access')).toBeInTheDocument(); + }); + + it('shows empty state when no listings exist', () => { + renderPage(); + expect(screen.getByTestId('marketplace-empty')).toBeInTheDocument(); + }); + + it('shows error state on load failure', () => { + mockError = true; + renderPage(); + expect(screen.getByTestId('marketplace-error')).toBeInTheDocument(); + }); + + it('renders listing cards with name, version, verified badge and tags', () => { + mockListings = [makeListing()]; + renderPage(); + expect(screen.getByTestId('marketplace-card-demo-plugin')).toBeInTheDocument(); + expect(screen.getByText('Demo Plugin')).toBeInTheDocument(); + expect(screen.getByText(/v1\.0\.0/)).toBeInTheDocument(); + expect(screen.getByTestId('marketplace-verified-demo-plugin')).toBeInTheDocument(); + expect(screen.getByText('crm')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('renders the search input and tag filter buttons', () => { + mockCategories = ['crm', 'mail']; + renderPage(); + expect(screen.getByTestId('marketplace-search')).toBeInTheDocument(); + expect(screen.getByTestId('marketplace-tag-all')).toBeInTheDocument(); + expect(screen.getByTestId('marketplace-tag-crm')).toBeInTheDocument(); + expect(screen.getByTestId('marketplace-tag-mail')).toBeInTheDocument(); + }); + + it('hides the install button for non-admin users', () => { + mockListings = [makeListing()]; + mockIsAdmin = false; + renderPage(); + expect(screen.queryByTestId('marketplace-install-demo-plugin')).not.toBeInTheDocument(); + // verify button is still there (marketplace:read) + expect(screen.getByTestId('marketplace-verify-demo-plugin')).toBeInTheDocument(); + }); + + it('installs a plugin after confirmation', async () => { + mockListings = [makeListing()]; + window.confirm = vi.fn(() => true); + renderPage(); + fireEvent.click(screen.getByTestId('marketplace-install-demo-plugin')); + await waitFor(() => { + expect(installMut).toHaveBeenCalledWith( + { name: 'demo-plugin', activate: true }, + expect.anything(), + ); + }); + // success result banner appears + expect(await screen.findByTestId('marketplace-result')).toBeInTheDocument(); + }); + + it('does not install when the confirmation is rejected', () => { + mockListings = [makeListing()]; + window.confirm = vi.fn(() => false); + renderPage(); + fireEvent.click(screen.getByTestId('marketplace-install-demo-plugin')); + expect(installMut).not.toHaveBeenCalled(); + }); + + it('verifies a plugin signature and shows the result', async () => { + mockListings = [makeListing()]; + renderPage(); + fireEvent.click(screen.getByTestId('marketplace-verify-demo-plugin')); + await waitFor(() => { + expect(verifyMut).toHaveBeenCalledWith( + { name: 'demo-plugin' }, + expect.anything(), + ); + }); + expect(await screen.findByTestId('marketplace-result')).toHaveTextContent(/valid/i); + }); +}); diff --git a/frontend/src/api/marketplace.ts b/frontend/src/api/marketplace.ts new file mode 100644 index 0000000..6baa4b7 --- /dev/null +++ b/frontend/src/api/marketplace.ts @@ -0,0 +1,136 @@ +/** + * Marketplace API client — browse, search, verify and install plugins. + * + * Backend: /api/v1/marketplace (listings, listing detail, install, verify, + * categories). Permissions: marketplace:read (browse/verify), admin + * (install). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost } from '@/api/client'; + +export interface MarketplaceListing { + id: string; + name: string; + display_name: string; + description: string; + version: string; + author: string; + homepage: string; + download_url: string; + icon: string; + screenshots: string[]; + tags: string[]; + price: number; + is_verified: boolean; + download_count: number; + min_app_version: string; + license: string; + created_at: string | null; + updated_at: string | null; +} + +export interface MarketplaceListResponse { + listings: MarketplaceListing[]; + total: number; + page: number; + page_size: number; +} + +export interface MarketplaceCategoriesResponse { + categories: string[]; + total: number; +} + +export interface MarketplaceInstallResponse { + success: boolean; + name: string; + display_name: string; + version: string; + installed: boolean; + activated: boolean; + message: string; + error: string | null; +} + +export interface MarketplaceVerifyResponse { + name: string; + version: string; + signature_valid: boolean; + message: string; +} + +export interface MarketplaceListParams { + search?: string; + tags?: string[]; + page?: number; + pageSize?: number; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function useMarketplaceListings(params: MarketplaceListParams = {}) { + const searchParams = new URLSearchParams(); + if (params.search) searchParams.set('search', params.search); + if (params.tags && params.tags.length > 0) searchParams.set('tags', params.tags.join(',')); + if (params.page) searchParams.set('page', String(params.page)); + if (params.pageSize) searchParams.set('page_size', String(params.pageSize)); + const qs = searchParams.toString(); + + return useQuery({ + queryKey: ['marketplace-listings', params.search, params.tags, params.page, params.pageSize], + queryFn: () => apiGet(`/marketplace/listings${qs ? `?${qs}` : ''}`), + }); +} + +export function useMarketplaceListing(name: string | null) { + return useQuery({ + queryKey: ['marketplace-listings', name], + queryFn: () => apiGet(`/marketplace/listings/${name}`), + enabled: !!name, + }); +} + +export function useMarketplaceCategories() { + return useQuery({ + queryKey: ['marketplace-categories'], + queryFn: () => apiGet('/marketplace/categories'), + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +export function useInstallFromMarketplace() { + const qc = useQueryClient(); + return useMutation< + MarketplaceInstallResponse, + Error, + { name: string; signature?: string; publicKey?: string; activate?: boolean } + >({ + mutationFn: ({ name, signature, publicKey, activate }) => + apiPost(`/marketplace/install/${name}`, { + name, + signature: signature ?? null, + public_key: publicKey ?? null, + activate: activate ?? false, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['marketplace-listings'] }); + }, + }); +} + +export function useVerifyMarketplacePlugin() { + return useMutation< + MarketplaceVerifyResponse, + Error, + { name: string; signature?: string; publicKey?: string } + >({ + mutationFn: ({ name, signature, publicKey }) => + apiPost(`/marketplace/verify/${name}`, { + name, + signature: signature ?? null, + public_key: publicKey ?? null, + }), + }); +} diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts index 1cbff66..47ddf0b 100644 --- a/frontend/src/generated/pluginComponents.generated.ts +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -45,6 +45,7 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/pages/ImportExport': () => import('@/pages/ImportExport').then((m) => ({ default: m.ImportExportPage })), '@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })), '@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })), + '@/pages/Marketplace': () => import('@/pages/Marketplace').then(normalizeModule), '@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then((m) => ({ default: m.ProactiveAISettings })), '@/pages/Reports': () => import('@/pages/Reports').then((m) => ({ default: m.ReportsPage })), '@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then((m) => ({ default: m.SettingsGroupsPage })), diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bf22914..85b3096 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -23,7 +23,8 @@ "wiki": "Wiki", "systemDashboard": "System Dashboard", "approvals": "Freigaben", - "delegations": "Delegationen" + "delegations": "Delegationen", + "marketplace": "Marketplace" }, "auth": { "login": "Anmelden", @@ -1705,5 +1706,22 @@ "defaultBadge": "Standard", "empty": "Keine Mandanten vorhanden.", "loadError": "Mandanten konnten nicht geladen werden." + }, + "marketplace": { + "title": "Marketplace", + "searchPlaceholder": "Plugins durchsuchen...", + "categories": "Kategorien", + "allTags": "Alle", + "free": "Kostenlos", + "verified": "Verifiziert", + "install": "Installieren", + "installConfirm": "Plugin '{{name}}' wirklich installieren und aktivieren?", + "installSuccess": "Plugin '{{name}}' erfolgreich installiert.", + "installFailed": "Installation fehlgeschlagen", + "verify": "Signatur pruefen", + "verifyFailed": "Signaturpruefung fehlgeschlagen", + "empty": "Keine Plugins gefunden.", + "loadError": "Marketplace konnte nicht geladen werden.", + "noAccess": "Kein Zugriff auf den Marketplace (marketplace:read noetig)." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4d84a68..7d8cdaf 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -23,7 +23,8 @@ "wiki": "Wiki", "systemDashboard": "System Dashboard", "approvals": "Approvals", - "delegations": "Delegations" + "delegations": "Delegations", + "marketplace": "Marketplace" }, "auth": { "login": "Sign In", @@ -1705,5 +1706,22 @@ "defaultBadge": "Default", "empty": "No tenants yet.", "loadError": "Failed to load tenants." + }, + "marketplace": { + "title": "Marketplace", + "searchPlaceholder": "Search plugins...", + "categories": "Categories", + "allTags": "All", + "free": "Free", + "verified": "Verified", + "install": "Install", + "installConfirm": "Really install and activate plugin '{{name}}'?", + "installSuccess": "Plugin '{{name}}' installed successfully.", + "installFailed": "Installation failed", + "verify": "Verify signature", + "verifyFailed": "Signature verification failed", + "empty": "No plugins found.", + "loadError": "Failed to load the marketplace.", + "noAccess": "No marketplace access (marketplace:read required)." } } diff --git a/frontend/src/pages/Marketplace.tsx b/frontend/src/pages/Marketplace.tsx new file mode 100644 index 0000000..89ed8f8 --- /dev/null +++ b/frontend/src/pages/Marketplace.tsx @@ -0,0 +1,349 @@ +/** + * Marketplace page — browse, search, verify and install plugins + * (UI-Backlog module 5/16). + * + * Backend: /api/v1/marketplace. Install requires admin, + * browse/verify requires marketplace:read. + * + * NOTE (Phase Q): this page is registered via the marketplace PLUGIN + * MANIFEST (page_routes + menu_items) — routes/index.tsx and Sidebar.tsx + * are NOT touched. The component map generator wires the lazy import. + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Store, + Search, + ShieldCheck, + Download, + Package, + BadgeCheck, + ChevronLeft, + ChevronRight, + AlertTriangle, + Inbox, +} from 'lucide-react'; +import { + useMarketplaceListings, + useMarketplaceCategories, + useInstallFromMarketplace, + useVerifyMarketplacePlugin, + type MarketplaceListing, +} from '@/api/marketplace'; +import { usePermission } from '@/hooks/usePermission'; +import { useAuthStore } from '@/store/authStore'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; + +const PAGE_SIZE = 12; + +function ListingCard({ + listing, + isAdmin, + onInstall, + onVerify, + isMutating, +}: { + listing: MarketplaceListing; + isAdmin: boolean; + onInstall: (listing: MarketplaceListing) => void; + onVerify: (listing: MarketplaceListing) => void; + isMutating: boolean; +}) { + const { t } = useTranslation(); + + return ( + +
+
+
+ {listing.price > 0 ? ( + {listing.price.toFixed(2)} € + ) : ( + {t('marketplace.free')} + )} +
+ +

+ {listing.description || '—'} +

+ +
+ {listing.tags.map((tag) => ( + {tag} + ))} +
+ +
+ + +
+ + {isAdmin && ( + + )} +
+
+
+ ); +} + +export function MarketplacePage() { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [activeTag, setActiveTag] = useState(null); + const [page, setPage] = useState(1); + const [resultMessage, setResultMessage] = useState<{ ok: boolean; text: string } | null>(null); + + const { data, isLoading, isError } = useMarketplaceListings({ + search: search || undefined, + tags: activeTag ? [activeTag] : undefined, + page, + pageSize: PAGE_SIZE, + }); + const { data: categoriesData } = useMarketplaceCategories(); + const installMut = useInstallFromMarketplace(); + const verifyMut = useVerifyMarketplacePlugin(); + const { hasPermission } = usePermission(); + const user = useAuthStore((s) => s.user); + + const canBrowse = hasPermission('marketplace:read'); + const isAdmin = !!user?.is_system_admin; + const isMutating = installMut.isPending || verifyMut.isPending; + + const handleInstall = (listing: MarketplaceListing) => { + if ( + !window.confirm( + t('marketplace.installConfirm', { name: listing.display_name }), + ) + ) { + return; + } + installMut.mutate( + { name: listing.name, activate: true }, + { + onSuccess: (result) => { + setResultMessage({ + ok: result.success, + text: result.success + ? t('marketplace.installSuccess', { name: result.display_name }) + : `${t('marketplace.installFailed')}: ${result.error ?? result.message}`, + }); + }, + onError: (err) => { + setResultMessage({ ok: false, text: `${t('marketplace.installFailed')}: ${err.message}` }); + }, + }, + ); + }; + + const handleVerify = (listing: MarketplaceListing) => { + verifyMut.mutate( + { name: listing.name }, + { + onSuccess: (result) => { + setResultMessage({ + ok: result.signature_valid, + text: `${listing.display_name}: ${result.message}`, + }); + }, + onError: (err) => { + setResultMessage({ ok: false, text: `${t('marketplace.verifyFailed')}: ${err.message}` }); + }, + }, + ); + }; + + const listings = data?.listings ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + if (!canBrowse) { + return ( +
+ + +
+ ); + } + + return ( +
+
+
+ + {/* Search + tag filter */} +
+
+
+
+ + {(categoriesData?.categories ?? []).length > 0 && ( +
+ + {(categoriesData?.categories ?? []).map((tag) => ( + + ))} +
+ )} + + {resultMessage && ( +
+ + {resultMessage.text} + +
+ )} + + {isLoading && ( +
+ + )} + + {isError && ( + + + )} + + {!isLoading && !isError && listings.length === 0 && ( + + + )} + +
+ {listings.map((listing) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+ ); +} + +export default MarketplacePage;