From 46292c74478ddfb5f3ba12e977d7a0f0f776dd8b Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 09:32:16 +0200 Subject: [PATCH] Add plugin administration UI: instance modes and pond activation (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api: PATCH /admin/plugins/:id/mode (Site Admin) switches disabled/optional/required; new PluginPondController exposes GET /ponds/:id/plugins (effective list: required + optional-enabled, pond read access — the SPA loads it per pond), GET .../plugins/settings and PUT .../plugins/:pluginId (Pond Admin) to toggle optional plugins. Toggling a non-optional plugin is refused (plugin_not_optional). Install/uninstall/mode/toggle are audit-logged. - web: PluginManager in the admin area lists installed plugins with their declared permissions surfaced prominently (security.md), an upload control that shows validation errors, a mode switch with an impact hint, and a link to the sandbox preview. PondPluginSettings adds a per-pond optional-plugin toggle section to pond settings. - shared: PondPluginSetting, mode/toggle input schemas, plugin_not_optional error code + de/en messages, plugins i18n (admin/mode/pond). - tests: api db test covers mode switching, per-pond activation, the required-everywhere and disabled-nowhere propagation, and the not-optional guard; e2e plugin-admin pack drives the admin list, permission display, mode switch, and pond toggle end to end. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 5 + .../src/plugins/plugin-admin.controller.ts | 20 ++- .../api/src/plugins/plugin-pond.controller.ts | 72 +++++++++ apps/api/src/plugins/plugins.e2e.db.test.ts | 86 +++++++++++ apps/api/src/plugins/plugins.module.ts | 3 +- apps/api/src/plugins/plugins.service.ts | 102 ++++++++++++- apps/web/e2e/plugin-admin.spec.ts | 86 +++++++++++ apps/web/src/pages/AdminSettingsPage.tsx | 2 + apps/web/src/pages/PluginManager.tsx | 138 ++++++++++++++++++ apps/web/src/pages/PondSettingsPage.tsx | 2 + apps/web/src/plugins/PondPluginSettings.tsx | 54 +++++++ apps/web/src/styles/base.css | 70 +++++++++ packages/shared/i18n/de/errors.json | 1 + packages/shared/i18n/de/plugins.json | 27 ++++ packages/shared/i18n/en/errors.json | 1 + packages/shared/i18n/en/plugins.json | 27 ++++ packages/shared/src/plugins.ts | 26 ++++ 17 files changed, 717 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/plugins/plugin-pond.controller.ts create mode 100644 apps/web/e2e/plugin-admin.spec.ts create mode 100644 apps/web/src/pages/PluginManager.tsx create mode 100644 apps/web/src/plugins/PondPluginSettings.tsx diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a899744..7cde38e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -337,6 +337,11 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/plugins.spec.ts + - name: Run plugin admin pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/plugin-admin.spec.ts + - name: Dump server logs on failure if: failure() run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true diff --git a/apps/api/src/plugins/plugin-admin.controller.ts b/apps/api/src/plugins/plugin-admin.controller.ts index 2cfb2e0..8afcef4 100644 --- a/apps/api/src/plugins/plugin-admin.controller.ts +++ b/apps/api/src/plugins/plugin-admin.controller.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Body, ConflictException, Controller, Delete, @@ -8,6 +9,7 @@ import { HttpException, NotFoundException, Param, + Patch, PayloadTooLargeException, Post, UploadedFile, @@ -15,9 +17,10 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import type { PluginView } from '@dorfteich/shared'; +import { pluginModeInputSchema, type PluginModeInput, type PluginView } from '@dorfteich/shared'; import { SiteAdminGuard } from '../admin/site-admin.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { PluginsService } from './plugins.service'; import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants'; @@ -32,6 +35,7 @@ function toHttpException(error: PluginPackageError): HttpException { return new NotFoundException(body); case 'plugin_required_cannot_uninstall': case 'plugin_version_not_higher': + case 'plugin_not_optional': return new ConflictException(body); case 'plugin_too_large': return new PayloadTooLargeException(body); @@ -69,6 +73,20 @@ export class PluginAdminController { return this.plugins.list(); } + /** Set a plugin's instance mode: disabled | optional | required (#72). */ + @Patch(':id/mode') + async setMode( + @Param('id') id: string, + @Body(new ZodValidationPipe(pluginModeInputSchema)) body: PluginModeInput, + ): Promise { + try { + return await this.plugins.setMode(id, body.mode); + } catch (error) { + if (error instanceof PluginPackageError) throw toHttpException(error); + throw error; + } + } + /** Uninstall a plugin (refused while `required`). */ @Delete(':id') @HttpCode(204) diff --git a/apps/api/src/plugins/plugin-pond.controller.ts b/apps/api/src/plugins/plugin-pond.controller.ts new file mode 100644 index 0000000..1fc794f --- /dev/null +++ b/apps/api/src/plugins/plugin-pond.controller.ts @@ -0,0 +1,72 @@ +import { + ConflictException, + Controller, + Get, + HttpCode, + HttpException, + NotFoundException, + Param, + Put, + Body, +} from '@nestjs/common'; +import { + pondPluginToggleInputSchema, + type PluginView, + type PondPluginSetting, + type PondPluginToggleInput, +} from '@dorfteich/shared'; + +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequiresPondRole } from '../permissions/permission.decorators'; + +import { PluginsService } from './plugins.service'; +import { PluginPackageError } from './plugin.constants'; + +/** + * Per-pond plugin surface (ADR 0008, issue #72). Reading the effective plugin + * list needs only pond read access (the SPA loads it at page load to know + * which plugins to mount); managing the per-pond activation of optional + * plugins is a Pond Admin action. + */ +@Controller('ponds/:pondId/plugins') +export class PluginPondController { + constructor(private readonly plugins: PluginsService) {} + + /** Plugins active for this pond (required + optional-enabled). */ + @Get() + @RequiresPondRole('reader', { idParam: 'pondId' }) + listActive(@Param('pondId') pondId: string): Promise { + return this.plugins.listForPond(pondId); + } + + /** Optional plugins with this pond's on/off state (Pond Admin settings). */ + @Get('settings') + @RequiresPondRole('pond_admin', { idParam: 'pondId' }) + listSettings(@Param('pondId') pondId: string): Promise { + return this.plugins.listPondSettings(pondId); + } + + /** Toggle one optional plugin for this pond. */ + @Put(':pluginId') + @HttpCode(204) + @RequiresPondRole('pond_admin', { idParam: 'pondId' }) + async toggle( + @Param('pondId') pondId: string, + @Param('pluginId') pluginId: string, + @Body(new ZodValidationPipe(pondPluginToggleInputSchema)) body: PondPluginToggleInput, + ): Promise { + try { + await this.plugins.setPondActivation(pondId, pluginId, body.enabled); + } catch (error) { + if (error instanceof PluginPackageError) throw toHttpException(error); + throw error; + } + } +} + +/** Maps a registry error to the HTTP status that fits its class. */ +function toHttpException(error: PluginPackageError): HttpException { + const body = { code: error.code, message: error.message }; + if (error.code === 'plugin_not_found') return new NotFoundException(body); + return new ConflictException(body); +} diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index ad313cf..223a027 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -210,6 +210,92 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { ).toBe(false); }); + it('switches instance mode and activates optional plugins per pond (#72)', async () => { + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'modeable', name: 'Modeable' })), 'm.zip') + .expect(201); + + // The admin owns a personal pond (created at verify-email) → pond_admin on it. + const ponds = await api().get('/api/v1/ponds').set('Cookie', adminCookie).expect(200); + const pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id; + + // Optional but not activated → absent from the pond's effective list. + await api() + .patch('/api/v1/admin/plugins/modeable/mode') + .set('Cookie', adminCookie) + .send({ mode: 'optional' }) + .expect(200) + .expect((r) => expect(r.body.mode).toBe('optional')); + let active = await api() + .get(`/api/v1/ponds/${pondId}/plugins`) + .set('Cookie', adminCookie) + .expect(200); + expect(active.body.map((p: { id: string }) => p.id)).not.toContain('modeable'); + + // Settings list shows it as an off toggle; turning it on makes it active. + const settings = await api() + .get(`/api/v1/ponds/${pondId}/plugins/settings`) + .set('Cookie', adminCookie) + .expect(200); + expect(settings.body).toEqual([ + expect.objectContaining({ + enabled: false, + plugin: expect.objectContaining({ id: 'modeable' }), + }), + ]); + await api() + .put(`/api/v1/ponds/${pondId}/plugins/modeable`) + .set('Cookie', adminCookie) + .send({ enabled: true }) + .expect(204); + active = await api() + .get(`/api/v1/ponds/${pondId}/plugins`) + .set('Cookie', adminCookie) + .expect(200); + expect(active.body.map((p: { id: string }) => p.id)).toContain('modeable'); + + // Required → present everywhere, even without a per-pond activation row, and + // toggling it per pond is refused. + await api() + .patch('/api/v1/admin/plugins/modeable/mode') + .set('Cookie', adminCookie) + .send({ mode: 'required' }) + .expect(200); + await prisma.pondPlugin.deleteMany({ where: { pluginId: 'modeable' } }); + active = await api() + .get(`/api/v1/ponds/${pondId}/plugins`) + .set('Cookie', adminCookie) + .expect(200); + expect(active.body.map((p: { id: string }) => p.id)).toContain('modeable'); + await api() + .put(`/api/v1/ponds/${pondId}/plugins/modeable`) + .set('Cookie', adminCookie) + .send({ enabled: true }) + .expect(409) + .expect((r) => expect(r.body.code).toBe('plugin_not_optional')); + + // Disabled → gone from the effective list. + await api() + .patch('/api/v1/admin/plugins/modeable/mode') + .set('Cookie', adminCookie) + .send({ mode: 'disabled' }) + .expect(200); + active = await api() + .get(`/api/v1/ponds/${pondId}/plugins`) + .set('Cookie', adminCookie) + .expect(200); + expect(active.body.map((p: { id: string }) => p.id)).not.toContain('modeable'); + + // The mode switch is Site-Admin only. + await api() + .patch('/api/v1/admin/plugins/modeable/mode') + .set('Cookie', outsiderCookie) + .send({ mode: 'optional' }) + .expect(403); + }); + it('refuses uninstall while required, then removes files and marks it removed', async () => { await api() .post('/api/v1/admin/plugins') diff --git a/apps/api/src/plugins/plugins.module.ts b/apps/api/src/plugins/plugins.module.ts index cc78fe4..ffafe50 100644 --- a/apps/api/src/plugins/plugins.module.ts +++ b/apps/api/src/plugins/plugins.module.ts @@ -4,6 +4,7 @@ import { CommonModule } from '../common/common.module'; import { PluginAdminController } from './plugin-admin.controller'; import { PluginAssetsController } from './plugin-assets.controller'; +import { PluginPondController } from './plugin-pond.controller'; import { PluginPackageService } from './plugin-package.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginWatcherService } from './plugin-watcher.service'; @@ -17,7 +18,7 @@ import { PluginsService } from './plugins.service'; */ @Module({ imports: [CommonModule], - controllers: [PluginAdminController, PluginAssetsController], + controllers: [PluginAdminController, PluginAssetsController, PluginPondController], providers: [PluginPackageService, PluginStorageService, PluginsService, PluginWatcherService], exports: [PluginsService], }) diff --git a/apps/api/src/plugins/plugins.service.ts b/apps/api/src/plugins/plugins.service.ts index 9753897..5f7f58e 100644 --- a/apps/api/src/plugins/plugins.service.ts +++ b/apps/api/src/plugins/plugins.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk'; -import type { PluginInstanceMode, PluginView } from '@dorfteich/shared'; +import type { PluginInstanceMode, PluginView, PondPluginSetting } from '@dorfteich/shared'; import { ClockService } from '../common/clock.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -16,11 +17,18 @@ const DB_MODE_TO_VIEW: Record = { REQUIRED: 'required', }; +const VIEW_MODE_TO_DB: Record = { + disabled: 'DISABLED', + optional: 'OPTIONAL', + required: 'REQUIRED', +}; + /** * Install registry for plugin packages (ADR 0008, issue #71): validates and * unpacks an uploaded ZIP, records/updates its metadata, and removes it on * uninstall. The install path is shared by the admin upload endpoint and the - * directory watcher. + * directory watcher. Instance-mode and per-pond activation (#72) also live + * here, since they read/write the same registry. */ @Injectable() export class PluginsService { @@ -29,7 +37,10 @@ export class PluginsService { private readonly packages: PluginPackageService, private readonly storage: PluginStorageService, private readonly clock: ClockService, - ) {} + private readonly logger: PinoLogger, + ) { + this.logger.setContext(PluginsService.name); + } /** * Validates and installs (or updates) a plugin from a ZIP buffer. An update @@ -79,9 +90,33 @@ export class PluginsService { await this.storage.removeVersion(manifest.id, existing.version); } + this.logger.info( + { plugin: manifest.id, version: manifest.version, update: isActiveUpdate }, + 'audit: plugin installed', + ); return this.toView(record); } + /** + * Sets a plugin's instance mode (Site Admin, #72). `required` makes it active + * in every pond and blocks uninstall; `disabled` hides it everywhere; + * `optional` lets Pond Admins toggle it per pond. Per-pond activation rows are + * kept across mode changes (they are simply ignored while non-optional), so + * flipping optional→required→optional restores the previous per-pond choices. + */ + async setMode(id: string, mode: PluginInstanceMode): Promise { + const plugin = await this.prisma.plugin.findUnique({ where: { id } }); + if (!plugin || plugin.removedAt !== null) { + throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); + } + const updated = await this.prisma.plugin.update({ + where: { id }, + data: { mode: VIEW_MODE_TO_DB[mode] }, + }); + this.logger.info({ plugin: id, mode }, 'audit: plugin mode set'); + return this.toView(updated); + } + /** * Uninstalls a plugin: refused while `required`; otherwise the metadata is * tombstoned (`removedAt` set, per-pond activations dropped) and every file is @@ -107,6 +142,67 @@ export class PluginsService { }), ]); await this.storage.removePlugin(id); + this.logger.info({ plugin: id }, 'audit: plugin uninstalled'); + } + + /** + * The plugins active for one pond (#72): every `required` plugin plus the + * `optional` ones this pond has switched on. This is the list the SPA loads + * per pond to decide which plugins to mount. `disabled` and un-activated + * optional plugins never appear. + */ + async listForPond(pondId: string): Promise { + const [plugins, activations] = await Promise.all([ + this.prisma.plugin.findMany({ + where: { removedAt: null, mode: { in: ['REQUIRED', 'OPTIONAL'] } }, + orderBy: { name: 'asc' }, + }), + this.prisma.pondPlugin.findMany({ where: { pondId } }), + ]); + const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); + return plugins + .filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true)) + .map((p) => this.toView(p)); + } + + /** + * The optional plugins a Pond Admin may toggle for one pond (#72), each with + * this pond's current on/off state (default off — activation is opt-in). + */ + async listPondSettings(pondId: string): Promise { + const [plugins, activations] = await Promise.all([ + this.prisma.plugin.findMany({ + where: { removedAt: null, mode: 'OPTIONAL' }, + orderBy: { name: 'asc' }, + }), + this.prisma.pondPlugin.findMany({ where: { pondId } }), + ]); + const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); + return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true })); + } + + /** + * Toggles an `optional` plugin for one pond (Pond Admin, #72). Only optional + * plugins are per-pond choices; toggling a required/disabled/absent plugin is + * rejected so the UI cannot desync the model. + */ + async setPondActivation(pondId: string, pluginId: string, enabled: boolean): Promise { + const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); + if (!plugin || plugin.removedAt !== null) { + throw new PluginPackageError('plugin_not_found', `Plugin ${pluginId} is not installed`); + } + if (plugin.mode !== 'OPTIONAL') { + throw new PluginPackageError( + 'plugin_not_optional', + `Plugin ${pluginId} is not optional and cannot be toggled per pond`, + ); + } + await this.prisma.pondPlugin.upsert({ + where: { pondId_pluginId: { pondId, pluginId } }, + create: { pondId, pluginId, enabled }, + update: { enabled }, + }); + this.logger.info({ plugin: pluginId, pond: pondId, enabled }, 'audit: pond plugin toggled'); } /** All installed (non-removed) plugins, for the Site Admin list (#72). */ diff --git a/apps/web/e2e/plugin-admin.spec.ts b/apps/web/e2e/plugin-admin.spec.ts new file mode 100644 index 0000000..aec79b8 --- /dev/null +++ b/apps/web/e2e/plugin-admin.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; +import { fixtureManifest, pluginZip, WELL_BEHAVED_SOURCE } from './plugin-fixtures'; + +/** + * Plugin administration UI (issue #72): the Site-Admin installs a plugin, sees + * its declared permissions surfaced in the list, switches its instance mode, + * and activates it for one pond — the pond's effective plugin list reflects the + * toggle. Mode propagation (required everywhere / disabled nowhere) and the + * not-optional guard are pinned at the API level in `plugins.e2e.db.test.ts`. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const PLUGIN_ID = 'e2e-admin-ui'; + +async function installFixture(context: BrowserContext): Promise { + await context.request.delete(`/api/v1/admin/plugins/${PLUGIN_ID}`); + const res = await context.request.post('/api/v1/admin/plugins', { + multipart: { + file: { + name: `${PLUGIN_ID}.zip`, + mimeType: 'application/zip', + buffer: pluginZip( + fixtureManifest(PLUGIN_ID, 'Admin UI Fixture', ['ui']), + WELL_BEHAVED_SOURCE, + ), + }, + }, + }); + expect(res.status(), await res.text()).toBe(201); +} + +test('admin lists a plugin with its permissions, switches mode, and a pond activates it', async ({ + browser, +}) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await installFixture(admin); + + // The admin's personal pond — a pond they administer. + const ponds = await (await admin.request.get('/api/v1/ponds')).json(); + const pond = ponds.find((p: { type: string }) => p.type === 'personal'); + + const page = await admin.newPage(); + try { + await page.goto('/admin'); + const item = page.locator(`.plugin-manager__item[data-plugin-id="${PLUGIN_ID}"]`); + await expect(item).toBeVisible(); + // Declared permission is surfaced prominently in the list (security.md). + await expect(item.locator('.plugin-manager__permission', { hasText: 'ui' })).toBeVisible(); + + // Switch the instance mode to optional so it becomes a per-pond choice. + await item.locator('.plugin-manager__mode').selectOption('optional'); + await expect(item.locator('.plugin-manager__mode')).toHaveValue('optional'); + + // Not yet active in the pond. + const before = await (await admin.request.get(`/api/v1/ponds/${pond.id}/plugins`)).json(); + expect(before.map((p: { id: string }) => p.id)).not.toContain(PLUGIN_ID); + + // Toggle it on in the pond's settings. + await page.goto(`/p/${pond.slug}/settings`); + const toggle = page + .locator(`.pond-plugins__item[data-plugin-id="${PLUGIN_ID}"]`) + .locator('.pond-plugins__toggle'); + await expect(toggle).toBeVisible(); + // Controlled checkbox: click and let the effective-list poll confirm the + // result, rather than check() which asserts an immediate DOM state change + // the server round-trip hasn't produced yet. + await toggle.click(); + + // The pond's effective plugin list now includes it. + await expect + .poll(async () => { + const active = await (await admin.request.get(`/api/v1/ponds/${pond.id}/plugins`)).json(); + return active.map((p: { id: string }) => p.id); + }) + .toContain(PLUGIN_ID); + } finally { + // Leave the instance clean for reruns. + await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, { + data: { mode: 'optional' }, + }); + await admin.request.delete(`/api/v1/admin/plugins/${PLUGIN_ID}`); + await admin.close(); + } +}); diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 77929a8..9089ba5 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Field, FormError, FormSuccess } from '../components/forms'; import { apiGet, apiPatch } from '../lib/api'; +import { PluginManager } from './PluginManager'; import { QuotaManager } from './QuotaManager'; import { UserManager } from './UserManager'; @@ -101,6 +102,7 @@ export function AdminSettingsPage(): React.JSX.Element { + diff --git a/apps/web/src/pages/PluginManager.tsx b/apps/web/src/pages/PluginManager.tsx new file mode 100644 index 0000000..8c68db5 --- /dev/null +++ b/apps/web/src/pages/PluginManager.tsx @@ -0,0 +1,138 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { PLUGIN_INSTANCE_MODES, type PluginInstanceMode, type PluginView } from '@dorfteich/shared'; + +import { FormError } from '../components/forms'; +import { apiDelete, apiGet, apiPatch, apiUploadFile } from '../lib/api'; + +/** + * Site Admin plugin administration (issue #72): the installed-plugin list with + * each plugin's declared permissions surfaced prominently (security.md), an + * upload dialog that shows validation errors, and the instance-mode switch + * (disabled / optional / required). Mode changes and installs are audit-logged + * server-side. + */ +export function PluginManager(): React.JSX.Element { + const { t } = useTranslation('plugins'); + const queryClient = useQueryClient(); + const fileRef = useRef(null); + const [uploadError, setUploadError] = useState(null); + + const plugins = useQuery({ + queryKey: ['admin', 'plugins'], + queryFn: () => apiGet('/admin/plugins'), + }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'plugins'] }); + + const install = useMutation({ + mutationFn: (file: File) => apiUploadFile('/admin/plugins', file), + onSuccess: () => { + setUploadError(null); + if (fileRef.current) fileRef.current.value = ''; + void invalidate(); + }, + onError: (error) => setUploadError(error), + }); + + const setMode = useMutation({ + mutationFn: ({ id, mode }: { id: string; mode: PluginInstanceMode }) => + apiPatch(`/admin/plugins/${id}/mode`, { mode }), + onSuccess: () => void invalidate(), + }); + + const uninstall = useMutation({ + mutationFn: (id: string) => apiDelete(`/admin/plugins/${id}`), + onSuccess: () => void invalidate(), + onError: (error) => setUploadError(error), + }); + + return ( +
+

