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
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:
parent
0875e2a087
commit
46292c7447
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
72
apps/api/src/plugins/plugin-pond.controller.ts
Normal file
72
apps/api/src/plugins/plugin-pond.controller.ts
Normal 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);
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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 optional→required→optional 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). */
|
||||
|
||||
86
apps/web/e2e/plugin-admin.spec.ts
Normal file
86
apps/web/e2e/plugin-admin.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
138
apps/web/src/pages/PluginManager.tsx
Normal file
138
apps/web/src/pages/PluginManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
54
apps/web/src/plugins/PondPluginSettings.tsx
Normal file
54
apps/web/src/plugins/PondPluginSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user