diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index d5641c35..0fffca59 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -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": { diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index d425aa22..447e468e 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -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": { /** diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 16d11bcf..eaf5503f 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -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'] diff --git a/admin/src/app/pageTitle.ts b/admin/src/app/pageTitle.ts index be67eee6..d31addf8 100644 --- a/admin/src/app/pageTitle.ts +++ b/admin/src/app/pageTitle.ts @@ -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 ?? '') } diff --git a/admin/src/app/router.ts b/admin/src/app/router.ts index 96b3a837..28a1653e 100644 --- a/admin/src/app/router.ts +++ b/admin/src/app/router.ts @@ -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). diff --git a/admin/src/components/shell/AppShell.vue b/admin/src/components/shell/AppShell.vue index 2073cd3f..1a5d429f 100644 --- a/admin/src/components/shell/AppShell.vue +++ b/admin/src/components/shell/AppShell.vue @@ -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))) }) diff --git a/admin/src/components/shell/Breadcrumbs.vue b/admin/src/components/shell/Breadcrumbs.vue index b5b7757f..7bf8f292 100644 --- a/admin/src/components/shell/Breadcrumbs.vue +++ b/admin/src/components/shell/Breadcrumbs.vue @@ -25,6 +25,9 @@ const crumbs = computed(() => { 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 diff --git a/admin/src/components/shell/PluginRail.vue b/admin/src/components/shell/PluginRail.vue index d859f7d5..5f36d94f 100644 --- a/admin/src/components/shell/PluginRail.vue +++ b/admin/src/components/shell/PluginRail.vue @@ -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), ) diff --git a/admin/src/state/useSettings.ts b/admin/src/state/useSettings.ts index b2213656..70718344 100644 --- a/admin/src/state/useSettings.ts +++ b/admin/src/state/useSettings.ts @@ -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/) or a link -// that opens an admin controller (its `controller` is set). +// An entry is either a singleton settings form (/settings/), 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, } } diff --git a/admin/src/views/PluginsView.vue b/admin/src/views/PluginsView.vue new file mode 100644 index 00000000..e61f682f --- /dev/null +++ b/admin/src/views/PluginsView.vue @@ -0,0 +1,126 @@ + + + diff --git a/admin/tests/fixtures/lang.json b/admin/tests/fixtures/lang.json index f1ba3148..95f9cb69 100644 --- a/admin/tests/fixtures/lang.json +++ b/admin/tests/fixtures/lang.json @@ -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" }, diff --git a/admin/tests/views/PluginsView.test.ts b/admin/tests/views/PluginsView.test.ts new file mode 100644 index 00000000..0a4c6b25 --- /dev/null +++ b/admin/tests/views/PluginsView.test.ts @@ -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 & { id: string }): InstalledPlugin { + return { + name: '', + description: '', + version: '', + source: '', + module: '', + package: '', + listed: false, + latest: '', + latestFramework: '', + updateAvailable: false, + updateCommand: '', + ...overrides, + } +} + +function overview(overrides: Partial = {}): PluginsOverview { + return { + framework: 'v0.6.0', + registry: { enabled: false, status: 'disabled' }, + plugins: [ + installed({ + id: 'acme.blog', + name: 'Blog', + description: 'Posts and 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 = {}) { + 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 and 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') + }) +}) diff --git a/docs/backend/admin-spa.md b/docs/backend/admin-spa.md index 1b2b12f2..bb976c70 100644 --- a/docs/backend/admin-spa.md +++ b/docs/backend/admin-spa.md @@ -41,6 +41,10 @@ The button at the top of the plugin rail switches the theme: each click moves fr When the server offers more than one admin language (`meta.locales` of `GET .../lang`), the user menu lists them under Language, each named in its own language. Choosing one saves it as the administrator's `locale` preference, caches it in `localStorage` (`summer-admin.locale`) and reloads the page, so the strings, navigation, settings, schemas and validation messages all come back in that language, in any browser and whatever `Accept-Language` says. A form with unsaved changes asks before the reload. +## Plugins screen + +Settings → System → Plugins opens `/system/plugins`, a read-only screen for administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`; superusers always pass). Its Installed tab lists the plugins the binary was built with, from `GET /api/v1/plugins`: the name and description from each plugin's `plugin.yaml` (only the ID for a plugin without one), the version and where the plugin came from (Go module, git submodule or the application itself), with the framework version the application was built with above the tabs. The settings entry carries a `path` instead of a controller, so the settings index, the settings panel and the breadcrumbs treat the screen like any other System entry. The SPA never calls the plugin registry itself; see [Publishing plugins](../plugins/marketplace.md#admin-plugins-screen). + ## New versions A tab opened before a deploy would otherwise keep running the old admin against the new server, because the session refreshes silently. The SPA reads the build it booted with from the `summer-admin-build` meta of the served `index.html` and asks `/build.json` again when the window gets focus, when the tab becomes visible and every 5 minutes while it is visible, at most once every 30 seconds. When the server answers a different build, a banner at the bottom right says that a new version is available, with Reload and Close buttons. The SPA never reloads by itself: Reload goes through the usual page reload, so a form with unsaved changes still asks first, and Close hides the banner until yet another build is deployed. When the served index carries no build, as under the Vite dev server, the check is off. diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index 5f8619b8..3752ce5f 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -68,7 +68,7 @@ func (p *BlogPlugin) Navigation() []pact.NavigationItem { A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. A plugin can also hide an entry per request with `pact.NavigationItem.Visible` (for a screen whose feature is turned off in its settings); the same drop and relink rules apply. `cabana.Allows` is the check and follows Winter's `hasAnyAccess` for JWT sessions: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. When the request carries a personal API token with a non-full scope, the scope is checked first (see [API tokens](#api-tokens)). The last lines of the activation example on [Admin controllers](admin-controllers.md) show it. -The admin registers two permissions of its own on the System tab of the permission editor: `backend.manage_users` (`cabana.PermissionManageUsers`) opens the Administrators screen and `backend.manage_roles` (`cabana.PermissionManageRoles`) the Roles screen. Neither is granted to any role in code, so after an upgrade only superusers reach those screens until an administrator grants a code to a role or to an administrator. +The admin registers three permissions of its own on the System tab of the permission editor: `backend.manage_users` (`cabana.PermissionManageUsers`) opens the Administrators screen, `backend.manage_roles` (`cabana.PermissionManageRoles`) the Roles screen and `backend.view_plugins` (`cabana.PermissionViewPlugins`) the read-only Plugins screen (see [Admin SPA](admin-spa.md#plugins-screen)). None is granted to any role in code, so after an upgrade only superusers reach those screens until an administrator grants a code to a role or to an administrator. An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`. diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md index 3b3d2d1f..d1cda7fc 100644 --- a/docs/plugins/marketplace.md +++ b/docs/plugins/marketplace.md @@ -180,6 +180,10 @@ The environment variable `SUMMER_PLUGINS__REGISTRY_URL` overrides the file. The Every registry call goes through `kiosk.Client`, which uses `fetchguard` with a 10 second timeout and a 4 MiB limit on the answer. Module paths, package paths and versions from the registry are checked (a plain module path, a strict `vX.Y.Z` version) before any `go` command runs. +## Admin Plugins screen + +The admin's Settings → System → Plugins screen shows administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`) what the application was built with. Its Installed tab reads `party.Installed` through `GET /api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. The screen changes nothing: plugins are installed and updated with the commands above, followed by `summer build`. With `plugins.registry_url` set to an empty value the screen reports the registry as disabled. See [Admin SPA](../backend/admin-spa.md#plugins-screen). + ## Registry API v1 summercms.io publishes the plugins it lists through a read-only JSON API. The marketplace pages, `summer` and the admin's Plugins screen read it. Its base path is `/api/registry/v1` (`kiosk.RegistryAPIPath`), and the `kiosk` module holds its wire types, so every client and the server share one definition of the shape. diff --git a/modules/cabana/README.md b/modules/cabana/README.md index de1cb5b2..dac0d068 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -57,6 +57,7 @@ Use cases: - Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack. The update form's Administrators tab lists the role's holders read-only (a form partial): superusers only for superuser editors, deleted and deactivated holders marked, the first 100 with a note naming the total, each login linked to the Administrators preview for an editor holding `cabana.PermissionManageUsers`; roles are assigned only on the Administrators form. - Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active. - Form fields on another plugin's controller: a `pact.AdminActionExtension` with `Form` set to a `pact.AdminFormExtension` places fields and tabs in any admin controller's form by its ID. Its fields document is the `fields.yaml` format plus `before:`/`after:` anchors, `zone:` (only the tab zone for now) and a `tabs:` list, compiled at boot with every `fields.yaml` check; extensions apply in plugin activation order and the form is sent in the compiled order, so the SPA adds no ordering. `Hide` removes owner fields, `Permissions` and `Visible` decide per request who sees the fields (schema, show and save alike), widget fields run the extension's `Actions` and partial fields render from its `Dir` through `PartialData`. Extension values leave the save body before any owner path reads them; `Save` runs in the record's save transaction after the owner row is written (`pact.AdminFormErrors` answers 422, its notices join `meta.notices`) and `Load` serves the values on show. A form no extension lays out compiles unchanged. Unknown anchors, duplicate names, anchor cycles, anchors outside `Requires()`, other zones, `fileupload` fields and clashing widget action or partial names stop the boot naming the plugin, the controller and the name. +- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`). - Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin. - Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant. - Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed. @@ -91,7 +92,8 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in | GET `/account/tokens/scopes` | Scopes the caller may assign (`cabana.AdminAPITokenScope`), including `*`, `admin:read` and overlapping plugin scopes. Interactive session only. | | GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. A section whose status or one of whose actions' `Applies` failed carries `error: true`. An empty list when no plugin contributes. Interactive session only. | | POST `/account/contributions/{name}/actions/{action}` | Run one contributed action for the signed-in administrator: 404 for an unknown section or action, 403 without the action's permissions, 409 when it no longer applies; `Run` gets the administrator's id inside a transaction and answers `cabana.AccountActionResultView` (translated `message`, optional `url`, only an `http`/`https` or same-origin URL). Interactive session only, AJAX header required. | -| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. | +| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form, and `path` (only on the admin's own Plugins entry) names the SPA route it opens. | +| GET `/plugins` | The Plugins screen (`cabana.PluginsOverview`): the framework version the application was built with, the registry state (`cabana.RegistryState`: `enabled`, `status` ok or disabled) and every activated plugin in summer.yaml order (`cabana.InstalledPlugin`). 403 without `cabana.PermissionViewPlugins`. | | GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. | | POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. | | GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. | @@ -296,6 +298,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.AdminRelationLinkRequest` | Documented body of the link route: `ids` and the optional `pivot` object. | | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | | `cabana.PermissionManageUsers` / `cabana.PermissionManageRoles` | The permission codes of the Administrators (`backend.manage_users`) and Roles (`backend.manage_roles`) screens, granted to no role in code. | +| `cabana.PermissionViewPlugins` | The permission code of the Plugins screen (`backend.view_plugins`), granted to no role in code. | +| `cabana.PluginsOverview` / `cabana.InstalledPlugin` / `cabana.RegistryState` | The GET `/plugins` payload: framework version, registry state and the installed plugins with their baked metadata. | +| `cabana.SettingsEntry.Path` | The SPA route a settings entry of the admin's own opens (`/system/plugins`); empty, and omitted from JSON, for plugin entries. | +| `cabana.AdminPluginsInstalled` | Swag annotation of GET `/plugins`. | | `cabana.AdministratorsControllerID` / `cabana.RolesControllerID` | The admin controller IDs of the Administrators (`summercms.cabana.users`) and Roles (`summercms.cabana.roles`) screens; a plugin names the first in `pact.AdminActionExtension.Controller` to add record actions to it. | | `cabana.BackendUser.Fillable` / `cabana.BackendUser.Rules` | The columns the Administrators screen fills from a form (login, email, first and last name) and their base rules. | | `cabana.BackendUserRole.Fillable` / `cabana.BackendUserRole.Rules` | The columns a role form fills (name, code, description) and their base rules. | @@ -396,7 +402,7 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo ## Dependencies -- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [diary](../diary/README.md) (component logger `cabana`; the `admin.auth` audit record keeps its message), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md). +- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [diary](../diary/README.md) (component logger `cabana`; the `admin.auth` audit record keeps its message), [kiosk](../kiosk/README.md) (the plugin registry URL and client of the Plugins screen), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md). - Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk). - Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`. - Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package, and [signpost](../signpost/README.md) for the dependent dropdown fixture. @@ -422,3 +428,5 @@ A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postg The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`). The Roles form's holders list is covered by the Docker-free TestRolesHoldersPartial in `backend_screens_test.go` (the users partial field on the update form's Administrators tab, the view model guard, links, state lines, escaped holder strings, the empty line and the note) and the Postgres test TestRolesHolders in `roles_postgres_test.go` (superusers hidden from non-superuser editors, D-03; deleted and deactivated holders; preview links only with `cabana.PermissionManageUsers`; the empty role; the 100-row cap with its note; 403 without `cabana.PermissionManageRoles`). + +The Plugins screen is covered by the Docker-free TestPluginsScreenSmoke in `plugins_smoke_test.go`: the installed list from `party.Installed` and the `party.Activated` fallback, 401 and 403 without `cabana.PermissionViewPlugins`, and the settings entry with its `path` only for allowed administrators. diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 43ab3de9..f3c4f201 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -479,6 +479,19 @@ func AdminFieldTypes() {} // @Router /settings [get] func AdminSettingsList() {} +// AdminPluginsInstalled documents GET /plugins. +// +// @Summary 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). +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Success 200 {object} Envelope[PluginsOverview] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Router /plugins [get] +func AdminPluginsInstalled() {} + // AdminSettingsSchema documents GET /settings/{code}/schema. // // @Summary Admin settings schema diff --git a/modules/cabana/backend_screens.go b/modules/cabana/backend_screens.go index e26c24ee..19b8d9cd 100644 --- a/modules/cabana/backend_screens.go +++ b/modules/cabana/backend_screens.go @@ -77,12 +77,15 @@ func (s *backendScreens) AdminControllers() []pact.AdminController { return []pact.AdminController{usersAdmin{screens: s}, rolesAdmin{screens: s}} } -// Permissions declares the two screen permissions with no role grants -// (D-04): an upgrade gives no existing role access to the screens. +// Permissions declares the screen permissions with no role grants (D-04): +// an upgrade gives no existing role access to the screens. The Plugins +// screen (PermissionViewPlugins) is served by cabana itself, not by an +// admin controller. func (*backendScreens) Permissions() []pact.Permission { return []pact.Permission{ {Code: PermissionManageUsers, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.manage_users"}, {Code: PermissionManageRoles, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.manage_roles"}, + {Code: PermissionViewPlugins, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.view_plugins"}, } } diff --git a/modules/cabana/http.go b/modules/cabana/http.go index b9d96bcf..e27f5ad2 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -337,6 +337,8 @@ func (s *service) mount(r pact.Router) { // Form previews render through RenderMarkdown; nothing is stored. g.Post("/markdown/preview", requireAjax(s.markdownPreview)) g.Get("/settings", s.settingsList) + // The read-only Plugins screen (backend.view_plugins). + g.Get("/plugins", s.pluginsInstalled) g.Get("/settings/{code}/schema", s.settingsSchema) constrainSetting(g) g.Get("/settings/{code}", s.settingsGet) diff --git a/modules/cabana/navigation.go b/modules/cabana/navigation.go index 9f6f6db2..173e6061 100644 --- a/modules/cabana/navigation.go +++ b/modules/cabana/navigation.go @@ -32,6 +32,9 @@ type SettingsEntry struct { // Controller is the admin controller a link entry opens; empty for a // singleton settings form. Controller string `json:"controller"` + // 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 `json:"path,omitempty"` } // Metadata returns only entries the backend principal may open. Denied @@ -103,6 +106,13 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t Controller: item.Controller, }) } + if r.listsPluginsScreen(principal) { + settings = append(settings, pluginsSettingsEntry( + translateKey(ctx, tr, "backend::lang.plugins.menu_label"), + translateKey(ctx, tr, "backend::lang.plugins.menu_description"), + translateKey(ctx, tr, "backend::lang.settings.category_system"), + )) + } sort.SliceStable(settings, func(i, j int) bool { if settings[i].Order == settings[j].Order { return settings[i].Code < settings[j].Code @@ -112,6 +122,20 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t return navigation, settings } +// listsPluginsScreen reports whether the settings list links the Plugins +// screen for principal: the admin's own screens are compiled (they declare +// PermissionViewPlugins), no plugin took the settings code "plugins", and +// the principal may open the screen. +func (r *Registry) listsPluginsScreen(principal *bouncer.Principal) bool { + if _, declared := r.permissions[PermissionViewPlugins]; !declared { + return false + } + if _, taken := r.settings[pluginsSettingsCode]; taken { + return false + } + return Allows(principal, []string{PermissionViewPlugins}) +} + // openableTarget returns the controller a main item should link to. It is the // item's own controller unless the principal cannot open it or it is the // controller of a side item hidden for this request (in hidden), in which diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 916b2457..e8281ef8 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -412,6 +412,9 @@ func conformCases() []conformCase { {"GET /settings", 200, "cabana.Envelope-array_cabana_SettingsEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/settings", nil, true) }, into[cabana.Envelope[[]cabana.SettingsEntry]](), nil}, + {"GET /plugins", 200, "cabana.Envelope-cabana_PluginsOverview", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/plugins", nil, true) + }, into[cabana.Envelope[cabana.PluginsOverview]](), nil}, {"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true) }, into[cabana.Envelope[cabana.FormView]](), nil}, @@ -1032,6 +1035,10 @@ func newConformEnv(t *testing.T) *conformEnv { } login := "conform-" + stamp insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + // The Plugins screen needs backend.view_plugins, which no role holds. + if err := gdb.Exec(`UPDATE backend_users SET permissions = ? WHERE login = ?`, `{"backend.view_plugins":1}`, login).Error; err != nil { + t.Fatal(err) + } dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-conform\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil { @@ -1041,7 +1048,8 @@ func newConformEnv(t *testing.T) *conformEnv { if err != nil { t.Fatal(err) } - for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} { + // plugins.registry_url "" keeps GET /plugins from calling a registry. + for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576, "plugins.registry_url": ""} { if err := cfg.Set(key, value); err != nil { t.Fatal(err) } diff --git a/modules/cabana/plugins.go b/modules/cabana/plugins.go new file mode 100644 index 00000000..8bc3844f --- /dev/null +++ b/modules/cabana/plugins.go @@ -0,0 +1,156 @@ +package cabana + +import ( + "net/http" + + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/kiosk" + "git.golem15.com/golem15/summercms/modules/party" +) + +// The admin's read-only Plugins screen (Settings → System → Plugins): the +// plugins the application was built with, their versions and, when the +// plugin registry is configured, available updates and a marketplace +// search. The SPA calls only these endpoints; cabana calls the registry. + +// PermissionViewPlugins opens the Plugins screen. No role is granted it in +// code: until an administrator grants it, only superusers see the screen. +const PermissionViewPlugins = "backend.view_plugins" + +// pluginsSettingsCode is the settings code of the Plugins screen entry and +// pluginsScreenPath the SPA route it opens. +const ( + pluginsSettingsCode = "plugins" + pluginsScreenPath = "/system/plugins" +) + +// States of the plugin registry on the Plugins screen. +const ( + // registryStatusOK: the registry answered (or is configured and was + // not needed for this answer). + registryStatusOK = "ok" + // registryStatusDisabled: plugins.registry_url is empty; no request + // leaves the process. + registryStatusDisabled = "disabled" + // registryStatusUnavailable: the registry did not answer in time or + // answered with an error. + registryStatusUnavailable = "unavailable" +) + +// PluginsOverview is the GET /plugins payload: the framework version the +// application was built with, the state of the plugin registry and every +// activated plugin in summer.yaml order. +type PluginsOverview struct { + Framework string `json:"framework"` + Registry RegistryState `json:"registry"` + Plugins []InstalledPlugin `json:"plugins"` +} + +// RegistryState tells the admin whether the plugin registry is configured +// (Enabled, false when plugins.registry_url is empty) and how it answered: +// Status is ok, disabled or unavailable. +type RegistryState struct { + Enabled bool `json:"enabled"` + Status string `json:"status"` +} + +// InstalledPlugin is one activated plugin with the metadata summer build +// baked in (party.Info) and, for a plugin the registry lists, the newest +// release the built framework version allows. Name, Description, Version, +// Source, Module and Package are empty for an application built before +// plugin metadata existed and for a plugin without plugin.yaml (Name and +// Description). Latest is the newest compatible release; UpdateAvailable +// is true when it is newer than the installed version, and UpdateCommand +// is then the summer command that installs it. LatestFramework names the +// framework version a newer, incompatible release needs. +type InstalledPlugin struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Source string `json:"source"` + Module string `json:"module"` + Package string `json:"package"` + Listed bool `json:"listed"` + Latest string `json:"latest"` + LatestFramework string `json:"latestFramework"` + UpdateAvailable bool `json:"updateAvailable"` + UpdateCommand string `json:"updateCommand"` +} + +// pluginsSettingsEntry is the Settings → System link to the Plugins +// screen. It opens an SPA route, not an admin controller. +func pluginsSettingsEntry(label, description, category string) SettingsEntry { + return SettingsEntry{ + Code: pluginsSettingsCode, Label: label, Description: description, + Category: category, Icon: "puzzle", Order: 120, Keywords: []string{}, + Path: pluginsScreenPath, + } +} + +// pluginsPrincipal answers 401 without a backend principal and 403 when +// the principal may not open the Plugins screen; ok is false after either. +func pluginsPrincipal(w http.ResponseWriter, r *http.Request) (*bouncer.Principal, bool) { + principal, ok := bouncer.User(r.Context()) + if !ok || principal == nil || !principal.Backend { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return nil, false + } + if !Allows(principal, []string{PermissionViewPlugins}) { + WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) + return nil, false + } + return principal, true +} + +// pluginsInstalled answers GET /plugins. +func (s *service) pluginsInstalled(w http.ResponseWriter, r *http.Request) { + if _, ok := pluginsPrincipal(w, r); !ok { + return + } + overview := s.installedPlugins() + overview.Registry = s.registryState() + WriteData(w, http.StatusOK, overview, nil) +} + +// registryState reports whether plugins.registry_url is set. +func (s *service) registryState() RegistryState { + if kiosk.RegistryURL(s.config()) == "" { + return RegistryState{Enabled: false, Status: registryStatusDisabled} + } + return RegistryState{Enabled: true, Status: registryStatusOK} +} + +// installedPlugins lists the activated plugins from the metadata summer +// build published (party.Installed). An application built before that +// metadata existed lists the IDs of party.Activated instead, and its +// framework version comes from the binary's build information. +func (s *service) installedPlugins() PluginsOverview { + overview := PluginsOverview{Plugins: []InstalledPlugin{}} + if s == nil || s.app == nil { + overview.Framework = bonfire.BuildVersion().Framework + return overview + } + if installed, ok := s.app.Lookup[party.Installed](); ok { + overview.Framework = installed.Framework + for _, info := range installed.Plugins { + overview.Plugins = append(overview.Plugins, InstalledPlugin{ + ID: info.ID, Name: info.Name, Description: info.Description, + Version: info.Version, Source: info.Source, + Module: info.Module, Package: info.Package, + }) + } + } else if activated, ok := s.app.Lookup[party.Activated](); ok { + for _, plugin := range activated { + if plugin == nil || plugin.ID() == backendScreensPluginID { + continue + } + overview.Plugins = append(overview.Plugins, InstalledPlugin{ID: plugin.ID()}) + } + } + if overview.Framework == "" { + overview.Framework = bonfire.BuildVersion().Framework + } + return overview +} diff --git a/modules/cabana/plugins_smoke_test.go b/modules/cabana/plugins_smoke_test.go new file mode 100644 index 00000000..75ce8d3d --- /dev/null +++ b/modules/cabana/plugins_smoke_test.go @@ -0,0 +1,164 @@ +package cabana + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/party" +) + +// pluginsConfig is an application config whose plugins.registry_url is +// registry ("" disables the registry). +func pluginsConfig(t *testing.T, registry string) *compass.Config { + t.Helper() + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development"}}) + if err != nil { + t.Fatal(err) + } + if err := cfg.Set("plugins.registry_url", registry); err != nil { + t.Fatal(err) + } + return cfg +} + +// pluginsService is a service over the admin's own screens and the +// screensExtender plugin, on an app with cfg that published installed +// (when not nil) and activated. +func pluginsService(t *testing.T, cfg *compass.Config, installed *party.Installed, activated party.Activated) *service { + t.Helper() + reg, err := compileWithScreens(t, []party.Plugin{screensExtender{}}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if installed != nil { + if err := party.Describe(app, installed.Plugins, installed.Framework); err != nil { + t.Fatal(err) + } + } + if activated != nil { + if err := app.Publish(activated); err != nil { + t.Fatal(err) + } + } + return &service{app: app, reg: reg} +} + +// pluginsCall runs handler for principal on GET target and returns the +// recorder. +func pluginsCall(svc *service, handler func(*service, http.ResponseWriter, *http.Request), principal *bouncer.Principal, target string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, adminAPI(target), nil) + if principal != nil { + req = req.WithContext(bouncer.WithUser(req.Context(), principal)) + } + rec := httptest.NewRecorder() + handler(svc, rec, req) + return rec +} + +func decodeOverview(t *testing.T, rec *httptest.ResponseRecorder) PluginsOverview { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body Envelope[PluginsOverview] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + return body.Data +} + +var ( + pluginsSuperuser = &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true} + pluginsViewer = &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{PermissionViewPlugins: true}} + pluginsDenied = &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.ext.access": true}} +) + +// testInstalled is what summer build baked for a module plugin, a +// submodule plugin and an in-app plugin without plugin.yaml. +func testInstalled() *party.Installed { + return &party.Installed{Framework: "v0.6.0", Plugins: []party.Info{ + {ID: "acme.blog", Module: "example.test/acme-blog", Package: "example.test/acme-blog", Name: "Blog", Description: "Posts and categories.", Version: "v0.1.0", Source: party.SourceModule}, + {ID: "acme.translate", Module: "example.test/acme-translate", Package: "example.test/acme-translate", Name: "Translate", Description: "Translations.", Version: "v0.1.1-3-gabc1234", Source: party.SourceSubmodule}, + {ID: "acme.local", Package: "example.test/app/plugins/acme/local", Source: party.SourceLocal}, + }} +} + +// TestPluginsScreenSmoke is the Plugins screen end to end through cabana's +// handlers: who may open it, what it lists and how it links from Settings. +func TestPluginsScreenSmoke(t *testing.T) { + t.Run("installed", func(t *testing.T) { + svc := pluginsService(t, pluginsConfig(t, ""), testInstalled(), nil) + + overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) + if overview.Framework != "v0.6.0" || len(overview.Plugins) != 3 { + t.Fatalf("superuser overview = %+v", overview) + } + blog := overview.Plugins[0] + if blog.ID != "acme.blog" || blog.Name != "Blog" || blog.Version != "v0.1.0" || blog.Source != party.SourceModule || blog.Description != "Posts and categories." || blog.Module != "example.test/acme-blog" { + t.Fatalf("blog = %+v", blog) + } + if got := overview.Plugins[1]; got.Version != "v0.1.1-3-gabc1234" || got.Source != party.SourceSubmodule { + t.Fatalf("translate = %+v", got) + } + if got := overview.Plugins[2]; got.ID != "acme.local" || got.Name != "" || got.Version != "" || got.Source != party.SourceLocal { + t.Fatalf("local = %+v", got) + } + if overview.Registry.Enabled || overview.Registry.Status != registryStatusDisabled { + t.Fatalf("registry with an empty plugins.registry_url = %+v", overview.Registry) + } + + // The permission opens the screen; without it the answer is 403. + if rec := pluginsCall(svc, (*service).pluginsInstalled, pluginsDenied, "/plugins"); rec.Code != http.StatusForbidden { + t.Fatalf("without %s: status=%d body=%s", PermissionViewPlugins, rec.Code, rec.Body.String()) + } + if rec := pluginsCall(svc, (*service).pluginsInstalled, nil, "/plugins"); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous: status=%d", rec.Code) + } + if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsViewer, "/plugins")); len(got.Plugins) != 3 { + t.Fatalf("viewer overview = %+v", got) + } + + // Settings → System links the screen only for allowed principals. + for _, tc := range []struct { + name string + principal *bouncer.Principal + want bool + }{{"superuser", pluginsSuperuser, true}, {"viewer", pluginsViewer, true}, {"denied", pluginsDenied, false}} { + rec := pluginsCall(svc, (*service).settingsList, tc.principal, "/settings") + var body Envelope[[]SettingsEntry] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + found := false + for _, entry := range body.Data { + if entry.Code == pluginsSettingsCode { + found = true + if entry.Path != "/system/plugins" || entry.Controller != "" { + t.Fatalf("%s: plugins entry = %+v", tc.name, entry) + } + } else if entry.Path != "" { + t.Fatalf("%s: entry %s has a path %q", tc.name, entry.Code, entry.Path) + } + } + if found != tc.want { + t.Fatalf("%s: plugins entry listed=%v, want %v: %s", tc.name, found, tc.want, rec.Body.String()) + } + } + + // An app built before plugin metadata existed lists the activated IDs. + legacy := pluginsService(t, pluginsConfig(t, "https://registry.example.test"), nil, party.Activated{screensExtender{}}) + old := decodeOverview(t, pluginsCall(legacy, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) + if len(old.Plugins) != 1 || old.Plugins[0].ID != "acme.ext" || old.Plugins[0].Version != "" || old.Framework == "" { + t.Fatalf("legacy overview = %+v", old) + } + if !old.Registry.Enabled { + t.Fatalf("configured registry reported %+v", old.Registry) + } + }) +} diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index 00a7553a..983bc501 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -53,6 +53,7 @@ var phase09Routes = []adminRoute{ {key: "GET /navigation"}, {key: "GET /field-types"}, {key: "GET /settings"}, + {key: "GET /plugins"}, {key: "GET /settings/{code}/schema"}, {key: "GET /settings/{code}"}, {key: "PUT /settings/{code}"}, @@ -307,6 +308,7 @@ type phase09Call struct { func phase09ProtectedCalls() []phase09Call { return []phase09Call{ + {"plugins-installed", (*service).pluginsInstalled}, {"list", (*service).list}, {"create", (*service).create}, {"bulk-delete", (*service).bulkDelete}, diff --git a/modules/cabana/token_routes_test.go b/modules/cabana/token_routes_test.go index 15a846a6..131dfa25 100644 --- a/modules/cabana/token_routes_test.go +++ b/modules/cabana/token_routes_test.go @@ -49,6 +49,7 @@ var tokenRouteClasses = map[string]tokenRouteClass{ "GET /navigation": classAllowsNav, "GET /field-types": classExplicitGate, "GET /settings": classAllowsSettings, + "GET /plugins": classAllowsProtect, "GET /settings/{code}/schema": classAllowsSettings, "GET /settings/{code}": classAllowsSettings, "PUT /settings/{code}": classAllowsSettings, diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 6865d128..81a8e04a 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -543,12 +543,30 @@ user: users_more: one: "Showing the first :shown of :count administrator. Filter the Administrators list by this role to see all of them." other: "Showing the first :shown of :count administrators. Filter the Administrators list by this role to see all of them." +plugins: + menu_label: "Plugins" + menu_description: "Installed plugins, their versions and available updates, and the plugin marketplace." + title: "Plugins" + framework: "Framework :version" + tabs_label: "Plugins screen sections" + tab_installed: "Installed" + tab_marketplace: "Marketplace" + column_plugin: "Plugin" + column_version: "Version" + column_source: "Source" + source_module: "Go module" + source_submodule: "Git submodule" + source_local: "In the application" + no_version: "No version" + empty: "No plugins are installed." + load_failed: "The plugin list could not be loaded." permissions: tab_system: "System" manage_users: "Manage administrators" manage_users_comment: "Create, edit, deactivate and delete administrator accounts." manage_roles: "Manage roles" manage_roles_comment: "Create and edit roles and the permissions they grant." + view_plugins: "View plugins" version: available: "A new version of the admin is available." reload: "Reload" diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index 7acde0da..5d354574 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -579,12 +579,30 @@ user: few: "Pokazano pierwszych :shown z :count administratorów. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." many: "Pokazano pierwszych :shown z :count administratorów. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." other: "Pokazano pierwszych :shown z :count administratora. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." +plugins: + menu_label: "Wtyczki" + menu_description: "Zainstalowane wtyczki, ich wersje i dostępne aktualizacje oraz katalog wtyczek." + title: "Wtyczki" + framework: "Framework :version" + tabs_label: "Sekcje ekranu Wtyczki" + tab_installed: "Zainstalowane" + tab_marketplace: "Katalog" + column_plugin: "Wtyczka" + column_version: "Wersja" + column_source: "Źródło" + source_module: "Moduł Go" + source_submodule: "Submoduł git" + source_local: "W aplikacji" + no_version: "Brak wersji" + empty: "Brak zainstalowanych wtyczek." + load_failed: "Nie udało się wczytać listy wtyczek." permissions: tab_system: "System" manage_users: "Zarządzanie administratorami" manage_users_comment: "Tworzenie, edycja, dezaktywacja i usuwanie kont administratorów." manage_roles: "Zarządzanie rolami" manage_roles_comment: "Tworzenie i edycja ról oraz uprawnień, które nadają." + view_plugins: "Podgląd wtyczek" version: available: "Dostępna jest nowa wersja panelu." reload: "Odśwież"