diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3390c9e5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + cooldown: + default-days: 1 + include: + - "*" + exclude: + - "@conduction/*" diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..3942d348 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Supply-chain hardening: reject any npm package published less than +# 24h ago. Compromised first-party-Conduction packages are excluded via +# Dependabot cooldown (.github/dependabot.yml); for fresh @conduction/* +# releases, override per-install with `npm install --min-release-age=0`. +min-release-age=1 diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 472b8a78..c481cb95 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -144,6 +144,7 @@ const config = createConfig({ /* themeConfig is shallow-merged into the preset's defaults (colorMode + navbar + footer). prism + mermaid land alongside. */ themeConfig: { + image: 'img/og-mydash.png', prism: { theme: require('prism-react-renderer/themes/github'), darkTheme: require('prism-react-renderer/themes/dracula'), diff --git a/docs/package-lock.json b/docs/package-lock.json index 4f898166..fb7659e2 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,7 +8,7 @@ "name": "mydash-docs", "version": "0.0.0", "dependencies": { - "@conduction/docusaurus-preset": "^2.6.1", + "@conduction/docusaurus-preset": "^3.6.0", "@docusaurus/core": "^3.10.0", "@docusaurus/preset-classic": "^3.10.0", "@docusaurus/theme-mermaid": "^3.10.0", @@ -2045,10 +2045,13 @@ } }, "node_modules/@conduction/docusaurus-preset": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-2.6.1.tgz", - "integrity": "sha512-hOkS2XAj3p1wfaZWjvIqKzwC5cbTSLUPm+DJxUp7TePbnU37kaHGegjLjfVOnQ312G8an+0M5VWIhmg/YhRVyA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-3.6.0.tgz", + "integrity": "sha512-gjnM6G+Cjx1WKniMhbQDYjgje0J4nqjeArKUHxisEdsY62fp8mfEzP1NXeD/2F2aYa+eHShCHeYGUM9jTl6yKQ==", "license": "EUPL-1.2", + "bin": { + "validate-ai-baseline": "bin/validate-ai-baseline.mjs" + }, "peerDependencies": { "@docusaurus/core": "^3.0.0", "@docusaurus/preset-classic": "^3.0.0", diff --git a/docs/package.json b/docs/package.json index 939628f6..ffa71b61 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,6 +6,8 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", + "postbuild": "validate-ai-baseline", + "validate:ai-baseline": "validate-ai-baseline", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -15,7 +17,7 @@ "ci": "npm ci --legacy-peer-deps && npm run build" }, "dependencies": { - "@conduction/docusaurus-preset": "^2.6.1", + "@conduction/docusaurus-preset": "^3.6.0", "@docusaurus/core": "^3.10.0", "@docusaurus/preset-classic": "^3.10.0", "@docusaurus/theme-mermaid": "^3.10.0", diff --git a/docs/static/img/og-mydash.png b/docs/static/img/og-mydash.png new file mode 100644 index 00000000..8c2ea8b6 Binary files /dev/null and b/docs/static/img/og-mydash.png differ diff --git a/docs/static/llms.txt b/docs/static/llms.txt new file mode 100644 index 00000000..ea366578 --- /dev/null +++ b/docs/static/llms.txt @@ -0,0 +1,23 @@ +# MyDash + +> MyDash is an open-source dashboard app for the Nextcloud workspace. + +MyDash is an open-source dashboard app for the Nextcloud workspace. It lets each user compose multiple personal dashboards from drag-and-drop widgets and shortcut tiles on a responsive GridStack canvas, and supports both the v1 and v2 Nextcloud Dashboard widget APIs out of the box. Administrators can ship pre-configured templates to user groups, pin compulsory widgets, and set conditional visibility rules based on group, time of day, or date range. Released under EUPL-1.2 and maintained by Conduction since 2019. + +## Docs + +- [Documentation](https://mydash.conduction.nl/docs/intro): main entry, including tutorials, user guide, and admin guide. +- [API reference](https://mydash.conduction.nl/api): OpenAPI documentation. + +## Optional + +- [Install](https://www.conduction.nl/install): self-host on your Nextcloud instance. +- [Source code](https://github.com/ConductionNL/mydash): repository and issue tracker. +- [App page](https://www.conduction.nl/apps/mydash): product positioning on conduction.nl. + +## Contact + +- Email: info@conduction.nl +- Web: https://www.conduction.nl +- GitHub: https://github.com/ConductionNL +- Conduction B.V. · KvK 76741850 · Lauriergracht 14h, Amsterdam, Netherlands diff --git a/openspec/changes/active-dashboard-resolution/tasks.md b/openspec/changes/active-dashboard-resolution/tasks.md index 0f7e067b..892e1209 100644 --- a/openspec/changes/active-dashboard-resolution/tasks.md +++ b/openspec/changes/active-dashboard-resolution/tasks.md @@ -1,52 +1,33 @@ # Tasks — active-dashboard-resolution -## 1. Backend resolver +## Tasks -- [ ] 1.1 Add user pref key constant `DashboardService::ACTIVE_DASHBOARD_UUID_PREF_KEY = 'active_dashboard_uuid'` -- [ ] 1.2 Add `DashboardService::resolveActiveDashboard(string $userId, ?string $primaryGroupId): ?array` returning `['dashboard' => Dashboard, 'source' => 'user'|'group'|'default']` or `null` -- [ ] 1.3 Implement the 7-step precedence chain exactly as REQ-DASH-018 lists (saved pref → group default → default-group default → first-in-group → first-in-default-group → first personal → null) -- [ ] 1.4 Implement stale-preference auto-clear: when the saved UUID is not in `findVisibleToUser` results, call `IConfig::deleteUserValue` (write-on-read) and emit a `LoggerInterface::warning` line -- [ ] 1.5 Resolver MUST be otherwise pure (no other side effects on read) +- [ ] Task 1: Define `DashboardService::ACTIVE_DASHBOARD_UUID_PREF_KEY = 'active_dashboard_uuid'` and `DashboardService::resolveActiveDashboard($userId, ?$primaryGroupId): ?array` returning `['dashboard' => Dashboard, 'source' => 'user'|'group'|'default']` or `null` +- [ ] Task 2: Implement the 7-step precedence chain in the resolver exactly as REQ-DASH-018 lists (saved pref → group default → default-group default → first-in-group → first-in-default-group → first personal → null); resolver MUST be otherwise pure (no other read-side effects) +- [ ] Task 3: Stale-preference auto-clear — when the saved UUID is not in `findVisibleToUser` results, call `IConfig::deleteUserValue` (write-on-read) and emit a `LoggerInterface::warning` line +- [ ] Task 4: Add `DashboardService::setActivePreference($userId, $uuid): void` that writes via `IConfig::setUserValue` (or deletes when uuid is empty); no existence check on write per REQ-DASH-019 +- [ ] Task 5: Add `DashboardController::setActiveDashboard()` mapped to `POST /api/dashboards/active` with `#[NoAdminRequired]` — accepts `{uuid: string}`, returns HTTP 200 `{status:'success'}`; route registered in `appinfo/routes.php` +- [ ] Task 6: Wire `WorkspaceController` to call `resolveActiveDashboard($currentUserId, $primaryGroupId)` on first render and push `activeDashboardId` (or `''` when null) + `dashboardSource` into initial-state JSON via `IInitialState`; null resolver result renders the empty-state UI +- [ ] Task 7: Frontend — mirror the 7-step precedence in `useDashboardsStore.resolveActive()` for client-side `switchDashboard()` flows after store mutations +- [ ] Task 8: Add `switchDashboard(uuid)` store action — updates store state and POSTs to `/api/dashboards/active` fire-and-forget (failure surfaces as a toast but does not block the UI) +- [ ] Task 9: Add the empty-state component shown when `resolveActive()` returns null, including a "Create your first dashboard" affordance +- [ ] Task 10: PHPUnit — table-driven test exercising all 7 precedence steps + permutations (saved pref / no pref; group default present/absent; default-group default present/absent; first-in-group present/absent; first personal present/absent; nothing-at-all); cross-group preference invalidated correctly +- [ ] Task 11: PHPUnit — stale preference cleared exactly once per request (not on every visibility check); `setActivePreference` accepts non-existent UUIDs without erroring; empty-string uuid clears the preference (REQ-DASH-019) +- [ ] Task 12: Playwright — empty state shows on a fresh user with no dashboards; `switchDashboard` POSTs the new UUID and the next page load picks it up; stale preference (dashboard deleted between sessions) silently falls through to step 2 — no error toast +- [ ] Task 13: Quality gates — `composer check:strict`, ESLint+Stylelint, OpenAPI/Postman regen for the new endpoint, `nl`+`en` i18n for the empty-state copy + new error strings, SPDX-in-docblock on new PHP, all 10 hydra-gates green; document in `design.md` why stale prefs are cleaned per-request rather than via cron -## 2. Backend write endpoint +## Verification -- [ ] 2.1 Add `DashboardService::setActivePreference(string $userId, string $uuid): void` that writes to `IConfig::setUserValue` (or deletes when uuid is empty string) -- [ ] 2.2 Add `DashboardController::setActiveDashboard()` mapped to `POST /api/dashboards/active` with `#[NoAdminRequired]` — accepts `{uuid: string}`, returns HTTP 200 `{status: 'success'}` -- [ ] 2.3 Register the route in `appinfo/routes.php` -- [ ] 2.4 No existence check on write (per REQ-DASH-019 scenario "no existence check on write") +`openspec validate` exits clean. Resolver returns the correct dashboard for all 7 precedence rows in the test matrix; stale prefs self-heal. -## 3. Workspace integration +## Tests (company-wide ADR-009) -- [ ] 3.1 Update `WorkspaceController` to call `resolveActiveDashboard($currentUserId, $primaryGroupId)` on first render -- [ ] 3.2 Push `activeDashboardId` (or `''` when null) and `dashboardSource` into initial-state JSON via `IInitialState` -- [ ] 3.3 When resolver returns null, ensure the page renders the empty-state UI (per REQ-DASH-018 scenario "empty state") +PHPUnit per Tasks 10–11; Playwright per Task 12. Newman/Postman updated for the new endpoint. -## 4. Frontend +## Documentation (company-wide ADR-010) -- [ ] 4.1 Mirror the 7-step precedence in `useDashboardsStore.resolveActive()` for client-side `switchDashboard()` flows after store mutations -- [ ] 4.2 Add `switchDashboard(uuid)` action that updates store state and POSTs to `/api/dashboards/active` (fire-and-forget; surface failure as a toast but do not block UI) -- [ ] 4.3 Empty-state component shown when `resolveActive()` returns null — includes a "Create your first dashboard" affordance +Changelog entry covering the active-dashboard resolution chain + the empty-state UX. -## 5. PHPUnit tests +## i18n (company-wide ADR-005) -- [ ] 5.1 Table-driven test covering all 7 steps with permutations (saved pref / no pref; group default present / absent; default-group default present / absent; first-in-group present / absent; first personal present / absent; nothing-at-all) -- [ ] 5.2 Stale preference cleared exactly once per request (not on every visibility check) -- [ ] 5.3 Cross-group preference invalidated correctly — alice pref points to a dashboard whose group she no longer belongs to -- [ ] 5.4 `setActivePreference` accepts non-existent UUIDs without erroring (REQ-DASH-019 scenario "no existence check on write") -- [ ] 5.5 Empty-string uuid clears the preference (REQ-DASH-019 scenario "empty uuid clears the preference") - -## 6. Playwright tests - -- [ ] 6.1 Empty state shows on a fresh user with no dashboards (any type, any group) -- [ ] 6.2 Switching dashboard fires `POST /api/dashboards/active` with the new UUID and the next page load picks up the saved choice -- [ ] 6.3 Stale preference (dashboard deleted between sessions) silently falls through to step 2 of the chain — no error toast - -## 7. Quality gates - -- [ ] 7.1 `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) passes — fix any pre-existing issues encountered along the way -- [ ] 7.2 ESLint + Stylelint clean on touched Vue/JS files -- [ ] 7.3 Update generated OpenAPI spec / Postman collection so external API consumers see the new endpoint -- [ ] 7.4 `i18n` keys for new error messages and the empty-state copy in both `nl` and `en` per the i18n requirement -- [ ] 7.5 SPDX headers on every new PHP file (inside the docblock per the SPDX-in-docblock convention) — gate-spdx must pass -- [ ] 7.6 Run all 10 `hydra-gates` locally before opening PR -- [ ] 7.7 Stale prefs are cleaned per request, not via cron — document the rationale in `design.md` if added (see proposal Notes) +`nl_NL` + `en_US` for the empty-state copy and new error strings. diff --git a/openspec/changes/custom-icon-upload-pattern/tasks.md b/openspec/changes/custom-icon-upload-pattern/tasks.md index bfd841bb..42c5a258 100644 --- a/openspec/changes/custom-icon-upload-pattern/tasks.md +++ b/openspec/changes/custom-icon-upload-pattern/tasks.md @@ -1,48 +1,32 @@ # Tasks — custom-icon-upload-pattern -## 1. Discriminator module +## Tasks -- [ ] 1.1 Add `isCustomIconUrl(name)` export to `src/constants/dashboardIcons.js` returning `true` only for non-null strings beginning with `'/'` or `'http'` -- [ ] 1.2 Update `getIconComponent(name)` to return `null` when `isCustomIconUrl(name)` is true (must NOT fall back to `DEFAULT_ICON` for URLs) -- [ ] 1.3 Add Vitest covering the truth table: URL prefixes (`/apps/...`, `http://`, `https://`), registry names (`Star`, `ViewDashboard`), and falsy inputs (`null`, `undefined`, `''`) -- [ ] 1.4 Add Vitest asserting `getIconComponent` returns `null` for a URL input AND returns `DEFAULT_ICON` for an unknown registry name (REQ-ICON-001 still holds) +- [ ] Task 1: Add `isCustomIconUrl(name)` to `src/constants/dashboardIcons.js` returning `true` only for non-null strings beginning with `/` or `http`; update `getIconComponent(name)` to return `null` for URL inputs (must NOT fall back to `DEFAULT_ICON` for URLs) +- [ ] Task 2: Vitest discriminator coverage — URL prefixes (`/apps/...`, `http://`, `https://`), registry names (`Star`, `ViewDashboard`), falsy inputs (`null`, `undefined`, `''`); `getIconComponent` returns `null` for URL input AND `DEFAULT_ICON` for unknown registry name (REQ-ICON-001 still holds) +- [ ] Task 3: Build `src/components/Dashboard/IconRenderer.vue` accepting `name`, `alt`, `size` — branches `` when `isCustomIconUrl(name)`, else ``; default `alt` falls back to consumer-supplied label (dashboard/widget name) +- [ ] Task 4: Vitest renderer coverage — rendering branches by input type (built-in name → svg, URL → img, null → default svg); `alt` prop propagated to the rendered `` for URL inputs +- [ ] Task 5: Build `src/components/Dashboard/IconPicker.vue` with both a `` of registry names AND a file-upload input visible at the same time -- [ ] 3.2 On select change: emit/update `v-model` with the chosen option string -- [ ] 3.3 On file select: POST the file to the `resource-uploads` endpoint, then update `v-model` with the returned URL string -- [ ] 3.4 Render a 24×24 live preview of the current value via `IconRenderer` -- [ ] 3.5 Surface loading and error states for the upload (spinner during POST, visible error when the request fails or the response is non-2xx) -- [ ] 3.6 On upload error: leave the previous `v-model` value unchanged (do not clobber) +Vitest per Tasks 2 + 4; Playwright per Task 10. No new backend surface. -## 4. Refactor existing call sites +## Documentation (company-wide ADR-010) -- [ ] 4.1 Replace ad-hoc icon-or-image branches in `DashboardSwitcher` with `` -- [ ] 4.2 Replace branches in the admin dashboard list / CRUD UI with `` and use `` in the create/edit forms -- [ ] 4.3 Replace branches in the link-button widget icon and the tile editor with `` and `` -- [ ] 4.4 Grep test: no remaining `v-if="iconUrl"` / inline `isCustomIconUrl` branches outside `IconRenderer.vue` and `IconPicker.vue` +PHP docblock updates per Task 9; changelog entry covering the unified renderer/picker pattern. -## 5. Documentation +## i18n (company-wide ADR-005) -- [ ] 5.1 Update the `icon` field docblock on `lib/Db/Dashboard.php` to state that the column may hold either a registry name, a `/apps/mydash/resource/...` URL, or NULL -- [ ] 5.2 Update the `tileIcon` field docblock on `lib/Db/WidgetPlacement.php` with the same convention - -## 6. End-to-end tests - -- [ ] 6.1 Playwright: open dashboard editor, switch from a built-in icon to an uploaded one, verify the preview swaps from `` to `` and the value persists after save -- [ ] 6.2 Playwright: switch back from an uploaded icon to a built-in one, verify the preview swaps back and the value persists -- [ ] 6.3 Playwright: render a workspace where multiple dashboards mix built-in and uploaded icons, confirm all render correctly with no console errors - -## 7. Quality - -- [ ] 7.1 ESLint clean on all changed `.vue` and `.js` files -- [ ] 7.2 `composer check:strict` clean for the touched PHP entity docblock changes (PHPCS, PHPMD, Psalm, PHPStan) +No user-facing strings added — picker surfaces existing labels via consumer-supplied props. diff --git a/openspec/changes/dashboard-icons/tasks.md b/openspec/changes/dashboard-icons/tasks.md index 909ff9f9..6bc5f9f6 100644 --- a/openspec/changes/dashboard-icons/tasks.md +++ b/openspec/changes/dashboard-icons/tasks.md @@ -1,42 +1,31 @@ # Tasks — dashboard-icons -## 1. Frontend registry module +## Tasks -- [ ] 1.1 Create `src/constants/dashboardIcons.js` exporting `DASHBOARD_ICONS`, `DEFAULT_ICON`, `getIconComponent`, `isCustomIconUrl` -- [ ] 1.2 Add 15 separate `import …Icon from 'vue-material-design-icons/.vue'` statements (no wildcard / barrel imports — see REQ-ICON-004) -- [ ] 1.3 Implement `getIconComponent(name)` so it returns the registry component, falling back to `DASHBOARD_ICONS[DEFAULT_ICON]` on null / undefined / empty / unknown (REQ-ICON-001, REQ-ICON-002) -- [ ] 1.4 Implement `isCustomIconUrl(name)` returning true when name is a non-empty string starting with `/` or `http` (consumed by `custom-icon-upload-pattern`) -- [ ] 1.5 Set `DEFAULT_ICON = 'ViewDashboard'` and assert `DASHBOARD_ICONS[DEFAULT_ICON]` exists at module load +- [ ] Task 1: Create `src/constants/dashboardIcons.js` exporting `DASHBOARD_ICONS`, `DEFAULT_ICON`, `getIconComponent`, `isCustomIconUrl` with 15 explicit `import …Icon from 'vue-material-design-icons/.vue'` statements (no wildcard/barrel imports — REQ-ICON-004) +- [ ] Task 2: Implement `getIconComponent(name)` returning the registry component and falling back to `DASHBOARD_ICONS[DEFAULT_ICON]` on null/undefined/empty/unknown (REQ-ICON-001 + REQ-ICON-002) +- [ ] Task 3: Implement `isCustomIconUrl(name)` returning true when `name` is a non-empty string starting with `/` or `http` (consumed by `custom-icon-upload-pattern`) +- [ ] Task 4: Set `DEFAULT_ICON = 'ViewDashboard'` and assert `DASHBOARD_ICONS[DEFAULT_ICON]` exists at module load +- [ ] Task 5: Build `src/components/Dashboard/IconRenderer.vue` with props `name: string|null` + `size: number = 20`; template branches `` when `isCustomIconUrl(name)`, otherwise ``; docblock notes the URL branch is foundation for `custom-icon-upload-pattern` +- [ ] Task 6: Refactor `DashboardSwitcher`, the admin dashboard list, and the tile editor to use `` / ``; add an icon picker `` driven by `Object.keys(DASHBOARD_ICONS)` in dashboard create/edit form (REQ-ICON-003) -- [ ] 3.5 Grep audit: no `vue-material-design-icons/.vue` import remains outside `dashboardIcons.js` for dashboard contexts +Vitest per Task 9; visual snapshot per Task 10. No backend surface. -## 4. Backend annotation (no schema change) +## Documentation (company-wide ADR-010) -- [ ] 4.1 Add a docblock on `lib/Db/Dashboard.php`'s `icon` field describing the convention (NULL or registry name or URL) -- [ ] 4.2 Confirm no migration is needed (column already exists on `oc_mydash_dashboards`) +Changelog entry covering the new icon registry + renderer; PHP docblock per Task 8. -## 5. Tests +## i18n (company-wide ADR-005) -- [ ] 5.1 Vitest: `getIconComponent` resolution table — built-in name, default, null, undefined, empty string, unknown name -- [ ] 5.2 Vitest: `DASHBOARD_ICONS` length is at least 15 and contains every name from REQ-ICON-001 -- [ ] 5.3 Vitest: `isCustomIconUrl` returns true for `/foo.svg` and `https://x/y.png`, false for `'Star'`, `''`, `null` -- [ ] 5.4 Visual snapshot (Storybook or equivalent): all 15 icons rendered at size 20 and 32 - -## 6. Quality gates - -- [ ] 6.1 ESLint clean on `src/constants/dashboardIcons.js` and `src/components/Dashboard/IconRenderer.vue` -- [ ] 6.2 Bundle-size check: production `main.js` delta ≤ 8 KB gzipped (the 15 icon SVGs) -- [ ] 6.3 PHPCS clean on `lib/Db/Dashboard.php` if the docblock change touches it +No user-facing strings introduced — icon labels surface via existing dashboard-name fields. diff --git a/openspec/changes/dashboard-public-share/tasks.md b/openspec/changes/dashboard-public-share/tasks.md index 0359665e..e933467e 100644 --- a/openspec/changes/dashboard-public-share/tasks.md +++ b/openspec/changes/dashboard-public-share/tasks.md @@ -1,125 +1,34 @@ # Tasks — dashboard-public-share -## 1. Schema migration +## Tasks -- [ ] 1.1 Create `lib/Migration/VersionXXXXDate2026...AddPublicShares.php` with table `oc_mydash_public_shares` containing all required fields: `id` (auto-increment PK), `dashboardUuid` (VARCHAR 36), `token` (VARCHAR 64 UNIQUE NOT NULL), `passwordHash` (VARCHAR 255 NULL), `expiresAt` (TIMESTAMP NULL), `createdBy` (VARCHAR 64), `createdAt` (TIMESTAMP NOT NULL), `revokedAt` (TIMESTAMP NULL), `viewCount` (INT DEFAULT 0), `lastViewedAt` (TIMESTAMP NULL) -- [ ] 1.2 Add composite index on `(dashboardUuid, revokedAt)` for fast active-share queries (filter where `revokedAt IS NULL`) -- [ ] 1.3 Add foreign key constraint from `dashboardUuid` to `oc_mydash_dashboards.uuid` with ON DELETE CASCADE -- [ ] 1.4 Confirm migration is reversible (drop table in postSchemaChange rollback path) -- [ ] 1.5 Run migration locally against sqlite, mysql, and postgres; verify schema applied cleanly each time +- [ ] Task 1: Ship the migration `lib/Migration/VersionXXXXDateXXXXAddPublicShares.php` creating `oc_mydash_public_shares` (PK `id`, `dashboardUuid` VARCHAR(36), `token` VARCHAR(64) UNIQUE, `passwordHash` VARCHAR(255), `expiresAt`, `createdBy`, `createdAt`, `revokedAt`, `viewCount`, `lastViewedAt`) with composite index `(dashboardUuid, revokedAt)`, FK to `oc_mydash_dashboards.uuid` ON DELETE CASCADE, and a reversible drop path — applied cleanly on sqlite/mysql/postgres +- [ ] Task 2: Add `lib/Db/PublicShare` entity with full getters/setters (no named args), `jsonSerialize()` that strips `passwordHash`, and a computed `url` property using `IURLGenerator::absolute('s/' . $token)` +- [ ] Task 3: Add `lib/Db/PublicShareMapper` (extends `QBMapper`) with `findByToken`, `findByDashboardUuid`, `findActiveByDashboardUuid` (filter revoked + expired), `save`, `delete`, `softRevoke`, `incrementViewCount` (60-second per-IP per-token debounce via APCu/Redis or transaction snapshot) +- [ ] Task 4: Add exception types `ShareNotFoundException`, `ShareExpiredException`, `SharePasswordRequired`, `ShareReadOnlyException` +- [ ] Task 5: Implement `lib/Service/PublicShareService` with `createPublicShare` (owner-or-admin guard, `IHasher::hash` for password, `Util::generateSecureRandom(64)` for token), `listActiveShares`, `revokeShare`, `renderShareContent` (public, validates token + expiry, returns read-only payload), `unlockShare` (public, `IThrottler` 10/hour on `public_share_unlock_{token}_{ip}`) +- [ ] Task 6: Add `lib/Controller/PublicShareController` with 5 endpoints — `POST /api/dashboards/{uuid}/public-share`, `GET /api/dashboards/{uuid}/public-shares`, `DELETE /api/dashboards/{uuid}/public-shares/{id}`, `GET /s/{token}` (`#[PublicPage]`), `POST /s/{token}/unlock` (`#[PublicPage]`) — and register all routes in `appinfo/routes.php` with correct auth attributes +- [ ] Task 7: Add bearer-context detection (token matches `oc_mydash_public_shares.token`) and harden `DashboardService` / `WidgetService` / `PlacementService` mutation paths to throw `ShareReadOnlyException` (HTTP 403) when called from a public-share bearer +- [ ] Task 8: When `renderShareContent()` loads GroupFolder-backed widget content, switch file-read context to the service account (`FolderManagementHandler` impersonation) so anonymous viewers never re-use a user session +- [ ] Task 9: Frontend `src/stores/publicShares.js` with `createShare`, `fetchShares`, `revokeShare` actions plus `unlockedTokens` state; ship `src/views/DashboardPublicShareView.vue` (no login UI, password-unlock modal that persists success in localStorage) +- [ ] Task 10: PHPUnit coverage — mapper (token lookup, active filter, debounce), service (create/unlock/render/expired/revoked), controller (revoke 403 for non-owner, idempotent), guard layer (mutation returns 403 under bearer) +- [ ] Task 11: Playwright coverage — anonymous renders unprotected share; password-protected unlock flow; expired share → 404; revoked share → 404; view-count debounce window (same IP within 60s = 1 increment, 65s apart = 2) +- [ ] Task 12: Quality gates — `composer check:strict`, ESLint+Stylelint clean, SPDX-in-docblock on new PHP, `nl`+`en` i18n for `share_*`/`cannot_modify_public_share`/`unlock_throttled`, OpenAPI/Postman update for the 5 new endpoints, token lookup verified <50ms locally on the indexed query +- [ ] Task 13: Documentation — add the "Sharing dashboards publicly" how-to under `docs/user-guide/` with the API + UI flow and the password/expiry/revoke semantics +- [ ] Task 14: File follow-up issues for deferred work (admin UI for share management, view analytics, token regeneration, email whitelist, hard-delete cleanup job >90d) -## 2. Domain model +## Verification -- [ ] 2.1 Create `lib/Db/PublicShare.php` Entity with all fields as properties + getters/setters (Entity `__call` pattern — no named args on setters) -- [ ] 2.2 Ensure `PublicShare::jsonSerialize()` excludes `passwordHash` (security — never expose hashed password to API responses) -- [ ] 2.3 Ensure `PublicShare` includes `url` computed property (`https://nextcloud.instance/s/{token}`) +`openspec validate` exits clean. All public endpoints round-trip via Playwright; `composer check:strict` green. -## 3. Mapper layer +## Tests (company-wide ADR-009) -- [ ] 3.1 Create `lib/Db/PublicShareMapper.php` extending `QBMapper` with methods: - - `findByToken(string $token): PublicShare` — returns single row or throws `DoesNotExistException` - - `findByDashboardUuid(string $uuid): array` — all shares (active + revoked) for a dashboard - - `findActiveByDashboardUuid(string $uuid): array` — `WHERE revokedAt IS NULL AND (expiresAt IS NULL OR expiresAt > now())` - - `save(PublicShare $share): PublicShare` — insert or update - - `delete(PublicShare $share): void` — hard-delete (used by cleanup job only) - - `softRevoke(int $id): void` — `UPDATE ... SET revokedAt = now() WHERE id = ?` - - `incrementViewCount(int $id, string $ip): void` — check debounce (max once per minute per IP per token), then `UPDATE viewCount, lastViewedAt` -- [ ] 3.2 Debounce logic: store last-seen (token, IP) pair in Redis/APCu (optional) or inline via transaction snapshot; if `lastViewedAt` is within 60 seconds ago, skip increment -- [ ] 3.3 Add fixture-based PHPUnit test covering: active shares, revoked shares (soft-delete), expired shares, debounce logic +PHPUnit + Playwright per Tasks 10–11. Newman/Postman updated for the 5 new public-share endpoints. -## 4. Service layer +## Documentation (company-wide ADR-010) -- [ ] 4.1 Create `lib/Service/PublicShareService.php` with methods: - - `createPublicShare(string $dashboardUuid, ?string $password, ?string $expiresAt): PublicShare` — owner-or-admin guard, validate UUID exists, hash password via `IHasher::hash()`, generate token via `Util::generateSecureRandom(64)`, persist, return entity - - `listActiveShares(string $dashboardUuid): array` — ownership guard, call mapper `findActiveByDashboardUuid()`, return array - - `revokeShare(int $id): void` — owner-or-admin guard (verify share's dashboard is owned), call mapper `softRevoke()`, return success - - `renderShareContent(string $token): array` — PUBLIC method (no auth check), validate token exists and not revoked/expired (throw `ShareExpiredException` or `ShareNotFoundException`), load dashboard and placements, return read-only JSON - - `unlockShare(string $token, string $password): bool` — PUBLIC method, throttle check via `IThrottler` (key format: `public_share_unlock_{token}_{IP}`, allow 10/hour), verify password via `IHasher::verify()`, return bool -- [ ] 4.2 Ownership guard implementation: inject `IUserSession` to get current user, query dashboard to verify `userId` matches OR user is admin -- [ ] 4.3 Add exception classes: `ShareExpiredException`, `ShareNotFoundException`, `SharePasswordRequired`, `ShareReadOnlyException` +Per Task 13 — user guide page plus a changelog entry noting the new public-share capability. -## 5. Controller + routes +## i18n (company-wide ADR-005) -- [ ] 5.1 Create `lib/Controller/PublicShareController.php` extending `Controller` with methods: - - `createPublicShare(string $uuid)` — POST /api/dashboards/{uuid}/public-share, parse `password` and `expiresAt` from request body, call service, return 201 with `{token, url, passwordRequired, expiresAt}` - - `listPublicShares(string $uuid)` — GET /api/dashboards/{uuid}/public-shares, return 200 with array of shares (active only, includes full token and view metrics) - - `revokePublicShare(string $uuid, int $id)` — DELETE /api/dashboards/{uuid}/public-shares/{id}, call service, return 204 - - `renderPublicShare(string $token)` — GET /s/{token}, check query param `?password=` OR header `X-Share-Password`, call service with password, catch `SharePasswordRequired` and return 401 with `{passwordRequired: true}`, catch `ShareExpiredException|ShareNotFoundException` and return 404, return 200 with dashboard JSON - - `unlockPublicShare(string $token)` — POST /s/{token}/unlock, parse `password` from request body, call service, return 200 `{access: true}` on match, return 401 `{access: false}` on mismatch, catch throttle exception and return 503 with `Retry-After` header -- [ ] 5.2 Public routes (no `#[NoAdminRequired]` semantic; manual `#[PublicPage]` or equivalent for GET /s/* endpoints) -- [ ] 5.3 Authenticated routes use standard `#[NoAdminRequired]` (user-scoped, not admin-only by attribute; guard inside service) -- [ ] 5.4 Register all 5 routes in `appinfo/routes.php`: - - `POST /api/dashboards/{uuid}/public-share` — authenticated - - `GET /api/dashboards/{uuid}/public-shares` — authenticated - - `DELETE /api/dashboards/{uuid}/public-shares/{id}` — authenticated - - `GET /s/{token}` — public - - `POST /s/{token}/unlock` — public -- [ ] 5.5 Verify every route carries correct Nextcloud auth attributes - -## 6. Read-only enforcement - -- [ ] 6.1 Create middleware or request context detector to identify if the current request is bearer-only a public-share token (compare against `PublicShare` table if token appears in Authorization header) -- [ ] 6.2 Extend `DashboardService` mutations (update, delete, create placements, etc.) with guard: if request is public-share bearer, throw `ShareReadOnlyException` (403) -- [ ] 6.3 Extend `WidgetService`, `PlacementService` mutations similarly -- [ ] 6.4 PHPUnit test: create public share, attempt `POST /api/dashboard/{uuid}/placements` via bearer token, verify 403 - -## 7. GroupFolder service-account integration - -- [ ] 7.1 When `renderShareContent()` loads dashboard placements, check if any widget references GroupFolder-backed content (consult sibling spec for signal — e.g., widget `source` field) -- [ ] 7.2 If GroupFolder content is detected, switch file-read context to service account: inject `FolderManagementHandler` (or equivalent) and set impersonation context -- [ ] 7.3 PHPUnit test (mocked): public share renders GroupFolder-backed widget without leaking underlying user session - -## 8. Frontend store - -- [ ] 8.1 Create `src/stores/publicShares.js` (Vuex or Pinia) with state: `shares` (map), `unlockedTokens` (set), getters for active shares per dashboard -- [ ] 8.2 Actions: `createShare(uuid, password?, expiresAt?)` → POST call, store token locally -- [ ] 8.3 Actions: `fetchShares(uuid)` → GET call, update state -- [ ] 8.4 Actions: `revokeShare(uuid, id)` → DELETE call, remove from state -- [ ] 8.5 Create `src/views/DashboardPublicShareView.vue` or equivalent — PUBLIC component, no login UI, renders dashboard data (read-only), password unlock modal if needed -- [ ] 8.6 Unlock modal calls `POST /s/{token}/unlock`, caches result in localStorage or session cookie, re-renders on success - -## 9. PHPUnit tests - -- [ ] 9.1 `PublicShareMapperTest::findByToken` — valid token, invalid token (DoesNotExistException), token case-sensitivity -- [ ] 9.2 `PublicShareMapperTest::findActiveByDashboardUuid` — revoked shares filtered, expired shares filtered (by timestamp comparison), active shares included -- [ ] 9.3 `PublicShareMapperTest::incrementViewCount` — debounce logic: same IP within 60s skips increment, different IP increments, beyond 60s resets debounce -- [ ] 9.4 `PublicShareServiceTest::createPublicShare` — password hashing, token generation, response includes `{token, url, passwordRequired, expiresAt}` -- [ ] 9.5 `PublicShareServiceTest::unlockShare` — correct password verifies, wrong password fails, throttle after 10 failures -- [ ] 9.6 `PublicShareServiceTest::renderShareContent` — expired share throws exception, revoked share throws exception, valid share returns dashboard + placements -- [ ] 9.7 `PublicShareControllerTest::revokeShare` — non-owner returns 403, revoke sets `revokedAt`, idempotent on already-revoked -- [ ] 9.8 `DashboardServiceTest` — mutations guarded: public-share bearer on POST /placements returns 403, same on PUT /dashboard/{uuid} - -## 10. End-to-end Playwright tests - -- [ ] 10.1 Dashboard owner creates a public share (no password) via API, anonymous user visits `/s/{token}`, dashboard renders read-only -- [ ] 10.2 Dashboard owner creates password-protected share, anonymous user visits `/s/{token}`, receives 401 with `passwordRequired: true`, POSTs unlock with correct password, gets 200 + dashboard -- [ ] 10.3 Dashboard owner creates share with expiry 1 minute in future, token renders successfully, test waits > 1 minute, token returns 404 -- [ ] 10.4 Dashboard owner revokes a share, anonymous user visits token, gets 404 -- [ ] 10.5 Dashboard owner lists public shares, sees all active shares with correct token, passwordRequired, viewCount, lastViewedAt -- [ ] 10.6 Multiple renders from same IP within 60s increment viewCount once; renders > 60s apart both increment -- [ ] 10.7 View count debounce: user reloads page 5 times in 30s, viewCount increments by 1; waits 65s, reloads again, increments by 1 more - -## 11. Quality gates - -- [ ] 11.1 `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) passes — fix any pre-existing issues encountered along the way -- [ ] 11.2 ESLint + Stylelint clean on all touched Vue/JS files -- [ ] 11.3 SPDX headers on every new PHP file (inside the docblock per the SPDX-in-docblock convention) — gate-spdx must pass -- [ ] 11.4 i18n: add message keys for `share_created`, `share_password_required`, `share_expired`, `share_revoked`, `share_view_count`, `cannot_modify_public_share`, `unlock_throttled` in both `nl` and `en` per the i18n requirement -- [ ] 11.5 Update OpenAPI spec / Postman collection with 5 new endpoints -- [ ] 11.6 Test coverage: all 10 `hydra-gates` passing locally before opening PR -- [ ] 11.7 Performance: public share token lookup (via indexed query on unique `token`) must complete in < 50ms locally -- [ ] 11.8 No hardcoded domain in URLs; use `OCP\Server::get(IURLGenerator::class)->absolute('s/' . $token)` for URL generation - -## 12. Documentation - -- [ ] 12.1 Add public-share workflow to `openspec/specs/dashboards/spec.md` (if deemed necessary, or leave as separate capability spec) -- [ ] 12.2 Add API documentation example (request/response JSON) in `docs/` directory or OpenAPI spec -- [ ] 12.3 Add "Sharing dashboards publicly" how-to guide in user-facing docs - -## 13. Optional follow-up changes (deferred) - -- [ ] 13.1 Admin UI for managing shares from dashboard settings (create, revoke via form instead of API) -- [ ] 13.2 Share analytics dashboard (chart of views over time per share) -- [ ] 13.3 Token regeneration (revoke old, create new with same settings) -- [ ] 13.4 Whitelist-by-email feature (share only with specific email-confirmed users) -- [ ] 13.5 Database cleanup job to hard-delete revoked shares older than 90 days +`nl_NL` + `en_US` for share-related messages enumerated in Task 12. diff --git a/openspec/changes/dashboard-switcher-sidebar/tasks.md b/openspec/changes/dashboard-switcher-sidebar/tasks.md index aaed25c7..74ce7638 100644 --- a/openspec/changes/dashboard-switcher-sidebar/tasks.md +++ b/openspec/changes/dashboard-switcher-sidebar/tasks.md @@ -1,40 +1,33 @@ # Tasks — dashboard-switcher-sidebar -## 1. Component - -- [ ] 1.1 Create `src/components/Workspace/DashboardSwitcherSidebar.vue` with props (`isOpen`, `groupName`, `groupDashboards`, `userDashboards`, `activeDashboardId`, `allowUserDashboards`) and emits (`switch`, `create-dashboard`, `delete-dashboard`, `update:open`) per REQ-SWITCH-001..007 -- [ ] 1.2 Add internal computed `matchedGroupDashboards = groupDashboards.filter(d => d.source !== 'default')` and `defaultGroupDashboards = groupDashboards.filter(d => d.source === 'default')` (REQ-SWITCH-001) -- [ ] 1.3 Use `` for every icon (no inline `v-if="iconUrl"` branches in this template) (REQ-SWITCH-007) -- [ ] 1.4 CSS root: `position: fixed; top: 50px; width: 280px; z-index: 1500; transform: translateX(-100%); transition: transform .25s ease` (REQ-SWITCH-006) -- [ ] 1.5 `&.open` selector toggles `transform: translateX(0)` when `isOpen === true` (REQ-SWITCH-006) -- [ ] 1.6 Personal-row delete button: `display: none` by default, `inline-flex` on row hover; click handler uses `@click.stop` and emits `delete-dashboard(id)` only (REQ-SWITCH-004) -- [ ] 1.7 Active-item highlight: row with `id === activeDashboardId` gets `.active` class with `--color-primary-element-light` background and `--color-primary` icon tint (REQ-SWITCH-003) -- [ ] 1.8 Click on dashboard row emits `update:open(false)` THEN `switch(id, source)` where `source` is derived from the row's section (REQ-SWITCH-002) -- [ ] 1.9 `+ New Dashboard` row only rendered when `allowUserDashboards === true`; click emits `update:open(false)` THEN `create-dashboard()` (REQ-SWITCH-005) -- [ ] 1.10 Add companion `src/components/Workspace/SidebarBackdrop.vue` (click-to-close backdrop) for the runtime shell to wire alongside the sidebar - -## 2. Integration - -- [ ] 2.1 Wire `` from `src/views/WorkspaceApp.vue` (runtime-shell) with `v-model:open="sidebarOpen"` -- [ ] 2.2 Parent maps `@switch` payload `(id, source)` to the correct API endpoint per REQ-DASH-013 (`source === 'user' → personal endpoint`, `'group' → group endpoint`, `'default' → default-group endpoint`) -- [ ] 2.3 Parent handles `@create-dashboard` per REQ-DASH-020 (or current personal-create endpoint) and `@delete-dashboard` per REQ-DASH-005 -- [ ] 2.4 Parent renders `` when `sidebarOpen === true`; clicking the backdrop sets `sidebarOpen = false` - -## 3. Tests - -- [ ] 3.1 Vitest: section visibility table — three sections × empty/non-empty matrix (REQ-SWITCH-001) -- [ ] 3.2 Vitest: emit order on switch — `update:open(false)` MUST be emitted before `switch(id, source)` (REQ-SWITCH-002) -- [ ] 3.3 Vitest: `source` discriminator on switch matches the section the row was rendered in (REQ-SWITCH-002) -- [ ] 3.4 Vitest: `delete-dashboard` does not also emit `switch` or `update:open` (REQ-SWITCH-004) -- [ ] 3.5 Vitest: `+ New Dashboard` row absent from the DOM when `allowUserDashboards: false` (REQ-SWITCH-005) -- [ ] 3.6 Vitest: `.active` class is on exactly the row whose id matches `activeDashboardId`, and updates reactively when the prop changes (REQ-SWITCH-003) -- [ ] 3.7 Playwright: hover reveals delete button on personal items only; group/default items have no delete affordance (REQ-SWITCH-004) -- [ ] 3.8 Playwright: clicking the backdrop or the topbar hamburger closes the sidebar; clicking the sidebar itself does not -- [ ] 3.9 Playwright: open/close animation completes in ~250 ms with `transform: translateX` driving the slide (REQ-SWITCH-006) - -## 4. Quality - -- [ ] 4.1 ESLint + Stylelint clean -- [ ] 4.2 Translation entries present in catalogue: `'Dashboards'`, `'Default'`, `'My Dashboards'`, `'+ New Dashboard'`, `'Delete dashboard'`, `'Close'` -- [ ] 4.3 WCAG: keyboard focus trap inside open sidebar, Esc key closes the sidebar (emits `update:open(false)`) -- [ ] 4.4 WCAG: every actionable row exposes an accessible name; delete button has `aria-label="Delete dashboard"` +## Tasks + +- [ ] Task 1: Create `src/components/Workspace/DashboardSwitcherSidebar.vue` with props (`isOpen`, `groupName`, `groupDashboards`, `userDashboards`, `activeDashboardId`, `allowUserDashboards`) and emits (`switch`, `create-dashboard`, `delete-dashboard`, `update:open`) per REQ-SWITCH-001..007 +- [ ] Task 2: Internal computeds `matchedGroupDashboards = groupDashboards.filter(d => d.source !== 'default')` and `defaultGroupDashboards = groupDashboards.filter(d => d.source === 'default')` (REQ-SWITCH-001) +- [ ] Task 3: Every icon rendered via `` (no inline `v-if="iconUrl"` branches in this template) — REQ-SWITCH-007 +- [ ] Task 4: CSS root: `position: fixed; top: 50px; width: 280px; z-index: 1500; transform: translateX(-100%); transition: transform .25s ease`; `&.open` selector toggles `transform: translateX(0)` when `isOpen === true` (REQ-SWITCH-006) +- [ ] Task 5: Personal-row delete button — `display:none` by default, `inline-flex` on row hover; click handler uses `@click.stop` and emits `delete-dashboard(id)` only (REQ-SWITCH-004) +- [ ] Task 6: Active-item highlight — row with `id === activeDashboardId` gets `.active` class with `--color-primary-element-light` background + `--color-primary` icon tint (REQ-SWITCH-003) +- [ ] Task 7: Dashboard-row click emits `update:open(false)` THEN `switch(id, source)` where `source` is derived from the row's section (REQ-SWITCH-002) +- [ ] Task 8: `+ New Dashboard` row only rendered when `allowUserDashboards === true`; click emits `update:open(false)` THEN `create-dashboard()` (REQ-SWITCH-005) +- [ ] Task 9: Add companion `src/components/Workspace/SidebarBackdrop.vue` (click-to-close backdrop) for the runtime shell to wire alongside the sidebar +- [ ] Task 10: Wire `` from `src/views/WorkspaceApp.vue` with `v-model:open="sidebarOpen"`; parent maps `@switch (id, source)` to the correct API per REQ-DASH-013 (user/group/default-group endpoints), handles `@create-dashboard` (REQ-DASH-020) + `@delete-dashboard` (REQ-DASH-005), and renders `` whose click sets `sidebarOpen = false` +- [ ] Task 11: Vitest — section visibility table (3 sections × empty/non-empty); emit order on switch (`update:open(false)` BEFORE `switch(id, source)`); `source` discriminator matches the section the row was rendered in; `delete-dashboard` does NOT also emit `switch` or `update:open`; `+ New Dashboard` absent when `allowUserDashboards: false`; `.active` class is reactive to `activeDashboardId` changes +- [ ] Task 12: Playwright — hover reveals delete button only on personal items (group/default have no delete affordance); clicking backdrop or topbar hamburger closes the sidebar (clicking the sidebar itself does not); open/close animation completes ~250ms via `transform: translateX` +- [ ] Task 13: Quality + a11y — ESLint + Stylelint clean; translations present (`'Dashboards'`, `'Default'`, `'My Dashboards'`, `'+ New Dashboard'`, `'Delete dashboard'`, `'Close'`); keyboard focus trap inside open sidebar; Esc closes (emits `update:open(false)`); every actionable row has an accessible name; delete button has `aria-label="Delete dashboard"` + +## Verification + +`openspec validate` exits clean. Sidebar opens/closes via keyboard + backdrop; all three sections render correctly and the active row stays highlighted across switches. + +## Tests (company-wide ADR-009) + +Vitest per Task 11; Playwright per Task 12. No backend surface. + +## Documentation (company-wide ADR-010) + +Changelog entry covering the new sidebar component and the parent wiring contract. + +## i18n (company-wide ADR-005) + +`nl_NL` + `en_US` per Task 13. diff --git a/openspec/changes/default-dashboard-flag/tasks.md b/openspec/changes/default-dashboard-flag/tasks.md index b387b487..dc6e2842 100644 --- a/openspec/changes/default-dashboard-flag/tasks.md +++ b/openspec/changes/default-dashboard-flag/tasks.md @@ -1,57 +1,32 @@ # Tasks — default-dashboard-flag -## 1. Domain model +## Tasks -- [ ] 1.1 Confirm `isDefault SMALLINT` already exists on the `Dashboard` entity from the original `admin-templates` change (no schema migration needed) -- [ ] 1.2 Confirm `Dashboard::jsonSerialize()` already emits `isDefault` as an integer (0|1) — add to serialiser if missing +- [ ] Task 1: Confirm `isDefault SMALLINT` already exists on the `Dashboard` entity (no migration) and ensure `jsonSerialize()` emits it as 0|1 — add to the serialiser if missing +- [ ] Task 2: Add `DashboardMapper::clearGroupDefaults($groupId, ?$exceptUuid = null): int` — `UPDATE oc_mydash_dashboards SET isDefault = 0 WHERE type = 'group_shared' AND groupId = ? AND (uuid <> ? OR ? IS NULL)`, returns affected row count +- [ ] Task 3: Add `DashboardMapper::setGroupDefaultUuid($groupId, $uuid): int` — `UPDATE ... SET isDefault = 1 WHERE type='group_shared' AND groupId=? AND uuid=?`, returns 0 when the uuid isn't in the group +- [ ] Task 4: Add `DashboardService::setGroupDefault($groupId, $uuid): void` — admin-only via `IGroupManager::isAdmin`; both mapper calls wrapped in `IDBConnection::beginTransaction()` / `commit()` / `rollBack()`; if `setGroupDefaultUuid` returns 0 throw the HTTP-404 not-found exception and roll back so the existing default is preserved +- [ ] Task 5: Defense-in-depth — `DashboardService::saveGroupShared` and `updateGroupShared` strip any `isDefault` field from incoming payload/patch before persistence (REQ-DASH-017) +- [ ] Task 6: Add `DashboardController::setGroupDefault($groupId)` reading `uuid` from body, mapped to `POST /api/dashboards/group/{groupId}/default`; `#[NoAdminRequired]` + in-body `IGroupManager::isAdmin` check returning 403 on failure; service-layer not-found surfaces as 404; route registered in `appinfo/routes.php` with the same `{groupId}` regex as `multi-scope-dashboards` +- [ ] Task 7: Frontend — add "Set Default" action button to the admin dashboard list row (visible only when `dash.isDefault === 0` AND current user is admin) and a "Default" badge where `isDefault === 1` +- [ ] Task 8: Frontend — optimistic store update on click flips target to `isDefault=1` and all other dashboards in the same `groupId` to 0, calls the API, rolls back both flips on 4xx/5xx, surfaces 403/404 toasts via existing i18n keys +- [ ] Task 9: PHPUnit service coverage — `setGroupDefault` flips others off; cross-group uuid throws not-found and preserves the source-group default; transaction rolls back when the second UPDATE fails +- [ ] Task 10: PHPUnit controller coverage — non-admin gets 403; POST with `isDefault: 1` in body still persists `isDefault=0`; PUT with `isDefault` in patch does not mutate the flag in either direction (REQ-DASH-017) +- [ ] Task 11: Playwright — admin "Set Default" badge moves optimistically and persists on reload; non-admins do not see the button on group-shared rows; two-tab same-admin scenario shows only one badge after reload +- [ ] Task 12: Quality gates — `composer check:strict`, ESLint+Stylelint, OpenAPI/Postman regen for the new endpoint, `nl`+`en` i18n for the new error messages + "Default" badge + "Set Default" label, SPDX-in-docblock on new PHP, all 10 hydra-gates green -## 2. Mapper layer +## Verification -- [ ] 2.1 Add `DashboardMapper::clearGroupDefaults(string $groupId, ?string $exceptUuid = null): int` — issues `UPDATE oc_mydash_dashboards SET isDefault = 0 WHERE type = 'group_shared' AND groupId = ? AND (uuid <> ? OR ? IS NULL)` and returns the row-count affected -- [ ] 2.2 Add `DashboardMapper::setGroupDefaultUuid(string $groupId, string $uuid): int` — issues `UPDATE oc_mydash_dashboards SET isDefault = 1 WHERE type = 'group_shared' AND groupId = ? AND uuid = ?` and returns the row-count affected (0 if uuid not in group) +`openspec validate` exits clean. Setting the default is transactional + admin-only and cross-group/payload-smuggling vectors are closed. -## 3. Service layer +## Tests (company-wide ADR-009) -- [ ] 3.1 Add `DashboardService::setGroupDefault(string $groupId, string $uuid): void` — admin-only via `IGroupManager::isAdmin($currentUserId)` guard; wraps both mapper calls in a single `IDBConnection::beginTransaction()` / `commit()` / `rollBack()` block -- [ ] 3.2 If `setGroupDefaultUuid` returns 0 (uuid not in group), throw the not-found exception that maps to HTTP 404 — DO NOT clear other defaults in that case (the transaction MUST roll back so the existing default is preserved) -- [ ] 3.3 Update `DashboardService::saveGroupShared` to drop any `isDefault` field from the incoming payload before persistence (defense-in-depth against payload smuggling) -- [ ] 3.4 Update `DashboardService::updateGroupShared` to drop any `isDefault` field from the patch before applying it (REQ-DASH-017) +PHPUnit per Tasks 9–10; Playwright per Task 11. Newman/Postman updated for the new endpoint. -## 4. Controller + routes +## Documentation (company-wide ADR-010) -- [ ] 4.1 Add `DashboardController::setGroupDefault(string $groupId)` accepting `uuid` from the request body, mapped to `POST /api/dashboards/group/{groupId}/default` -- [ ] 4.2 Annotate the new method with `#[NoAdminRequired]` and perform the runtime admin check inside the body via `IGroupManager::isAdmin($currentUserId)` (matches the pattern used by `updateGroup`/`deleteGroup` in `multi-scope-dashboards`); HTTP 403 on failure -- [ ] 4.3 Reject when target uuid does not belong to the given groupId — HTTP 404 (delegated to the service-layer exception from task 3.2) -- [ ] 4.4 Register the new route in `appinfo/routes.php` with the same `{groupId}` regex requirement used by the `multi-scope-dashboards` group routes -- [ ] 4.5 Confirm the new method passes `gate-route-auth` and `gate-semantic-auth` +Changelog entry covering the new default-flag transaction semantics and the admin UI affordance. -## 5. Frontend +## i18n (company-wide ADR-005) -- [ ] 5.1 Add "Set Default" action button to the admin dashboard list row — only visible when `dash.isDefault === 0` and the current user is an admin -- [ ] 5.2 Add a "Default" badge to the row where `isDefault === 1` -- [ ] 5.3 Implement optimistic store update on click — set `isDefault=1` on the target and `isDefault=0` on all other dashboards in the same `groupId` immediately, then call the API; rollback both flips on 4xx/5xx -- [ ] 5.4 Surface 403 / 404 error toasts using existing i18n keys - -## 6. PHPUnit tests - -- [ ] 6.1 `DashboardServiceTest::testSetGroupDefaultFlipsOthersOff` — three dashboards in a group, one is default, calling setGroupDefault on a different one moves the flag and clears the previous default -- [ ] 6.2 `DashboardServiceTest::testSetGroupDefaultRejectsCrossGroupUuid` — uuid belongs to group A, called against group B path → throws not-found and existing default in group A is preserved -- [ ] 6.3 `DashboardServiceTest::testSetGroupDefaultIsTransactional` — simulate failure between the two UPDATE calls and assert rollback restores the previous default -- [ ] 6.4 `DashboardControllerTest::testSetGroupDefaultRejectsNonAdmin` — HTTP 403 for non-admin caller -- [ ] 6.5 `DashboardControllerTest::testCreateGroupSharedIgnoresIsDefaultInBody` — POST with `isDefault: 1` in body still results in `isDefault = 0` -- [ ] 6.6 `DashboardControllerTest::testUpdateGroupSharedDoesNotMutateIsDefault` — PUT with `isDefault: 0` on a default dashboard leaves the flag at 1; PUT with `isDefault: 1` on a non-default dashboard leaves it at 0 - -## 7. End-to-end Playwright tests - -- [ ] 7.1 Admin clicks "Set Default" on a non-default group-shared dashboard row — badge moves to that row immediately (optimistic) and persists on reload -- [ ] 7.2 Non-admin user does not see the "Set Default" button on group-shared dashboard rows -- [ ] 7.3 Two browser tabs as the same admin: clicking "Set Default" in tab A is reflected in tab B on next reload (no two badges) - -## 8. Quality gates - -- [ ] 8.1 `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) passes — fix any pre-existing issues encountered along the way -- [ ] 8.2 ESLint + Stylelint clean on touched Vue/JS files -- [ ] 8.3 Update generated OpenAPI spec / Postman collection to include `POST /api/dashboards/group/{groupId}/default` -- [ ] 8.4 i18n keys for new error messages and the "Default" badge / "Set Default" button label exist in both `nl` and `en` -- [ ] 8.5 SPDX headers on every new PHP file (inside the docblock per the SPDX-in-docblock convention) — `gate-spdx` must pass -- [ ] 8.6 Run all 10 `hydra-gates` locally before opening PR +`nl_NL` + `en_US` per Task 12. diff --git a/openspec/changes/group-priority-order/tasks.md b/openspec/changes/group-priority-order/tasks.md index 70476a12..1da508bb 100644 --- a/openspec/changes/group-priority-order/tasks.md +++ b/openspec/changes/group-priority-order/tasks.md @@ -1,42 +1,33 @@ # Tasks — group-priority-order -## 1. Backend — Service & Entity - -- [ ] 1.1 Add constant `AdminSetting::KEY_GROUP_ORDER = 'group_order'` -- [ ] 1.2 Add `AdminSettingsService::getGroupOrder(): array` — `json_decode` the value, return `[]` on null/missing/corrupt JSON, never throw -- [ ] 1.3 Add `AdminSettingsService::setGroupOrder(array $groupIds): void` — validate all elements are non-empty strings, deduplicate (first occurrence wins, preserve order), persist as JSON - -## 2. Backend — Controller & Routes - -- [ ] 2.1 Add `AdminSettingsController::listGroups()` — assemble `{active, inactive, allKnown}` from `IGroupManager::search('')` and `getGroupOrder()`; sort `inactive` by displayName (case-insensitive) -- [ ] 2.2 Add `AdminSettingsController::updateGroupOrder()` — parse body, validate `groups` is an array of strings, return HTTP 400 on validation failure, else call `setGroupOrder` and return HTTP 200 -- [ ] 2.3 Register `GET /api/admin/groups` and `POST /api/admin/groups` in `appinfo/routes.php` -- [ ] 2.4 Both endpoints admin-only — guard via `IGroupManager::isAdmin($userId)` in controller (NOT via `#[NoAdminRequired]` absence alone, because the controller may be shared with other admin-gated methods); return HTTP 403 on non-admin - -## 3. Frontend - -- [ ] 3.1 Two-list drag-and-drop component using existing `vuedraggable` (active vs inactive columns) in `src/views/AdminApp.vue` -- [ ] 3.2 Filter input above each list (case-insensitive substring match on `displayName || id`) -- [ ] 3.3 Auto-save on every drag (`@change` triggers POST), with 300ms debounce to throttle drag-spam -- [ ] 3.4 Toast success/error via `@nextcloud/dialogs` -- [ ] 3.5 Stale ID rendering: append "(removed)" to display name when `id ∉ allKnown` -- [ ] 3.6 i18n: all UI strings in `en` + `nl` translation files (per project i18n requirement) - -## 4. Tests - -- [ ] 4.1 PHPUnit `AdminSettingsServiceTest`: `getGroupOrder` returns `[]` when row absent -- [ ] 4.2 PHPUnit `AdminSettingsServiceTest`: `getGroupOrder` returns `[]` on corrupt JSON without throwing -- [ ] 4.3 PHPUnit `AdminSettingsServiceTest`: `setGroupOrder` deduplicates while preserving order -- [ ] 4.4 PHPUnit `AdminSettingsServiceTest`: `setGroupOrder` rejects non-string elements -- [ ] 4.5 PHPUnit `AdminSettingsControllerTest`: replace-wholesale semantics (POST `["c","b"]` over `["a","b","c"]` → `["c","b"]`) -- [ ] 4.6 PHPUnit `AdminSettingsControllerTest`: 403 on both endpoints for non-admin -- [ ] 4.7 PHPUnit `AdminSettingsControllerTest`: `listGroups` returns disjoint exhaustive lists with stale ID surfacing -- [ ] 4.8 Playwright: drag from inactive → active fires POST and persists across reload -- [ ] 4.9 Playwright: stale ID renders with "(removed)" indicator and is removable - -## 5. Documentation & Quality - -- [ ] 5.1 OpenAPI updated for `GET /api/admin/groups` and `POST /api/admin/groups` -- [ ] 5.2 `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan) -- [ ] 5.3 Frontend lint passes -- [ ] 5.4 Throttling: confirm 300ms debounce is implemented in the Vue layer (per task 3.3) +## Tasks + +- [ ] Task 1: Add `AdminSetting::KEY_GROUP_ORDER = 'group_order'` and `AdminSettingsService::getGroupOrder(): array` — `json_decode` the value, return `[]` on null/missing/corrupt JSON, never throw +- [ ] Task 2: Add `AdminSettingsService::setGroupOrder(array $groupIds): void` — validate all elements are non-empty strings, deduplicate (first occurrence wins, preserve order), persist as JSON +- [ ] Task 3: Add `AdminSettingsController::listGroups()` assembling `{active, inactive, allKnown}` from `IGroupManager::search('')` + `getGroupOrder()`; `inactive` sorted by `displayName` (case-insensitive) +- [ ] Task 4: Add `AdminSettingsController::updateGroupOrder()` — parse body, validate `groups` is array-of-strings, return 400 on invalid payload, else call `setGroupOrder` and return 200 +- [ ] Task 5: Register `GET /api/admin/groups` and `POST /api/admin/groups` in `appinfo/routes.php`; both endpoints admin-only via in-body `IGroupManager::isAdmin($userId)` guard returning 403 for non-admins +- [ ] Task 6: Frontend — two-list drag-and-drop (active vs inactive) in `src/views/AdminApp.vue` using existing `vuedraggable`, with case-insensitive substring filter input above each list (matches `displayName || id`) +- [ ] Task 7: Frontend — auto-save on each drag (`@change` triggers POST) with 300ms debounce to throttle drag-spam; success/error toast via `@nextcloud/dialogs` +- [ ] Task 8: Frontend — stale ID rendering appends "(removed)" to display name when `id ∉ allKnown`, and the row stays removable +- [ ] Task 9: PHPUnit service — `getGroupOrder` returns `[]` when row absent + on corrupt JSON without throwing; `setGroupOrder` deduplicates preserving order + rejects non-string elements +- [ ] Task 10: PHPUnit controller — replace-wholesale semantics (POST `["c","b"]` over `["a","b","c"]` → `["c","b"]`); 403 on both endpoints for non-admin; `listGroups` returns disjoint exhaustive lists with stale-ID surfacing +- [ ] Task 11: Playwright — drag from inactive → active fires POST and persists across reload; stale ID renders with "(removed)" indicator and is removable +- [ ] Task 12: Quality + docs — OpenAPI updated for both endpoints; `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan); frontend lint passes; confirm the 300ms debounce is implemented per Task 7 +- [ ] Task 13: i18n — `nl_NL` + `en_US` for all new UI strings (filter placeholder, "(removed)" indicator, toast copy) + +## Verification + +`openspec validate` exits clean. Drag reorders persist across reload; stale IDs render with the "(removed)" affordance. + +## Tests (company-wide ADR-009) + +PHPUnit per Tasks 9–10; Playwright per Task 11. Newman/Postman updated with both endpoints (Task 12). + +## Documentation (company-wide ADR-010) + +Changelog entry for the new admin priority-order surface. + +## i18n (company-wide ADR-005) + +`nl_NL` + `en_US` per Task 13. diff --git a/openspec/changes/groupfolder-storage-backend/tasks.md b/openspec/changes/groupfolder-storage-backend/tasks.md index d6ba1ef3..e2aaf555 100644 --- a/openspec/changes/groupfolder-storage-backend/tasks.md +++ b/openspec/changes/groupfolder-storage-backend/tasks.md @@ -1,121 +1,34 @@ # Tasks — groupfolder-storage-backend -## 1. Core storage interface +## Tasks -- [ ] 1.1 Create `lib/Service/DashboardContentStorage/DashboardContentStorageInterface.php` with methods: `read(string $dashboardUuid): array`, `write(string $dashboardUuid, array $content): void`, `delete(string $dashboardUuid): void`, `exists(string $dashboardUuid): bool` -- [ ] 1.2 Each method throws `DashboardContentStorageException` on I/O failure with a descriptive message -- [ ] 1.3 The `read()` method returns the dashboard content object (widgets array, layout metadata, etc.) as a parsed PHP array; if file does not exist, throw `DashboardNotFoundException` (extend `DashboardContentStorageException`) -- [ ] 1.4 The `write()` method accepts the content array and persists it; it MUST be idempotent (overwriting an existing file is safe) -- [ ] 1.5 All methods include PHPDoc with type hints and exception docs +- [ ] Task 1: Define `lib/Service/DashboardContentStorage/DashboardContentStorageInterface.php` with `read`, `write`, `delete`, `exists` methods and supporting exception hierarchy (`DashboardContentStorageException`, `DashboardNotFoundException`, `GroupFoldersNotInstalledException`) +- [ ] Task 2: Implement `DbContentStorage` against the existing `DashboardMapper` (reads/writes the entity `content` field, idempotent `write`, soft `delete`) +- [ ] Task 3: Implement `GroupFolderContentStorage` against `IRootFolder`/`IGroupManager`/`IAppManager` with `ensureMyDashGroupFolder()` bootstrap, `resolvePath()` (`MyDash//.json`), and 503-wrapping for all I/O failures +- [ ] Task 4: Implement `DashboardContentStorageFactory::getStorage()` reading the `mydash.content_storage` admin setting (`db` default, `groupfolder` opt-in) +- [ ] Task 5: Wire `DashboardContentStorageFactory` into `DashboardService` so `get/create/update/delete` route through the active backend; catch storage exceptions and rethrow with user-friendly messages +- [ ] Task 6: Update `lib/Db/Dashboard.php` to add the optional `locale` property and document the now-optional `content` column; keep `jsonSerialize()` returning `content` +- [ ] Task 7: Register `mydash.content_storage` as an admin setting (enum `db|groupfolder`, default `db`) with GET/POST validation returning 400 on invalid values +- [ ] Task 8: Set restrictive ACL on the auto-created `MyDash` GroupFolder (admins full, all others denied; per-dashboard ACL stays in the API layer) and document the layout in code comments +- [ ] Task 9: Ship `lib/Command/MigrateStorageToGroupFolder` (idempotent DB→GroupFolder copy, skips entries already in GroupFolder, verbose/quiet options) registered via bootstrap +- [ ] Task 10: Ship `lib/Command/ToggleStorageSetting` (`mydash:storage:toggle-backend {db|groupfolder}`) with a warning when switching back to `db` about not auto-copying GroupFolder data +- [ ] Task 11: Controller layer catches `DashboardContentStorageException` and returns HTTP 503 with `{"error":"dashboard_content_storage_unavailable", ...}`; never silently falls back to DB +- [ ] Task 12: PHPUnit coverage across the new storage layer (interface, both implementations, factory, service integration, migration command, controller failure-path) +- [ ] Task 13: Playwright + integration coverage — create dashboard via API on `groupfolder` backend (file appears in GroupFolder), then run migration command and confirm reads still resolve via the API +- [ ] Task 14: Quality gates — `composer check:strict`, SPDX headers in-docblock on new PHP files, `nl`+`en` i18n for new error/CLI strings, OpenAPI/Postman regen if error responses are documented -## 2. Database storage implementation +## Verification -- [ ] 2.1 Create `lib/Service/DashboardContentStorage/DbContentStorage.php` implementing `DashboardContentStorageInterface` -- [ ] 2.2 Inject `DashboardMapper` and `DashboardFactory` in the constructor -- [ ] 2.3 `read()` method: fetch the dashboard entity via mapper, extract the content field (deserialised JSON), return as array; throw `DashboardNotFoundException` if mapper throws DoesNotExistException -- [ ] 2.4 `write()` method: fetch the existing dashboard, update its content field, call `mapper->update()` -- [ ] 2.5 `delete()` method: fetch dashboard, set content to `null` or empty array, call `mapper->update()` -- [ ] 2.6 `exists()` method: attempt to fetch via mapper, return boolean (no exception thrown if not found) -- [ ] 2.7 Add PHPUnit tests for DbContentStorage: read existing, read non-existent, write, delete, exists checks +`openspec validate` exits clean. Storage layer hits ≥85% line coverage via PHPUnit; Playwright migration scenario passes on the local dev container. -## 3. GroupFolder storage implementation +## Tests (company-wide ADR-009) -- [ ] 3.1 Create `lib/Service/DashboardContentStorage/GroupFolderContentStorage.php` implementing `DashboardContentStorageInterface` -- [ ] 3.2 Inject `IRootFolder`, `IGroupManager`, `IAppManager` (to check if `groupfolders` is installed), `ILogger` in the constructor -- [ ] 3.3 Constructor MUST validate that the `groupfolders` app is installed; if not, throw `GroupFoldersNotInstalledException` (extend `DashboardContentStorageException`) -- [ ] 3.4 Add private method `ensureMyDashGroupFolder(): int` — creates or fetches the "MyDash" GroupFolder, returns its folder ID; if creation fails, log warning and throw exception -- [ ] 3.5 Add private method `resolvePath(string $dashboardUuid, ?string $locale = null): string` returning `MyDash//.json` (e.g., `MyDash/nl/abc123.json` or `MyDash/abc123.json` if no locale) -- [ ] 3.6 `read()` method: call `ensureMyDashGroupFolder()`, navigate via `IRootFolder` to the resolved file path, read and `json_decode()` the content, return array; throw `DashboardNotFoundException` if file not found -- [ ] 3.7 `write()` method: call `ensureMyDashGroupFolder()`, create parent directories if needed, write `json_encode($content)` to the resolved path, return (no exception on overwrite) -- [ ] 3.8 `delete()` method: call `ensureMyDashGroupFolder()`, delete the file at the resolved path; do not throw if file does not exist (idempotent) -- [ ] 3.9 `exists()` method: attempt to open file at resolved path, return boolean (no exception) -- [ ] 3.10 All I/O errors (permissions, disk full, etc.) MUST be caught and wrapped in `DashboardContentStorageException` with HTTP 503 status hint -- [ ] 3.11 Add PHPUnit tests for GroupFolderContentStorage: read existing, read non-existent, write, delete, exists; test with and without locale; test groupfolder creation +PHPUnit per Task 12; Playwright per Task 13. No new REST endpoints (storage is internal); existing dashboard CRUD endpoints get failure-path coverage via Task 11. -## 4. Storage factory and dependency injection +## Documentation (company-wide ADR-010) -- [ ] 4.1 Create `lib/Service/DashboardContentStorage/DashboardContentStorageFactory.php` with method `getStorage(): DashboardContentStorageInterface` -- [ ] 4.2 Inject `IConfig` (for admin settings) and both implementations in the constructor -- [ ] 4.3 `getStorage()` method reads the `mydash.content_storage` admin setting; returns `DbContentStorage` if `db` (or unset), `GroupFolderContentStorage` if `groupfolder` -- [ ] 4.4 Add PHPUnit test for factory: verify correct implementation returned based on setting value +Add the "Storage Backend" admin guide section (db vs groupfolder, ACL, migration workflow, CLI commands) and a changelog entry noting the opt-in nature. -## 5. Domain model updates +## i18n (company-wide ADR-005) -- [ ] 5.1 Update `lib/Db/Dashboard.php` entity — keep the `content` field (column) for backward compatibility, but document that it may be unused if GroupFolder backend is active -- [ ] 5.2 Add optional GUID parameter `locale` to the entity for multi-language support; default to empty string -- [ ] 5.3 Update `jsonSerialize()` to always include the `content` field in the response (the storage layer reads it; the API client sees it regardless of backend) -- [ ] 5.4 No database schema changes needed for the entity itself - -## 6. Service layer integration - -- [ ] 6.1 Update `lib/Service/DashboardService.php` — inject `DashboardContentStorageFactory` in the constructor -- [ ] 6.2 Refactor `getDashboard($uuid)` to: call `dashboardMapper->findByUuid()` to get the entity, then call `getStorage()->read($uuid)` to fetch content, merge into response object -- [ ] 6.3 Refactor `createDashboard()` to: create entity via factory, call `getStorage()->write()` with the initial widget tree, then persist the entity -- [ ] 6.4 Refactor `updateDashboard($uuid, $patch)` to: fetch entity, call `getStorage()->write()` with the merged content, update entity metadata (name, description) via mapper -- [ ] 6.5 Refactor `deleteDashboard($uuid)` to: call `getStorage()->delete($uuid)` first, then delete the entity via mapper -- [ ] 6.6 Catch `DashboardContentStorageException` in all methods and re-throw as `\Exception` with a user-friendly message; include the underlying error in logs -- [ ] 6.7 Add PHPUnit tests for DashboardService integration: verify it calls the correct storage backend based on factory output - -## 7. Migration command - -- [ ] 7.1 Create `lib/Command/MigrateStorageToGroupFolder.php` extending `Command` -- [ ] 7.2 Register in `appinfo/info.xml` or bootstrap (depending on app structure) as a console command -- [ ] 7.3 Command MUST: - - [ ] 7.3.1 Query all dashboards from DB via `DashboardMapper->findAll()` - - [ ] 7.3.2 For each dashboard: read its content from DB, write to GroupFolder via `GroupFolderContentStorage`, delete from DB (optional; see 7.4 below) - - [ ] 7.3.3 Skip dashboards already in GroupFolder (check via `exists()` on GroupFolder storage) - - [ ] 7.3.4 Log progress and any errors - - [ ] 7.3.5 Return exit code 0 on success, non-zero on error -- [ ] 7.4 Decide on retention: EITHER delete DB content after migration (recommended for cleanup) OR leave it in place for safety. Document the choice. -- [ ] 7.5 Make the command idempotent — re-running it MUST not cause errors or data loss -- [ ] 7.6 Add output options (verbose, quiet) for automation-friendly logging -- [ ] 7.7 PHPUnit test: mock both storages, verify migration copies all records and skips duplicates - -## 8. Error handling and failover - -- [ ] 8.1 Create exception hierarchy: `DashboardContentStorageException` (base), `DashboardNotFoundException`, `GroupFoldersNotInstalledException` extending it -- [ ] 8.2 In the controller (DashboardController), catch `DashboardContentStorageException` and return HTTP 503 with a JSON error body: `{"error": "dashboard_content_storage_unavailable", "message": "The dashboard content store is unreachable. Please contact your administrator."}` -- [ ] 8.3 Log all storage exceptions at WARN level with full context (dashboard UUID, storage type, underlying error) -- [ ] 8.4 NEVER silently fall back from GroupFolder to DB — fail closed with an explicit error -- [ ] 8.5 PHPUnit test: mock storage to throw exception, verify controller returns 503 - -## 9. Admin settings integration - -- [ ] 9.1 Update `lib/Db/AdminSetting.php` (or equivalent admin settings entity) to include `mydash.content_storage` as a recognized setting key -- [ ] 9.2 Add to `admin-settings` spec: `mydash.content_storage` with type `string`, enum values `["db", "groupfolder"]`, default `"db"` -- [ ] 9.3 Update the admin settings controller to include this setting in GET/POST responses -- [ ] 9.4 Add validation: POST to the admin settings endpoint with invalid `mydash.content_storage` value returns HTTP 400 -- [ ] 9.5 PHPUnit test: retrieve, update, and validate the setting - -## 10. GroupFolder ACL and auto-creation - -- [ ] 10.1 When the GroupFolder is first created, set ACL rules: - - [ ] 10.1.1 Administrators: full access (read, write, delete) - - [ ] 10.1.2 All other users: no default access (ACL is restrictive by default) - - [ ] 10.1.3 Dashboard access is mediated by the API layer (the storage layer does not enforce per-dashboard ACL — that is the responsibility of the dashboard permission layer) -- [ ] 10.2 Document the GroupFolder creation process in a README or inline comment so operators understand the structure -- [ ] 10.3 PHPUnit test: verify GroupFolder creation includes correct ACL rules - -## 11. CLI helper command (optional) - -- [ ] 11.1 Create `lib/Command/ToggleStorageSetting.php` to allow admins to change the setting via CLI -- [ ] 11.2 Command signature: `mydash:storage:toggle-backend {db|groupfolder}` -- [ ] 11.3 Validate the argument, update the setting, confirm to the user -- [ ] 11.4 Output a warning if switching away from `groupfolder`: "Note: dashboards already in the GroupFolder will not be automatically copied back to the DB" - -## 12. Quality gates and testing - -- [ ] 12.1 `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) passes — fix any pre-existing issues encountered -- [ ] 12.2 All new PHP files include SPDX headers inside the docblock (per SPDX-in-docblock convention) -- [ ] 12.3 PHPUnit test coverage: aim for 85%+ on the storage layer (interface, implementations, factory) -- [ ] 12.4 E2E test (Playwright): create a dashboard via API, verify it appears in GroupFolder when backend is `groupfolder`; switch backend to `db` and verify fallback read still works -- [ ] 12.5 Integration test: run migration command, verify all dashboards are copied and the API still reads them correctly -- [ ] 12.6 i18n: all error messages and CLI output in both `nl` and `en` (error keys: `dashboard_content_storage_unavailable`, `groupfolder_not_installed`, etc.) -- [ ] 12.7 Update OpenAPI spec / Postman collection if it documents error responses - -## 13. Documentation - -- [ ] 13.1 Add a "Storage Backend" section to the MyDash admin documentation explaining the two backends, when to use each, and the migration process -- [ ] 13.2 Include example GroupFolder structure and ACL rules -- [ ] 13.3 Document the CLI commands and their options -- [ ] 13.4 Add a changelog entry noting the new capability, the default behaviour (unchanged), and the opt-in migration path +`nl_NL` + `en_US` for the new admin-facing strings: `dashboard_content_storage_unavailable`, `groupfolder_not_installed`, CLI output for the migration + toggle commands. diff --git a/openspec/changes/image-widget/tasks.md b/openspec/changes/image-widget/tasks.md index 463c962d..79a66651 100644 --- a/openspec/changes/image-widget/tasks.md +++ b/openspec/changes/image-widget/tasks.md @@ -1,46 +1,33 @@ # Tasks — image-widget -## 1. Renderer - -- [ ] 1.1 Create `src/components/Widgets/Renderers/ImageWidget.vue` with props `content` and `placement` and the persisted shape `{url, alt, link, fit}` -- [ ] 1.2 Add a Vue prop validator on `fit` restricting values to `['cover','contain','fill','none']` with fallback to `'cover'` on unknown input (warns via `console.warn` per Vue's standard validator behaviour) -- [ ] 1.3 Render `` with `width: 100%`, `height: 100%`, and inline `object-fit` bound to `fit` (REQ-IMG-001) -- [ ] 1.4 Set `overflow: hidden` on the cell wrapper so over-fit images do not bleed out -- [ ] 1.5 Implement empty-URL placeholder: 48 px CameraIcon + `t('No image')`, centred, in `var(--color-text-maxcontrast)` (REQ-IMG-002) -- [ ] 1.6 Add `@error="onImageError"` on `` that swaps to placeholder + `t('Image failed to load')` and ensures no exception bubbles to the GridStack grid (REQ-IMG-004) -- [ ] 1.7 Bind `cursor: pointer` on the cell wrapper only when `link` is non-empty (REQ-IMG-003) -- [ ] 1.8 Wire click handler: `window.open(link, '_blank', 'noopener,noreferrer')` when `link` is non-empty, no-op otherwise (REQ-IMG-003) - -## 2. Form - -- [ ] 2.1 Create `src/components/Widgets/Forms/ImageForm.vue` with file input, URL input, alt input, link input, and fit select -- [ ] 2.2 Implement upload pipeline: file → `FileReader.readAsDataURL` → POST `/api/resources` (resource-uploads) → on success set `form.url` from response `{url}` (REQ-IMG-005) -- [ ] 2.3 Display live preview `` thumbnail below the URL input whenever `url` is non-empty -- [ ] 2.4 Implement `validate()` returning `[t('Image URL is required')]` when `form.url.trim() === ''` -- [ ] 2.5 Display inline error `t('Failed to upload image')` under the upload input when the resource-uploads POST fails; leave `form.url` unchanged -- [ ] 2.6 Wire fit `` (4 options, default `cover` for new placements); live preview `` thumbnail below the URL input whenever `url` is non-empty +- [ ] Task 7: Upload pipeline — file → `FileReader.readAsDataURL` → `POST /api/resources` → on success set `form.url` from response `{url}`; on failure surface inline `t('Failed to upload image')` under the upload input and leave `form.url` unchanged (REQ-IMG-005) +- [ ] Task 8: Form `validate()` returns `[t('Image URL is required')]` when `form.url.trim() === ''` +- [ ] Task 9: Register `image` in `src/constants/widgetRegistry.js` with defaults `{url:'', alt:'', link:'', fit:'cover'}`, mapped to the new renderer + form and a label `t('Image')` +- [ ] Task 10: Vitest renderer — `object-fit` inline style equals `fit`; prop validator falls back from unknown values (e.g. `'stretch'`); cell `cursor` toggles correctly with `link`; `` error event swaps in the placeholder +- [ ] Task 11: Vitest form — `validate()` reports the required-URL message on empty/whitespace; upload-error path surfaces the inline message and leaves `form.url` untouched +- [ ] Task 12: Playwright — upload an image → preview appears → save → reload → image still visible on the cell; external URL with click-through opens in a new tab with `noopener,noreferrer`; empty-URL cell shows the camera placeholder and does NOT respond to clicks +- [ ] Task 13: Quality + i18n — ESLint + Stylelint clean; SPDX-in-docblock on every new file; `nl_NL` + `en_US` translations for `Image`, `No image`, `Image failed to load`, `Upload Image`, `Or enter Image URL`, `Alt Text`, `Link (optional)`, `Fit`, `Cover`, `Contain`, `Fill`, `None`, `Failed to upload image`, `Image URL is required`; confirm no backend route beyond `resource-uploads` + +## Verification + +`openspec validate` exits clean. Renderer + form round-trip through edit mode with persistence, and broken image URLs degrade to the placeholder without GridStack errors. + +## Tests (company-wide ADR-009) + +Vitest per Tasks 10–11; Playwright per Task 12. No new backend surface. + +## Documentation (company-wide ADR-010) + +Changelog entry covering the new widget type; user-guide screenshot of the image-fit options. + +## i18n (company-wide ADR-005) + +`nl_NL` + `en_US` per Task 13. diff --git a/openspec/changes/initial-state-contract/tasks.md b/openspec/changes/initial-state-contract/tasks.md index 0f521e73..14fe8816 100644 --- a/openspec/changes/initial-state-contract/tasks.md +++ b/openspec/changes/initial-state-contract/tasks.md @@ -1,41 +1,34 @@ # Tasks — initial-state-contract -## 1. Backend — InitialStateBuilder service +## Tasks -- [ ] 1.1 Create `lib/Service/InitialStateBuilder.php` with a `Page` enum (`WORKSPACE`, `ADMIN`) and a constructor accepting `IInitialState` + `Page` -- [ ] 1.2 Add typed setter methods per page key (`setWidgets`, `setLayout`, `setPrimaryGroup`, `setPrimaryGroupName`, `setIsAdmin`, `setActiveDashboardId`, `setDashboardSource`, `setGroupDashboards`, `setUserDashboards`, `setAllowUserDashboards`, `setAllGroups`, `setConfiguredGroups`) -- [ ] 1.3 Implement `apply(): void` that validates required keys per page and throws `MissingInitialStateException` (new class under `lib/Exception/`) naming the missing key -- [ ] 1.4 Add `INITIAL_STATE_SCHEMA_VERSION = 1` constant; push it under key `_schemaVersion` in `apply()` -- [ ] 1.5 Document the contract in the class docblock with link to REQ-INIT-002 +- [ ] Task 1: Create `lib/Service/InitialStateBuilder.php` with a `Page` enum (`WORKSPACE`, `ADMIN`), constructor accepting `IInitialState` + `Page`, and typed setter methods per page key (`setWidgets`, `setLayout`, `setPrimaryGroup`, `setPrimaryGroupName`, `setIsAdmin`, `setActiveDashboardId`, `setDashboardSource`, `setGroupDashboards`, `setUserDashboards`, `setAllowUserDashboards`, `setAllGroups`, `setConfiguredGroups`) +- [ ] Task 2: Implement `apply(): void` that validates required keys per page and throws `MissingInitialStateException` (new class under `lib/Exception/`) naming the missing key +- [ ] Task 3: Add `INITIAL_STATE_SCHEMA_VERSION = 1` constant; push it under key `_schemaVersion` in `apply()`; document the contract in the class docblock with a link to REQ-INIT-002 +- [ ] Task 4: Refactor `lib/Controller/WorkspaceController::index` to construct `InitialStateBuilder(Page::WORKSPACE)`, call all setters, then `apply()` +- [ ] Task 5: Refactor `lib/Settings/Admin/AdminSettings::getForm` to construct `InitialStateBuilder(Page::ADMIN)`, call all setters, then `apply()` +- [ ] Task 6: Add a CI lint task (shell or PHPUnit) that greps `provideInitialState` outside `lib/Service/InitialStateBuilder.php` and fails the build if found +- [ ] Task 7: Create `src/utils/loadInitialState.js` exporting `loadInitialState(page)` with per-page key/default tables mirroring REQ-INIT-002; include an `INITIAL_STATE_SCHEMA_VERSION = 1` constant that must equal the PHP value and emit a console warning when received `_schemaVersion` mismatches +- [ ] Task 8: Refactor `src/main.js` (workspace entry) to call `loadInitialState('workspace')` and `app.provide(key, value)` for every key +- [ ] Task 9: Refactor `src/admin.js` (admin entry) to call `loadInitialState('admin')` and `app.provide(key, value)` for every key +- [ ] Task 10: Add a CI lint task (shell or Vitest) that greps `loadState\(['"]mydash['"]` outside `src/utils/loadInitialState.js` and fails the build if found +- [ ] Task 11: PHPUnit — builder rejects missing required keys for each page; builder writes all keys with correct values via a stub `IInitialState`; `_schemaVersion` key is always pushed +- [ ] Task 12: Vitest — reader fills defaults for missing keys (mock `loadState`); reader logs a warning on schema-version mismatch; provide/inject pipe-through works for every workspace key; mutating a component clone of an injected value does not affect siblings (REQ-INIT-005) +- [ ] Task 13: Wire the CI lint pair (PHP grep + JS grep) into the workflow +- [ ] Task 14: Quality — `composer check:strict` passes; ESLint clean; class docblock on `InitialStateBuilder` links REQ-INIT-002 and lists all keys for each page; changelog note describing the new contract and how to add a key (spec update + version bump + reader/builder update in the same commit) -## 2. Backend — Controller refactor +## Verification -- [ ] 2.1 Refactor `lib/Controller/WorkspaceController::index` to construct `InitialStateBuilder(Page::WORKSPACE)`, call all setters, then `apply()` -- [ ] 2.2 Refactor `lib/Settings/Admin/AdminSettings::getForm` to construct `InitialStateBuilder(Page::ADMIN)`, call all setters, then `apply()` -- [ ] 2.3 Add a CI lint task (shell or PHPUnit) that greps `provideInitialState` outside `lib/Service/InitialStateBuilder.php` and fails the build if found +`openspec validate` exits clean. Both lint guards fail loudly on stray `provideInitialState` / `loadState('mydash')` calls outside the canonical files. -## 3. Frontend — JS reader +## Tests (company-wide ADR-009) -- [ ] 3.1 Create `src/utils/loadInitialState.js` exporting `loadInitialState(page)`; declare per-page key/default tables that mirror REQ-INIT-002 -- [ ] 3.2 Add `INITIAL_STATE_SCHEMA_VERSION = 1` constant in the reader (must equal the PHP value); compare against received `_schemaVersion` and emit a console warning on mismatch -- [ ] 3.3 Refactor `src/main.js` (workspace entry) to call `loadInitialState('workspace')` and `app.provide(key, value)` for every key -- [ ] 3.4 Refactor `src/admin.js` (admin entry) to call `loadInitialState('admin')` and `app.provide(key, value)` for every key -- [ ] 3.5 Add a CI lint task (shell or Vitest) that greps `loadState\(['"]mydash['"]` outside `src/utils/loadInitialState.js` and fails the build if found +PHPUnit per Task 11; Vitest per Task 12. No new endpoint surface. -## 4. Tests +## Documentation (company-wide ADR-010) -- [ ] 4.1 PHPUnit: builder rejects missing required keys for each page (one test per page) -- [ ] 4.2 PHPUnit: builder writes all keys with correct values via a stub `IInitialState` -- [ ] 4.3 PHPUnit: schema version key `_schemaVersion` is always pushed -- [ ] 4.4 Vitest: reader fills defaults for missing keys (mock `loadState`) -- [ ] 4.5 Vitest: reader logs warning on schema version mismatch -- [ ] 4.6 Vitest: provide/inject pipe-through works for every workspace key -- [ ] 4.7 Vitest: mutating a component clone of an injected value does not affect siblings (REQ-INIT-005) -- [ ] 4.8 CI lint pair (PHP grep + JS grep) wired into the workflow +Changelog entry per Task 14 plus the inline docblock contract. -## 5. Quality +## i18n (company-wide ADR-005) -- [ ] 5.1 `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan) -- [ ] 5.2 ESLint clean -- [ ] 5.3 Class docblock on `InitialStateBuilder` links to REQ-INIT-002 and lists all keys for each page -- [ ] 5.4 Add a changelog note describing the new contract and how to add a key (spec update + version bump + reader/builder update in same commit) +No user-facing strings added — initial-state plumbing is contract-only. diff --git a/openspec/changes/label-widget/tasks.md b/openspec/changes/label-widget/tasks.md index 67063710..6fee2b86 100644 --- a/openspec/changes/label-widget/tasks.md +++ b/openspec/changes/label-widget/tasks.md @@ -1,53 +1,32 @@ # Tasks — label-widget -## 1. Renderer component +## Tasks -- [ ] 1.1 Create `src/components/Widgets/Renderers/LabelWidget.vue` with a `
{{ displayText }}
` template — interpolation only, never `v-html` -- [ ] 1.2 Implement `displayText` computed: returns `content.text` when `text.trim() !== ''`, else returns `t('mydash', 'Label')` -- [ ] 1.3 Implement `wrapperStyle` computed returning `width:100%; height:100%; padding:12px; display:flex; align-items:center; justify-content:center; background-color: ` -- [ ] 1.4 Implement `spanStyle` computed returning `font-size; font-weight; text-align; color; overflow-wrap: break-word`, falling back to defaults per REQ-LBL-002 when fields are missing -- [ ] 1.5 Default `color` MUST resolve to `var(--color-main-text)` so theming works in both light and dark mode -- [ ] 1.6 Add component-scoped CSS for `overflow-wrap: break-word` as a safety net even if inline style is overridden +- [ ] Task 1: Create `src/components/Widgets/Renderers/LabelWidget.vue` with a `
{{ displayText }}
` template — interpolation only, NEVER `v-html` +- [ ] Task 2: Implement `displayText` computed returning `content.text` when `text.trim() !== ''`, otherwise the localised fallback `t('mydash', 'Label')` +- [ ] Task 3: Implement `wrapperStyle` (`width:100%; height:100%; padding:12px; display:flex; align-items:center; justify-content:center; background-color: `) and `spanStyle` (`font-size; font-weight; text-align; color; overflow-wrap: break-word`) with REQ-LBL-002 defaults; `color` default resolves to `var(--color-main-text)` so theming works light/dark +- [ ] Task 4: Component-scoped CSS includes `overflow-wrap: break-word` as a safety net even when inline style is overridden +- [ ] Task 5: Create `src/components/Widgets/Forms/LabelForm.vue` with the six REQ-LBL-005 controls (text input, fontSize text, color picker, backgroundColor picker, fontWeight ``); pre-fill every control from `editingWidget.content` in edit mode and emit `update:content` on every input +- [ ] Task 6: Implement form `validate()` returning `[t('mydash', 'Label text is required')]` when `text.trim() === ''`, otherwise `[]`; use translation keys `Font Weight` and `Alignment` for the two select labels +- [ ] Task 7: Register `label` in `src/constants/widgetRegistry.js` with `renderer: LabelWidget`, `form: LabelForm`, `defaultContent: {text:'', fontSize:'16px', color:'', backgroundColor:'', fontWeight:'bold', textAlign:'center'}`, icon + `displayName: t('mydash','Label')`, and verify the type is distinct from `text` in the picker +- [ ] Task 8: Translations — add `Label`, `Label text is required`, `Font Weight`, `Alignment` to `src/l10n/en.json`; Dutch equivalents (`Label`, `Labeltekst is verplicht`, `Letterdikte`, `Uitlijning`) to `src/l10n/nl.json` +- [ ] Task 9: Vitest renderer — HTML in `text` appears as literal text (no `` element in DOM — REQ-LBL-001); defaults applied to `{text:'Hi'}` set `font-size:16px`, `font-weight:bold`, `text-align:center` (REQ-LBL-002); long single word wraps in a narrow container (REQ-LBL-003); empty text shows the `t('Label')` fallback (REQ-LBL-004) +- [ ] Task 10: Vitest form + registry — `validate()` reports the required-text message correctly; pre-fills all six controls from `editingWidget.content`; importing `widgetRegistry.js` exposes a `label` entry with the correct `defaultContent` (REQ-LBL-007) +- [ ] Task 11: Playwright (`tests/e2e/label-widget.spec.ts`) — open Add Widget modal → pick Label → fill text + change fontSize to `24px` + change colour → save → reopen in edit mode and confirm all six fields round-trip; pasting `HTML` renders as literal text on the dashboard +- [ ] Task 12: Quality — ESLint clean on the two new `.vue` files + modified `widgetRegistry.js`; `npm run build` succeeds with no warnings; no new browser console errors on render/edit/remove; manual smoke test in `nldesign` theme confirms the default colour resolves correctly in light + dark mode -## 2. Form component +## Verification -- [ ] 2.1 Create `src/components/Widgets/Forms/LabelForm.vue` with the six controls listed in REQ-LBL-005 (text input, fontSize text input, color picker, backgroundColor picker, fontWeight select, textAlign select) -- [ ] 2.2 Pre-fill every control from `editingWidget.content` when in edit mode (per REQ-LBL-005) -- [ ] 2.3 Implement `validate()` returning `[t('mydash', 'Label text is required')]` when `text.trim() === ''`, otherwise an empty array -- [ ] 2.4 Wire form input events to `$emit('update:content', {...})` so the parent modal sees live changes -- [ ] 2.5 Use translation keys `Font Weight` and `Alignment` for the two select labels +`openspec validate` exits clean. Label rendering remains XSS-safe (literal text); add/edit round-trip preserves all six controls. -## 3. Widget registry +## Tests (company-wide ADR-009) -- [ ] 3.1 Add a `label` entry to `src/constants/widgetRegistry.js` with `renderer: LabelWidget`, `form: LabelForm` -- [ ] 3.2 Set `defaultContent: {text: '', fontSize: '16px', color: '', backgroundColor: '', fontWeight: 'bold', textAlign: 'center'}` -- [ ] 3.3 Add an icon and `displayName: t('mydash', 'Label')` so the type appears as a selectable option in the Add Widget modal -- [ ] 3.4 Verify the `label` type is distinct from `text` in the modal's type-picker UI +Vitest per Tasks 9–10; Playwright per Task 11. No backend surface. -## 4. Translations +## Documentation (company-wide ADR-010) -- [ ] 4.1 Add the four English keys to `src/l10n/en.json`: `Label`, `Label text is required`, `Font Weight`, `Alignment` -- [ ] 4.2 Add the four Dutch translations to `src/l10n/nl.json`: `Label` → `Label`, `Label text is required` → `Labeltekst is verplicht`, `Font Weight` → `Letterdikte`, `Alignment` → `Uitlijning` +Changelog entry for the new widget type; user-guide screenshot of a styled label widget. -## 5. Vitest unit tests +## i18n (company-wide ADR-005) -- [ ] 5.1 `LabelWidget.spec.js`: HTML in `text` appears as literal text — assert no `` element appears in mounted DOM (REQ-LBL-001) -- [ ] 5.2 `LabelWidget.spec.js`: defaults applied when `content = {text: 'Hi'}` — assert inline style contains `font-size: 16px`, `font-weight: bold`, `text-align: center` (REQ-LBL-002) -- [ ] 5.3 `LabelWidget.spec.js`: long single word wraps — mount in narrow container, assert no horizontal overflow on inner span (REQ-LBL-003) -- [ ] 5.4 `LabelWidget.spec.js`: empty `text` shows `t('Label')` fallback (REQ-LBL-004) -- [ ] 5.5 `LabelForm.spec.js`: `validate()` returns error on empty text, empty array on non-empty text (REQ-LBL-005) -- [ ] 5.6 `LabelForm.spec.js`: pre-fills all six controls from `editingWidget.content` (REQ-LBL-005) -- [ ] 5.7 Registry test: importing `widgetRegistry.js` exposes a `label` entry with the correct `defaultContent` (REQ-LBL-007) - -## 6. Playwright end-to-end test - -- [ ] 6.1 Add `tests/e2e/label-widget.spec.ts` covering: open Add Widget modal → pick Label → fill text + change fontSize to `24px` + change colour → save -- [ ] 6.2 Same test reopens the placement in edit mode and asserts all six fields round-trip identically -- [ ] 6.3 Same test verifies that pasting `HTML` into the text field renders as literal text (no bold styling) on the dashboard - -## 7. Quality gates - -- [ ] 7.1 ESLint clean on the two new `.vue` files and the modified `widgetRegistry.js` -- [ ] 7.2 `npm run build` succeeds without warnings -- [ ] 7.3 No new console errors in the browser when the widget is rendered, edited, or removed -- [ ] 7.4 Manual smoke test in `nldesign` theme to confirm the default `var(--color-main-text)` colour resolves correctly in both light and dark mode +`nl_NL` + `en_US` per Task 8. diff --git a/openspec/changes/link-button-widget/tasks.md b/openspec/changes/link-button-widget/tasks.md index cd2d50b8..ecf60619 100644 --- a/openspec/changes/link-button-widget/tasks.md +++ b/openspec/changes/link-button-widget/tasks.md @@ -1,59 +1,32 @@ # Tasks — link-button-widget -## 1. Backend (file creation endpoint) - -- [ ] 1.1 Create `lib/Service/FileService.php::createFile(string $userId, string $filename, string $dir, string $content): array` -- [ ] 1.2 Filename validation: regex `^[a-zA-Z0-9_\-. ]+$`, ≤255 chars, no `..` or `/` or `\` or null byte -- [ ] 1.3 Dir validation: no `..`, no null byte -- [ ] 1.4 Extension validation against admin-configured allow-list (default `txt, md, docx, xlsx, csv, odt`) -- [ ] 1.5 Resolve via `IRootFolder::getUserFolder`; create subdirectory if missing -- [ ] 1.6 Overwrite if file exists; return `{status, fileId, url}` with `URLGenerator::linkToRouteAbsolute('files.view.index', ['openfile' => fileId])` -- [ ] 1.7 Add `lib/Controller/FileController.php::createFile` mapped to `POST /api/files/create` in `appinfo/routes.php` -- [ ] 1.8 Wrap exceptions; do not leak raw exception messages to the response - -## 2. Renderer - -- [ ] 2.1 Create `src/components/Widgets/Renderers/LinkButtonWidget.vue` -- [ ] 2.2 Implement three click branches by `actionType` (external / internal / createFile) -- [ ] 2.3 Suppress all click handlers in admin/edit mode (`isAdmin === true` and shell `canEdit === true`) -- [ ] 2.4 Apply `disabled` attribute while `isExecuting === true` -- [ ] 2.5 Add inline `createFile` modal child component (filename prompt + Cancel/Create) -- [ ] 2.6 Resolve icon via shared `IconRenderer` (built-in MDI name OR custom URL) -- [ ] 2.7 Apply default colour fallback to `var(--color-primary)` / `var(--color-primary-text)` when empty -- [ ] 2.8 Add hover lift (translate up 2 px + soft drop shadow) - -## 3. Internal actions composable - -- [ ] 3.1 Create `src/composables/useInternalActions.js` exposing `register(id, fn)`, `invoke(id)`, `has(id)` -- [ ] 3.2 Use a singleton-style module-level `Map` -- [ ] 3.3 `invoke` MUST log `console.warn('Unknown internal action: ')` on missing id and not throw - -## 4. Form - -- [ ] 4.1 Create `src/components/Widgets/Forms/LinkButtonForm.vue` with all six fields -- [ ] 4.2 Placeholder text for `url` swaps based on `actionType` (`https://...`, `action-id`, `docx`) -- [ ] 4.3 `validate()` requires both `label` AND `url` non-empty; returns non-empty error array otherwise -- [ ] 4.4 Pre-fill all fields from `editingWidget.content` when editing an existing widget - -## 5. Registry - -- [ ] 5.1 Add `link` entry to `src/constants/widgetRegistry.js` with defaults `{label:'', url:'', icon:'', actionType:'external', backgroundColor:'', textColor:''}` - -## 6. Tests - -- [ ] 6.1 PHPUnit: filename validation rejects path traversal, special chars, oversized input -- [ ] 6.2 PHPUnit: extension allow-list enforced (allowed extension OK, disallowed extension HTTP 400) -- [ ] 6.3 PHPUnit: existing file overwritten and returned `fileId` matches the existing entry -- [ ] 6.4 PHPUnit: raw exception messages NOT leaked to caller -- [ ] 6.5 Vitest: three click branches; admin-mode suppression; disabled-while-in-flight -- [ ] 6.6 Vitest: internal action registry warn-on-miss; register/invoke happy path -- [ ] 6.7 Vitest: form validation requires label + url; placeholder swaps with actionType -- [ ] 6.8 Playwright: createFile flow end-to-end (modal opens → POST → opens Files tab) -- [ ] 6.9 Playwright: external link opens in a `_blank` tab - -## 7. Quality - -- [ ] 7.1 `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan) -- [ ] 7.2 ESLint clean -- [ ] 7.3 OpenAPI spec updated for `POST /api/files/create` -- [ ] 7.4 Translation entries added in `l10n/en.js` and `l10n/nl.js`: `Link Button`, `Action Type`, `External Link`, `Internal Function`, `Create File`, `Background Color`, `Text Color`, `Upload Icon (optional)`, `Create Document`, `File Name`, `Enter filename`, `Cancel`, `Create`, `Creating…`, `Failed to create document`, `Please enter a file name` +## Tasks + +- [ ] Task 1: Add `lib/Service/FileService::createFile(userId, filename, dir, content)` with strict filename validation (`^[a-zA-Z0-9_\-. ]+$`, ≤255 chars, no `..`/`/`/`\`/null byte), dir validation (no `..`/null), and admin-configured extension allow-list (default `txt, md, docx, xlsx, csv, odt`) +- [ ] Task 2: `FileService::createFile` resolves the user folder via `IRootFolder::getUserFolder`, creates subdirectories on demand, overwrites existing files, and returns `{status, fileId, url}` using `URLGenerator::linkToRouteAbsolute('files.view.index', ['openfile' => $fileId])`; raw exception messages are NEVER returned to the caller +- [ ] Task 3: Expose `POST /api/files/create` via `lib/Controller/FileController::createFile`, registered in `appinfo/routes.php` +- [ ] Task 4: Build `src/components/Widgets/Renderers/LinkButtonWidget.vue` with three click branches per `actionType` (`external`/`internal`/`createFile`), all click handlers suppressed in admin/edit mode, `disabled` while `isExecuting`, and hover lift (translate-up 2px + soft drop shadow) +- [ ] Task 5: Renderer mounts an inline `createFile` modal (filename prompt + Cancel/Create), resolves icons via the shared `IconRenderer` (MDI name OR custom URL), and falls back to `var(--color-primary)` / `var(--color-primary-text)` when colours are empty +- [ ] Task 6: Add `src/composables/useInternalActions.js` exposing `register(id, fn)` / `invoke(id)` / `has(id)` backed by a singleton module-level `Map`; `invoke` warns (`console.warn`) on missing ids and never throws +- [ ] Task 7: Build `src/components/Widgets/Forms/LinkButtonForm.vue` with all six fields, `url` placeholder swapping by `actionType` (`https://...` / `action-id` / `docx`), `validate()` requiring both `label` AND `url`, and pre-fill from `editingWidget.content` when editing +- [ ] Task 8: Register `link` in `src/constants/widgetRegistry.js` with defaults `{label:'', url:'', icon:'', actionType:'external', backgroundColor:'', textColor:''}` +- [ ] Task 9: PHPUnit coverage — filename validation (traversal/special chars/oversize), extension allow-list (allowed 200, disallowed 400), overwrite returns existing `fileId`, no raw exception leakage +- [ ] Task 10: Vitest coverage — three click branches; admin-mode suppression; disabled-while-in-flight; internal-action registry warn-on-miss + register/invoke happy path; form validation + placeholder swap +- [ ] Task 11: Playwright — createFile flow end-to-end (modal opens → POST → opens Files tab); external link opens in `_blank` tab +- [ ] Task 12: Quality gates — `composer check:strict`, ESLint clean, OpenAPI updated for `POST /api/files/create`, `nl`+`en` translations for all new UI strings (Link Button, Action Type, External Link, Internal Function, Create File, Background Color, Text Color, Upload Icon (optional), Create Document, File Name, Enter filename, Cancel, Create, Creating…, Failed to create document, Please enter a file name) + +## Verification + +`openspec validate` exits clean. Renderer behaves identically in admin vs view mode; createFile flow round-trips to the Files app. + +## Tests (company-wide ADR-009) + +PHPUnit, Vitest, and Playwright per Tasks 9–11. Newman/Postman updated for `POST /api/files/create`. + +## Documentation (company-wide ADR-010) + +Changelog entry covering the new widget type and the file-creation endpoint; user-guide screenshot of the widget configuration form. + +## i18n (company-wide ADR-005) + +`nl_NL` + `en_US` for all UI strings listed in Task 12. diff --git a/openspec/changes/multi-scope-dashboards/tasks.md b/openspec/changes/multi-scope-dashboards/tasks.md index fb90a7c7..461463c7 100644 --- a/openspec/changes/multi-scope-dashboards/tasks.md +++ b/openspec/changes/multi-scope-dashboards/tasks.md @@ -1,81 +1,33 @@ # Tasks — multi-scope-dashboards -## 1. Schema migration +## Tasks -- [ ] 1.1 Create `lib/Migration/VersionXXXXDate2026...AddGroupIdColumn.php` adding `groupId VARCHAR(64) NULL` to `oc_mydash_dashboards` -- [ ] 1.2 Same migration adds composite index `idx_mydash_dash_type_group` on `(type, groupId)` for fast `findByGroup` and `findVisibleToUser` lookups -- [ ] 1.3 Confirm migration is reversible (drop column + index in `postSchemaChange` rollback path) -- [ ] 1.4 Run migration locally against sqlite, mysql, and postgres; verify schema applied cleanly each time +- [ ] Task 1: Ship the migration adding `groupId VARCHAR(64) NULL` to `oc_mydash_dashboards` plus composite index `idx_mydash_dash_type_group(type, groupId)`; reversible drop in `postSchemaChange` rollback; applied cleanly on sqlite/mysql/postgres +- [ ] Task 2: Extend `Dashboard` entity with `TYPE_GROUP_SHARED` + `SOURCE_USER|SOURCE_GROUP|SOURCE_DEFAULT` constants, `groupId` getter/setter (no named args), and `jsonSerialize()` exposing `groupId` (nullable) +- [ ] Task 3: Add `DashboardMapper::findByGroup(groupId)` and `DashboardMapper::findVisibleToUser(userId, userGroupIds)` (3 indexed queries — personal/group/default — unioned + deduped by UUID, each row tagged with its `source`) +- [ ] Task 4: Enforce the `(type='group_shared' XOR groupId IS NULL)` invariant in `DashboardFactory::create()` with `\InvalidArgumentException` on mismatch +- [ ] Task 5: Add `DashboardService::createGroupShared / updateGroupShared / deleteGroupShared / getVisibleToUser` with admin guards via `IGroupManager::isAdmin`, ownership checks, and last-in-non-default-group delete guard (HTTP 400; `default` group exempt) +- [ ] Task 6: Update `PermissionService::getEffectivePermissionLevel()` so non-admins on `group_shared` get `view_only` and admins get `full`; personal + admin_template scopes keep their current matrix +- [ ] Task 7: Add 6 controller endpoints (`GET /api/dashboards/visible`, `GET /api/dashboards/group/{groupId}`, `POST /api/dashboards/group/{groupId}`, `GET|PUT|DELETE /api/dashboards/group/{groupId}/{uuid}`) registered in `appinfo/routes.php` with `#[NoAdminRequired]` + in-body admin checks on mutations; `groupId` regex accepts `default` + any valid NC group id +- [ ] Task 8: Seed three group-shared dashboards (Welcome→`default`, Campaigns→marketing, Sprint→engineering) with their placements in `_registers.json`; verify the local seed command applies them cleanly +- [ ] Task 9: Update `src/stores/dashboards.js` to consume `/api/dashboards/visible`, expose `groupSharedDashboards` + `defaultGroupDashboards` getters, and route subsequent edits via the `source` field (personal vs group) +- [ ] Task 10: PHPUnit — mapper coverage (findByGroup empty/nonexistent, findVisibleToUser mixed fixtures + 0-group user + UUID-overlap dedup), controller admin enforcement (403 on mutation by non-admin), last-in-group guard (400, default exempt), invariant guard, permission matrix (incl. regression on personal + admin_template) +- [ ] Task 11: Playwright — admin creates group-shared via API and member sees it on `/visible`; 0-group user still sees default-group rows; non-admin PUT to group-shared dashboard returns 403; admin rename propagates to members on next reload +- [ ] Task 12: Quality gates — `composer check:strict`, ESLint+Stylelint, OpenAPI/Postman regen, `nl`+`en` i18n for new error strings, SPDX-in-docblock on new PHP, all 10 hydra-gates green +- [ ] Task 13: File the follow-up `admin-group-management` change for the admin-facing group-shared CRUD UI and note the deferral in the changelog -## 2. Domain model +## Verification -- [ ] 2.1 Add `Dashboard::TYPE_GROUP_SHARED = 'group_shared'` constant alongside existing `TYPE_USER`, `TYPE_ADMIN_TEMPLATE` -- [ ] 2.2 Add `groupId` field to `Dashboard` entity with getter/setter (Entity `__call` pattern — no named args) -- [ ] 2.3 Add `Dashboard::SOURCE_USER`, `SOURCE_GROUP`, `SOURCE_DEFAULT` constants (used only in `/visible` serialisation) -- [ ] 2.4 Update `Dashboard::jsonSerialize()` to include `groupId` (nullable in output) +`openspec validate` exits clean. `/api/dashboards/visible` returns the correct merged + deduped set with `source` tags; admin-only mutation routes return 403 for non-admins. -## 3. Mapper layer +## Tests (company-wide ADR-009) -- [ ] 3.1 Add `DashboardMapper::findByGroup(string $groupId): array` — `WHERE type = 'group_shared' AND groupId = ?` -- [ ] 3.2 Add `DashboardMapper::findVisibleToUser(string $userId, array $userGroupIds): array` — issues 3 indexed queries (personal, group-matching, default-group), unions in PHP, dedupes by UUID -- [ ] 3.3 Each result row in `findVisibleToUser` is tagged with its source (`user` / `group` / `default`) before merge so the `source` field can be set on the response -- [ ] 3.4 Add fixture-based PHPUnit test covering: user with 1 personal + 2 group + 1 default; user in 0 matching groups; UUID overlap dedup edge case +PHPUnit per Task 10; Playwright per Task 11. Newman/Postman updated with the 6 new endpoints (Task 12). -## 4. Service layer +## Documentation (company-wide ADR-010) -- [ ] 4.1 In `DashboardFactory::create()` accept optional `type` and `groupId` kwargs; enforce the invariant `(type='group_shared' XOR groupId IS NULL)`; throw `\InvalidArgumentException` on mismatch -- [ ] 4.2 Add `DashboardService::createGroupShared(string $groupId, string $name, ?string $description, ?int $gridColumns)` — admin-only via `IGroupManager::isAdmin($currentUserId)` guard -- [ ] 4.3 Add `DashboardService::updateGroupShared(string $groupId, string $uuid, array $patch)` — admin guard, ownership check (`type === group_shared AND groupId matches path`) -- [ ] 4.4 Add `DashboardService::deleteGroupShared(string $groupId, string $uuid)` with last-in-group guard (HTTP 400 when removing would leave non-`default` group with zero group-shared dashboards); `default` group exempt from the guard -- [ ] 4.5 Add `DashboardService::getVisibleToUser(string $userId): array` that wires `IGroupManager::getUserGroupIds($userId)` into the mapper call and adds `source` to each result -- [ ] 4.6 Update `PermissionService::getEffectivePermissionLevel()` to return `view_only` for non-admin members on `group_shared` dashboards, `full` for admins +Changelog entry covering the new scope (group-shared dashboards), the `default` group convention, and the deferred admin UI follow-up. -## 5. Controller + routes +## i18n (company-wide ADR-005) -- [ ] 5.1 Add `DashboardController::visible()` mapped to `GET /api/dashboards/visible` (logged-in user, `#[NoAdminRequired]`) -- [ ] 5.2 Add `DashboardController::listGroup(string $groupId)` mapped to `GET /api/dashboards/group/{groupId}` (logged-in) -- [ ] 5.3 Add `DashboardController::createGroup(string $groupId)` mapped to `POST /api/dashboards/group/{groupId}` (admin-only via `IGroupManager::isAdmin` check inside method body, since `#[NoAdminRequired]` semantic-auth gate requires runtime check) -- [ ] 5.4 Add `DashboardController::getGroup(string $groupId, string $uuid)` mapped to `GET /api/dashboards/group/{groupId}/{uuid}` (logged-in) -- [ ] 5.5 Add `DashboardController::updateGroup(string $groupId, string $uuid)` mapped to `PUT /api/dashboards/group/{groupId}/{uuid}` (admin-only) -- [ ] 5.6 Add `DashboardController::deleteGroup(string $groupId, string $uuid)` mapped to `DELETE /api/dashboards/group/{groupId}/{uuid}` (admin-only) -- [ ] 5.7 Register all six routes in `appinfo/routes.php` with proper requirements (groupId regex allows `default` and any valid Nextcloud group ID) -- [ ] 5.8 Confirm every new method carries the correct Nextcloud auth attribute (`#[NoAdminRequired]` + in-body admin check for mutations) — gate-route-auth + gate-semantic-auth must pass - -## 6. OpenRegister seed data - -- [ ] 6.1 Add three group-shared seed dashboards to `_registers.json` per the design's Seed Data section: Welcome (default), Campaigns (marketing), Sprint (engineering) -- [ ] 6.2 Each seed includes its placements with appropriate widget types and grid positions -- [ ] 6.3 Verify seed data applies cleanly via `occ mydash:seed` or whichever local command the app uses - -## 7. Frontend store - -- [ ] 7.1 Extend `src/stores/dashboards.js` with `groupSharedDashboards` and `defaultGroupDashboards` getters derived from `/api/dashboards/visible` payload -- [ ] 7.2 Add `source` field plumbing — every dashboard tracked in the store carries `source` so the component layer can route subsequent edit calls (PUT to `/api/dashboard/{uuid}` for `source='user'`, PUT to `/api/dashboards/group/{groupId}/{uuid}` for `source='group'|'default'`) -- [ ] 7.3 Make the listing page call `/api/dashboards/visible` instead of the older `/api/dashboards` endpoint (the old endpoint stays available for legacy clients) -- [ ] 7.4 Defer admin-only group-shared CRUD UI to follow-up `admin-group-management` change — note this in the changelog - -## 8. PHPUnit tests - -- [ ] 8.1 `DashboardMapperTest::findByGroup` — basic lookup, empty-group case, non-existent group case -- [ ] 8.2 `DashboardMapperTest::findVisibleToUser` — mixed personal + group + default fixtures; user with 0 group memberships still gets default-group rows; UUID overlap deduped -- [ ] 8.3 `DashboardControllerTest` — admin-only enforcement on POST/PUT/DELETE returns 403 for non-admins -- [ ] 8.4 `DashboardControllerTest::testDeleteLastInGroupGuard` — HTTP 400 when deleting the only dashboard in a non-default group; default group exempt -- [ ] 8.5 `DashboardServiceTest::testCreateRejectsTypeGroupSharedWithoutGroupId` — invariant guard -- [ ] 8.6 `PermissionServiceTest` — `view_only` for non-admin viewing group_shared, `full` for admin viewing same record -- [ ] 8.7 Test all 3 permission levels (`view_only`, `add_only`, `full`) round-trip correctly on personal + admin_template (regression — these scopes must keep working unchanged) - -## 9. End-to-end Playwright tests - -- [ ] 9.1 Admin user creates a group-shared dashboard via the new endpoint (using API call from a fixture; UI ships in follow-up change), and a member of the targeted group sees it on `/visible` -- [ ] 9.2 User in 0 matching groups still sees default-group dashboards in `/visible` -- [ ] 9.3 Non-admin user attempting PUT on a group-shared dashboard via direct API call gets HTTP 403 -- [ ] 9.4 Admin update to a group-shared dashboard's name is visible to a member on next page reload (no per-user copy interference) - -## 10. Quality gates - -- [ ] 10.1 `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) passes — fix any pre-existing issues encountered along the way -- [ ] 10.2 ESLint + Stylelint clean on all touched Vue/JS files -- [ ] 10.3 Update generated OpenAPI spec / Postman collection so external API consumers see the new endpoints -- [ ] 10.4 `i18n` keys for all new error messages (`Cannot delete the only dashboard in the group`, etc.) in both `nl` and `en` per the i18n requirement -- [ ] 10.5 SPDX headers on every new PHP file (inside the docblock per the SPDX-in-docblock convention) — gate-spdx must pass -- [ ] 10.6 Run all 10 `hydra-gates` locally before opening PR +`nl_NL` + `en_US` for the new error messages (e.g. `Cannot delete the only dashboard in the group`). diff --git a/openspec/changes/mydash-adopt-or-abstractions/tasks.md b/openspec/changes/mydash-adopt-or-abstractions/tasks.md index b2e7dcbd..19c4062c 100644 --- a/openspec/changes/mydash-adopt-or-abstractions/tasks.md +++ b/openspec/changes/mydash-adopt-or-abstractions/tasks.md @@ -3,125 +3,35 @@ > Spec-only change. No PR / merge / archive tasks here — those belong > to Hydra coordination per `feedback_opsx-no-process-tasks.md`. -## Phase 1 — Manifest pilot (Tier 1) +## Tasks -- [ ] 1.1 Add `src/manifest.json` describing current MyDash UI: - - top-level Dashboards menu (i18n key `mydash.menu.dashboards`) - - per-dashboard pages (`type: "dashboard"`, route - `/dashboards/:id`, `config.{widgets, layout}` populated from the - existing GridStack model) - - admin templates index (`type: "index"`, route - `/admin/templates`) - - admin settings page (`type: "custom"`, route `/admin/settings`, - `component: "AdminSettingsPage"`) -- [ ] 1.2 Set `$schema` to the published nc-vue - app-manifest schema URL. -- [ ] 1.3 Set `dependencies: []` (explicit empty — verify in code review - that no follow-up commit silently adds `"openregister"`). -- [ ] 1.4 Add `version: "0.1.0"` and bump on each manifest content - change. -- [ ] 1.5 Wire `useAppManifest('mydash', bundled)` in `src/main.js` - alongside the existing router setup. Tier 1 — manifest loaded but - vue-router still hand-wired. -- [ ] 1.6 Add `npm run check:manifest` script to `package.json` calling - the validator from `@conduction/nextcloud-vue`. -- [ ] 1.7 Wire `npm run check:manifest` into the existing CI lint job. +- [ ] Task 1: Add `src/manifest.json` describing the current MyDash UI — top-level Dashboards menu (`mydash.menu.dashboards` i18n key), per-dashboard pages (`type: "dashboard"`, route `/dashboards/:id`, `config.{widgets, layout}` populated from the existing GridStack model), admin templates index (`type: "index"`, `/admin/templates`), admin settings page (`type: "custom"`, `/admin/settings`, `component: "AdminSettingsPage"`) +- [ ] Task 2: Set `$schema` to the published nc-vue app-manifest schema URL, `dependencies: []` (explicit empty — code review must guard against `"openregister"` slipping in), and `version: "0.1.0"` (bump on each manifest change) +- [ ] Task 3: Wire `useAppManifest('mydash', bundled)` in `src/main.js` alongside the existing router setup (Tier 1 — manifest loaded but vue-router still hand-wired); add `npm run check:manifest` calling the nc-vue validator and wire it into the existing CI lint job +- [ ] Task 4: Add `src/composables/useOrFeatureDetect.js` wrapping `useAppStatus('openregister')` and exposing `{ enabled, version, error }`; document the canonical OR-backed widget pattern (feature-detect → conditional render → graceful empty state) in `docs/widgets/or-data.md` +- [ ] Task 5: Audit existing widgets for ad-hoc OR calls and record each one (file:line) as a follow-up migration checklist (no code edits in this change) +- [ ] Task 6: Rewrite `openspec/specs/dashboard-sharing/spec.md` — keep native permission levels (`view_only`/`add_only`/`full`) on `oc_mydash_dashboards.permissions`, add a "Runtime OR delegation (optional)" section describing how a dashboard MAY delegate row-level permission checks to OR's per-object RBAC at render time when OR is enabled, plus an explicit "MUST NOT add a hard OR dependency" requirement +- [ ] Task 7: Rewrite `openspec/specs/admin-templates/spec.md` — declare admin templates persist in `oc_mydash_admin_settings` (local table), explicit rationale ("MyDash must work standalone"), explicit "MUST NOT store templates in OR" requirement +- [ ] Task 8: Create `openspec/specs/runtime-or-consumption/spec.md` with REQs: MyDash MUST NOT declare an install-time dependency on openregister/openconnector; OR-data widgets MUST feature-detect OR at runtime; OR-data widgets MUST pass `?_lang=` when fetching translatable OR data; OR-data widgets MUST consume `useTenantContext()` from nc-vue when surfacing tenant-scoped OR data; OR-data widgets MUST render a documented empty state when OR is absent or returns 5xx — each REQ accompanied by ≥1 GIVEN/WHEN/THEN scenario, and cross-linked from the rewritten Phase-3 specs +- [ ] Task 9: Hygiene — `lib/Db/ColumnTypeRegistry.php:31-45` add a docblock explaining why MyDash column types are intentionally separate from JSON-schema `type` (constants stay local, docblock-only change) +- [ ] Task 10: Hygiene — `lib/Db/AdminSetting.php:42-85` collect the eight `KEY_*` constants under a single `AdminSettingKey` const-list (or PHP 8.1 enum) with doc comments; keep BC by aliasing the old constants +- [ ] Task 11: Hygiene — `lib/Service/FileService.php:62` extract `FILENAME_PATTERN` to a named class constant + unit test asserting allowed (`dashboard-export.json`, `template_v2.json`, `template-with-dashes.json`) and rejected (`../etc/passwd`, `name.with.two.dots.json`, paths with slashes, paths with leading dots) inputs +- [ ] Task 12: Track Tier-3 graduation prerequisites in this tasks.md only (no code in this change) — dashboard `type:"dashboard"` page-type contract stable in nc-vue, GridStack adapter component shipped in nc-vue or local, admin pages converted from `type:"custom"` to declarative config where possible — and open follow-up opsx change `mydash-manifest-tier-3` ONLY once prerequisites are met (tracking only here) +- [ ] Task 13: Documentation — update `docs/architecture.md` describing manifest as the single source of truth for routes/menu, the runtime-only OR consumption policy, and the permission model on `oc_mydash_dashboards`; cross-link the new docs from the app's README +- [ ] Task 14: Verification — `npm run check:manifest` passes; clean Nextcloud install (no OR/OC) boots, renders empty dashboards, and shows graceful empty states for OR-backed widgets; Nextcloud install with OR enabled surfaces OR-backed widget data through the runtime API contract; `composer check:strict` + `npm run lint` + the FILENAME_PATTERN unit test all pass -## Phase 2 — Runtime OR consumption pattern +## Verification -- [ ] 2.1 Add `src/composables/useOrFeatureDetect.js` that wraps - `useAppStatus('openregister')` from nc-vue and exposes - `{ enabled, version, error }`. -- [ ] 2.2 Document in `docs/widgets/or-data.md` the canonical pattern - for an OR-backed widget: feature-detect → conditional render → - graceful empty state when OR is absent. -- [ ] 2.3 Add a runtime-OR-consumption section to the new - `runtime-or-consumption` spec capability (see Phase 4). -- [ ] 2.4 Audit existing widgets for ad-hoc OR calls; list each one - with file:line. Result lives in tasks.md as a checklist for - follow-up migration changes (no code edits in this change). +`openspec validate` exits clean. Manifest validator + standalone-install smoke test both pass per Task 14. -## Phase 3 — Spec rewrites (NEEDS-REWRITE cohort) +## Tests (company-wide ADR-009) -- [ ] 3.1 Rewrite `openspec/specs/dashboard-sharing/spec.md`: - - keep MyDash permission levels (view_only / add_only / full) as - native concepts on `oc_mydash_dashboards.permissions` - - add a "Runtime OR delegation (optional)" section describing how - a dashboard MAY delegate its row-level permission check to OR's - per-object RBAC at render time when OR is enabled - - explicit "MUST NOT add a hard OR dependency" requirement -- [ ] 3.2 Rewrite `openspec/specs/admin-templates/spec.md`: - - declare admin templates persist in `oc_mydash_admin_settings` - (local table) - - explicit rationale: MyDash must work standalone - - explicit "MUST NOT store templates in OR" requirement -- [ ] 3.3 Cross-link both rewritten specs from the new - `runtime-or-consumption` capability. +PHPUnit for the FILENAME_PATTERN regex (Task 11); manifest validation gate (Task 3); smoke install/runtime checks (Task 14). No new business-logic test surface in this spec-only change. -## Phase 4 — New `runtime-or-consumption` spec capability +## Documentation (company-wide ADR-010) -- [ ] 4.1 Create `openspec/specs/runtime-or-consumption/spec.md` with: - - `Requirement: MyDash MUST NOT declare an install-time dependency - on openregister or openconnector` - - `Requirement: MyDash widgets that surface OR data MUST - feature-detect OR at runtime` - - `Requirement: MyDash widgets MUST pass ?_lang= when fetching - translatable OR data` - - `Requirement: MyDash widgets MUST consume useTenantContext() from - nc-vue when surfacing tenant-scoped OR data` - - `Requirement: MyDash widgets MUST render a documented empty - state when OR is absent or returns 5xx` -- [ ] 4.2 Each requirement gets at least one - GIVEN / WHEN / THEN scenario. +`docs/architecture.md` + `docs/widgets/or-data.md` per Tasks 4 + 13; README cross-link. -## Phase 5 — Local hygiene cleanups (stream 4) +## i18n (company-wide ADR-005) -- [ ] 5.1 `lib/Db/ColumnTypeRegistry.php:31-45` — add a docblock - explaining why MyDash column types are intentionally separate - from JSON-schema `type`. Constants stay local. No code edit - required beyond the docblock. -- [ ] 5.2 `lib/Db/AdminSetting.php:42-85` — collect the eight `KEY_*` - constants under a single `AdminSettingKey` const-list (or PHP 8.1 - `enum`) with doc comments. Keep BC by aliasing the old constants. -- [ ] 5.3 `lib/Service/FileService.php:62` — extract - `FILENAME_PATTERN` to a named class constant; add a unit test that - asserts: - - allowed: `dashboard-export.json`, `template_v2.json`, - `template-with-dashes.json` - - rejected: `../etc/passwd`, `name.with.two.dots.json`, paths - containing slashes, paths with leading dots -- [ ] 5.4 Run `composer check:strict` and fix any pre-existing - PHPCS/PHPMD/Psalm/PHPStan warnings touched by the above edits - (per project policy in CLAUDE.md). - -## Phase 6 — Manifest Tier 3 graduation (follow-up tracking) - -- [ ] 6.1 Track in this tasks.md (no code in this change) the - prerequisites for Tier 3: - - dashboard `type: "dashboard"` page-type contract stable in nc-vue - - GridStack adapter component shipped in nc-vue or local - - admin pages converted from `type: "custom"` to declarative - config where possible -- [ ] 6.2 Open a follow-up opsx change `mydash-manifest-tier-3` - once Phase 6 prerequisites are met. (Tracking only — do not - create the change in this proposal.) - -## Phase 7 — Documentation - -- [ ] 7.1 Update `docs/architecture.md` (or create) to describe: - - manifest as the single source of truth for routes / menu - - runtime-only OR consumption policy - - permission model on `oc_mydash_dashboards` -- [ ] 7.2 Add `docs/widgets/or-data.md` (referenced from Phase 2.2). -- [ ] 7.3 Cross-link the new docs from the app's README. - -## Phase 8 — Verification - -- [ ] 8.1 Run `npm run check:manifest` locally — must pass. -- [ ] 8.2 Verify in a clean Nextcloud install (no OR, no OC) that - MyDash boots, renders empty dashboards, and shows graceful empty - states for any OR-backed widget the operator manually adds. -- [ ] 8.3 Verify in a Nextcloud install with OR enabled that - OR-backed widgets surface data through the runtime API contract. -- [ ] 8.4 Confirm `composer check:strict` and `npm run lint` pass. -- [ ] 8.5 Confirm unit tests for the FILENAME_PATTERN regex pass. +Manifest `mydash.menu.dashboards` key only; no other user-facing strings introduced here. diff --git a/openspec/changes/mydash-legacy-quality-cleanup/tasks.md b/openspec/changes/mydash-legacy-quality-cleanup/tasks.md index d71616cc..eda5f90e 100644 --- a/openspec/changes/mydash-legacy-quality-cleanup/tasks.md +++ b/openspec/changes/mydash-legacy-quality-cleanup/tasks.md @@ -1,70 +1,32 @@ # Tasks: MyDash Legacy Quality Cleanup -## Phase 1 — Inventory + planning +## Tasks -- [ ] Run `composer phpcs` and capture current baseline error count - (target: starting from 3 exclude-patterns in phpcs.xml) -- [ ] Run `composer phpmd` for the first time as a unified gate - and capture violation count + categories -- [ ] Run `composer phpstan` and capture current error count - (target: starting from 81-line phpstan-baseline.neon) -- [ ] Decide PHPMD strategy: fix-outright or capture baseline -- [ ] Confirm CI runs `composer check:strict` on every PR before - starting burn-down work +- [ ] Task 1: Run `composer phpcs` + `composer phpmd` + `composer phpstan` and capture the baseline (starting from the 3 exclude-patterns in `phpcs.xml` and the 81-line `phpstan-baseline.neon`) plus PHPMD violation count + categories +- [ ] Task 2: Decide the PHPMD strategy (fix-outright vs capture baseline) based on Task 1 volume and confirm CI runs `composer check:strict` on every PR before starting burn-down work +- [ ] Task 3: PHPCS — fix sniffs in excluded file 1 and remove its `` entry from `phpcs.xml`; gate stays green +- [ ] Task 4: PHPCS — fix sniffs in excluded file 2 and remove its `` entry; gate stays green +- [ ] Task 5: PHPCS — fix sniffs in excluded file 3, remove its `` entry, and drop the legacy-debt block from `phpcs.xml` entirely +- [ ] Task 6: PHPMD — burn down the captured baseline (or fix-outright per Task 2): reshape `if/else` → early-return (`ElseExpression`), extract methods (`CyclomaticComplexity`/`NPathComplexity`), add `use` statements (`MissingImport`), replace `StaticAccess` with DI, address variable-naming sniffs (`Long/Short/Undefined/UnusedFormalParameter`) +- [ ] Task 7: Once PHPMD baseline reaches 0 lines: delete `phpmd.baseline.xml` and drop `--baseline-file` from the composer.json `phpmd` script +- [ ] Task 8: PHPStan — inventory errors by file/type and fix common patterns (missing return/param types, mixed→generic/union, possibly-null dereferences, `==`→`===` strict-comparison nudges); regenerate baseline and confirm 0 lines, then delete `phpstan-baseline.neon` +- [ ] Task 9: CI — verify `composer check:strict` runs in CI on every PR; add a weekly smoke-test cron that runs it on `development` +- [ ] Task 10: Cleanup — after every baseline is empty, drop the residual legacy-debt section from `phpcs.xml` (if not already gone) and confirm `phpmd.baseline.xml` + `phpstan-baseline.neon` are deleted +- [ ] Task 11: Documentation — update the README quality-gates section, note in `app-config.json` that legacy quality cleanup is done, and close the burn-down tracking issue once the last baseline line is removed +- [ ] Task 12: Verification — final `composer check:strict` exits clean with no baselines, no excludes, no skipped sniffs -## Phase 2 — PHPCS burn-down (per excluded file) +## Verification -For each file: fix errors, remove the phpcs.xml `` -entry, verify gate stays green. +`composer check:strict` exits clean on `development` with no baselines or excluded files remaining; the weekly cron stays green. -- [ ] Excluded file 1 — fix sniffs + drop exclude -- [ ] Excluded file 2 — fix sniffs + drop exclude -- [ ] Excluded file 3 — fix sniffs + drop exclude -- [ ] Once all excludes are gone, drop the legacy-debt block from - phpcs.xml entirely +## Tests (company-wide ADR-009) -## Phase 3 — PHPMD burn-down +No new business-logic tests; the change tightens static-analysis gates. The weekly cron (Task 9) is the long-term regression guard. -Contingent on Phase 1's first-run output. If volume is small, this -phase collapses to a single fix-outright PR. +## Documentation (company-wide ADR-010) -- [ ] If baseline captured: ElseExpression — re-shape `if/else` to - early-return -- [ ] If baseline captured: CyclomaticComplexity / NPathComplexity — - extract methods -- [ ] If baseline captured: MissingImport — add `use` statements -- [ ] If baseline captured: StaticAccess — replace with DI -- [ ] If baseline captured: variable-naming sniffs (Long/Short/ - Undefined/UnusedFormalParameter) -- [ ] Once baseline reaches 0 lines: delete phpmd.baseline.xml and - drop `--baseline-file` from composer.json's phpmd script +README + `app-config.json` updates per Task 11. -## Phase 4 — PHPStan burn-down (81 lines) +## i18n (company-wide ADR-005) -Single-PR cluster — small baseline, no need to phase further. - -- [ ] Inventory phpstan errors by file/type -- [ ] Common patterns to fix: - - [ ] Missing return-type / param-type declarations - - [ ] Mixed types (specify generic / union) - - [ ] Possibly-null dereferences - - [ ] Strict-comparison nudges (`==` to `===`) -- [ ] Regenerate baseline; confirm 0 lines -- [ ] Delete phpstan-baseline.neon - -## Phase 5 — CI integration - -- [ ] Verify `composer check:strict` runs in CI on every PR -- [ ] Once all baselines are empty: - - [ ] Delete `phpmd.baseline.xml` (if it was created) - - [ ] Delete `phpstan-baseline.neon` - - [ ] Drop the legacy-debt section from `phpcs.xml` -- [ ] Add a smoke-test cron that runs `composer check:strict` - weekly on `development` - -## Phase 6 — Documentation - -- [ ] Update README quality-gates section -- [ ] Note in `app-config.json` that legacy quality cleanup is done -- [ ] Close the burn-down tracking issue once the last baseline - line is removed +No user-facing strings introduced — quality-only cleanup. diff --git a/openspec/changes/nc-dashboard-widget-proxy/tasks.md b/openspec/changes/nc-dashboard-widget-proxy/tasks.md index 2988b76c..ec1e60c5 100644 --- a/openspec/changes/nc-dashboard-widget-proxy/tasks.md +++ b/openspec/changes/nc-dashboard-widget-proxy/tasks.md @@ -1,46 +1,30 @@ # Tasks — nc-dashboard-widget-proxy -## 1. Bridge +## Tasks -- [ ] Add `WidgetBridge::pollForCallback(widgetId, options)` returning a cancellable Promise -- [ ] Use `setInterval` with cleanup on resolve, abort, or max retries -- [ ] First check is synchronous (no `setInterval` if already registered) -- [ ] Internally calls `hasWidgetCallback` (single source of truth) +- [ ] Task 1: Add `WidgetBridge::pollForCallback(widgetId, options)` returning a cancellable `Promise` — `setInterval` with cleanup on resolve/abort/max-retries; first check synchronous (no `setInterval` if already registered); internally calls `hasWidgetCallback` as the single source of truth +- [ ] Task 2: Create `src/components/Widgets/Renderers/NcDashboardWidget.vue` — on mount try native; if absent fire API request AND `pollForCallback`; switch to native if poll resolves true (race winner wins, no flicker) +- [ ] Task 3: Renderer hardening — defensive normalisation for PHP-serialised sequential arrays (`Array.isArray(injected) ? injected : Object.values(injected)`); header shows title + iconUrl from `widgetMeta`; two display modes per REQ-WDG-020 CSS +- [ ] Task 4: Create `src/components/Widgets/Forms/NcDashboardForm.vue` — picker `` (vertical/horizontal); `validate()` requires non-empty `widgetId`; pre-fill from `editingWidget.content` +- [ ] Task 5: Register `nc-widget` in `widgetRegistry.js` with defaults `{widgetId:'', displayMode:'vertical'}` +- [ ] Task 6: Vitest bridge — `pollForCallback` happy path (callback registers mid-poll → resolves true); timeout (no registration → resolves false after ~3s); abort (signal aborts → resolves false immediately); synchronous resolve when already registered +- [ ] Task 7: Vitest renderer — switches mode mid-flight when poll wins; array normalisation handles object-with-numeric-keys input +- [ ] Task 8: Playwright — `weather_status` widget renders natively when the bundle is present; widget falls back to API list when the bundle is absent; empty-list state shows the translated string +- [ ] Task 9: Quality — ESLint clean +- [ ] Task 10: i18n — `nl_NL` + `en_US` translations for `Nextcloud Widget`, `Select Widget`, `Choose a widget…`, `Display Mode`, `Vertical (list)`, `Horizontal (cards)`, `Loading…`, `No items available` -## 2. Renderer +## Verification -- [ ] Create `src/components/Widgets/Renderers/NcDashboardWidget.vue` -- [ ] On mount: try native; if absent, fire API request AND `pollForCallback` -- [ ] Switch to native if poll resolves true (race winner wins, no flicker) -- [ ] Defensive normalisation: PHP can serialise sequential arrays as objects; do `Array.isArray(injected) ? injected : Object.values(injected)` -- [ ] Header: title + iconUrl from `widgetMeta` -- [ ] Two display modes per REQ-WDG-020 CSS +`openspec validate` exits clean. Both rendering modes (native + API fallback) work for at least one stock NC widget; poll cancellation leaks no intervals. -## 3. Form +## Tests (company-wide ADR-009) -- [ ] Create `src/components/Widgets/Forms/NcDashboardForm.vue` -- [ ] Picker `` (vertical/horizontal) -- [ ] `validate()` requires non-empty `widgetId` -- [ ] Pre-fill from `editingWidget.content` +Vitest per Tasks 6–7; Playwright per Task 8. No new backend surface. -## 4. Registry +## Documentation (company-wide ADR-010) -- [ ] Add `nc-widget` to `widgetRegistry.js` with defaults `{widgetId:'', displayMode:'vertical'}` +Changelog entry covering the new widget type + the bridging + fallback behaviour. -## 5. Tests +## i18n (company-wide ADR-005) -- [ ] Vitest: `pollForCallback` happy path (callback registers mid-poll → resolves true) -- [ ] Vitest: timeout (no registration → resolves false after ~3 s) -- [ ] Vitest: abort (signal aborts → resolves false immediately) -- [ ] Vitest: synchronous resolve when already registered -- [ ] Vitest: renderer switches mode mid-flight when poll wins -- [ ] Vitest: array normalisation handles object-with-numeric-keys input -- [ ] Playwright: weather_status widget renders natively when bundle present -- [ ] Playwright: widget falls back to API list when bundle absent -- [ ] Playwright: empty-list state translated string - -## 6. Quality - -- [ ] ESLint clean -- [ ] Translation entries: `Nextcloud Widget`, `Select Widget`, `Choose a widget…`, `Display Mode`, `Vertical (list)`, `Horizontal (cards)`, `Loading…`, `No items available` +`nl_NL` + `en_US` per Task 10. diff --git a/openspec/changes/resource-serving/tasks.md b/openspec/changes/resource-serving/tasks.md index 337712b9..df7caa33 100644 --- a/openspec/changes/resource-serving/tasks.md +++ b/openspec/changes/resource-serving/tasks.md @@ -1,41 +1,29 @@ # Tasks — resource-serving -## 1. Controller methods +## Tasks -- [ ] 1.1 Add `lib/Controller/ResourceController.php::getResource(string $filename): StreamResponse` -- [ ] 1.2 Treat `{filename}` as leaf-only — reject any decoded `/` or `..` with 404 (Symfony route param matches `[^/]+` by default; verify in route registration too) -- [ ] 1.3 Implement Content-Type extension map (jpg/jpeg → image/jpeg, png → image/png, gif → image/gif, svg → image/svg+xml, webp → image/webp, default → application/octet-stream) -- [ ] 1.4 Set `Cache-Control: public, max-age=31536000` on the StreamResponse -- [ ] 1.5 413 guard: check `$file->getSize()` BEFORE loading bytes; refuse files > 5 MB with HTTP 413 `{status: 'error', error: 'file_too_large'}` -- [ ] 1.6 Add `lib/Controller/ResourceController.php::listResources(): DataResponse` returning `{status: 'success', resources: [{name, url, size, modifiedAt}]}` ordered by `modifiedAt desc` -- [ ] 1.7 Empty / non-existent folder → empty array (HTTP 200, NOT 404) -- [ ] 1.8 Document the cache-busting strategy (uniqid in filename) in PHP docblocks for both methods +- [ ] Task 1: Add `ResourceController::getResource(string $filename): StreamResponse` — treat `{filename}` as leaf-only (reject any decoded `/` or `..` with 404; Symfony route param `[^/]+` plus belt-and-braces in the method body); set Content-Type via extension map (jpg/jpeg→`image/jpeg`, png→`image/png`, gif→`image/gif`, svg→`image/svg+xml`, webp→`image/webp`, default→`application/octet-stream`); set `Cache-Control: public, max-age=31536000` +- [ ] Task 2: 413 guard — check `$file->getSize()` BEFORE loading bytes; refuse files > 5MB with HTTP 413 `{status:'error', error:'file_too_large'}` (never read large files into memory) +- [ ] Task 3: Add `ResourceController::listResources(): DataResponse` returning `{status:'success', resources:[{name, url, size, modifiedAt}]}` ordered by `modifiedAt desc`; empty/non-existent folder returns HTTP 200 `{resources:[]}` (NOT 404) +- [ ] Task 4: Document the cache-busting strategy (uniqid in filename) in PHP docblocks for both methods +- [ ] Task 5: Register routes — `GET /resource/{filename}` under the non-OCS `routes` array, `GET /api/resources` under the OCS `ocs` array; both methods carry `#[NoAdminRequired]` (logged-in user only); gate-route-auth + gate-semantic-auth pass +- [ ] Task 6: PHPUnit — png serve returns bytes + `image/png` + `Cache-Control` header; svg uses `image/svg+xml` (NOT `application/svg+xml`); unknown extension `.bin` → `application/octet-stream`; missing file → 404 (empty body acceptable) +- [ ] Task 7: PHPUnit — encoded path traversal `..%2F..%2Fetc%2Fpasswd` → 404 with no system file leak; 50MB file → 413 with file NOT read into memory; list returns resources sorted by `modifiedAt desc`; list with no folder returns HTTP 200 `{resources:[]}` +- [ ] Task 8: Playwright — image widget renders an uploaded resource via `GET /apps/mydash/resource/`; unauthenticated direct browser fetch redirects to login (no bytes served) +- [ ] Task 9: Quality gates — `composer check:strict` (fix any pre-existing issues encountered along the way); OpenAPI updated for `GET /api/resources` (the binary `/resource/{filename}` is intentionally excluded — not API consumer surface); SPDX-in-docblock on every new/modified PHP file; all 10 hydra-gates green -## 2. Routes +## Verification -- [ ] 2.1 Register `['name' => 'resource#getResource', 'url' => '/resource/{filename}', 'verb' => 'GET']` in `appinfo/routes.php` under the NON-OCS `routes` array (plain web route) -- [ ] 2.2 Register `['name' => 'resource#listResources', 'url' => '/api/resources', 'verb' => 'GET']` in `appinfo/routes.php` under the OCS `ocs` array -- [ ] 2.3 Confirm both methods carry the correct Nextcloud auth attribute (`#[NoAdminRequired]` — logged-in user only) — gate-route-auth + gate-semantic-auth must pass +`openspec validate` exits clean. Oversize + traversal vectors return the correct error codes without memory exhaustion; auth gate is enforced. -## 3. PHPUnit tests +## Tests (company-wide ADR-009) -- [ ] 3.1 `ResourceControllerTest::testServePngReturnsBytesAndHeaders` — Content-Type `image/png` + `Cache-Control: public, max-age=31536000` + exact bytes -- [ ] 3.2 `ResourceControllerTest::testServeSvgUsesImageSvgXmlContentType` — `image/svg+xml`, NOT `application/svg+xml` -- [ ] 3.3 `ResourceControllerTest::testUnknownExtensionFallsBackToOctetStream` — `.bin` → `application/octet-stream` -- [ ] 3.4 `ResourceControllerTest::testMissingFileReturns404` — 404, empty body acceptable -- [ ] 3.5 `ResourceControllerTest::testEncodedPathTraversalReturns404` — `..%2F..%2Fetc%2Fpasswd` → 404, no system file leak -- [ ] 3.6 `ResourceControllerTest::testOversizeFileRefusedWithoutMemoryExhaustion` — 50 MB file → 413, file NOT read into memory -- [ ] 3.7 `ResourceControllerTest::testListReturnsResourcesSortedByModifiedDesc` — newest first -- [ ] 3.8 `ResourceControllerTest::testListWithNoFolderReturnsEmptyArray` — HTTP 200 + `{resources: []}` +PHPUnit per Tasks 6–7; Playwright per Task 8. Newman/Postman updated for `GET /api/resources`. -## 4. End-to-end Playwright tests +## Documentation (company-wide ADR-010) -- [ ] 4.1 Image widget renders an uploaded resource via the served `GET /apps/mydash/resource/` URL -- [ ] 4.2 Direct browser fetch of `/apps/mydash/resource/` while unauthenticated redirects to login (no bytes served) +Inline PHP docblock per Task 4; changelog entry covering the resource-serving + listing endpoints. -## 5. Quality gates +## i18n (company-wide ADR-005) -- [ ] 5.1 `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) passes — fix any pre-existing issues encountered along the way -- [ ] 5.2 OpenAPI spec updated for `GET /api/resources` (the non-OCS `/resource/{filename}` is intentionally excluded — binary streaming, not API consumer surface) -- [ ] 5.3 SPDX headers on every new/modified PHP file (inside the docblock per the SPDX-in-docblock convention) — gate-spdx must pass -- [ ] 5.4 Run all 10 `hydra-gates` locally before opening PR +No user-facing strings — error responses are machine-readable codes consumed by existing widgets. diff --git a/openspec/changes/resource-uploads/tasks.md b/openspec/changes/resource-uploads/tasks.md index 549da3d7..d2529965 100644 --- a/openspec/changes/resource-uploads/tasks.md +++ b/openspec/changes/resource-uploads/tasks.md @@ -1,44 +1,33 @@ # Tasks — resource-uploads -## 1. Backend - -- [ ] Create `lib/Service/ResourceService.php` with `upload(string $base64DataUrl): array` returning `{url, name, size}` or throwing typed exceptions -- [ ] Create `lib/Service/ImageMimeValidator.php::validate(string $declaredType, string $bytes): void` -- [ ] Create `lib/Controller/ResourceController.php::upload` mapped to `POST /api/resources` -- [ ] Read raw input via `file_get_contents('php://input')` + `json_decode` -- [ ] Admin guard via `IGroupManager::isAdmin` -- [ ] 5 MB cap on decoded bytes (guard before `getimagesizefromstring` to bound memory) -- [ ] Cross-MIME check for raster types -- [ ] Delegate SVG sanitisation to `SvgSanitiser` (separate change) -- [ ] Persist via `IAppData->getFolder('resources')` (auto-create); filename `uniqid('resource_', true) . '.' . $ext` -- [ ] Define typed exceptions with stable error codes: `ForbiddenException`, `InvalidImageFormatException`, `InvalidDataUrlException`, `FileTooLargeException`, `MimeMismatchException`, `CorruptImageException` -- [ ] Map exceptions to standardised error envelope in controller (no raw `$e->getMessage()`) - -## 2. Frontend - -- [ ] Add `src/services/resourceService.js::uploadDataUrl(dataUrl): Promise<{url}>` wrapper -- [ ] Used by `image-widget` form, `link-button-widget` icon picker, `IconPicker` - -## 3. Tests - -- [ ] PHPUnit: 403 on non-admin -- [ ] PHPUnit: each rejection path returns the exact error code -- [ ] PHPUnit: oversize rejected before `getimagesizefromstring` (mock memory check) -- [ ] PHPUnit: MIME mismatch table (declared png, actual jpeg/gif/webp) -- [ ] PHPUnit: successful upload writes to app-data, returns URL -- [ ] PHPUnit: error responses NEVER contain `Exception` / stack trace strings -- [ ] Playwright: file upload from icon picker → URL appears in form - -## 4. Quality - -- [ ] `composer check:strict` passes -- [ ] OpenAPI updated for `POST /api/resources` -- [ ] Translation entries: `Personal dashboards are not enabled by your administrator`, `Failed to upload image`, error message strings (one per error code) -- [ ] Document v1 limits in admin help text: 5 MB cap, allowed types - -## 5. Follow-ups (separate changes) - -- [ ] `resource-serving` — GET endpoint -- [ ] `svg-sanitisation` — DOM-based whitelist sanitiser -- [ ] (Future) `resource-gc` — cleanup of orphaned resources -- [ ] (Future) `resource-acl` — per-resource access control if non-public assets are added +## Tasks + +- [ ] Task 1: Create `lib/Service/ResourceService::upload(string $base64DataUrl): array` returning `{url, name, size}` or throwing typed exceptions; create `lib/Service/ImageMimeValidator::validate(string $declaredType, string $bytes): void` +- [ ] Task 2: Define typed exceptions with stable error codes — `ForbiddenException`, `InvalidImageFormatException`, `InvalidDataUrlException`, `FileTooLargeException`, `MimeMismatchException`, `CorruptImageException` +- [ ] Task 3: Add `lib/Controller/ResourceController::upload` mapped to `POST /api/resources`; read raw input via `file_get_contents('php://input') + json_decode`; admin guard via `IGroupManager::isAdmin` +- [ ] Task 4: 5MB cap on decoded bytes (guard BEFORE `getimagesizefromstring` to bound memory); cross-MIME check for raster types via `ImageMimeValidator`; delegate SVG sanitisation to the `SvgSanitiser` (separate change) +- [ ] Task 5: Persist via `IAppData->getFolder('resources')` (auto-create); filename `uniqid('resource_', true) . '.' . $ext` +- [ ] Task 6: Map exceptions to a standardised error envelope in the controller — never surface raw `$e->getMessage()` (every response uses the stable error code) +- [ ] Task 7: Add `src/services/resourceService.js::uploadDataUrl(dataUrl): Promise<{url}>` wrapper consumed by `image-widget` form, `link-button-widget` icon picker, and `IconPicker` +- [ ] Task 8: PHPUnit — 403 on non-admin; each rejection path returns the exact error code; oversize rejected before `getimagesizefromstring` (mock memory check); MIME mismatch table (declared png, actual jpeg/gif/webp); successful upload writes to app-data and returns the URL +- [ ] Task 9: PHPUnit — error responses NEVER contain `Exception` / stack-trace strings (regression guard against raw message leakage) +- [ ] Task 10: Playwright — file upload from icon picker → URL appears in the form on success; non-admin attempt surfaces the 403 message via the existing toast +- [ ] Task 11: Quality — `composer check:strict` passes; OpenAPI updated for `POST /api/resources`; SPDX-in-docblock on every new PHP file +- [ ] Task 12: i18n — `nl_NL` + `en_US` for `Personal dashboards are not enabled by your administrator`, `Failed to upload image`, plus an error-message string per stable error code; document v1 limits in admin help text (5MB cap, allowed types) +- [ ] Task 13: File follow-ups (separate changes) — `resource-serving` GET endpoint (in flight), `svg-sanitisation` DOM whitelist (in flight), future `resource-gc` (orphan cleanup), future `resource-acl` (per-resource access control if non-public assets are added) + +## Verification + +`openspec validate` exits clean. Rejection paths return their stable error code and no exception text leaks; admin-only enforcement holds. + +## Tests (company-wide ADR-009) + +PHPUnit per Tasks 8–9; Playwright per Task 10. Newman/Postman updated for the new endpoint. + +## Documentation (company-wide ADR-010) + +Admin help text per Task 12; changelog entry covering the new endpoint and the v1 limits. + +## i18n (company-wide ADR-005) + +`nl_NL` + `en_US` per Task 12. diff --git a/openspec/changes/role-based-content/tasks.md b/openspec/changes/role-based-content/tasks.md index 4ea2eca7..8124cb5a 100644 --- a/openspec/changes/role-based-content/tasks.md +++ b/openspec/changes/role-based-content/tasks.md @@ -1,224 +1,64 @@ -> **Stage 1-3 complete on build/role-based-content (PR #95).** Native mydash -> persistence used in place of OpenRegister-based design (see PR description). -> Tasks 0.x, 4.2-4.3, 8.2, 9.3, 11.2-11.3, 12.x-14.x, 15.2, 16.x deferred to -> Stage 4 / follow-up commits. - # Tasks — role-based-content -## 0. Deduplication check - -- [ ] 0.1 Search `openspec/specs/` for any existing capability covering widget-level role - filtering (distinct from `permissions` which covers per-dashboard edit rights and - `admin-templates` which covers dashboard distribution). Document findings here. -- [ ] 0.2 Grep `openregister/lib/Service/` and `lib/Service/` for any existing - `getAllowedWidgetIds`, `widgetPermission`, or `roleFilter` methods — confirm none exist. -- [ ] 0.3 Verify `@conduction/nextcloud-vue` does not already expose a role-filtered widget - picker component that would make `RolePermissionsSection.vue` redundant. -- [ ] 0.4 Record findings (even "no overlap found") in a comment block at the top of - `RoleFeaturePermissionService.php`. - -## 1. OpenRegister schemas and seed data - -- [x] 1.1 Add `RoleFeaturePermission` schema definition to `lib/Settings/mydash_register.json` - (schema key `role-feature-permission`, register key `mydash`) with all properties from - design.md: `name`, `description`, `groupId`, `allowedWidgets`, `deniedWidgets`, - `priorityWeights`. Mark `name`, `groupId`, `allowedWidgets` as required. Use schema.org - vocabulary per ADR-011. -- [x] 1.2 Add `RoleLayoutDefault` schema definition to `lib/Settings/mydash_register.json` - (schema key `role-layout-default`) with properties: `name`, `groupId`, `widgetId`, - `gridX`, `gridY`, `gridWidth`, `gridHeight`, `sortOrder`, `isCompulsory`, `description`. - Mark `name`, `groupId`, `widgetId`, `gridX`, `gridY`, `gridWidth`, `gridHeight`, - `sortOrder` as required. -- [ ] 1.3 Add the 5 RoleFeaturePermission seed objects from design.md to - `lib/Settings/mydash_register.json` under `components.objects[]` using the `@self` - envelope (register, schema, slug per design.md). -- [ ] 1.4 Add the 5 RoleLayoutDefault seed objects from design.md to - `lib/Settings/mydash_register.json` using the `@self` envelope. -- [ ] 1.5 Verify idempotency: re-running `ConfigurationService::importFromApp()` with - `force: false` MUST NOT create duplicate objects (matching by slug). - -## 2. Backend service - -- [x] 2.1 Create `lib/Service/RoleFeaturePermissionService.php` with: - - `@spec openspec/changes/role-based-content/tasks.md#task-2` - - Constructor injection: `ObjectService $objectService`, - `AdminSettingsService $adminSettingsService`, `IGroupManager $groupManager`, - `LoggerInterface $logger` - - `getAllowedWidgetIds(string $userId): ?array` — returns null (unconfigured) or the - effective allowed-widget ID array (REQ-RFP-009 backwards-compat, REQ-RFP-005 - multi-group algorithm from design.md) - - `isWidgetAllowed(string $userId, string $widgetId): bool` — convenience wrapper - - `seedLayoutFromRoleDefaults(string $userId, object $dashboard): void` — reads - RoleLayoutDefault objects for primary group, creates WidgetPlacement records - (REQ-RFP-002) - - `authorizeAdminObject(IUser $user): void` — throws `OCSForbiddenException` if not - admin (ADR-005 pattern) - - All methods MUST be stateless (no instance state between requests, ADR-003) -- [x] 2.2 Implement multi-group resolution algorithm per design.md §"Multi-group Resolution - Algorithm": walk `group_order`, base set from first matching group, union additional - matches, deny-wins rule (REQ-RFP-005, REQ-RFP-006). -- [x] 2.3 Implement fallback to `'default'` RoleFeaturePermission when no `group_order` match - found, and null fallback (return all widgets) when no `'default'` object exists - (REQ-RFP-009). -- [x] 2.4 In `seedLayoutFromRoleDefaults()`: only call when the dashboard has zero existing - placements (guard against overwriting personal customisations, REQ-RFP-002 scenario 3). - -## 3. Backend controller - -- [x] 3.1 Create `lib/Controller/RoleFeaturePermissionController.php` with: - - `@spec openspec/changes/role-based-content/tasks.md#task-3` - - All methods annotated `#[AuthorizedAdminSetting(Application::APP_ID)]` - - `listPermissions(): JSONResponse` — `GET /api/role-feature-permissions` - - `savePermission(Request $request): JSONResponse` — `POST /api/role-feature-permissions` - - `listLayoutDefaults(): JSONResponse` — `GET /api/role-layout-defaults` - - `saveLayoutDefault(Request $request): JSONResponse` — `POST /api/role-layout-defaults` - - Each method: thin (<10 lines), calls service, returns JSONResponse (ADR-003) - - Error responses: static generic messages only — NEVER `$e->getMessage()` (ADR-015) -- [x] 3.2 Register all four routes in `appinfo/routes.php` before any wildcard `{slug}` route: - - `GET /api/role-feature-permissions` - - `POST /api/role-feature-permissions` - - `GET /api/role-layout-defaults` - - `POST /api/role-layout-defaults` - -## 4. Extend existing widget controller - -- [x] 4.1 In `lib/Controller/WidgetController.php` `list()` method: inject - `RoleFeaturePermissionService` and call `getAllowedWidgetIds($userId)`; if the result is - not null, filter the widget array to only those with IDs in the allowed set (REQ-RFP-001, - REQ-RFP-003). -- [ ] 4.2 In `WidgetController` method(s) that serve widget feature content (e.g. `getItems()`): - call `isWidgetAllowed($userId, $widgetId)` before delegating to the widget loader; return - HTTP 403 with `{"message": "Not authorized"}` and write an audit entry if denied - (REQ-RFP-001 scenario 3, REQ-RFP-006 scenario 2). -- [ ] 4.3 Audit entry format: use `AuditTrailService` (OpenRegister), record - `$user->getUID()` (NOT display name, ADR-005), `widgetId`, ISO timestamp, reason string - `"role_permission_denied"` or `"interest_without_role"` as applicable. - -## 5. Extend dashboard resolver - -- [x] 5.1 In `lib/Service/DashboardResolver.php` (or equivalent) `tryCreateFromTemplate()`: - after all admin-template matching fails, call - `RoleFeaturePermissionService::seedLayoutFromRoleDefaults()` if RoleLayoutDefault - objects exist for the user's primary group (REQ-RFP-002). -- [x] 5.2 Verify the guard: `seedLayoutFromRoleDefaults()` MUST only run when the new - dashboard has zero placements — assert this in the unit test (REQ-RFP-002 scenario 3). - -## 6. Initial-state payload - -- [x] 6.1 In the settings/initial-state controller (e.g. `SettingsController`): call - `RoleFeaturePermissionService::getAllowedWidgetIds()` and include the result as - `allowedWidgets` in the JSON response (REQ-RFP-010). Return `null` when unconfigured. -- [x] 6.2 Ensure the initial-state type definition / PHP doc reflects the new field so Psalm - does not flag it as undeclared. - -## 7. Frontend — store - -- [x] 7.1 Create `src/store/modules/roleFeaturePermission.js`: - `createObjectStore('role-feature-permission')` with `auditTrailsPlugin` (Pinia pattern, - ADR-004). Register in `src/store/store.js` via `registerObjectType`. -- [x] 7.2 Create `src/store/modules/roleLayoutDefault.js`: - `createObjectStore('role-layout-default')`. Register in `src/store/store.js`. -- [x] 7.3 Extend settings store: add `allowedWidgets: null` field, populated from the - initial-state payload (REQ-RFP-010). - -## 8. Frontend — card library filtering - -- [x] 8.1 In the widget card library / picker component: read `allowedWidgets` from the - settings store; if non-null, filter the widget list before rendering so that - disallowed widgets are absent from the DOM entirely (not hidden via CSS) (REQ-RFP-003, - non-functional accessibility requirement). -- [ ] 8.2 Wrap the store action call in `try/catch` with user-facing error feedback (ADR-004 - rule: EVERY `await store.action()` MUST be in try/catch). - -## 9. Frontend — admin UI - -- [x] 9.1 Create `src/components/RolePermissionsSection.vue` (scoped style, EUPL header): - - Lists existing RoleFeaturePermission objects in a `CnDataTable` - - Add button opens `CnFormDialog` (schema-driven form for RoleFeaturePermission) - - Edit opens `CnFormDialog` pre-populated - - Delete opens `CnDeleteDialog` - - All user-visible strings via `t(appName, 'key')` — no hardcoded strings (ADR-007) -- [x] 9.2 Add `RolePermissionsSection` to `src/views/AdminApp.vue` beneath existing admin - sections. Register the component in `components: {}` (ADR-004: every component used - in template MUST be imported AND registered). -- [ ] 9.3 Add a RoleLayoutDefault section (`CnDataTable` + `CnFormDialog` + `CnDeleteDialog`) - to the admin UI — either as a second tab within `RolePermissionsSection` or as a - separate `RoleLayoutDefaultsSection.vue` component. - -## 10. i18n - -- [x] 10.1 Add English translation keys to `l10n/en.json` for all new user-facing strings: - admin section titles, column headers, form labels, error messages (ADR-007 sentence case). -- [x] 10.2 Add Dutch (`nl`) translations to `l10n/nl.json` for every key added in 10.1. - Both files MUST contain exactly the same keys with zero gaps. - -## 11. Unit tests (PHPUnit) - -- [x] 11.1 `tests/Unit/Service/RoleFeaturePermissionServiceTest.php` — table-driven tests - covering every REQ-RFP scenario: - - single group, allowed widget in list → allowed - - single group, widget not in allowed list → denied - - multi-group, first-match wins (REQ-RFP-005 scenario 1) - - multi-group, deny-wins rule (REQ-RFP-005 scenario 2) - - no RoleFeaturePermission exists → returns null (REQ-RFP-009) - - group not in group_order → falls back to `'default'` group - - no `'default'` group → returns null - - `seedLayoutFromRoleDefaults()` with zero existing placements → creates placements - - `seedLayoutFromRoleDefaults()` with existing placements → no-op (REQ-RFP-002 s.3) -- [ ] 11.2 `tests/Unit/Controller/RoleFeaturePermissionControllerTest.php` — at minimum: - - non-admin request → 403 - - list returns all objects - - save with valid body → 201 - - save with invalid body → 400 (static error message, no stack trace) -- [ ] 11.3 `tests/Unit/Controller/WidgetControllerTest.php` (extend existing or create): - - `allowedWidgets = null` → full list returned unchanged - - `allowedWidgets = ["activity"]` → only activity in response - - direct access to restricted widget → 403 + audit entry written - -## 12. Integration tests - -- [ ] 12.1 Add Postman/Newman collection entries in `tests/integration/` covering all five new - endpoints with happy-path (200/201) and error-path (403, 400) scenarios (ADR-008). -- [ ] 12.2 Include a test asserting `GET /api/widgets` returns the filtered list when a - RoleFeaturePermission exists for the test user's group. - -## 13. Browser / spec scenarios - -- [ ] 13.1 Add Playwright test verifying REQ-RFP-001 scenario 1: employee-role user does not - see admin-only widget in card library (widget absent from DOM, not merely hidden). -- [ ] 13.2 Add Playwright test verifying REQ-RFP-002 scenario 1: new manager-role user's - seeded dashboard contains the correct widgets at the correct grid positions. - -## 14. Smoke testing (ADR-008) - -- [ ] 14.1 Call `GET /api/role-feature-permissions` with admin credentials — verify 200 + array. -- [ ] 14.2 Call `POST /api/role-feature-permissions` with non-admin user — verify 403. -- [ ] 14.3 Call `GET /api/widgets` as a user whose group has a configured RoleFeaturePermission - — verify only allowed widgets are returned. -- [ ] 14.4 Attempt direct access to a restricted widget endpoint as an unpermitted user — - verify 403 response with `{"message": "Not authorized"}` (no stack trace, no internal - path in response body). - -## 15. Documentation - -- [x] 15.1 Add `docs/role-based-content.md` describing the feature for IT admins: how to - create RoleFeaturePermission objects, how group priority interacts with widget filtering, - how RoleLayoutDefault seeds new users (ADR-009). -- [ ] 15.2 Include at least one screenshot of the admin UI role-permissions section. - -## 16. Quality gates - -- [ ] 16.1 `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan) on all new PHP files. -- [ ] 16.2 ESLint + Stylelint clean on all new / modified Vue and JS files. -- [ ] 16.3 SPDX `@license` + `@copyright` PHPDoc tags present in every new `lib/**/*.php` file - (`gate-spdx` / `hydra-gate-spdx` must pass). -- [ ] 16.4 No forbidden debug helpers (`var_dump`, `die`, `error_log`, `print_r`, `dd`) - (`hydra-gate-forbidden-patterns` must pass). -- [ ] 16.5 No stub code — no empty `run()` bodies, no "In a complete implementation" comments - (`hydra-gate-stub-scan` must pass). -- [ ] 16.6 No `#[NoAdminRequired]` without a per-object auth check (all new endpoints use - `#[AuthorizedAdminSetting]` — confirm `hydra-gate-no-admin-idor` and - `hydra-gate-route-auth` pass). -- [ ] 16.7 Run all 10 `hydra-gates` locally (`/hydra-gates`) before opening PR. -- [ ] 16.8 `@spec openspec/changes/role-based-content/tasks.md#task-N` PHPDoc tag present on - every new class and public method (ADR-003 spec traceability). +> **Stage 1-3 complete on build/role-based-content (PR #95).** Native mydash +> persistence used in place of OpenRegister-based design (see PR description). +> Remaining unchecked tasks below cover deferred Stage 4 + follow-up work. + +## Completed in Stage 1-3 (PR #95) + +- [x] 1.1 Add `RoleFeaturePermission` schema to `lib/Settings/mydash_register.json` (REQ-RFP per design) +- [x] 1.2 Add `RoleLayoutDefault` schema to `lib/Settings/mydash_register.json` +- [x] 2.1 Create `lib/Service/RoleFeaturePermissionService.php` with `getAllowedWidgetIds`, `isWidgetAllowed`, `seedLayoutFromRoleDefaults`, `authorizeAdminObject` (stateless, DI-only per ADR-003) +- [x] 2.2 Multi-group resolution algorithm (REQ-RFP-005/006) — first-match base + union additional matches + deny-wins +- [x] 2.3 Fallback chain to `'default'` group then null (REQ-RFP-009) +- [x] 2.4 `seedLayoutFromRoleDefaults` only fires on dashboards with zero placements (REQ-RFP-002 s.3) +- [x] 3.1 Create `lib/Controller/RoleFeaturePermissionController.php` with the 4 admin endpoints (`#[AuthorizedAdminSetting]`) +- [x] 3.2 Register all 4 routes in `appinfo/routes.php` BEFORE wildcard `{slug}` routes +- [x] 4.1 `WidgetController::list()` filters by `getAllowedWidgetIds` when non-null (REQ-RFP-001/003) +- [x] 5.1 `DashboardResolver::tryCreateFromTemplate()` calls `seedLayoutFromRoleDefaults` when admin-template matching fails +- [x] 5.2 Zero-placements guard asserted in unit tests +- [x] 6.1 Settings/initial-state includes `allowedWidgets` (REQ-RFP-010), null when unconfigured +- [x] 6.2 Initial-state type/PHPDoc declares the new field +- [x] 7.1 `src/store/modules/roleFeaturePermission.js` via `createObjectStore` + `auditTrailsPlugin`, registered in `store.js` +- [x] 7.2 `src/store/modules/roleLayoutDefault.js`, registered in `store.js` +- [x] 7.3 Settings store exposes `allowedWidgets` from initial state +- [x] 8.1 Card library / picker filters by `allowedWidgets` (filtered out of DOM, NOT hidden via CSS) +- [x] 9.1 `RolePermissionsSection.vue` with `CnDataTable` + `CnFormDialog` + `CnDeleteDialog`, EUPL header, all strings via `t(appName, ...)` +- [x] 9.2 `RolePermissionsSection` wired into `src/views/AdminApp.vue` +- [x] 10.1 English translation keys in `l10n/en.json` +- [x] 10.2 Dutch (`nl`) parity in `l10n/nl.json` (zero key gaps vs English) +- [x] 11.1 `RoleFeaturePermissionServiceTest` table-driven coverage of every REQ-RFP scenario (single-group allow/deny, multi-group first-match, deny-wins, null fallback, `default` fallback, no-default null, seed creates placements, seed no-op on existing placements) +- [x] 15.1 `docs/role-based-content.md` admin guide (RoleFeaturePermission creation, group priority interaction, RoleLayoutDefault seeding) + +## Tasks (Stage 4 / follow-up) + +- [ ] Task 1: Dedup audit — search `openspec/specs/` for prior widget-level role filtering capability (vs `permissions` and `admin-templates`); grep `openregister/lib/Service/` + `lib/Service/` for `getAllowedWidgetIds`/`widgetPermission`/`roleFilter`; verify `@conduction/nextcloud-vue` does not already expose a role-filtered picker; record findings (even "no overlap") in a comment block at the top of `RoleFeaturePermissionService.php` +- [ ] Task 2: Seed data — add the 5 RoleFeaturePermission seed objects + 5 RoleLayoutDefault seed objects from design.md to `lib/Settings/mydash_register.json` under `components.objects[]` using the `@self` envelope; verify idempotency (re-running `ConfigurationService::importFromApp()` with `force:false` MUST NOT duplicate) +- [ ] Task 3: `WidgetController` getItems / per-widget content endpoints call `isWidgetAllowed($userId, $widgetId)` before delegating; return HTTP 403 `{"message":"Not authorized"}` AND write an audit-trail entry on denial (REQ-RFP-001 s.3 + REQ-RFP-006 s.2) +- [ ] Task 4: Audit-trail entry format — `AuditTrailService` (OpenRegister), `$user->getUID()` (NOT display name per ADR-005), `widgetId`, ISO timestamp, reason string `"role_permission_denied"` or `"interest_without_role"` +- [ ] Task 5: Frontend hardening — every `await store.action()` wrapped in `try/catch` with user-facing error feedback per ADR-004 (covers card-library + admin UI store calls) +- [ ] Task 6: Admin UI — add a RoleLayoutDefault section (`CnDataTable` + `CnFormDialog` + `CnDeleteDialog`) either as a second tab in `RolePermissionsSection` or as a separate `RoleLayoutDefaultsSection.vue` component +- [ ] Task 7: PHPUnit controller — `RoleFeaturePermissionControllerTest`: non-admin → 403; list returns all objects; save with valid body → 201; save with invalid body → 400 (static message, no stack trace) +- [ ] Task 8: PHPUnit widget controller — extend `WidgetControllerTest`: `allowedWidgets = null` → unchanged full list; `allowedWidgets = ["activity"]` → only activity returned; direct access to a restricted widget → 403 + audit entry written +- [ ] Task 9: Newman/Postman — add `tests/integration/` entries for all 5 new endpoints with happy-path (200/201) and error-path (403/400) scenarios per ADR-008; include a test asserting `GET /api/widgets` is filtered for a user whose group has a configured RoleFeaturePermission +- [ ] Task 10: Playwright — employee-role user does NOT see admin-only widget in card library (widget absent from DOM, not hidden); new manager-role user's seeded dashboard contains the correct widgets at the correct grid positions (REQ-RFP-001 s.1, REQ-RFP-002 s.1) +- [ ] Task 11: Smoke ADR-008 — `GET /api/role-feature-permissions` admin creds → 200 + array; `POST /api/role-feature-permissions` non-admin → 403; `GET /api/widgets` for configured-group user returns only allowed widgets; direct restricted-widget endpoint as unpermitted user → 403 `{"message":"Not authorized"}` (no stack trace, no internal path) +- [ ] Task 12: Documentation — add at least one screenshot of the admin role-permissions section to `docs/role-based-content.md` +- [ ] Task 13: Quality gates — `composer check:strict` clean on all new PHP; ESLint+Stylelint clean on new/modified Vue+JS; SPDX `@license`+`@copyright` in every new `lib/**/*.php`; no forbidden debug helpers (`var_dump`/`die`/`error_log`/`print_r`/`dd`); no stub code (no empty `run()` bodies, no "In a complete implementation" placeholders); `#[NoAdminRequired]` always paired with a per-object auth check; all 10 `hydra-gates` green +- [ ] Task 14: ADR-003 traceability — `@spec openspec/changes/role-based-content/tasks.md#task-N` PHPDoc tag present on every new class and public method + +## Verification + +`openspec validate` exits clean. Hydra gates 1-10 pass on the follow-up branch; Playwright + Newman gates per Tasks 9–11 green. + +## Tests (company-wide ADR-009) + +PHPUnit per Tasks 7–8; Newman per Task 9; Playwright per Task 10. Smoke calls per Task 11. + +## Documentation (company-wide ADR-010) + +`docs/role-based-content.md` already exists; screenshot supplement per Task 12. + +## i18n (company-wide ADR-005) + +`l10n/en.json` + `l10n/nl.json` parity already achieved; any new admin-UI strings shipped in Task 6 follow the same convention. diff --git a/openspec/changes/runtime-shell/tasks.md b/openspec/changes/runtime-shell/tasks.md index 5b5fad0a..ba8abfbe 100644 --- a/openspec/changes/runtime-shell/tasks.md +++ b/openspec/changes/runtime-shell/tasks.md @@ -1,43 +1,32 @@ # Tasks — runtime-shell -## 1. Backend (template + controller) - -- [ ] 1.1 Update `templates/index.php` to render `
` -- [ ] 1.2 Update `WorkspaceController::index` to pass `'id-app-content' => '#app-workspace'` and `'id-app-navigation' => null` to the template -- [ ] 1.3 Confirm initial-state push (handled by the separate `initial-state-contract` change) wires `isAdmin`, `dashboardSource`, `activeDashboardId`, `allowUserDashboards`, `layout` into the page - -## 2. Frontend shell component - -- [ ] 2.1 Refactor `src/views/WorkspaceApp.vue` into the four-region shell (sidebar, hamburger+title strip, toolbar, grid) -- [ ] 2.2 Add computed `canEdit = isAdmin || dashboardSource === 'user'` (REQ-SHELL-002) -- [ ] 2.3 Conditional toolbar via `v-if="canEdit"` (NOT `v-show` — keep DOM clean for non-edit users) (REQ-SHELL-003) -- [ ] 2.4 `saveLayout()` chooses endpoint by `dashboardSource` and PUTs `{layout}`; sets `saving = true` until response resolves (REQ-SHELL-003) -- [ ] 2.5 Sidebar backdrop component (fixed, `top: 50px`) that closes the sidebar on click (REQ-SHELL-006) -- [ ] 2.6 Empty-state component branching on `allowUserDashboards` (REQ-SHELL-005) -- [ ] 2.7 Hamburger button + active-dashboard label rendered above the toolbar (REQ-SHELL-004) -- [ ] 2.8 `onMounted`: register `document.click` listener after `nextTick`; init grid via composable (REQ-SHELL-007) -- [ ] 2.9 `onBeforeUnmount`: remove listener; destroy grid (REQ-SHELL-007) - -## 3. Styles - -- [ ] 3.1 Add `src/styles/workspace.css` (or extend existing) with the four-region layout -- [ ] 3.2 Style the fixed sidebar backdrop (`position: fixed; top: 50px; bottom: 0; left: 0; right: 0;`) -- [ ] 3.3 Style the empty-state container inside the grid area - -## 4. Tests - -- [ ] 4.1 Playwright: admin sees toolbar regardless of `dashboardSource` -- [ ] 4.2 Playwright: non-admin viewing a group dashboard does NOT see toolbar; grid is in `staticGrid: true` mode -- [ ] 4.3 Playwright: non-admin viewing own personal dashboard sees toolbar; grid is editable -- [ ] 4.4 Playwright: hamburger toggles sidebar; backdrop click closes it; click on the sidebar itself does not close it -- [ ] 4.5 Playwright: empty state renders the correct CTA for both `allowUserDashboards: true` and `false` -- [ ] 4.6 Playwright: Save button disabled while in flight; no double-submit fires -- [ ] 4.7 Vitest: `onBeforeUnmount` removes the `document.click` listener and destroys the GridStack instance - -## 5. Quality - -- [ ] 5.1 ESLint + Stylelint clean on all touched Vue/JS/CSS files -- [ ] 5.2 PHPCS clean on `templates/index.php` and `lib/Controller/WorkspaceController.php` -- [ ] 5.3 Translation entries (`nl` + `en`) for all toolbar / empty-state strings per the i18n requirement -- [ ] 5.4 SPDX headers inside the docblock on every touched/new PHP file -- [ ] 5.5 Run all 10 `hydra-gates` locally before opening PR +## Tasks + +- [ ] Task 1: Update `templates/index.php` to render `
`; update `WorkspaceController::index` to pass `'id-app-content' => '#app-workspace'` + `'id-app-navigation' => null` to the template; confirm initial-state push (separate `initial-state-contract` change) wires `isAdmin`, `dashboardSource`, `activeDashboardId`, `allowUserDashboards`, `layout` +- [ ] Task 2: Refactor `src/views/WorkspaceApp.vue` into the four-region shell (sidebar, hamburger+title strip, toolbar, grid) +- [ ] Task 3: Add computed `canEdit = isAdmin || dashboardSource === 'user'` (REQ-SHELL-002) +- [ ] Task 4: Toolbar uses `v-if="canEdit"` (NOT `v-show` — keep DOM clean for non-edit users) per REQ-SHELL-003 +- [ ] Task 5: `saveLayout()` chooses the endpoint by `dashboardSource` and PUTs `{layout}`; sets `saving = true` until the response resolves (REQ-SHELL-003) +- [ ] Task 6: Add a fixed sidebar backdrop component (`top:50px; bottom:0; left:0; right:0`) that closes the sidebar on click (REQ-SHELL-006); add the empty-state component branching on `allowUserDashboards` (REQ-SHELL-005); add hamburger button + active-dashboard label above the toolbar (REQ-SHELL-004) +- [ ] Task 7: Lifecycle — `onMounted` registers the `document.click` listener after `nextTick` and inits the grid via the composable; `onBeforeUnmount` removes the listener and destroys the grid (REQ-SHELL-007) +- [ ] Task 8: Styles — add `src/styles/workspace.css` (or extend existing) with the four-region layout, the fixed-sidebar backdrop styling, and the empty-state container styling inside the grid area +- [ ] Task 9: Playwright — admin sees toolbar regardless of `dashboardSource`; non-admin viewing a group dashboard does NOT see the toolbar and grid is in `staticGrid: true`; non-admin viewing own personal dashboard sees the toolbar and grid is editable +- [ ] Task 10: Playwright — hamburger toggles sidebar; backdrop click closes it; click on the sidebar itself does NOT close it; empty state renders the correct CTA for both `allowUserDashboards: true` and `false`; Save button disabled while in flight (no double-submit) +- [ ] Task 11: Vitest — `onBeforeUnmount` removes the `document.click` listener AND destroys the GridStack instance (no leaks) +- [ ] Task 12: Quality + i18n — ESLint+Stylelint clean on touched Vue/JS/CSS; PHPCS clean on `templates/index.php` + `lib/Controller/WorkspaceController.php`; SPDX-in-docblock on every touched/new PHP file; `nl_NL`+`en_US` translations for all toolbar + empty-state strings; all 10 hydra-gates green + +## Verification + +`openspec validate` exits clean. Shell renders the four regions correctly and toolbar visibility tracks `canEdit` exactly per the test matrix. + +## Tests (company-wide ADR-009) + +Playwright per Tasks 9–10; Vitest per Task 11. Backend touched only via template + controller — no new endpoints. + +## Documentation (company-wide ADR-010) + +Changelog entry covering the new shell layout + the `canEdit` rule. + +## i18n (company-wide ADR-005) + +`nl_NL` + `en_US` for all toolbar + empty-state strings per Task 12. diff --git a/openspec/changes/svg-sanitisation/tasks.md b/openspec/changes/svg-sanitisation/tasks.md index ba48c0b5..59a3efc6 100644 --- a/openspec/changes/svg-sanitisation/tasks.md +++ b/openspec/changes/svg-sanitisation/tasks.md @@ -1,60 +1,32 @@ # Tasks — svg-sanitisation -## 1. Sanitiser service - -- [ ] 1.1 Create `lib/Service/SvgSanitiser.php` with public method `sanitize(string $bytes): ?string` -- [ ] 1.2 Define private static const `ALLOWED_ELEMENTS` containing the 24 element names from REQ-RES-010 (lowercase) -- [ ] 1.3 Define private static const `ALLOWED_ATTRIBUTES` containing the 50 attribute names from REQ-RES-011 (lowercase) -- [ ] 1.4 Call `libxml_use_internal_errors(true)` BEFORE parse and `libxml_clear_errors()` AFTER per REQ-RES-013 -- [ ] 1.5 Parse via `DOMDocument::loadXML($bytes, LIBXML_NONET | LIBXML_NOENT)`; return `null` on parse failure -- [ ] 1.6 Walk the DOM recursively; snapshot the child node list BEFORE mutation so removals are safe during iteration -- [ ] 1.7 Remove any element whose lowercased localName is not in `ALLOWED_ELEMENTS` (along with its children) -- [ ] 1.8 Remove any attribute whose lowercased name is not in `ALLOWED_ATTRIBUTES` -- [ ] 1.9 Strip ALL attributes whose lowercased name starts with `on` regardless of whitelist (REQ-RES-011 defence in depth) -- [ ] 1.10 Filter `href` and `xlink:href`: trim + lowercase + reject `javascript:` / `data:` prefixes (REQ-RES-012) -- [ ] 1.11 Filter `style`: regex `/expression\s*\(|javascript\s*:|url\s*\(\s*["\']?\s*data\s*:/i` — full attribute removal on match -- [ ] 1.12 Serialise back via `DOMDocument::saveXML($root)`; return `null` if result is empty or has no root element -- [ ] 1.13 Document the whitelist policy in the class docblock with a link to REQ-RES-010 / REQ-RES-011 - -## 2. Exception + controller wiring - -- [ ] 2.1 Create `lib/Exception/InvalidSvgException.php` extending `\RuntimeException` -- [ ] 2.2 In `lib/Service/ResourceService.php::upload()` detect SVG MIME (`image/svg` or `image/svg+xml`) BEFORE the size check -- [ ] 2.3 For SVG branch: call `SvgSanitiser::sanitize($bytes)`; on `null` throw `InvalidSvgException`; otherwise replace `$bytes` with the sanitised string -- [ ] 2.4 Run the existing REQ-RES-003 size check AFTER sanitisation so the 5 MB cap measures the persisted bytes -- [ ] 2.5 In `lib/Controller/ResourceController.php` catch `InvalidSvgException` and return `JSONResponse(['status'=>'error','error'=>'invalid_svg'], Http::STATUS_BAD_REQUEST)` -- [ ] 2.6 Confirm no file is written when the exception is thrown (filesystem write happens AFTER sanitiser returns non-null) - -## 3. PHPUnit tests — sanitiser unit - -- [ ] 3.1 Clean SVG round-trips with semantically equivalent output (whitespace differences allowed) -- [ ] 3.2 `