Add plugin administration UI: instance modes and pond activation (#72)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m56s
CD / Build and push images (push) Successful in 3m11s
CD / Deploy to Test (push) Successful in 8s
CI / Auth e2e pack (push) Successful in 4m12s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Promote to Int (push) Successful in 13s
CI / Import/export fidelity gate (push) Successful in 42s
CI / Build container images (push) Has been skipped

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 09:32:16 +02:00
parent 0875e2a087
commit 46292c7447
17 changed files with 717 additions and 5 deletions

View File

@ -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

View File

@ -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<PluginView> {
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)

View File

@ -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<PluginView[]> {
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<PondPluginSetting[]> {
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<void> {
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);
}

View File

@ -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')

View File

@ -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],
})

View File

@ -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<DbPluginMode, PluginInstanceMode> = {
REQUIRED: 'required',
};
const VIEW_MODE_TO_DB: Record<PluginInstanceMode, DbPluginMode> = {
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 optionalrequiredoptional restores the previous per-pond choices.
*/
async setMode(id: string, mode: PluginInstanceMode): Promise<PluginView> {
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<PluginView[]> {
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<PondPluginSetting[]> {
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<void> {
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). */

View File

@ -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<void> {
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();
}
});

View File

@ -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 {
<UploadSettingsForm settings={settings.data} />
<PluginManager />
<QuotaManager />
<UserManager />
</>

View File

@ -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<HTMLInputElement | null>(null);
const [uploadError, setUploadError] = useState<unknown>(null);
const plugins = useQuery({
queryKey: ['admin', 'plugins'],
queryFn: () => apiGet<PluginView[]>('/admin/plugins'),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'plugins'] });
const install = useMutation({
mutationFn: (file: File) => apiUploadFile<PluginView>('/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<PluginView>(`/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 (
<section className="settings-section plugin-manager">
<h2>{t('admin.title')}</h2>
<p className="plugin-manager__intro">{t('admin.intro')}</p>
<div className="plugin-manager__upload">
<label className="button">
{t('admin.upload')}
<input
ref={fileRef}
type="file"
accept=".zip"
className="plugin-manager__upload-input"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) install.mutate(file);
}}
/>
</label>
<FormError error={uploadError} />
</div>
{plugins.data && plugins.data.length === 0 ? <p>{t('admin.empty')}</p> : null}
<ul className="plugin-manager__list">
{plugins.data?.map((plugin) => (
<li key={plugin.id} className="plugin-manager__item" data-plugin-id={plugin.id}>
<div className="plugin-manager__head">
<span className="plugin-manager__name">{plugin.name}</span>
<span className="plugin-manager__version">v{plugin.version}</span>
<span className="plugin-manager__kind">{plugin.kind}</span>
</div>
<div className="plugin-manager__permissions">
<span>{t('admin.permissions')}:</span>{' '}
{plugin.permissions.length === 0 ? (
<em>{t('admin.noPermissions')}</em>
) : (
plugin.permissions.map((permission) => (
<code key={permission} className="plugin-manager__permission">
{permission}
</code>
))
)}
</div>
<div className="plugin-manager__controls">
<label>
{t('admin.mode')}:{' '}
<select
className="plugin-manager__mode"
value={plugin.mode}
onChange={(event) =>
setMode.mutate({
id: plugin.id,
mode: event.target.value as PluginInstanceMode,
})
}
>
{PLUGIN_INSTANCE_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`mode.${mode}`)}
</option>
))}
</select>
</label>
<span className="plugin-manager__mode-hint">{t(`mode.hint.${plugin.mode}`)}</span>
<Link className="plugin-manager__preview" to={`/admin/plugins/${plugin.id}/preview`}>
{t('admin.preview')}
</Link>
<button
type="button"
className="button button--danger"
disabled={plugin.mode === 'required'}
title={plugin.mode === 'required' ? t('admin.requiredLocked') : undefined}
onClick={() => uninstall.mutate(plugin.id)}
>
{t('admin.uninstall')}
</button>
</div>
</li>
))}
</ul>
</section>
);
}

View File

@ -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 {
/>
</section>
)}
{canModify && <PondPluginSettings pondId={pond.data.id} />}
<section className="pond-export">
<h2>{tExport('pond.heading')}</h2>
<p className="pond-export__hint">{tExport('pond.hint')}</p>

View File

@ -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<PondPluginSetting[]>(`/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 (
<section className="pond-plugins">
<h2>{t('pond.title')}</h2>
<p className="pond-plugins__hint">{t('pond.hint')}</p>
<ul className="pond-plugins__list">
{settings.data?.map(({ plugin, enabled }) => (
<li key={plugin.id} className="pond-plugins__item" data-plugin-id={plugin.id}>
<label>
<input
type="checkbox"
className="pond-plugins__toggle"
checked={enabled}
onChange={(event) =>
toggle.mutate({ pluginId: plugin.id, enabled: event.target.checked })
}
/>{' '}
{plugin.name} <span className="pond-plugins__version">v{plugin.version}</span>
</label>
</li>
))}
</ul>
</section>
);
}

View File

@ -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;
}

View File

@ -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.",

View File

@ -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."
}
}

View File

@ -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.",

View File

@ -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."
}
}

View File

@ -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<typeof pluginModeInputSchema>;
/** 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<typeof pondPluginToggleInputSchema>;