Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions openspec/parity/capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,10 @@
"dashy": "no",
"built": {
"state": "built",
"evidence": "src/modals/WidgetMovePanel.vue:27; src/components/Widgets/WidgetContextMenu.vue:27",
"evidence": "nextcloud-vue CnDashboardGrid.vue:35-47 (main 0f1c0ffbb) makes every grid item in edit mode a focusable named group (role group, aria-label, tabindex 0 while keyboardRepositioning, default true) and turns Enter or Space into the contextmenu event a right-click sends; src/views/Views.vue:290 catches it on the placement and opens WidgetContextMenu; src/components/Widgets/WidgetContextMenu.vue:22-27 offers Move, which src/views/Views.vue:420 and :444 route to src/modals/WidgetMovePanel.vue; WidgetMovePanel.vue:286-310 moves with the arrow keys, resizes with Shift and the arrow keys, saves on Enter and cancels on Escape, through nudgePlacement (src/composables/useGridManager.js:248), the collision model the drag path uses; Views.vue:1964 handleMoveSave persists it. Tests: src/modals/__tests__/WidgetMovePanel.spec.js, tests/e2e/keyboard-widget-repositioning.spec.ts (drives the page with the keyboard only, checks the position after reload)",
"owner": "ConductionNL/launchpad",
"change": "openspec/changes/keyboard-accessible-widget-repositioning"
"change": "openspec/changes/keyboard-accessible-widget-repositioning",
"readOn": "2026-10-07"
},
"reachedOn": "Workspace page, widget context menu, Move",
"provider": "launchpad",
Expand All @@ -620,7 +621,8 @@
"dashy": "source read at 4.7.0: searched src for keydown, arrow, move with keyboard: src/utils/ArrowKeyNavigation.js:1-6 only moves focus between tiles in search results; reordering is pointer drag only (src/directives/DragSort.js:24-35 forceFallback). Items can be copied or moved to another section through the context menu (src/assets/locales/en.json:320 'Copy or Move'), widgets and sections cannot",
"workspace-365": "not checked: W365 user guide and FAQ for keyboard move or resize of tiles, not found",
"microsoft-viva": "not checked: support.microsoft.com c4f573dd screen reader guide covers touch gestures to reorder cards only; no keyboard move or resize found"
}
},
"note": "Re-rated 2026-10-07 from code: built, yes, unchanged. The change keyboard-accessible-widget-repositioning still shows its 11 tasks unticked, but every one is delivered: tasks 1-3 (focusable, named grid item; Enter opens the menu) by nextcloud-vue CnDashboardGrid instead of the retired src/components/DashboardGrid.vue the tasks name, tasks 4-9 in launchpad, tasks 10-11 in the Playwright spec. The change is ready to tick and archive. Not checked live in a browser in this round."
},
{
"id": "build-columns",
Expand Down Expand Up @@ -2092,9 +2094,10 @@
"dashy": "partial",
"built": {
"state": "built",
"evidence": "lib/widget-types.json:12; src/services/graphqlClient.js:16",
"evidence": "src/constants/widgetRegistry.js:116-120 routes the chart and stats-block types to src/components/Widgets/Renderers/ChartHost.vue and StatsBlockHost.vue, thin adapters onto nextcloud-vue's CnChartWidget and CnStatsBlockWidget, which fetch their figures from an OpenRegister dataSource; stat, gauge, delta and table are nextcloud-vue registry types LaunchPad passes through unchanged (lib/widget-types.json:12-38). Renderers, configuration forms and data are nextcloud-vue's and OpenRegister's (nextcloud-vue src/components/CnWidgetGrid/registerDashboardWidgets.js:81-127, main 0f1c0ffbb); src/services/graphqlClient.js, cited before, serves the spend-analytics widget, not these",
"owner": "ConductionNL/openregister",
"spec": "none"
"spec": "none",
"readOn": "2026-10-07"
},
"reachedOn": "Workspace page, Add widget, Chart, Stat or Table, only with OpenRegister installed",
"provider": "openregister",
Expand Down Expand Up @@ -2775,7 +2778,7 @@
"state": "built",
"evidence": "src/components/admin/tabs/RolesPermissionsTab.vue:15; lib/Service/ActionAuthService.php:95",
"owner": "ConductionNL/launchpad",
"spec": "none"
"spec": "openspec/specs/action-authorization"
},
"reachedOn": "Admin settings, Roles and permissions tab, action matrix",
"provider": "launchpad",
Expand Down Expand Up @@ -4554,7 +4557,7 @@
"state": "built",
"evidence": "appinfo/info.xml:75",
"owner": "ConductionNL/launchpad",
"spec": "none"
"spec": "openspec/specs/runtime-or-consumption"
},
"reachedOn": "the app installs and boots with no OpenRegister; OpenRegister-backed widgets are feature-detected",
"provider": "launchpad",
Expand Down Expand Up @@ -4799,7 +4802,7 @@
"state": "built",
"owner": "ConductionNL/nextcloud-vue",
"evidence": "src/manifest.json:475",
"spec": "none"
"spec": "openspec/specs/runtime-shell"
},
"reachedOn": "Workspace page on first visit, 'Getting started' tour, and again from the help menu",
"evidence": {
Expand Down
105 changes: 105 additions & 0 deletions openspec/specs/action-authorization/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
capability: action-authorization
status: done
cross-links:
- openspec/architecture/adr-023-action-authorization.md
- openspec/specs/admin-roles/spec.md
- openspec/specs/permissions/spec.md
---

# Action Authorization Specification

## Purpose

An administrator decides in one grid which Nextcloud groups may take each LaunchPad action: list dashboards, publish one, react, see analytics, edit the navigation, and so on. Every controller method names its action and asks one service whether the caller may take it. This is action RBAC as ADR-023 defines it. Which objects a user may touch stays with PermissionService and the admin roles spec; this spec only answers "may this user call this endpoint at all".

Written on 2026-10-07 after the fact, from the code on `development`. It describes what ships today and covers parity row `t-action-matrix`.

Code:
- `lib/Service/ActionAuthService.php`: `requireAction()` (line 117), `getAllowedGroups()` (171), `getMatrix()` (187), `setMatrix()` (234).
- `lib/Controller/ActionMatrixController.php`: `getMatrix()` (73) and `setMatrix()` (111), routed at `GET` and `PUT /api/admin/action-matrix` (`appinfo/routes.php:440-441`).
- `lib/actions.seed.json`: 82 actions and their default groups.
- `lib/Repair/InitializeActions.php` (run, line 71) and `lib/Repair/ApplyActionBaseline.php` (run, line 127), both registered as install and upgrade steps in `appinfo/info.xml`.
- `src/components/admin/ActionAuthMatrix.vue`, mounted by `src/components/admin/tabs/RolesPermissionsTab.vue:15` in the Roles and permissions tab of the admin settings (`src/components/admin/AdminSettings.vue:246`).

## Requirements

### Requirement: Every action endpoint asks the action service (REQ-ACT-001)

A controller method that performs a LaunchPad action MUST call `ActionAuthService::requireAction()` with the caller and a dot-separated action name (for example `dashboard-reaction.add-reaction`) before it does any work. The service SHALL refuse with an `OCSForbiddenException` when the caller may not take the action. Editing the matrix itself is not an action: it is guarded by `#[AuthorizedAdminSetting(LaunchPadAdmin::class)]` at the route.

@e2e exclude Backend authorization; asserted per controller in tests/Unit/Controller (for example DashboardReactionApiControllerTest.php). No page to drive.

#### Scenario: A member outside the allowed groups is refused
- GIVEN the matrix entry for `analytics.top-dashboards` is `["admin"]`
- AND Pieter is not an administrator
- WHEN Pieter's session calls the endpoint behind that action
- THEN the service MUST throw `OCSForbiddenException` with the message "Action 'analytics.top-dashboards' requires admin rights"
- AND the controller MUST NOT run the action

#### Scenario: A member of an allowed group passes
- GIVEN the matrix entry for `dashboard.publish` is `["admin", "redactie"]`
- AND Pieter is a member of the group `redactie`
- WHEN Pieter's session calls the endpoint behind that action
- THEN `requireAction()` MUST return without throwing

### Requirement: How an action resolves (REQ-ACT-002)

The service SHALL resolve an action in this order. An administrator always passes. An entry holding the sentinel `@all` passes every signed-in user. An entry that is empty or holds only `admin` refuses everyone else. Otherwise the caller passes when one of their groups, read through `AdminTemplateService::getUserGroupIdsFor()`, is in the entry. An action missing from the matrix MUST resolve to `["admin"]`, so a new action is closed until an administrator opens it.

@e2e exclude Pure service logic; covered by tests/Unit/Service/ActionAuthBaselineTest.php.

#### Scenario: The all-users sentinel wins over an admin-only reading
- GIVEN the entry for `dashboard.list` is `["admin", "@all"]`
- WHEN any signed-in user calls an endpoint behind `dashboard.list`
- THEN the call MUST pass

#### Scenario: An unknown action is admin-only
- GIVEN the matrix has no entry for `example.new-action`
- WHEN a user who is not an administrator calls the endpoint behind it
- THEN the service MUST refuse the call

#### Scenario: An unreadable matrix denies by default
- GIVEN the stored `actions` app config value is not valid JSON
- WHEN the service reads the matrix
- THEN it MUST treat the matrix as empty
- AND every action MUST resolve to `["admin"]`

### Requirement: The administrator edits the matrix in one grid (REQ-ACT-003)

The Roles and permissions tab of the LaunchPad admin settings MUST show an "Action authorization" table with one row per action and one column per group: first `admin`, then "All logged-in users" for `@all`, then every Nextcloud group. The action list SHALL be the union of the stored matrix and the seed file, so an action no one has configured yet still has a row. Each cell is a checkbox. The `admin` column MUST be ticked and disabled. "Save action matrix" MUST send the whole matrix with `PUT /api/admin/action-matrix`, always keeping `admin` in each entry, and confirm with "Action matrix saved." or report "Failed to save the action matrix.".

@e2e exclude Written after the fact; no Playwright spec drives this table yet. Adding one belongs to a build change.

#### Scenario: An administrator opens the publish action to a group
- GIVEN Fatima is an administrator on the Roles and permissions tab
- WHEN she ticks the `redactie` column on the `dashboard.publish` row and presses "Save action matrix"
- THEN the request MUST carry `dashboard.publish: ["admin", "redactie"]`
- AND the table MUST redraw from the matrix the server returns
- AND the message "Action matrix saved." MUST appear

#### Scenario: The admin column cannot be unticked
- GIVEN the action table is shown
- THEN every cell in the `admin` column MUST be ticked and disabled

#### Scenario: Only administrators reach the matrix endpoints
- GIVEN Pieter is not a LaunchPad administrator
- WHEN his session calls `GET` or `PUT /api/admin/action-matrix`
- THEN Nextcloud MUST refuse the request before the controller runs

### Requirement: A fresh install is usable and an upgrade keeps the administrator's choices (REQ-ACT-004)

On install, when the stored matrix is empty, the `InitializeActions` repair step MUST write the actions from `lib/actions.seed.json`, which grants the ordinary end-user actions to `@all` and keeps administrative actions at `["admin"]`. When the matrix already has entries it MUST leave them alone. The `ApplyActionBaseline` step SHALL, once per baseline version, widen only entries that still hold the untouched `["admin"]` default or are missing, and MUST leave every entry an administrator changed.

@e2e exclude Install and upgrade repair steps; covered by tests/Unit/Repair/ApplyActionBaselineTest.php and tests/Unit/Service/ActionSeedCoverageTest.php.

#### Scenario: Fresh install
- GIVEN LaunchPad is installed on an instance with no stored action matrix
- WHEN the install repair steps run
- THEN the matrix MUST equal the seed file's actions
- AND a non-admin user MUST be able to list their dashboards

#### Scenario: An administrator's narrowing survives the upgrade
- GIVEN an administrator set `dashboard.list` to `["admin", "medewerkers"]`
- WHEN a later version runs `ApplyActionBaseline`
- THEN the entry for `dashboard.list` MUST still be `["admin", "medewerkers"]`
23 changes: 23 additions & 0 deletions openspec/specs/runtime-shell/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,26 @@ When the option is off, or the initial state does not carry the key, nothing cha
- THEN every entry the panel would have shown MUST have a destination in the menu, and every router destination MUST be a page the app declares

@e2e openspec/specs/runtime-shell/spec.md#req-shell-009

### Requirement: REQ-SHELL-010 First-visit tour

LaunchPad MUST declare one guided tour in `src/manifest.json` (`walkthrough`, lines 470-525), and the shell MUST hand that manifest to the shared library's `CnAppRoot` (`src/App.vue:16-17`), which draws the tour with `CnWalkthrough`. The tour SHALL be `launchpad:getting-started`, titled "Getting started", triggered on a user's first visit, with three steps in this order: "Welcome to Launchpad" centred on the Workspace page, "Open Dashboards from the menu" pointing at the `dashboards` menu item, and a step pointing at the Flows menu item that the user may skip. Completion MUST be stored per user under `walkthrough_completed_version` against the declared tour `version` (1), so raising the version shows the tour again. Drawing, dimming, skipping and reopening the tour belong to the library; this requirement covers only what LaunchPad declares.

Written on 2026-10-07 after the fact, from the code on `development`; covers parity row `d-tour`. The tour does not visit the admin settings.

@e2e exclude The tour is drawn by nextcloud-vue's CnWalkthrough from a manifest block; no LaunchPad Playwright spec drives it. Written after the fact.

#### Scenario: A first visit starts the tour
- GIVEN Pieter has never completed the LaunchPad tour
- WHEN he opens LaunchPad
- THEN the "Welcome to Launchpad" step MUST open in the centre of the Workspace page

#### Scenario: Opening Dashboards advances the tour
- GIVEN the tour shows the step "Open Dashboards from the menu"
- WHEN Pieter clicks Dashboards in the menu and the Workspace route matches
- THEN the tour MUST move on to the Flows step

#### Scenario: A finished tour stays closed
- GIVEN Pieter completed tour version 1
- WHEN he opens LaunchPad again
- THEN the tour MUST NOT start
Loading