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 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/plugins.spec.ts
|
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
|
- name: Dump server logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
Body,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@ -8,6 +9,7 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
PayloadTooLargeException,
|
PayloadTooLargeException,
|
||||||
Post,
|
Post,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
@ -15,9 +17,10 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
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 { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
|
||||||
import { PluginsService } from './plugins.service';
|
import { PluginsService } from './plugins.service';
|
||||||
import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants';
|
import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants';
|
||||||
@ -32,6 +35,7 @@ function toHttpException(error: PluginPackageError): HttpException {
|
|||||||
return new NotFoundException(body);
|
return new NotFoundException(body);
|
||||||
case 'plugin_required_cannot_uninstall':
|
case 'plugin_required_cannot_uninstall':
|
||||||
case 'plugin_version_not_higher':
|
case 'plugin_version_not_higher':
|
||||||
|
case 'plugin_not_optional':
|
||||||
return new ConflictException(body);
|
return new ConflictException(body);
|
||||||
case 'plugin_too_large':
|
case 'plugin_too_large':
|
||||||
return new PayloadTooLargeException(body);
|
return new PayloadTooLargeException(body);
|
||||||
@ -69,6 +73,20 @@ export class PluginAdminController {
|
|||||||
return this.plugins.list();
|
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`). */
|
/** Uninstall a plugin (refused while `required`). */
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@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);
|
).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 () => {
|
it('refuses uninstall while required, then removes files and marks it removed', async () => {
|
||||||
await api()
|
await api()
|
||||||
.post('/api/v1/admin/plugins')
|
.post('/api/v1/admin/plugins')
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { CommonModule } from '../common/common.module';
|
|||||||
|
|
||||||
import { PluginAdminController } from './plugin-admin.controller';
|
import { PluginAdminController } from './plugin-admin.controller';
|
||||||
import { PluginAssetsController } from './plugin-assets.controller';
|
import { PluginAssetsController } from './plugin-assets.controller';
|
||||||
|
import { PluginPondController } from './plugin-pond.controller';
|
||||||
import { PluginPackageService } from './plugin-package.service';
|
import { PluginPackageService } from './plugin-package.service';
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
import { PluginWatcherService } from './plugin-watcher.service';
|
import { PluginWatcherService } from './plugin-watcher.service';
|
||||||
@ -17,7 +18,7 @@ import { PluginsService } from './plugins.service';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
controllers: [PluginAdminController, PluginAssetsController],
|
controllers: [PluginAdminController, PluginAssetsController, PluginPondController],
|
||||||
providers: [PluginPackageService, PluginStorageService, PluginsService, PluginWatcherService],
|
providers: [PluginPackageService, PluginStorageService, PluginsService, PluginWatcherService],
|
||||||
exports: [PluginsService],
|
exports: [PluginsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client';
|
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk';
|
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 { ClockService } from '../common/clock.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@ -16,11 +17,18 @@ const DB_MODE_TO_VIEW: Record<DbPluginMode, PluginInstanceMode> = {
|
|||||||
REQUIRED: 'required',
|
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
|
* Install registry for plugin packages (ADR 0008, issue #71): validates and
|
||||||
* unpacks an uploaded ZIP, records/updates its metadata, and removes it on
|
* 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
|
* 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()
|
@Injectable()
|
||||||
export class PluginsService {
|
export class PluginsService {
|
||||||
@ -29,7 +37,10 @@ export class PluginsService {
|
|||||||
private readonly packages: PluginPackageService,
|
private readonly packages: PluginPackageService,
|
||||||
private readonly storage: PluginStorageService,
|
private readonly storage: PluginStorageService,
|
||||||
private readonly clock: ClockService,
|
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
|
* 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);
|
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);
|
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
|
* Uninstalls a plugin: refused while `required`; otherwise the metadata is
|
||||||
* tombstoned (`removedAt` set, per-pond activations dropped) and every file is
|
* tombstoned (`removedAt` set, per-pond activations dropped) and every file is
|
||||||
@ -107,6 +142,67 @@ export class PluginsService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
await this.storage.removePlugin(id);
|
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). */
|
/** 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 { Field, FormError, FormSuccess } from '../components/forms';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
|
import { PluginManager } from './PluginManager';
|
||||||
import { QuotaManager } from './QuotaManager';
|
import { QuotaManager } from './QuotaManager';
|
||||||
import { UserManager } from './UserManager';
|
import { UserManager } from './UserManager';
|
||||||
|
|
||||||
@ -101,6 +102,7 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
|
|
||||||
<UploadSettingsForm settings={settings.data} />
|
<UploadSettingsForm settings={settings.data} />
|
||||||
|
|
||||||
|
<PluginManager />
|
||||||
<QuotaManager />
|
<QuotaManager />
|
||||||
<UserManager />
|
<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 { PondFileManager } from '../files/PondFileManager';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
import { MemberManager } from '../members/MemberManager';
|
import { MemberManager } from '../members/MemberManager';
|
||||||
|
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections;
|
* Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections;
|
||||||
@ -84,6 +85,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
||||||
<section className="pond-export">
|
<section className="pond-export">
|
||||||
<h2>{tExport('pond.heading')}</h2>
|
<h2>{tExport('pond.heading')}</h2>
|
||||||
<p className="pond-export__hint">{tExport('pond.hint')}</p>
|
<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 {
|
.plugin-preview__surface {
|
||||||
margin-top: var(--space-4);
|
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_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_required_cannot_uninstall": "Ein erforderliches Plugin kann nicht deinstalliert werden.",
|
||||||
"plugin_not_found": "Dieses Plugin ist nicht installiert.",
|
"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_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_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.",
|
"grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.",
|
||||||
|
|||||||
@ -12,5 +12,32 @@
|
|||||||
"noPermissions": "Dieses Plugin fordert keine Berechtigungen an.",
|
"noPermissions": "Dieses Plugin fordert keine Berechtigungen an.",
|
||||||
"surface": "Oberfläche",
|
"surface": "Oberfläche",
|
||||||
"noSurfaces": "Dieses Plugin stellt keine Oberflächen bereit."
|
"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_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_required_cannot_uninstall": "A required plugin cannot be uninstalled.",
|
||||||
"plugin_not_found": "This plugin is not installed.",
|
"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_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_pond_admin_personal_pond": "A personal pond's only administrator is its owner.",
|
||||||
"grant_subject_id_mismatch": "The grant's subject is inconsistent.",
|
"grant_subject_id_mismatch": "The grant's subject is inconsistent.",
|
||||||
|
|||||||
@ -12,5 +12,32 @@
|
|||||||
"noPermissions": "This plugin requests no permissions.",
|
"noPermissions": "This plugin requests no permissions.",
|
||||||
"surface": "Surface",
|
"surface": "Surface",
|
||||||
"noSurfaces": "This plugin provides no surfaces."
|
"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).
|
* Plugin administration types shared between api and web (ADR 0008, issue #71).
|
||||||
* The manifest itself lives in `@dorfteich/plugin-sdk`; these types describe an
|
* 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_version_not_higher',
|
||||||
'plugin_required_cannot_uninstall',
|
'plugin_required_cannot_uninstall',
|
||||||
'plugin_not_found',
|
'plugin_not_found',
|
||||||
|
'plugin_not_optional',
|
||||||
] as const;
|
] as const;
|
||||||
export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number];
|
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