{t('admin.title')}

+

{t('admin.intro')}

+ +
+ + +
+ + {plugins.data && plugins.data.length === 0 ?

{t('admin.empty')}

: null} + +
    + {plugins.data?.map((plugin) => ( +
  • +
    + {plugin.name} + v{plugin.version} + {plugin.kind} +
    + +
    + {t('admin.permissions')}:{' '} + {plugin.permissions.length === 0 ? ( + {t('admin.noPermissions')} + ) : ( + plugin.permissions.map((permission) => ( + + {permission} + + )) + )} +
    + +
    + + {t(`mode.hint.${plugin.mode}`)} + + {t('admin.preview')} + + +
    +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index b677f06..841384e 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -13,6 +13,7 @@ import { EffectivePermissionsInspector } from '../access/EffectivePermissionsIns import { PondFileManager } from '../files/PondFileManager'; import { apiGet } from '../lib/api'; import { MemberManager } from '../members/MemberManager'; +import { PondPluginSettings } from '../plugins/PondPluginSettings'; /** * Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections; @@ -84,6 +85,7 @@ export function PondSettingsPage(): React.JSX.Element { /> )} + {canModify && }

{tExport('pond.heading')}

{tExport('pond.hint')}

diff --git a/apps/web/src/plugins/PondPluginSettings.tsx b/apps/web/src/plugins/PondPluginSettings.tsx new file mode 100644 index 0000000..210b9db --- /dev/null +++ b/apps/web/src/plugins/PondPluginSettings.tsx @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import type { PondPluginSetting } from '@dorfteich/shared'; + +import { apiGet, apiPut } from '../lib/api'; + +/** + * Pond-settings section for optional plugins (issue #72): a Pond Admin toggles + * each optional plugin on or off for this pond. Required plugins are always on + * and disabled ones never available, so neither appears here. + */ +export function PondPluginSettings({ pondId }: { pondId: string }): React.JSX.Element | null { + const { t } = useTranslation('plugins'); + const queryClient = useQueryClient(); + + const settings = useQuery({ + queryKey: ['pond', pondId, 'plugins', 'settings'], + queryFn: () => apiGet(`/ponds/${pondId}/plugins/settings`), + }); + + const toggle = useMutation({ + mutationFn: ({ pluginId, enabled }: { pluginId: string; enabled: boolean }) => + apiPut(`/ponds/${pondId}/plugins/${pluginId}`, { enabled }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'plugins'] }), + }); + + // Nothing to manage if no optional plugins are installed — hide the section. + if (settings.data && settings.data.length === 0) return null; + + return ( +
+

{t('pond.title')}

+

{t('pond.hint')}

+
    + {settings.data?.map(({ plugin, enabled }) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 8a6043d..27535ac 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1996,3 +1996,73 @@ button { .plugin-preview__surface { margin-top: var(--space-4); } + +/* --- Plugin administration (issue #72) ------------------------------------ */ + +.plugin-manager__upload-input { + display: none; +} + +.plugin-manager__list { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.plugin-manager__item { + border: 1px solid var(--color-border); + border-radius: 6px; + padding: var(--space-3); +} + +.plugin-manager__head { + display: flex; + align-items: baseline; + gap: var(--space-2); +} + +.plugin-manager__name { + font-weight: 600; +} + +.plugin-manager__version, +.plugin-manager__kind { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.plugin-manager__permissions { + margin: var(--space-2) 0; + font-size: 0.875rem; +} + +.plugin-manager__permission { + margin-right: var(--space-1); +} + +.plugin-manager__controls { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.plugin-manager__mode-hint { + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.pond-plugins__list { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.pond-plugins__version { + color: var(--color-text-muted); + font-size: 0.875rem; +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 0e561e5..f9a6510 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -50,6 +50,7 @@ "plugin_version_not_higher": "Ein Update muss eine höhere Version als das installierte haben.", "plugin_required_cannot_uninstall": "Ein erforderliches Plugin kann nicht deinstalliert werden.", "plugin_not_found": "Dieses Plugin ist nicht installiert.", + "plugin_not_optional": "Nur optionale Plugins können pro Teich aktiviert werden.", "grant_pond_admin_scope": "Eine Teich-Admin-Berechtigung muss für den ganzen Teich und eine bestimmte Person gelten.", "grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.", "grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.", diff --git a/packages/shared/i18n/de/plugins.json b/packages/shared/i18n/de/plugins.json index 2a0dab1..3f30c35 100644 --- a/packages/shared/i18n/de/plugins.json +++ b/packages/shared/i18n/de/plugins.json @@ -12,5 +12,32 @@ "noPermissions": "Dieses Plugin fordert keine Berechtigungen an.", "surface": "Oberfläche", "noSurfaces": "Dieses Plugin stellt keine Oberflächen bereit." + }, + "admin": { + "title": "Plugins", + "intro": "Installierte Plugins laufen isoliert (Sandbox). Prüfe die angeforderten Berechtigungen, bevor du ein Plugin aktivierst.", + "upload": "Plugin hochladen (.zip)", + "empty": "Noch keine Plugins installiert.", + "permissions": "Berechtigungen", + "noPermissions": "keine", + "mode": "Modus", + "preview": "Vorschau", + "uninstall": "Deinstallieren", + "requiredLocked": "Ein erforderliches Plugin kann nicht deinstalliert werden." + }, + "mode": { + "disabled": "Deaktiviert", + "optional": "Optional", + "required": "Erforderlich", + "hint": { + "disabled": "Installiert, aber überall inaktiv.", + "optional": "Teich-Admins können es pro Teich aktivieren.", + "required": "In jedem Teich immer aktiv." + } + }, + "pond": { + "title": "Plugins", + "hint": "Aktiviere optionale Plugins für diesen Teich.", + "empty": "Es sind keine optionalen Plugins verfügbar." } } diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 0ddedf6..17be79d 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -50,6 +50,7 @@ "plugin_version_not_higher": "An update must have a higher version than the installed one.", "plugin_required_cannot_uninstall": "A required plugin cannot be uninstalled.", "plugin_not_found": "This plugin is not installed.", + "plugin_not_optional": "Only optional plugins can be activated per pond.", "grant_pond_admin_scope": "A Pond Admin grant must apply to the whole pond and a specific user.", "grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.", "grant_subject_id_mismatch": "The grant's subject is inconsistent.", diff --git a/packages/shared/i18n/en/plugins.json b/packages/shared/i18n/en/plugins.json index 566ef93..2bc02aa 100644 --- a/packages/shared/i18n/en/plugins.json +++ b/packages/shared/i18n/en/plugins.json @@ -12,5 +12,32 @@ "noPermissions": "This plugin requests no permissions.", "surface": "Surface", "noSurfaces": "This plugin provides no surfaces." + }, + "admin": { + "title": "Plugins", + "intro": "Installed plugins run sandboxed. Review the permissions a plugin requests before enabling it.", + "upload": "Upload plugin (.zip)", + "empty": "No plugins installed yet.", + "permissions": "Permissions", + "noPermissions": "none", + "mode": "Mode", + "preview": "Preview", + "uninstall": "Uninstall", + "requiredLocked": "A required plugin cannot be uninstalled." + }, + "mode": { + "disabled": "Disabled", + "optional": "Optional", + "required": "Required", + "hint": { + "disabled": "Installed but inactive everywhere.", + "optional": "Pond Admins can enable it per pond.", + "required": "Always active in every pond." + } + }, + "pond": { + "title": "Plugins", + "hint": "Enable optional plugins for this pond.", + "empty": "No optional plugins are available." } } diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts index fcec22e..eaaa307 100644 --- a/packages/shared/src/plugins.ts +++ b/packages/shared/src/plugins.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + /** * Plugin administration types shared between api and web (ADR 0008, issue #71). * The manifest itself lives in `@dorfteich/plugin-sdk`; these types describe an @@ -55,5 +57,29 @@ export const PLUGIN_ERROR_CODES = [ 'plugin_version_not_higher', 'plugin_required_cannot_uninstall', 'plugin_not_found', + 'plugin_not_optional', ] as const; export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number]; + +/** + * An optional plugin as shown in a pond's plugin settings (issue #72): the + * installed plugin plus whether this pond has activated it. Only `optional` + * plugins appear here — `required` ones are always on and `disabled` ones are + * never available, so neither is a per-pond choice. + */ +export interface PondPluginSetting { + plugin: PluginView; + enabled: boolean; +} + +/** Payload to switch a plugin's instance mode (Site Admin, issue #72). */ +export const pluginModeInputSchema = z.object({ + mode: z.enum(PLUGIN_INSTANCE_MODES), +}); +export type PluginModeInput = z.infer; + +/** Payload to toggle an optional plugin for one pond (Pond Admin, issue #72). */ +export const pondPluginToggleInputSchema = z.object({ + enabled: z.boolean(), +}); +export type PondPluginToggleInput = z.infer;