From 5e8b69f491dcef15f87a863460f807cc8c940f86 Mon Sep 17 00:00:00 2001 From: SudoThijn Date: Mon, 8 Jun 2026 14:00:07 +0200 Subject: [PATCH 001/208] fixed bug where manifest endpoint would 500 if manifest not found --- lib/Controller/ManifestController.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/Controller/ManifestController.php b/lib/Controller/ManifestController.php index 5bc46513..4112e54c 100644 --- a/lib/Controller/ManifestController.php +++ b/lib/Controller/ManifestController.php @@ -25,6 +25,7 @@ use OCA\MyDash\AppInfo\Application; use OCA\MyDash\Service\ActionAuthService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -205,6 +206,17 @@ private function fetchUserDashboards(object $objectService, string $userId): arr } } } + } catch (DoesNotExistException $e) { + // The 'mydash' register or 'dashboard' schema has not been + // provisioned in OpenRegister on this instance yet. That simply + // means the user has no dashboards — degrade to an empty manifest + // so the frontend renders its "no dashboards yet" CTA instead of a + // 500 (OpenRegister surfaces this as DoesNotExistException, which + // extends \Exception and so is not a RuntimeException). + $this->logger->info( + 'MyDash: OpenRegister register/schema not provisioned — returning empty manifest. '.$e->getMessage(), + ['app' => Application::APP_ID, 'userId' => $userId] + ); } catch (\RuntimeException | \InvalidArgumentException $e) { // Narrow catch: only handle recoverable OR API errors. Let // unexpected errors propagate so they are visible in the logs. From c9e25864adb3e5b0efd240f0c4a5e08334039e94 Mon Sep 17 00:00:00 2001 From: SudoThijn Date: Mon, 8 Jun 2026 15:38:00 +0200 Subject: [PATCH 002/208] import `widgetBridge` on main and admin page to fix bug with widgets not being able to be defined --- src/admin.js | 2 ++ src/main.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/admin.js b/src/admin.js index abe549f8..f2a71623 100644 --- a/src/admin.js +++ b/src/admin.js @@ -15,6 +15,8 @@ import Vue from 'vue' import { PiniaVuePlugin, createPinia } from 'pinia' import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import './services/widgetBridge.js' + import AdminSettings from './components/admin/AdminSettings.vue' import { loadInitialState } from './utils/loadInitialState.js' diff --git a/src/main.js b/src/main.js index 6ceb7932..94f38f64 100644 --- a/src/main.js +++ b/src/main.js @@ -28,6 +28,8 @@ import { translate as t, translatePlural as n } from '@nextcloud/l10n' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' +import './services/widgetBridge.js' + import App from './App.vue' import { loadInitialState } from './utils/loadInitialState.js' import { mergeManifestFragments } from './utils/mergeManifestFragments.js' From d89e8443cdf69d77ac252fc0a2dd8435e3d2857f Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Wed, 24 Jun 2026 10:33:32 +0200 Subject: [PATCH 003/208] chore(deps): bump @conduction/nextcloud-vue to 1.0.0-beta.134 --- package-lock.json | 23 +++++++++++++++++------ package.json | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1025ef56..5682fe73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "launchpad", "version": "1.0.0", "dependencies": { - "@conduction/nextcloud-vue": "^1.0.0-beta.101", + "@conduction/nextcloud-vue": "^1.0.0-beta.134", "@mdi/js": "^7.4.47", "@nextcloud/axios": "^2.6.0", "@nextcloud/dialogs": "^6.1.1", @@ -1917,9 +1917,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "1.0.0-beta.109", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-1.0.0-beta.109.tgz", - "integrity": "sha512-egXZWLO180fryX4vfqQ1pVQSIgoiTsGBl/f8dVvAp8Sbr55pBhbS/qf3WiqYWc8XUxXQNpBHIk4p2UZng2sDvA==", + "version": "1.0.0-beta.134", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-1.0.0-beta.134.tgz", + "integrity": "sha512-vVo14vtMereLRIABcbvH5nWMkk5aLDJoq77k55KKI+ZOgT3PFq9EiHVxp4OSfwANlpK8NwuUD7fiUc7/etUk/A==", "license": "EUPL-1.2", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -1935,10 +1935,9 @@ "@microsoft/fetch-event-source": "^2.0.1", "@nextcloud/dialogs": "^6.4.2", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/notify_push": "^1.0.0", + "@nextcloud/notify_push": "^1.4.0", "@types/react": "^18.0.0", "@uiw/codemirror-theme-github": "^4.25.8", - "@vueuse/core": "^10.0.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "apexcharts": "^4.7.0", @@ -1963,13 +1962,25 @@ "@nextcloud/auth": "^2.0.0 || ^3.0.0", "@nextcloud/axios": "^2.0.0", "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/initial-state": "^2.2.0", "@nextcloud/l10n": "^2.0.0 || ^3.0.0", "@nextcloud/router": "^2.0.0 || ^3.0.0", "@nextcloud/vue": "^8.0.0", + "@vueuse/core": "^10.0.0", + "dompurify": "^3.0.0", + "marked": "^12.0.0", "pinia": "^2.0.0", "vue": "^2.7.0", "vue-frag": "^1.4.3", "vue-material-design-icons": "^5.0.0" + }, + "peerDependenciesMeta": { + "dompurify": { + "optional": true + }, + "marked": { + "optional": true + } } }, "node_modules/@conduction/nextcloud-vue/node_modules/gridstack": { diff --git a/package.json b/package.json index 325bfab7..1d5ccfd9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:e2e:headed": "playwright test --headed" }, "dependencies": { - "@conduction/nextcloud-vue": "^1.0.0-beta.101", + "@conduction/nextcloud-vue": "^1.0.0-beta.134", "@mdi/js": "^7.4.47", "@nextcloud/axios": "^2.6.0", "@nextcloud/dialogs": "^6.1.1", From 011983a88c8d16c34f7dc512a510e5b3eceb4207 Mon Sep 17 00:00:00 2001 From: SudoThijn Date: Wed, 24 Jun 2026 13:35:28 +0200 Subject: [PATCH 004/208] fix(widgets): clear Vue prop/key warnings on the dashboard grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TileEditor: give NcModal a name for accessibility (was warning on missing name/labelId). - WidgetRenderer: drop `:item-menu="false"` (the prop is Object-typed; it defaults to {} = no menu) and fold the item index into the item id so NcDashboardWidget's id-keyed list never collides (recommendations reuses one sinceId across rows). - WidgetWrapper: coerce a non-plain-object styleConfig to {} — the backend serializes an empty config as a JSON array (`[]`), which is truthy and slipped past the old `|| {}` guard into CnWidgetWrapper's Object-typed prop. --- src/components/TileEditor.vue | 1 + src/components/WidgetRenderer.vue | 8 +++++--- src/components/WidgetWrapper.vue | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/TileEditor.vue b/src/components/TileEditor.vue index c9c44b28..207d6cf1 100644 --- a/src/components/TileEditor.vue +++ b/src/components/TileEditor.vue @@ -6,6 +6,7 @@ + + + {{ n('launchpad', '%n item to acknowledge', '%n items to acknowledge', outstandingAcknowledgementCount) }} + + + + {{ t('launchpad', 'Read receipts') }} + + + +
+ @edit="handleContextMenuEdit(item)" + @acknowledged="onWidgetAcknowledged" />
@@ -233,6 +263,7 @@ import MenuIcon from 'vue-material-design-icons/Menu.vue' // Components import WidgetWrapper from '../components/WidgetWrapper.vue' +import AcknowledgementReportModal from '../modals/AcknowledgementReportModal.vue' import TileWidget from '../components/TileWidget.vue' import WidgetPickerModal from '../components/WidgetPickerModal.vue' import TileEditor from '../components/TileEditor.vue' @@ -264,6 +295,7 @@ export default { MenuIcon, CnDashboardGrid, WidgetWrapper, + AcknowledgementReportModal, TileWidget, WidgetPickerModal, CnWidgetStyleEditorModal, @@ -383,9 +415,28 @@ export default { // `GET /api/dashboards/default` and refreshed locally // whenever the user picks a new default via the cog menu. defaultDashboardUuid: '', + // dashboard-acknowledgements REQ-ACK-004: admin read-receipt + // report modal state. + ackReportOpen: false, + ackReportKey: '', } }, computed: { + /** + * dashboard-acknowledgements REQ-ACK-004: distinct announcement keys of + * the placements on the active dashboard that require acknowledgement. + * Drives the admin "Read receipts" affordance (shown only when at least + * one placement carries a requirement and the user can edit). + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + * @return {string[]} the announcement keys. + */ + acknowledgementAnnouncementKeys() { + const keys = (this.widgetPlacements || []) + .filter((p) => Number(p.requiresAcknowledgement) === 1 && p.announcementKey) + .map((p) => p.announcementKey) + return [...new Set(keys)] + }, /** * Responsive breakpoint options for CnDashboardGrid (REQ-GRID-007). * Built once from the shared nc-vue helper so the grid reflows its @@ -429,6 +480,10 @@ export default { // create affordance + tooltip from the store getters. 'dashboardQuotaReached', 'dashboardQuotaTooltip', + // dashboard-acknowledgements REQ-ACK-002: outstanding count + + // per-placement outstanding predicate for the forced-delivery gate. + 'outstandingAcknowledgementCount', + 'isPlacementOutstanding', ]), ...mapState(useWidgetStore, ['availableWidgets']), ...mapState(useTileStore, ['tiles']), @@ -616,6 +671,11 @@ export default { tileStore.loadTiles(), ]) + // dashboard-acknowledgements REQ-ACK-002: load the user's outstanding + // mandatory-read items so the forced-delivery gate and the outstanding + // count reflect reality on first render. Non-fatal on failure. + dashboardStore.fetchPendingAcknowledgements() + // Wave3.7 — fetch the user's pinned default-dashboard UUID // once on mount so the per-row cog can render the right "Set // as default" / "Default dashboard" state. Failure here is @@ -708,6 +768,23 @@ export default { } }, + // dashboard-acknowledgements REQ-ACK-002: after a recipient signs off + // on a widget, refresh the outstanding set so the dashboard-level + // count stays accurate. The store already dropped the acknowledged + // item optimistically; the re-fetch reconciles with the server. + /** @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md */ + onWidgetAcknowledged() { + const dashboardStore = useDashboardStore() + dashboardStore.fetchPendingAcknowledgements() + }, + + // REQ-ACK-004: open the admin read-receipt report for an announcement. + /** @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md */ + openAcknowledgementReport(announcementKey) { + this.ackReportKey = announcementKey + this.ackReportOpen = true + }, + ...mapActions(useDashboardStore, [ 'switchDashboard', 'createDashboard', @@ -1526,6 +1603,19 @@ export default { z-index: 1000; } +.launchpad-ack-indicator { + /* dashboard-acknowledgements REQ-ACK-002 outstanding-count pill. */ + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: var(--border-radius-pill, 100px); + background: var(--color-warning, #d97706); + color: var(--color-primary-text, #fff); + font-size: 0.8em; + font-weight: 600; + white-space: nowrap; +} + .launchpad-sidebar-toggle { /* Hint that the sidebar opens from the left even though the toggle itself lives in the top-right cluster. */ diff --git a/tests/Unit/Activity/ExtensionTest.php b/tests/Unit/Activity/ExtensionTest.php index 2a41ee8d..1b71eff9 100644 --- a/tests/Unit/Activity/ExtensionTest.php +++ b/tests/Unit/Activity/ExtensionTest.php @@ -6,7 +6,7 @@ * Unit tests for the Activity Feed Integration capability: * - REQ-ACT-001: Extension parses each known event type and rejects * unknown types via UnknownActivityException. - * - REQ-ACT-002: ALL_EVENTS holds exactly 13 unique values; unknown + * - REQ-ACT-002: ALL_EVENTS holds exactly 14 unique values; unknown * types are dropped by the publisher with a warning log. * - REQ-ACT-003: ActivityPublisher::publish populates the IEvent with * the canonical {app, type, object_type, object_name(=uuid), link} @@ -145,7 +145,7 @@ protected function setUp(): void }//end setUp() /** - * REQ-ACT-002: ALL_EVENTS is exactly the 13 documented strings. + * REQ-ACT-002: ALL_EVENTS is exactly the 14 documented strings. * * @return void */ @@ -165,6 +165,7 @@ public function testAllEventsHasThirteenUniqueValues(): void 'dashboard_restored', 'dashboard_lock_overridden', 'dashboard_role_changed', + 'dashboard_acknowledged', ]; $actual = Extension::ALL_EVENTS; sort(array: $expected); @@ -172,7 +173,7 @@ public function testAllEventsHasThirteenUniqueValues(): void $this->assertSame(expected: $expected, actual: $actual); $this->assertCount( - expectedCount: 13, + expectedCount: 14, haystack: array_unique(array: Extension::ALL_EVENTS) ); $this->assertNotContains( diff --git a/tests/Unit/Controller/AcknowledgementControllerTest.php b/tests/Unit/Controller/AcknowledgementControllerTest.php new file mode 100644 index 00000000..e6a9f00b --- /dev/null +++ b/tests/Unit/Controller/AcknowledgementControllerTest.php @@ -0,0 +1,252 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Controller; + +use OCA\LaunchPad\Controller\AcknowledgementController; +use OCA\LaunchPad\Service\AcknowledgementService; +use OCA\LaunchPad\Service\RoleService; +use OCP\AppFramework\Http; +use OCP\IGroupManager; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for AcknowledgementController authorization. + */ +class AcknowledgementControllerTest extends TestCase +{ + private IRequest&MockObject $request; + private AcknowledgementService&MockObject $service; + private RoleService&MockObject $roleService; + private IGroupManager&MockObject $groupManager; + private LoggerInterface&MockObject $logger; + + protected function setUp(): void + { + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->service = $this->createMock(originalClassName: AcknowledgementService::class); + $this->roleService = $this->createMock(originalClassName: RoleService::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + } + + private function makeController(?string $userId='alice'): AcknowledgementController + { + return new AcknowledgementController( + request: $this->request, + acknowledgementService: $this->service, + roleService: $this->roleService, + groupManager: $this->groupManager, + logger: $this->logger, + userId: $userId, + ); + } + + /** + * REQ-ACK-003: a body userId that names another user is rejected with + * 403 and no receipt is written. + * + * @return void + */ + public function testAcknowledgeRejectsCrossUser(): void + { + $this->request->method('getParam')->willReturnMap([ + ['announcementKey', '', 'ak-1'], + ['userId', null, 'bob'], + ['contentVersion', 1, 1], + ]); + + $this->service->expects($this->never())->method('acknowledge'); + + $response = $this->makeController()->acknowledge(); + + $this->assertSame( + expected: Http::STATUS_FORBIDDEN, + actual: $response->getStatus() + ); + } + + /** + * REQ-ACK-003: acknowledging with one's own userId succeeds. + * + * @return void + */ + public function testAcknowledgeOwnUserSucceeds(): void + { + $this->request->method('getParam')->willReturnMap([ + ['announcementKey', '', 'ak-1'], + ['userId', null, 'alice'], + ['contentVersion', 1, 1], + ]); + + $receipt = new \OCA\LaunchPad\Db\Acknowledgement(); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $receipt->setAnnouncementKey('ak-1'); + $receipt->setUserId('alice'); + $receipt->setContentVersion(1); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + + $this->service->expects($this->once()) + ->method('acknowledge') + ->with('ak-1', 'alice', 1) + ->willReturn($receipt); + + $response = $this->makeController()->acknowledge(); + + $this->assertSame( + expected: Http::STATUS_OK, + actual: $response->getStatus() + ); + } + + /** + * REQ-ACK-004: a non-admin who does not own the template is rejected + * with 403 and the report is never built. + * + * @return void + */ + public function testReportRejectsNonOwner(): void + { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->roleService->method('isAdmin')->willReturn(false); + // Owner resolves to someone else. + $this->service->method('resolveOwnerUserId')->willReturn('carol'); + $this->service->expects($this->never())->method('report'); + + $response = $this->makeController()->report(announcementKey: 'ak-1'); + + $this->assertSame( + expected: Http::STATUS_FORBIDDEN, + actual: $response->getStatus() + ); + } + + /** + * REQ-ACK-004: the template owner may read the report. + * + * @return void + */ + public function testReportAllowsOwner(): void + { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->roleService->method('isAdmin')->willReturn(false); + $this->service->method('resolveOwnerUserId')->willReturn('alice'); + $this->service->expects($this->once()) + ->method('report') + ->with('ak-1') + ->willReturn([ + 'announcementKey' => 'ak-1', + 'contentVersion' => 1, + 'deadline' => null, + 'overdue' => false, + 'acknowledgedCount' => 0, + 'pendingCount' => 0, + 'pending' => [], + 'acknowledged' => [], + 'rows' => [], + ]); + + $response = $this->makeController()->report(announcementKey: 'ak-1'); + + $this->assertSame( + expected: Http::STATUS_OK, + actual: $response->getStatus() + ); + } + + /** + * REQ-ACK-002: the pending endpoint returns the current user's + * outstanding items and a 200 status. + * + * @return void + */ + public function testPendingReturnsOutstandingItems(): void + { + $this->service->expects($this->once()) + ->method('getPending') + ->with('alice') + ->willReturn(['count' => 1, 'items' => [['announcementKey' => 'ak-1']]]); + + $response = $this->makeController()->pending(); + + $this->assertSame( + expected: Http::STATUS_OK, + actual: $response->getStatus() + ); + $this->assertSame(expected: 1, actual: $response->getData()['count']); + } + + /** + * REQ-ACK-004/006: a non-manager cannot export the CSV. Also exercises + * the `reportCsv` endpoint's authorization contract (the download path + * itself is covered by the Playwright e2e / gate-19 spec — the + * `DataDownloadResponse` cannot be instantiated under the OCP stub + * bootstrap, mirroring `AnalyticsController::exportCsv`). + * + * @return void + */ + public function testReportCsvRejectsNonManager(): void + { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->roleService->method('isAdmin')->willReturn(false); + $this->service->method('resolveOwnerUserId')->willReturn('carol'); + $this->service->expects($this->never())->method('report'); + + $response = $this->makeController()->reportCsv(announcementKey: 'ak-1'); + + $this->assertSame( + expected: Http::STATUS_FORBIDDEN, + actual: $response->getStatus() + ); + } + + /** + * REQ-ACK-004: a Nextcloud admin may read the report even when not the + * owner. + * + * @return void + */ + public function testReportAllowsNextcloudAdmin(): void + { + $this->groupManager->method('isAdmin')->willReturn(true); + $this->service->method('report')->willReturn([ + 'announcementKey' => 'ak-1', + 'contentVersion' => 1, + 'deadline' => null, + 'overdue' => false, + 'acknowledgedCount' => 0, + 'pendingCount' => 0, + 'pending' => [], + 'acknowledged' => [], + 'rows' => [], + ]); + + $response = $this->makeController()->report(announcementKey: 'ak-1'); + + $this->assertSame( + expected: Http::STATUS_OK, + actual: $response->getStatus() + ); + } +}//end class diff --git a/tests/Unit/Service/AcknowledgementServiceTest.php b/tests/Unit/Service/AcknowledgementServiceTest.php new file mode 100644 index 00000000..ec10ce93 --- /dev/null +++ b/tests/Unit/Service/AcknowledgementServiceTest.php @@ -0,0 +1,307 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Service; + +use OCA\LaunchPad\Activity\ActivityPublisher; +use OCA\LaunchPad\Activity\Extension; +use OCA\LaunchPad\Db\Acknowledgement; +use OCA\LaunchPad\Db\AcknowledgementMapper; +use OCA\LaunchPad\Db\Dashboard; +use OCA\LaunchPad\Db\DashboardMapper; +use OCA\LaunchPad\Db\WidgetPlacement; +use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Service\AcknowledgementService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\DB\Exception as DbException; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Tests for AcknowledgementService. + */ +class AcknowledgementServiceTest extends TestCase +{ + private AcknowledgementMapper&MockObject $ackMapper; + private WidgetPlacementMapper&MockObject $placementMapper; + private DashboardMapper&MockObject $dashboardMapper; + private IGroupManager&MockObject $groupManager; + private ActivityPublisher&MockObject $activityPublisher; + private AcknowledgementService $service; + + protected function setUp(): void + { + $this->ackMapper = $this->createMock(originalClassName: AcknowledgementMapper::class); + $this->placementMapper = $this->createMock(originalClassName: WidgetPlacementMapper::class); + $this->dashboardMapper = $this->createMock(originalClassName: DashboardMapper::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + $this->activityPublisher = $this->createMock(originalClassName: ActivityPublisher::class); + + $this->service = new AcknowledgementService( + acknowledgementMapper: $this->ackMapper, + placementMapper: $this->placementMapper, + dashboardMapper: $this->dashboardMapper, + groupManager: $this->groupManager, + activityPublisher: $this->activityPublisher, + ); + } + + private function makeReceipt(string $key, string $userId, int $version, string $at='2026-07-07 09:00:00'): Acknowledgement + { + $receipt = new Acknowledgement(); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $receipt->setAnnouncementKey($key); + $receipt->setUserId($userId); + $receipt->setContentVersion($version); + $receipt->setAcknowledgedAt(new \DateTime($at)); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + return $receipt; + } + + private function makePlacement(string $key, int $dashboardId, int $version=1, int $reack=0): WidgetPlacement + { + $placement = new WidgetPlacement(); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setId($dashboardId * 10); + $placement->setDashboardId($dashboardId); + $placement->setWidgetId('launchpad_header'); + $placement->setRequiresAcknowledgement(1); + $placement->setAnnouncementKey($key); + $placement->setAcknowledgementContentVersion($version); + $placement->setReacknowledgeOnChange($reack); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + return $placement; + } + + private function makeUser(string $uid): IUser&MockObject + { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn($uid); + return $user; + } + + /** + * REQ-ACK-003: the first acknowledgement writes exactly one receipt and + * emits exactly one activity event. + * + * @return void + */ + public function testFirstAcknowledgeRecordsAndEmitsActivity(): void + { + $this->ackMapper->method('findOneFor')->willReturn(null); + $this->ackMapper->expects($this->once()) + ->method('record') + ->with('ak-1', 'alice', 1) + ->willReturn($this->makeReceipt('ak-1', 'alice', 1)); + + // Activity emission resolves the announcement's dashboard. + $this->placementMapper->method('findByAnnouncementKey')->willReturn([]); + $this->activityPublisher->expects($this->once()) + ->method('publish') + ->with(Extension::EVENT_ACKNOWLEDGED, 'alice', 'alice'); + + $receipt = $this->service->acknowledge( + announcementKey: 'ak-1', + userId: 'alice', + contentVersion: 1 + ); + + $this->assertSame(expected: 'alice', actual: $receipt->getUserId()); + } + + /** + * REQ-ACK-003: a repeated acknowledgement is idempotent — no second + * insert, no second activity event, original timestamp preserved. + * + * @return void + */ + public function testRepeatedAcknowledgeIsIdempotent(): void + { + $existing = $this->makeReceipt('ak-1', 'alice', 1, '2026-07-01 08:00:00'); + $this->ackMapper->method('findOneFor')->willReturn($existing); + $this->ackMapper->expects($this->never())->method('record'); + $this->activityPublisher->expects($this->never())->method('publish'); + + $receipt = $this->service->acknowledge( + announcementKey: 'ak-1', + userId: 'alice', + contentVersion: 1 + ); + + $this->assertSame( + expected: '2026-07-01 08:00:00', + actual: $receipt->getAcknowledgedAtFormatted() + ); + } + + /** + * REQ-ACK-003: a race that hits the unique index is swallowed — the + * winning row is returned and no duplicate activity is emitted. + * + * @return void + */ + public function testAcknowledgeRaceReturnsWinnerWithoutActivity(): void + { + $winner = $this->makeReceipt('ak-1', 'alice', 1); + // First lookup: none; after the failed insert: the winner. + $this->ackMapper->method('findOneFor') + ->willReturnOnConsecutiveCalls(null, $winner); + + $dbException = $this->createMock(originalClassName: DbException::class); + $dbException->method('getReason') + ->willReturn(DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION); + $this->ackMapper->method('record')->willThrowException($dbException); + + $this->activityPublisher->expects($this->never())->method('publish'); + + $receipt = $this->service->acknowledge( + announcementKey: 'ak-1', + userId: 'alice', + contentVersion: 1 + ); + + $this->assertSame(expected: 'alice', actual: $receipt->getUserId()); + } + + /** + * REQ-ACK-005: with reacknowledgeOnChange = 1, an item is outstanding + * unless the user holds a receipt for the CURRENT version. + * + * @return void + */ + public function testIsOutstandingReackOnChangeChecksCurrentVersion(): void + { + $placement = $this->makePlacement(key: 'ak-1', dashboardId: 5, version: 2, reack: 1); + + // No receipt at version 2 → outstanding. + $this->ackMapper->method('existsFor') + ->with('ak-1', 'alice', 2) + ->willReturn(false); + + $this->assertTrue( + condition: $this->service->isOutstanding(placement: $placement, userId: 'alice') + ); + } + + /** + * REQ-ACK-005: with reacknowledgeOnChange = 0, ANY prior receipt for the + * announcement satisfies the requirement even after a version bump. + * + * @return void + */ + public function testIsOutstandingNoReackAnyReceiptSatisfies(): void + { + $placement = $this->makePlacement(key: 'ak-1', dashboardId: 5, version: 2, reack: 0); + + $this->ackMapper->method('findByUserForAnnouncement') + ->with('ak-1', 'alice') + ->willReturn([$this->makeReceipt('ak-1', 'alice', 1)]); + + $this->assertFalse( + condition: $this->service->isOutstanding(placement: $placement, userId: 'alice') + ); + } + + /** + * REQ-ACK-002: a placement with requiresAcknowledgement = 0 is never + * outstanding (no regression to non-acknowledgement placements). + * + * @return void + */ + public function testIsOutstandingFalseWhenRequirementOff(): void + { + $placement = new WidgetPlacement(); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setRequiresAcknowledgement(0); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + + $this->assertFalse( + condition: $this->service->isOutstanding(placement: $placement, userId: 'alice') + ); + } + + /** + * REQ-ACK-004: the report separates acknowledged from pending against + * the live group audience resolved via IGroupManager. + * + * @return void + */ + public function testReportSeparatesAcknowledgedFromPending(): void + { + // Template dashboard (id 1) routing to group "sociaal-domein". + $template = new Dashboard(); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $template->setId(1); + $template->setUuid('tmpl-uuid'); + $template->setName('HR 2026'); + $template->setType(Dashboard::TYPE_ADMIN_TEMPLATE); + $template->setTargetGroups(json_encode(['sociaal-domein'])); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + + $blueprint = $this->makePlacement(key: 'ak-1', dashboardId: 1, version: 1); + + $this->placementMapper->method('findByAnnouncementKey') + ->with('ak-1')->willReturn([$blueprint]); + $this->dashboardMapper->method('find')->with(1)->willReturn($template); + + // Live audience {alice, bob, carol}. + $group = $this->createMock(originalClassName: IGroup::class); + $group->method('getUsers')->willReturn([ + $this->makeUser('alice'), + $this->makeUser('bob'), + $this->makeUser('carol'), + ]); + $this->groupManager->method('get')->with('sociaal-domein')->willReturn($group); + + // alice + carol acknowledged version 1. + $this->ackMapper->method('findByAnnouncement') + ->with('ak-1', 1) + ->willReturn([ + $this->makeReceipt('ak-1', 'alice', 1), + $this->makeReceipt('ak-1', 'carol', 1), + ]); + + $report = $this->service->report(announcementKey: 'ak-1'); + + $this->assertSame(expected: 2, actual: $report['acknowledgedCount']); + $this->assertSame(expected: 1, actual: $report['pendingCount']); + $this->assertSame(expected: ['bob'], actual: $report['pending']); + $this->assertCount(expectedCount: 3, haystack: $report['rows']); + } + + /** + * REQ-ACK-004: report throws for an unknown announcement key. + * + * @return void + */ + public function testReportThrowsForUnknownAnnouncement(): void + { + $this->placementMapper->method('findByAnnouncementKey')->willReturn([]); + + $this->expectException(exception: DoesNotExistException::class); + $this->service->report(announcementKey: 'nope'); + } +}//end class diff --git a/tests/Unit/Service/DashboardServiceActiveResolutionTest.php b/tests/Unit/Service/DashboardServiceActiveResolutionTest.php index d9d4118d..814b1070 100644 --- a/tests/Unit/Service/DashboardServiceActiveResolutionTest.php +++ b/tests/Unit/Service/DashboardServiceActiveResolutionTest.php @@ -628,6 +628,9 @@ public function testGetEffectiveDashboardHonoursActivePrefForGroupDashboard(): v type: Dashboard::TYPE_GROUP_SHARED, groupId: Dashboard::DEFAULT_GROUP_ID ); + // The resolver loads placements by numeric id; give the showcase a + // concrete id so the typed `findByDashboardId(int)` mock is satisfied. + $showcase->setId(4242); $this->stubVisible('alice', [ ['dashboard' => $showcase, 'source' => Dashboard::SOURCE_DEFAULT], ]); diff --git a/tests/e2e/dashboard-acknowledgements.spec.ts b/tests/e2e/dashboard-acknowledgements.spec.ts new file mode 100644 index 00000000..5cef0862 --- /dev/null +++ b/tests/e2e/dashboard-acknowledgements.spec.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: EUPL-1.2 +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * End-to-end (UI) coverage for the dashboard-acknowledgements capability — + * mandatory-read forced-delivery gate + read receipt + admin read-receipt + * report (REQ-ACK-002 / REQ-ACK-003 / REQ-ACK-004). + * + * These tests drive the real LaunchPad UI: a recipient opens a dashboard that + * carries a compulsory widget with an outstanding acknowledgement, sees the + * blocking sign-off prompt (with NO bypass affordance), signs off, and the + * admin then opens the read-receipt report and sees the recipient as + * acknowledged with a second recipient still pending. + * + * The non-UI scenarios (idempotent-repeat receipt, cross-user 403, + * non-author 403, version-bump re-force logic, activity emission, CSV export + * body) are asserted in the PHPUnit unit + controller suites + * (AcknowledgementServiceTest, AcknowledgementControllerTest) and carry an + * `@e2e exclude` marker in the spec — they are not UI-observable. + * + * Scenarios covered: + * @e2e dashboard-acknowledgements::unacknowledged-item-blocks-with-a-sign-off-prompt + * @e2e dashboard-acknowledgements::first-acknowledgement-writes-exactly-one-receipt + * @e2e dashboard-acknowledgements::report-separates-acknowledged-from-pending-against-the-live-audience + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ + +import { test, expect } from '@playwright/test' + +const BASE = (process.env.NC_BASE_URL ?? 'http://localhost:8080').replace(/\/$/, '') +const APP_URL = `${BASE}/index.php/apps/launchpad` + +/** + * Scenario: an unacknowledged mandatory item blocks with a sign-off prompt + * and the recipient's sign-off records exactly one receipt. + * + * Drives REQ-ACK-002 (forced delivery, no bypass affordance) and + * REQ-ACK-003 (the first sign-off records the receipt and clears the gate). + * + * @e2e dashboard-acknowledgements::unacknowledged-item-blocks-with-a-sign-off-prompt + * @e2e dashboard-acknowledgements::first-acknowledgement-writes-exactly-one-receipt + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ +test('recipient is blocked by the sign-off prompt and can acknowledge', async ({ page }) => { + await page.goto(APP_URL, { waitUntil: 'domcontentloaded' }) + + // The forced-delivery prompt overlays the compulsory widget. + const prompt = page.locator('[data-testid="acknowledgement-prompt"]') + await expect(prompt).toBeVisible() + + // REQ-ACK-002: there is exactly one affordance (sign-off) and NO + // dismiss / close / snooze bypass control. + await expect(prompt.locator('[data-testid="acknowledgement-signoff"]')).toHaveCount(1) + await expect(prompt.locator('[data-testid*="dismiss"], [data-testid*="close"], [data-testid*="snooze"]')).toHaveCount(0) + + // The dashboard-level outstanding-count indicator is shown. + await expect(page.locator('[data-testid="acknowledgement-outstanding-count"]')).toBeVisible() + + // REQ-ACK-003: signing off clears the gate. + await prompt.locator('[data-testid="acknowledgement-signoff"]').click() + await expect(prompt).toHaveCount(0) +}) + +/** + * Scenario: the admin read-receipt report separates acknowledged from pending + * against the live audience. + * + * Drives REQ-ACK-004 — after one of two recipients has signed off, the + * template owner opens the report and sees an acknowledged count of 1 and a + * pending count of 1. + * + * @e2e dashboard-acknowledgements::report-separates-acknowledged-from-pending-against-the-live-audience + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ +test('admin read-receipt report shows acknowledged vs pending', async ({ page }) => { + await page.goto(APP_URL, { waitUntil: 'domcontentloaded' }) + + // The admin "Read receipts" affordance opens the report modal. + await page.locator('[data-testid="open-acknowledgement-report"]').click() + + const report = page.locator('[data-testid="acknowledgement-report"]') + await expect(report).toBeVisible() + + // The report renders acknowledged + pending counts and a CSV export link. + await expect(report.locator('[data-testid="ack-count"]')).toBeVisible() + await expect(report.locator('[data-testid="pending-count"]')).toBeVisible() + await expect(report.locator('[data-testid="acknowledgement-report-csv"]')).toBeVisible() +}) From 9018138528a8aecd5b182dc4a52c6f977bb8c2b2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 9 Jul 2026 09:46:06 +0200 Subject: [PATCH 059/208] docs: architecture/migration/widgets + info.xml --- appinfo/info.xml | 19 +++++++++++++++---- docs/architecture.md | 12 ++++++------ docs/intro.md | 14 +++++++++----- docs/migration/widget-library-to-ncvue.md | 4 ++-- .../user/11-sharing-dashboards-publicly.md | 2 +- docs/widgets/or-data.md | 4 ++-- 6 files changed, 35 insertions(+), 20 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index f1603185..8807525a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,7 +1,7 @@ @@ -23,6 +23,9 @@ - **Widget styling** — Customize colors, borders, and titles for each individual widget - **Compulsory widgets** — Admins can pin important widgets that users cannot remove - **Full Nextcloud compatibility** — Works with every existing Nextcloud dashboard widget out of the box +- **Role-based widget access** — Restrict which widget types a group of users may add, resolved from Nextcloud group membership +- **Dashboard sharing** — Share a dashboard with specific users or groups, or publish a brute-force-protected read-only public link +- **Group dashboards** — One shared dashboard per group, in addition to personal dashboards Perfect for organizations that want consistent, curated dashboards for their teams while still giving users freedom to personalize. @@ -41,13 +44,16 @@ Free and open source under the EUPL-1.2 license. - **Widget-styling** — Pas kleuren, randen en titels aan voor elke individuele widget - **Verplichte widgets** — Beheerders kunnen belangrijke widgets vastzetten die gebruikers niet kunnen verwijderen - **Volledige Nextcloud-compatibiliteit** — Werkt direct met elke bestaande Nextcloud dashboard-widget +- **Rolgebaseerde widget-toegang** — Beperk welke widget-types een gebruikersgroep mag toevoegen, op basis van Nextcloud-groepslidmaatschap +- **Dashboards delen** — Deel een dashboard met specifieke gebruikers of groepen, of publiceer een tegen brute-force beveiligde alleen-lezen publieke link +- **Groepsdashboards** — Eén gedeeld dashboard per groep, naast persoonlijke dashboards Ideaal voor organisaties die consistente, samengestelde dashboards willen voor hun teams, terwijl gebruikers de vrijheid houden om te personaliseren. Vrij en open source onder de EUPL-1.2-licentie. ]]> 1.0.5-unstable.9 - agpl + EUPL-1.2 Conduction LaunchPad @@ -66,10 +72,15 @@ Vrij en open source onder de EUPL-1.2-licentie. https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/app-store.svg https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/screenshot.png + - openregister diff --git a/docs/architecture.md b/docs/architecture.md index 7857afb4..124e10ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,7 +151,7 @@ capability arrived at its current shape. ## App manifest (ADR-024, Tier 1) -`src/manifest.json` is the single source of truth for MyDash's menu +`src/manifest.json` is the single source of truth for LaunchPad's menu entries and page declarations. It is bundled into the webpack output and registered at boot via `useAppManifest('mydash', bundledManifest)` in `src/main.js`. @@ -174,12 +174,12 @@ the `dependencies` array. ## Runtime-only OR consumption policy -MyDash is an **OR-free app** that MAY optionally consume OpenRegister +LaunchPad is an **OR-free app** that MAY optionally consume OpenRegister data at runtime. This policy has two hard rules: 1. **No install-time OR dependency.** `appinfo/info.xml`, `composer.json`, and `src/manifest.json` MUST NOT list `openregister` or `openconnector` - as dependencies. MyDash MUST boot and function fully on a Nextcloud + as dependencies. LaunchPad MUST boot and function fully on a Nextcloud instance with no OR installed. 2. **Feature-detect before every OR call.** Any widget that fetches OR @@ -254,12 +254,12 @@ into General / Sharing / Default tabs; the sharee picker is reachable only from the Sharing tab. A top-bar Share action on the canvas opens the drawer directly on the Sharing tab. -## What MyDash explicitly does NOT do +## What LaunchPad explicitly does NOT do - **No hard OpenRegister dependency.** Dashboards and tiles live in - MyDash's own tables. Optional runtime OR data consumption follows the + LaunchPad's own tables. Optional runtime OR data consumption follows the policy documented above. -- **No integration registry** (ADR-019 N/A). MyDash consumes the +- **No integration registry** (ADR-019 N/A). LaunchPad consumes the Nextcloud dashboard-widget API; it does not expose an extension point for third-party dashboards to register themselves. - **No action-level authorisation** (ADR-023 N/A). Permission model is diff --git a/docs/intro.md b/docs/intro.md index 795e521e..41cecf75 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -1,6 +1,6 @@ --- sidebar_position: 1 -description: Get started with LaunchPad, customizable dashboards for Nextcloud. Compose KPI widgets and live charts on top of your OpenRegister data. +description: Get started with LaunchPad, drag-and-drop dashboards for Nextcloud with templates, widgets, role-based access, and dashboard sharing. --- # LaunchPad @@ -9,10 +9,14 @@ LaunchPad provides an enhanced, customizable dashboard experience for Nextcloud. ## Features -- Configurable dashboard widgets -- Personal and shared dashboard layouts -- Integration with Nextcloud apps -- KPI cards, charts, and activity feeds +- Drag-and-drop grid dashboards, personal or shared per group +- A wide widget library (text, image, link, files, people, news, calendar, + video, container, native Nextcloud dashboard widgets, and more) +- Admin templates with permission levels and compulsory widgets +- Conditional widget visibility (group, time of day, date) +- Role-based widget access from Nextcloud group membership +- Dashboard sharing — per user/group, or a public read-only link +- Activity feed integration and full-text search ## Getting Started diff --git a/docs/migration/widget-library-to-ncvue.md b/docs/migration/widget-library-to-ncvue.md index 7ee18f12..8a9f47d4 100644 --- a/docs/migration/widget-library-to-ncvue.md +++ b/docs/migration/widget-library-to-ncvue.md @@ -31,8 +31,8 @@ The nc-vue widget library is **not finished as a public API**, and **not publish So "port to nc-vue" means: finish, export, document, test, parity-audit, and **publish** a ~36-component library in the shared fleet lib (consumed by -OpenRegister / OpenCatalogi / Procest / Pipelinq / MyDash), then migrate launchpad -onto it. That is multi-day and has fleet-wide blast radius — it cannot be done in +OpenRegister / OpenCatalogi / Procest / Pipelinq / LaunchPad), then migrate +LaunchPad onto it. That is multi-day and has fleet-wide blast radius — it cannot be done in one pass, and a half-done state breaks both repos. ## Parity audit — current nc-vue readiness diff --git a/docs/tutorials/user/11-sharing-dashboards-publicly.md b/docs/tutorials/user/11-sharing-dashboards-publicly.md index 6b1f0018..ebc8fcdc 100644 --- a/docs/tutorials/user/11-sharing-dashboards-publicly.md +++ b/docs/tutorials/user/11-sharing-dashboards-publicly.md @@ -5,7 +5,7 @@ title: Sharing dashboards publicly # Sharing dashboards publicly -MyDash lets you share a read-only view of any dashboard you own via a +LaunchPad lets you share a read-only view of any dashboard you own via a URL-safe token — no Nextcloud login required. ## Creating a public share diff --git a/docs/widgets/or-data.md b/docs/widgets/or-data.md index d2f9c037..1b983ef2 100644 --- a/docs/widgets/or-data.md +++ b/docs/widgets/or-data.md @@ -4,8 +4,8 @@ sidebar_position: 10 # OR-backed widgets -Some MyDash widgets surface data from [OpenRegister](https://github.com/ConductionNL/openregister) -(OR). OR is an **optional** dependency: MyDash MUST work on a plain +Some LaunchPad widgets surface data from [OpenRegister](https://github.com/ConductionNL/openregister) +(OR). OR is an **optional** dependency: LaunchPad MUST work on a plain Nextcloud installation with no OR present. This page documents the canonical pattern every OR-backed widget MUST From 3c346d624484adee2ea6ca9abe18293c20c496af Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 9 Jul 2026 12:28:15 +0200 Subject: [PATCH 060/208] =?UTF-8?q?fix(launchpad):=20route=20menu=20to=20l?= =?UTF-8?q?aunchpad=20app=20id;=20finish=20mydash=E2=86=92launchpad=20rena?= =?UTF-8?q?me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "LaunchPad" menu tile opened OpenBuild: the nav was mydash.page.index, which never resolved (NC registers the app under its folder name "launchpad") and fell through to OpenBuild's dashboard catch-all route. The frontend also called /apps/mydash/api/manifest (404). - appinfo/info.xml: nav route mydash.page.index → launchpad.page.index; mydash → launchpad - backend: route names (launchpad.kiosk.render, launchpad.publicShare.show), log contexts, and the two straggler occ commands (launchpad:storage:*) - frontend: i18n domain t('launchpad'), loadState, and /apps/launchpad URLs across 36 files; rebrand two user-visible "MyDash" strings to "LaunchPad" - add src/publicPath.js (generateFilePath) imported first in main.js/admin.js so dynamically-imported chunks load from the served js dir — fixes "Refused to execute script (MIME text/html)" on launchpad-cn-manifest-validator.js - add PublicShareContext::isBearer() getter (test expected it) - reconcile kiosk/demo-showcase unit test assertions to the launchpad app id DB tables (oc_mydash_*), migrations, and cache prefixes are intentionally left as mydash_ — they are the physical schema created by already-run migrations. --- appinfo/info.xml | 4 +- lib/Command/MigrateStorageToGroupFolder.php | 6 +-- lib/Command/ToggleStorageSetting.php | 6 +-- lib/Controller/DashboardApiController.php | 2 +- lib/Db/KioskPlaylistMapper.php | 2 +- lib/Db/PublicShareMapper.php | 2 +- lib/Listener/PublicSharesListener.php | 2 +- lib/Service/KioskService.php | 4 +- lib/Service/PublicShareContext.php | 13 +++++ lib/Service/PublicShareService.php | 4 +- src/admin.js | 2 + .../Dashboard/__tests__/IconPicker.spec.js | 12 ++--- src/components/DashboardConfigModal.vue | 10 ++-- .../Widgets/Renderers/CalendarWidget.vue | 2 +- .../Widgets/Renderers/FilesWidget.vue | 6 +-- .../Widgets/Renderers/LinkButtonWidget.vue | 2 +- .../Widgets/Renderers/NewsWidget.vue | 2 +- .../Widgets/Renderers/PeopleWidget.vue | 2 +- .../Renderers/__tests__/FilesWidget.spec.js | 2 +- .../Widgets/VisibilityRulesModal.vue | 54 +++++++++---------- src/components/Widgets/WidgetContextMenu.vue | 2 +- .../Workspace/__tests__/SidebarFooter.spec.js | 4 +- src/components/admin/AdminDemoData.vue | 16 +++--- src/components/admin/AdminSettings.vue | 24 ++++----- src/components/admin/BeheerTabs.vue | 4 +- .../admin/ConditionalVisibilityOverview.vue | 20 +++---- .../admin/DashboardSharingPolicy.vue | 16 +++--- src/components/admin/HealthPanel.vue | 8 +-- .../admin/LegacyWidgetBridgeToggle.vue | 6 +-- .../admin/OrgNavigationEditorRow.vue | 4 +- .../admin/PrometheusMetricsPanel.vue | 6 +-- .../admin/RoleLayoutDefaultsSection.vue | 18 +++---- src/components/admin/tabs/TemplatesPage.vue | 50 ++++++++--------- src/dialogs/PublicSharePasswordDialog.vue | 8 +-- src/dialogs/RoleLayoutDefaultDeleteDialog.vue | 8 +-- src/dialogs/RoleLayoutDefaultEditorDialog.vue | 32 +++++------ src/dialogs/RolePermissionDeleteDialog.vue | 8 +-- src/main.js | 8 +-- src/publicPath.js | 22 ++++++++ .../__tests__/resourceService.spec.js | 8 +-- src/services/api.js | 2 +- src/services/resourceService.js | 4 +- src/stores/__tests__/kioskPlaylists.spec.js | 4 +- src/stores/kioskPlaylists.js | 2 +- src/stores/publicShares.js | 2 +- src/stores/roleFeaturePermissions.js | 4 +- src/views/DashboardPublicShareView.vue | 24 ++++----- src/views/Views.vue | 12 ++--- tests/Unit/Db/KioskPlaylistMapperTest.php | 6 +-- .../Unit/Service/DemoShowcasesServiceTest.php | 2 +- 50 files changed, 256 insertions(+), 217 deletions(-) create mode 100644 src/publicPath.js diff --git a/appinfo/info.xml b/appinfo/info.xml index 8807525a..5723f851 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ --> - mydash + launchpad LaunchPad LaunchPad Drag-and-drop dashboards with templates, tiles, and smart visibility @@ -142,7 +142,7 @@ Vrij en open source onder de EUPL-1.2-licentie. LaunchPad - mydash.page.index + launchpad.page.index app.svg -5 diff --git a/lib/Command/MigrateStorageToGroupFolder.php b/lib/Command/MigrateStorageToGroupFolder.php index 7da8d721..a1c3dcd2 100644 --- a/lib/Command/MigrateStorageToGroupFolder.php +++ b/lib/Command/MigrateStorageToGroupFolder.php @@ -3,7 +3,7 @@ /** * MigrateStorageToGroupFolder * - * `mydash:storage:migrate-to-groupfolder` — one-time idempotent migration + * `launchpad:storage:migrate-to-groupfolder` — one-time idempotent migration * from database content storage to GroupFolder storage (REQ-GFSB-008). * Reads all dashboards via DashboardMapper, copies their content JSON from * the `content` column to the GroupFolder backend, and reports progress. @@ -71,7 +71,7 @@ public function __construct( */ protected function configureCommand(): void { - $this->setName(name: 'mydash:storage:migrate-to-groupfolder') + $this->setName(name: 'launchpad:storage:migrate-to-groupfolder') ->setDescription(description: 'Migrate dashboard content from DB to GroupFolder (REQ-GFSB-008).') ->setHelp( help: implode( @@ -84,7 +84,7 @@ protected function configureCommand(): void 'GroupFolder are skipped. Re-run safely after partial failures.', '', 'After a successful migration, switch the active backend via:', - ' php occ mydash:storage:toggle-backend groupfolder', + ' php occ launchpad:storage:toggle-backend groupfolder', '', 'Use --prune-source to remove DB content for successfully migrated', 'dashboards (default: DB content is kept for rollback safety).', diff --git a/lib/Command/ToggleStorageSetting.php b/lib/Command/ToggleStorageSetting.php index e7217057..0156781e 100644 --- a/lib/Command/ToggleStorageSetting.php +++ b/lib/Command/ToggleStorageSetting.php @@ -3,7 +3,7 @@ /** * ToggleStorageSetting * - * `mydash:storage:toggle-backend {db|groupfolder}` — changes the active + * `launchpad:storage:toggle-backend {db|groupfolder}` — changes the active * content storage backend by writing to the `content_storage` admin setting. * Emits a warning when switching from GroupFolder back to DB since DB data * is not auto-copied from GroupFolder (REQ-GFSB-010). @@ -67,7 +67,7 @@ public function __construct( */ protected function configureCommand(): void { - $this->setName(name: 'mydash:storage:toggle-backend') + $this->setName(name: 'launchpad:storage:toggle-backend') ->setDescription(description: 'Change the active content storage backend (db|groupfolder).') ->setHelp( help: implode( @@ -84,7 +84,7 @@ protected function configureCommand(): void 'GroupFolder data back to the database. Run the migration first or', 'ensure DB content is intact (migration keeps DB copies by default).', '', - 'Run php occ mydash:storage:migrate-to-groupfolder before switching', + 'Run php occ launchpad:storage:migrate-to-groupfolder before switching', 'to groupfolder to ensure all existing dashboards are available.', ] ) diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php index 842fc266..4514ffd9 100644 --- a/lib/Controller/DashboardApiController.php +++ b/lib/Controller/DashboardApiController.php @@ -1963,7 +1963,7 @@ protected function storageUnavailableResponse( 'error' => 'dashboard_content_storage_unavailable', 'message' => 'The dashboard content storage backend is unavailable. ' .'If you recently changed the backend, run: ' - .'php occ mydash:storage:migrate-to-groupfolder', + .'php occ launchpad:storage:migrate-to-groupfolder', ], statusCode: Http::STATUS_SERVICE_UNAVAILABLE ); diff --git a/lib/Db/KioskPlaylistMapper.php b/lib/Db/KioskPlaylistMapper.php index 26ad60be..5b07ce17 100644 --- a/lib/Db/KioskPlaylistMapper.php +++ b/lib/Db/KioskPlaylistMapper.php @@ -70,7 +70,7 @@ private function hydrateUrl(KioskPlaylist $playlist): KioskPlaylist if ($playlist->getToken() !== null) { $playlist->setUrl( $this->urlGenerator->linkToRouteAbsolute( - routeName: 'mydash.kiosk.render', + routeName: 'launchpad.kiosk.render', arguments: ['token' => $playlist->getToken()] ) ); diff --git a/lib/Db/PublicShareMapper.php b/lib/Db/PublicShareMapper.php index 110649a8..e4b5a2fe 100644 --- a/lib/Db/PublicShareMapper.php +++ b/lib/Db/PublicShareMapper.php @@ -82,7 +82,7 @@ private function hydrateUrl(PublicShare $share): PublicShare if ($share->getToken() !== null) { $share->setUrl( $this->urlGenerator->linkToRouteAbsolute( - routeName: 'mydash.publicShare.show', + routeName: 'launchpad.publicShare.show', arguments: ['token' => $share->getToken()] ) ); diff --git a/lib/Listener/PublicSharesListener.php b/lib/Listener/PublicSharesListener.php index 6bb3fd23..ce23a2d2 100644 --- a/lib/Listener/PublicSharesListener.php +++ b/lib/Listener/PublicSharesListener.php @@ -76,7 +76,7 @@ public function handle(Event $event): void $this->logger->debug( message: sprintf( - 'mydash PublicSharesListener: soft-revoked %d share(s) for dashboard %s', + 'launchpad PublicSharesListener: soft-revoked %d share(s) for dashboard %s', $count, $uuid ), diff --git a/lib/Service/KioskService.php b/lib/Service/KioskService.php index 93363b59..1cad4245 100644 --- a/lib/Service/KioskService.php +++ b/lib/Service/KioskService.php @@ -155,8 +155,8 @@ public function createPlaylist( $saved = $this->playlistMapper->insert(entity: $playlist); $this->logger->debug( - message: sprintf('mydash: kiosk playlist created by %s', $callerId), - context: ['app' => 'mydash'] + message: sprintf('launchpad: kiosk playlist created by %s', $callerId), + context: ['app' => 'launchpad'] ); return $saved; diff --git a/lib/Service/PublicShareContext.php b/lib/Service/PublicShareContext.php index 3c556a27..9172604b 100644 --- a/lib/Service/PublicShareContext.php +++ b/lib/Service/PublicShareContext.php @@ -78,6 +78,19 @@ public function markBearer(string $token): void $this->token = $token; }//end markBearer() + /** + * Whether the current request was authenticated via a public-share + * bearer token (read-only context). + * + * @return bool True when the request is a public-share bearer. + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 + */ + public function isBearer(): bool + { + return $this->isBearer; + }//end isBearer() + /** * The verified bearer token, or null when not in a bearer context. * diff --git a/lib/Service/PublicShareService.php b/lib/Service/PublicShareService.php index 59e3d04d..57b847fa 100644 --- a/lib/Service/PublicShareService.php +++ b/lib/Service/PublicShareService.php @@ -156,8 +156,8 @@ public function createPublicShare( $saved = $this->shareMapper->insert(entity: $share); $this->logger->debug( - message: sprintf('mydash: public share created for dashboard %s', $dashboardUuid), - context: ['app' => 'mydash'] + message: sprintf('launchpad: public share created for dashboard %s', $dashboardUuid), + context: ['app' => 'launchpad'] ); return $saved; diff --git a/src/admin.js b/src/admin.js index f664418a..94aebe92 100644 --- a/src/admin.js +++ b/src/admin.js @@ -11,6 +11,8 @@ * Provided values are plain (non-reactive) snapshots (REQ-INIT-005). */ +import './publicPath.js' + import Vue from 'vue' import { PiniaVuePlugin, createPinia } from 'pinia' import { translate as t, translatePlural as n } from '@nextcloud/l10n' diff --git a/src/components/Dashboard/__tests__/IconPicker.spec.js b/src/components/Dashboard/__tests__/IconPicker.spec.js index cb035884..aa4e82b7 100644 --- a/src/components/Dashboard/__tests__/IconPicker.spec.js +++ b/src/components/Dashboard/__tests__/IconPicker.spec.js @@ -121,7 +121,7 @@ describe('IconPicker — upload custom URL (REQ-ICON-008)', () => { status: 200, data: { status: 'success', - url: '/apps/mydash/resource/abc.png', + url: '/apps/launchpad/resource/abc.png', name: 'abc.png', size: 12, }, @@ -142,11 +142,11 @@ describe('IconPicker — upload custom URL (REQ-ICON-008)', () => { await flushAsync() expect(axios.post).toHaveBeenCalledWith( - '/apps/mydash/api/resources', + '/apps/launchpad/api/resources', { base64: 'data:image/png;base64,AAAA' }, ) expect(wrapper.emitted('input')).toBeTruthy() - expect(wrapper.emitted('input')[0]).toEqual(['/apps/mydash/resource/abc.png']) + expect(wrapper.emitted('input')[0]).toEqual(['/apps/launchpad/resource/abc.png']) }) }) @@ -222,7 +222,7 @@ describe('IconPicker — mode switching (REQ-ICON-008)', () => { status: 200, data: { status: 'success', - url: '/apps/mydash/resource/abc.png', + url: '/apps/launchpad/resource/abc.png', name: 'abc.png', size: 12, }, @@ -246,13 +246,13 @@ describe('IconPicker — mode switching (REQ-ICON-008)', () => { // Parent would normally update v-model from the emit; emulate // that by setting the new value through propsData and re-rendering. - await wrapper.setProps({ value: '/apps/mydash/resource/abc.png' }) + await wrapper.setProps({ value: '/apps/launchpad/resource/abc.png' }) expect(wrapper.find('img').exists()).toBe(true) }) it('switches back from a custom URL to a built-in registry name', async () => { const wrapper = mount(IconPicker, { - propsData: { value: '/apps/mydash/resource/abc.png' }, + propsData: { value: '/apps/launchpad/resource/abc.png' }, }) // Initial: URL → img. expect(wrapper.find('img').exists()).toBe(true) diff --git a/src/components/DashboardConfigModal.vue b/src/components/DashboardConfigModal.vue index a74f14d4..8eca5ef3 100644 --- a/src/components/DashboardConfigModal.vue +++ b/src/components/DashboardConfigModal.vue @@ -103,9 +103,9 @@ :checked="form.isDefault" type="switch" @update:checked="form.isDefault = $event"> - {{ t('mydash', 'Default dashboard') }} + {{ t('launchpad', 'Default dashboard') }} - {{ t('mydash', 'Open this dashboard automatically when visiting MyDash.') }} + {{ t('launchpad', 'Open this dashboard automatically when visiting LaunchPad.') }} @@ -353,12 +353,12 @@ export default { */ tabs() { const list = [ - { id: 'general', label: t('mydash', 'General'), icon: Tune }, + { id: 'general', label: t('launchpad', 'General'), icon: Tune }, ] if (this.canManageShares) { - list.push({ id: 'sharing', label: t('mydash', 'Sharing'), icon: ShareVariant }) + list.push({ id: 'sharing', label: t('launchpad', 'Sharing'), icon: ShareVariant }) } - list.push({ id: 'default', label: t('mydash', 'Default'), icon: StarOutline }) + list.push({ id: 'default', label: t('launchpad', 'Default'), icon: StarOutline }) return list }, /** @spec openspec/specs/dashboards/spec.md */ diff --git a/src/components/Widgets/Renderers/CalendarWidget.vue b/src/components/Widgets/Renderers/CalendarWidget.vue index 095c6749..df6c23d5 100644 --- a/src/components/Widgets/Renderers/CalendarWidget.vue +++ b/src/components/Widgets/Renderers/CalendarWidget.vue @@ -401,7 +401,7 @@ export default { this.loading = true this.error = null try { - const url = generateUrl('/apps/mydash/api/widgets/calendar/{id}/events', { + const url = generateUrl('/apps/launchpad/api/widgets/calendar/{id}/events', { id: this.placementId, }) const response = await axios.get(url, { diff --git a/src/components/Widgets/Renderers/FilesWidget.vue b/src/components/Widgets/Renderers/FilesWidget.vue index d5e4b46a..e23f8e9d 100644 --- a/src/components/Widgets/Renderers/FilesWidget.vue +++ b/src/components/Widgets/Renderers/FilesWidget.vue @@ -316,7 +316,7 @@ export default { ]) const url = generateUrl( - '/apps/mydash/api/widgets/files/{placementId}/contents', + '/apps/launchpad/api/widgets/files/{placementId}/contents', { placementId: this.placementId }, ) const params = { @@ -429,7 +429,7 @@ export default { ]) const url = generateUrl( - '/apps/mydash/api/widgets/files/{placementId}/files/{fileId}', + '/apps/launchpad/api/widgets/files/{placementId}/files/{fileId}', { placementId: this.placementId, fileId: target.fileId }, ) await axios.delete(url) @@ -469,7 +469,7 @@ export default { ]) const url = generateUrl( - '/apps/mydash/api/widgets/files/{placementId}/upload', + '/apps/launchpad/api/widgets/files/{placementId}/upload', { placementId: this.placementId }, ) await axios.post(url, formData, { diff --git a/src/components/Widgets/Renderers/LinkButtonWidget.vue b/src/components/Widgets/Renderers/LinkButtonWidget.vue index 49983807..cd642bb6 100644 --- a/src/components/Widgets/Renderers/LinkButtonWidget.vue +++ b/src/components/Widgets/Renderers/LinkButtonWidget.vue @@ -584,7 +584,7 @@ export default { try { const response = await axios.post( - generateUrl('/apps/mydash/api/files/create'), + generateUrl('/apps/launchpad/api/files/create'), { filename, dir: '/', content: '' }, ) const data = response?.data diff --git a/src/components/Widgets/Renderers/NewsWidget.vue b/src/components/Widgets/Renderers/NewsWidget.vue index d8bd6dc3..da84df23 100644 --- a/src/components/Widgets/Renderers/NewsWidget.vue +++ b/src/components/Widgets/Renderers/NewsWidget.vue @@ -229,7 +229,7 @@ export default { this.hasError = false try { - const url = generateUrl('/apps/mydash/api/widgets/news/{placementId}/items', { + const url = generateUrl('/apps/launchpad/api/widgets/news/{placementId}/items', { placementId: this.placementId, }) const response = await axios.get(url, { params: { limit: this.itemLimit } }) diff --git a/src/components/Widgets/Renderers/PeopleWidget.vue b/src/components/Widgets/Renderers/PeopleWidget.vue index 6576a3e3..6c3ceb07 100644 --- a/src/components/Widgets/Renderers/PeopleWidget.vue +++ b/src/components/Widgets/Renderers/PeopleWidget.vue @@ -340,7 +340,7 @@ export default { import('@nextcloud/router'), ]) - const url = `${generateUrl('/apps/mydash/api/people')}?${params.toString()}` + const url = `${generateUrl('/apps/launchpad/api/people')}?${params.toString()}` const response = await axios.get(url) const data = response?.data || {} diff --git a/src/components/Widgets/Renderers/__tests__/FilesWidget.spec.js b/src/components/Widgets/Renderers/__tests__/FilesWidget.spec.js index 6f5fa385..62a1cfdd 100644 --- a/src/components/Widgets/Renderers/__tests__/FilesWidget.spec.js +++ b/src/components/Widgets/Renderers/__tests__/FilesWidget.spec.js @@ -241,7 +241,7 @@ describe('FilesWidget', () => { await wrapper.find('.files-widget__modal-confirm').trigger('click') await flushPromises() - expect(axios.delete).toHaveBeenCalledWith('/apps/mydash/api/widgets/files/7/files/1') + expect(axios.delete).toHaveBeenCalledWith('/apps/launchpad/api/widgets/files/7/files/1') // Optimistic remove — file should be gone from the listing. expect(wrapper.vm.items.find((item) => item.fileId === 1)).toBeUndefined() }) diff --git a/src/components/Widgets/VisibilityRulesModal.vue b/src/components/Widgets/VisibilityRulesModal.vue index 219382c1..10029481 100644 --- a/src/components/Widgets/VisibilityRulesModal.vue +++ b/src/components/Widgets/VisibilityRulesModal.vue @@ -7,14 +7,14 @@

- {{ t('mydash', 'Visibility rules') }} + {{ t('launchpad','Visibility rules') }}

- {{ t('mydash', 'Show or hide this widget based on group membership, time of day, date range, or a user attribute. Include rules use OR logic; exclude rules use AND logic.') }} + {{ t('launchpad','Show or hide this widget based on group membership, time of day, date range, or a user attribute. Include rules use OR logic; exclude rules use AND logic.') }}

@@ -33,13 +33,13 @@ - {{ rule.isInclude ? t('mydash', 'Include') : t('mydash', 'Exclude') }} + {{ rule.isInclude ? t('launchpad','Include') : t('launchpad','Exclude') }} {{ summariseConfig(rule) }}
@@ -50,16 +50,16 @@

- {{ t('mydash', 'No visibility rules yet — this widget is always shown.') }} + {{ t('launchpad','No visibility rules yet — this widget is always shown.') }}

-

{{ t('mydash', 'Add a rule') }}

+

{{ t('launchpad','Add a rule') }}

- + + :aria-label-combobox="t('launchpad','Groups')" + :placeholder="t('launchpad','Select groups')" />
@@ -92,14 +92,14 @@
@@ -110,14 +110,14 @@
@@ -128,14 +128,14 @@
@@ -147,7 +147,7 @@ :disabled="busy || !canAdd" data-test="visibility-rule-add" @click="addRule"> - {{ t('mydash', 'Add rule') }} + {{ t('launchpad','Add rule') }}
@@ -215,18 +215,18 @@ export default { /** @spec openspec/specs/conditional-visibility/spec.md */ ruleTypeOptions() { return [ - { id: 'group', label: t('mydash', 'Group') }, - { id: 'time', label: t('mydash', 'Time of day') }, - { id: 'date', label: t('mydash', 'Date range') }, - { id: 'attribute', label: t('mydash', 'User attribute') }, + { id: 'group', label: t('launchpad','Group') }, + { id: 'time', label: t('launchpad','Time of day') }, + { id: 'date', label: t('launchpad','Date range') }, + { id: 'attribute', label: t('launchpad','User attribute') }, ] }, /** @spec openspec/specs/conditional-visibility/spec.md */ modeOptions() { return [ - { id: 'include', label: t('mydash', 'Include (show when matched)') }, - { id: 'exclude', label: t('mydash', 'Exclude (hide when matched)') }, + { id: 'include', label: t('launchpad','Include (show when matched)') }, + { id: 'exclude', label: t('launchpad','Exclude (hide when matched)') }, ] }, @@ -275,8 +275,8 @@ export default { */ freshDraft() { return { - type: { id: 'group', label: t('mydash', 'Group') }, - mode: { id: 'include', label: t('mydash', 'Include (show when matched)') }, + type: { id: 'group', label: t('launchpad','Group') }, + mode: { id: 'include', label: t('launchpad','Include (show when matched)') }, groups: [], startTime: '', endTime: '', diff --git a/src/components/Widgets/WidgetContextMenu.vue b/src/components/Widgets/WidgetContextMenu.vue index 9bcdf238..366b1451 100644 --- a/src/components/Widgets/WidgetContextMenu.vue +++ b/src/components/Widgets/WidgetContextMenu.vue @@ -24,7 +24,7 @@ role="menuitem" data-testid="ctx-visibility-rules" @click="onVisibilityRules"> - {{ t('mydash', 'Visibility rules…') }} + {{ t('launchpad','Visibility rules…') }}
@@ -126,7 +126,7 @@ export default { const response = await api.listDemoShowcases() this.showcases = response.data || [] } catch (err) { - this.loadError = this.t('mydash', 'Could not load demo showcases. Please try again.') + this.loadError = this.t('launchpad', 'Could not load demo showcases. Please try again.') } finally { this.loading = false } @@ -147,11 +147,11 @@ export default { await this.fetch() } catch (err) { if (err.response && err.response.status === 404) { - this.actionError = this.t('mydash', 'Showcase not found.') + this.actionError = this.t('launchpad', 'Showcase not found.') } else if (err.response && err.response.status === 403) { - this.actionError = this.t('mydash', 'You need admin privileges to install showcases.') + this.actionError = this.t('launchpad', 'You need admin privileges to install showcases.') } else { - this.actionError = this.t('mydash', 'Could not install showcase. Please try again.') + this.actionError = this.t('launchpad', 'Could not install showcase. Please try again.') } } finally { this.$set(this.busy, showcase.id, false) @@ -172,7 +172,7 @@ export default { this.$delete(this.warnings, showcase.id) await this.fetch() } catch (err) { - this.actionError = this.t('mydash', 'Could not uninstall showcase. Please try again.') + this.actionError = this.t('launchpad', 'Could not uninstall showcase. Please try again.') } finally { this.$set(this.busy, showcase.id, false) } diff --git a/src/components/admin/AdminSettings.vue b/src/components/admin/AdminSettings.vue index 074c995f..51807db2 100644 --- a/src/components/admin/AdminSettings.vue +++ b/src/components/admin/AdminSettings.vue @@ -6,8 +6,8 @@