feat(cabana): read-only Plugins screen with installed plugins

- GET /plugins (backend.view_plugins, superusers always pass) lists the
  plugins from party.Installed, or party.Activated IDs for older builds,
  with the built framework version and whether the registry is configured
- Settings -> System -> Plugins entry with the new SettingsEntry.path,
  added only for principals allowed to open it
- PluginsView at /system/plugins with an Installed tab (reka-ui Tabs);
  settings panel, rail, breadcrumbs and page title know the route
- OpenAPI and SPA types regenerated; contract, token-class and
  conformance inventories list the route; en/pl strings; docs
This commit is contained in:
Jakub Zych
2026-10-29 19:05:46 +01:00
parent 0418900122
commit c4b1ac4076
27 changed files with 998 additions and 12 deletions

View File

@@ -1191,6 +1191,21 @@
],
"type": "object"
},
"cabana.Envelope-cabana_PluginsOverview": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.PluginsOverview"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_RelationMutationResult": {
"properties": {
"data": {
@@ -1895,6 +1910,61 @@
],
"type": "object"
},
"cabana.InstalledPlugin": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"latest": {
"type": "string"
},
"latestFramework": {
"type": "string"
},
"listed": {
"type": "boolean"
},
"module": {
"type": "string"
},
"name": {
"type": "string"
},
"package": {
"type": "string"
},
"source": {
"type": "string"
},
"updateAvailable": {
"type": "boolean"
},
"updateCommand": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"description",
"id",
"latest",
"latestFramework",
"listed",
"module",
"name",
"package",
"source",
"updateAvailable",
"updateCommand",
"version"
],
"type": "object"
},
"cabana.LangBundle": {
"additionalProperties": {
"$ref": "#/components/schemas/cabana.MessageForms"
@@ -2453,6 +2523,28 @@
],
"type": "object"
},
"cabana.PluginsOverview": {
"properties": {
"framework": {
"type": "string"
},
"plugins": {
"items": {
"$ref": "#/components/schemas/cabana.InstalledPlugin"
},
"type": "array"
},
"registry": {
"$ref": "#/components/schemas/cabana.RegistryState"
}
},
"required": [
"framework",
"plugins",
"registry"
],
"type": "object"
},
"cabana.RecordAction": {
"properties": {
"confirm": {
@@ -2562,6 +2654,21 @@
],
"type": "object"
},
"cabana.RegistryState": {
"properties": {
"enabled": {
"type": "boolean"
},
"status": {
"type": "string"
}
},
"required": [
"enabled",
"status"
],
"type": "object"
},
"cabana.RelationColumn": {
"properties": {
"key": {
@@ -2881,6 +2988,10 @@
},
"order": {
"type": "integer"
},
"path": {
"description": "Path is the admin SPA route an entry of the admin's own opens (the\nPlugins screen, /system/plugins); omitted for plugin entries.",
"type": "string"
}
},
"required": [
@@ -4170,6 +4281,52 @@
]
}
},
"/plugins": {
"get": {
"description": "The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and whether the plugin registry is configured (registry.enabled false and registry.status disabled when plugins.registry_url is empty). Requires backend.view_plugins (superusers always pass).",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_PluginsOverview"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Installed plugins",
"tags": [
"admin"
]
}
},
"/settings": {
"get": {
"responses": {

View File

@@ -1217,6 +1217,63 @@ export interface paths {
patch?: never;
trace?: never;
};
"/plugins": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Installed plugins
* @description The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and whether the plugin registry is configured (registry.enabled false and registry.status disabled when plugins.registry_url is empty). Requires backend.view_plugins (superusers always pass).
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_PluginsOverview"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings": {
parameters: {
query?: never;
@@ -5856,6 +5913,10 @@ export interface components {
data: components["schemas"]["cabana.PartialView"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_PluginsOverview": {
data: components["schemas"]["cabana.PluginsOverview"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_RelationMutationResult": {
data: components["schemas"]["cabana.RelationMutationResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -6160,6 +6221,20 @@ export interface components {
*/
singleton?: boolean;
};
"cabana.InstalledPlugin": {
description: string;
id: string;
latest: string;
latestFramework: string;
listed: boolean;
module: string;
name: string;
package: string;
source: string;
updateAvailable: boolean;
updateCommand: string;
version: string;
};
"cabana.LangBundle": {
[key: string]: components["schemas"]["cabana.MessageForms"];
};
@@ -6338,6 +6413,11 @@ export interface components {
note: string;
value: number;
};
"cabana.PluginsOverview": {
framework: string;
plugins: components["schemas"]["cabana.InstalledPlugin"][];
registry: components["schemas"]["cabana.RegistryState"];
};
"cabana.RecordAction": {
confirm?: string;
/**
@@ -6386,6 +6466,10 @@ export interface components {
*/
title?: string;
};
"cabana.RegistryState": {
enabled: boolean;
status: string;
};
"cabana.RelationColumn": {
key: string;
label: string;
@@ -6486,6 +6570,11 @@ export interface components {
label: string;
model: string;
order: number;
/**
* @description Path is the admin SPA route an entry of the admin's own opens (the
* Plugins screen, /system/plugins); omitted for plugin entries.
*/
path?: string;
};
"cabana.SettingsMeta": {
/**

View File

@@ -27,6 +27,10 @@ export type AdminAPITokenScope = Schemas['cabana.AdminAPITokenScope']
export type NavigationEntry = Schemas['cabana.NavigationEntry']
export type SettingsEntry = Schemas['cabana.SettingsEntry']
export type SettingsResult = Schemas['cabana.SettingsResult']
/** GET /plugins: the built framework version, the registry state and the installed plugins. */
export type PluginsOverview = Schemas['cabana.PluginsOverview']
export type InstalledPlugin = Schemas['cabana.InstalledPlugin']
export type RegistryState = Schemas['cabana.RegistryState']
export type ListSchema = Schemas['cabana.ListSchema']
export type ListColumn = Schemas['cabana.ListColumn']
export type ListFilter = Schemas['cabana.ListFilter']

View File

@@ -18,6 +18,9 @@ export function pageIndicator(route: RouteLocationNormalizedLoaded): string {
if (route.name === 'account') {
return t('backend::lang.myaccount.menu_label')
}
if (route.name === 'plugins') {
return t('backend::lang.plugins.title')
}
if (route.name === 'settings-form') {
return settingsEntry(String(route.params.code ?? ''))?.label ?? String(route.params.code ?? '')
}

View File

@@ -12,6 +12,7 @@ import ReorderView from '../views/ReorderView.vue'
import SettingsFormView from '../views/SettingsFormView.vue'
import SettingsIndexView from '../views/SettingsIndexView.vue'
import AccountView from '../views/AccountView.vue'
import PluginsView from '../views/PluginsView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
@@ -71,6 +72,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
// My account: the signed-in admin's own profile and password.
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
// The read-only Plugins screen (Settings → System, backend.view_plugins).
{ path: '/system/plugins', name: 'plugins', component: PluginsView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
// The tree reorder page of a list with config_reorder.yaml (D-09).

View File

@@ -11,7 +11,7 @@ import ShortcutHelpDialog from './ShortcutHelpDialog.vue'
import { ExternalLink } from '@lucide/vue'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { activeEntry } from '../../state/useNavigation'
import { settingsEntry, settingsLinkFor, settingsSectionOf } from '../../state/useSettings'
import { settingsEntry, settingsEntryForPath, settingsLinkFor, settingsSectionOf } from '../../state/useSettings'
import { useSidebar } from '../../state/useSidebar'
import { t } from '../../app/i18n'
import { closeShortcutHelp, installHotkeys } from '../../app/hotkeys'
@@ -52,6 +52,9 @@ const settingsSection = computed(() => {
if (route.name === 'settings-form') {
return settingsSectionOf(settingsEntry(String(route.params.code ?? '')))
}
if (route.name === 'plugins') {
return settingsSectionOf(settingsEntryForPath(route.path))
}
return settingsSectionOf(settingsLinkFor(controllerIdFromPath(route.path)))
})

View File

@@ -25,6 +25,9 @@ const crumbs = computed<Crumb[]>(() => {
if (route.name === 'account') {
return [{ label: t('backend::lang.myaccount.menu_label') }]
}
if (route.name === 'plugins') {
return [{ label: t('backend::lang.nav.settings'), to: '/settings' }, { label: t('backend::lang.plugins.title') }]
}
if (route.name === 'settings' || route.name === 'settings-form') {
const out: Crumb[] = [{ label: t('backend::lang.nav.settings'), to: '/settings' }]
const entry = route.name === 'settings-form' ? settingsEntry(String(route.params.code ?? '')) : null

View File

@@ -7,7 +7,7 @@ import { controllerIdFromPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
import { cycleTheme, themeChoice } from '../../state/usePreferences'
import { hasSettings, settingsLinkFor } from '../../state/useSettings'
import { hasSettings, settingsEntryForPath, settingsLinkFor } from '../../state/useSettings'
import { useSidebar } from '../../state/useSidebar'
import SectionFlyout from './SectionFlyout.vue'
@@ -45,6 +45,7 @@ const inSettings = computed(
() =>
route.path === '/settings' ||
route.path.startsWith('/settings/') ||
settingsEntryForPath(route.path) !== null ||
(active.value === null && settingsLinkFor(controllerIdFromPath(route.path)) !== null),
)

View File

@@ -1,7 +1,8 @@
// Settings pages the admin may manage (D-21), server-filtered like the
// navigation. The rail shows "Ustawienia" only when this list is non-empty.
// An entry is either a singleton settings form (/settings/<code>) or a link
// that opens an admin controller (its `controller` is set).
// An entry is either a singleton settings form (/settings/<code>), a link
// that opens an admin controller (its `controller` is set) or one of the
// admin's own screens (its `path` is set, for example /system/plugins).
import { computed, readonly, ref } from 'vue'
import { api } from '../api/client'
import { controllerPath } from '../app/controllerRoutes'
@@ -28,10 +29,14 @@ export function settingsEntry(code: string): SettingsEntry | null {
}
/**
* Route a settings entry opens: its controller for a link entry (the index
* when the ID is malformed), otherwise its singleton form.
* Route a settings entry opens: its own SPA path for an admin screen, its
* controller for a link entry (the index when the ID is malformed),
* otherwise its singleton form.
*/
export function settingsPath(entry: SettingsEntry): string {
if (entry.path) {
return entry.path
}
if (entry.controller) {
return controllerPath(entry.controller) ?? '/settings'
}
@@ -46,6 +51,11 @@ export function settingsLinkFor(controllerId: string | null): SettingsEntry | nu
return entries.value.find((entry) => entry.controller !== '' && entry.controller === controllerId) ?? null
}
/** The entry that opens the admin screen at path (entry.path), if any. */
export function settingsEntryForPath(path: string): SettingsEntry | null {
return entries.value.find((entry) => !!entry.path && entry.path === path) ?? null
}
/** Entries grouped by category, categories in first-seen order, by order. */
export const settingsGroups = computed(() => {
const sorted = entries.value
@@ -97,6 +107,7 @@ export function useSettings() {
settingsEntry,
settingsPath,
settingsLinkFor,
settingsEntryForPath,
settingsSectionOf,
}
}

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { CircleAlert } from '@lucide/vue'
import { api } from '../api/client'
import type { InstalledPlugin, PluginsOverview } from '../api/types'
import { t } from '../app/i18n'
// The read-only Plugins screen (Settings → System, backend.view_plugins).
// Installed lists the plugins the binary was built with, as summer build
// baked them (GET /plugins). Every string from the server is rendered as
// text, never as HTML.
const overview = ref<PluginsOverview | null>(null)
const failed = ref(false)
const loading = ref(true)
const tab = ref('installed')
const SOURCE_LABELS: Record<string, string> = {
module: 'backend::lang.plugins.source_module',
submodule: 'backend::lang.plugins.source_submodule',
local: 'backend::lang.plugins.source_local',
}
function sourceLabel(plugin: InstalledPlugin): string {
const key = SOURCE_LABELS[plugin.source]
return key ? t(key) : plugin.source
}
async function load(): Promise<void> {
loading.value = true
failed.value = false
try {
const { data, response } = await api.GET('/plugins')
if (response.ok && data) {
overview.value = data.data
} else {
failed.value = true
}
} catch {
failed.value = true
} finally {
loading.value = false
}
}
onMounted(load)
const tabClass =
'inline-flex h-[34px] items-center gap-2 rounded-tab px-4 font-semibold text-muted transition-colors duration-150 ease-out hover:text-text data-[state=active]:bg-surface data-[state=active]:text-text data-[state=active]:shadow-tab'
</script>
<template>
<section class="flex flex-col gap-5" data-plugins-screen>
<header class="flex flex-col gap-1">
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ t('backend::lang.plugins.title') }}</h1>
<p v-if="overview?.framework" class="text-[13px] text-muted" data-plugins-framework>
{{ t('backend::lang.plugins.framework', { version: overview.framework }) }}
</p>
</header>
<p v-if="failed" role="alert" class="flex items-center gap-2 text-danger" data-plugins-failed>
<CircleAlert :size="16" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.plugins.load_failed') }}</span>
</p>
<TabsRoot v-else-if="overview" v-model="tab" class="flex flex-col gap-4">
<TabsList
:aria-label="t('backend::lang.plugins.tabs_label')"
class="inline-flex gap-1 self-start rounded-inner border border-border bg-subtle p-1"
>
<TabsTrigger value="installed" data-tab="installed" :class="tabClass">
{{ t('backend::lang.plugins.tab_installed') }}
</TabsTrigger>
<TabsTrigger v-if="overview.registry.enabled" value="marketplace" data-tab="marketplace" :class="tabClass">
{{ t('backend::lang.plugins.tab_marketplace') }}
</TabsTrigger>
</TabsList>
<TabsContent value="installed" class="flex flex-col gap-3">
<p v-if="overview.plugins.length === 0" class="text-muted">{{ t('backend::lang.plugins.empty') }}</p>
<div v-else class="overflow-x-auto rounded-card border border-border bg-surface shadow-card">
<table class="w-full border-collapse text-left">
<thead>
<tr class="border-b border-border text-[13px] text-muted">
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_plugin') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_version') }}</th>
<th scope="col" class="px-5 py-3 font-semibold">{{ t('backend::lang.plugins.column_source') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="plugin in overview.plugins"
:key="plugin.id"
:data-plugin="plugin.id"
class="border-b border-border align-top last:border-b-0"
>
<td class="px-5 py-3">
<div class="flex min-w-0 flex-col">
<span class="font-semibold" data-plugin-name>{{ plugin.name || plugin.id }}</span>
<span v-if="plugin.name" class="font-mono text-[12px] text-muted">{{ plugin.id }}</span>
<span v-if="plugin.description" class="text-[13px] text-muted" data-plugin-description>{{
plugin.description
}}</span>
</div>
</td>
<td class="px-5 py-3 whitespace-nowrap">
<span v-if="plugin.version" class="font-mono text-[13px]" data-plugin-version>{{ plugin.version }}</span>
<span v-else class="text-[13px] text-muted" data-plugin-version>{{
t('backend::lang.plugins.no_version')
}}</span>
</td>
<td class="px-5 py-3 text-[13px] whitespace-nowrap" data-plugin-source>
{{ plugin.source ? sourceLabel(plugin) : '' }}
</td>
</tr>
</tbody>
</table>
</div>
</TabsContent>
<TabsContent v-if="overview.registry.enabled" value="marketplace" class="flex flex-col gap-4" />
</TabsRoot>
<p v-else-if="loading" class="text-muted" aria-busy="true">…</p>
</section>
</template>

View File

@@ -645,6 +645,57 @@
"backend::lang.page.not_found": {
"other": "Nie znaleziono strony."
},
"backend::lang.permissions.view_plugins": {
"other": "Podgląd wtyczek"
},
"backend::lang.plugins.column_plugin": {
"other": "Wtyczka"
},
"backend::lang.plugins.column_source": {
"other": "Źródło"
},
"backend::lang.plugins.column_version": {
"other": "Wersja"
},
"backend::lang.plugins.empty": {
"other": "Brak zainstalowanych wtyczek."
},
"backend::lang.plugins.framework": {
"other": "Framework :version"
},
"backend::lang.plugins.load_failed": {
"other": "Nie udało się wczytać listy wtyczek."
},
"backend::lang.plugins.menu_description": {
"other": "Zainstalowane wtyczki, ich wersje i dostępne aktualizacje oraz katalog wtyczek."
},
"backend::lang.plugins.menu_label": {
"other": "Wtyczki"
},
"backend::lang.plugins.no_version": {
"other": "Brak wersji"
},
"backend::lang.plugins.source_local": {
"other": "W aplikacji"
},
"backend::lang.plugins.source_module": {
"other": "Moduł Go"
},
"backend::lang.plugins.source_submodule": {
"other": "Submoduł git"
},
"backend::lang.plugins.tab_installed": {
"other": "Zainstalowane"
},
"backend::lang.plugins.tab_marketplace": {
"other": "Katalog"
},
"backend::lang.plugins.tabs_label": {
"other": "Sekcje ekranu Wtyczki"
},
"backend::lang.plugins.title": {
"other": "Wtyczki"
},
"backend::lang.relation.add": {
"other": "Dodaj"
},

View File

@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount } from '@vue/test-utils'
import PluginsView from '../../src/views/PluginsView.vue'
import { settingsPath } from '../../src/state/useSettings'
import type { InstalledPlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
const PLUGINS = `${API}/plugins`
function installed(overrides: Partial<InstalledPlugin> & { id: string }): InstalledPlugin {
return {
name: '',
description: '',
version: '',
source: '',
module: '',
package: '',
listed: false,
latest: '',
latestFramework: '',
updateAvailable: false,
updateCommand: '',
...overrides,
}
}
function overview(overrides: Partial<PluginsOverview> = {}): PluginsOverview {
return {
framework: 'v0.6.0',
registry: { enabled: false, status: 'disabled' },
plugins: [
installed({
id: 'acme.blog',
name: 'Blog',
description: 'Posts <b>and</b> categories.',
version: 'v0.1.0',
source: 'module',
module: 'example.test/acme-blog',
package: 'example.test/acme-blog',
}),
installed({ id: 'acme.local', source: 'local' }),
],
...overrides,
}
}
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
async function openPlugins(data: PluginsOverview = overview(), routes: Record<string, Route> = {}) {
return mountApp('/system/plugins', { [`GET ${PLUGINS}`]: envelope(data), ...routes }, { attach: true })
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('PluginsView', () => {
it('renders the installed plugins from GET /plugins', async () => {
const { wrapper, calls } = await openPlugins()
expect(wrapper.findComponent(PluginsView).exists()).toBe(true)
expect(requestsTo(calls, 'GET', PLUGINS)).toHaveLength(1)
expect(wrapper.find('h1').text()).toBe('Wtyczki')
expect(wrapper.find('[data-plugins-framework]').text()).toBe('Framework v0.6.0')
const rows = wrapper.findAll('[data-plugin]')
expect(rows.map((row) => row.attributes('data-plugin'))).toEqual(['acme.blog', 'acme.local'])
const blog = rows[0]!
expect(blog.find('[data-plugin-name]').text()).toBe('Blog')
expect(blog.find('[data-plugin-version]').text()).toBe('v0.1.0')
expect(blog.find('[data-plugin-source]').text()).toBe('Moduł Go')
// Registry and plugin text is text, never HTML.
expect(blog.find('[data-plugin-description]').text()).toBe('Posts <b>and</b> categories.')
expect(blog.find('b').exists()).toBe(false)
const local = rows[1]!
expect(local.find('[data-plugin-name]').text()).toBe('acme.local')
expect(local.find('[data-plugin-version]').text()).toBe('Brak wersji')
expect(local.find('[data-plugin-source]').text()).toBe('W aplikacji')
})
it('shows an error when the list cannot be loaded', async () => {
const { wrapper } = await openPlugins(overview(), {
[`GET ${PLUGINS}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden', details: {} } } },
})
expect(wrapper.find('[data-plugins-failed]').exists()).toBe(true)
expect(wrapper.findAll('[data-plugin]')).toHaveLength(0)
})
it('opens from the settings entry path', () => {
const entry: SettingsEntry = {
code: 'plugins',
label: 'Wtyczki',
description: '',
category: 'System',
icon: 'puzzle',
order: 120,
keywords: [],
model: '',
controller: '',
path: '/system/plugins',
}
expect(settingsPath(entry)).toBe('/system/plugins')
})
})