feat(dashboard): Dashboard & My Work MVP — real KPI cards and My Work view#17
feat(dashboard): Dashboard & My Work MVP — real KPI cards and My Work view#17rubenvdlinde wants to merge 7 commits intodevelopmentfrom
Conversation
Adds the Dashboard & My Work MVP spec (design.md, tasks.md, specs/) to openspec/changes/spec/ as required by the Hydra pipeline.
…ister Fetches tasks assigned to the current user and all member projects on mount (two parallel API calls). Computes four KPI cards — Open, Overdue, In Progress, Completed Today — from live task data. Each card navigates to My Work with the relevant filter on click. Closes task 1 of the Dashboard & My Work MVP spec.
Adds a new /my-work route backed by MyWork.vue. Tasks assigned to the current user are grouped into three sections — Overdue, Due this week, and Everything else — each sorted by priority (urgent → high → normal → low). Each task shows a priority dot, project badge, title (clickable to board), and due date. An empty state with a 'Browse projects' CTA is shown when no tasks are assigned. My Work is added to the main nav. Closes task 3 of the Dashboard & My Work MVP spec.
All acceptance criteria for the Dashboard & My Work MVP are implemented.
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
|
[CRITICAL] Unhandled promise rejection in Both mounted() {
this.fetchData() // no .catch()
},
methods: {
async fetchData() {
this.loading = true
try {
// ...await Promise.all(...)
} finally {
this.loading = false // no catch block
}
}
}If the OpenRegister API call fails (network error, 401, 500), the promise rejection is unhandled. The Fix: Add a async fetchData() {
this.loading = true
try {
// ...
} catch (error) {
console.error('Failed to load data', error)
showError(t('planix', 'Failed to load your tasks. Please try again.'))
} finally {
this.loading = false
}
} |
|
[CRITICAL] EUPL-1.2 license header duplicated in
File-level comments before |
|
[WARNING]
Note: other pre-existing views ( |
|
[WARNING] The spec scenario "Navigate to task detail from My Work" (dashboard-my-work.md) states:
The current implementation at goToTask(task) {
if (task.project) {
this.$router.push({ name: 'ProjectBoard', params: { id: task.project } })
}
},This navigates to the project board, not to a task detail. Additionally, if Given that Recommendation: Either add a |
|
[WARNING] All projects fetched without server-side filtering — scalability concern Both objectStore.fetchCollection(PROJECT_SCHEMA, {})The .filter((p) => Array.isArray(p.members) && p.members.includes(uid))On a Nextcloud instance with hundreds of projects, this loads the full project collection into the browser for a client-side filter. Tasks are correctly filtered server-side ( Recommendation: Pass a server-side filter if the OpenRegister API supports |
|
[WARNING] Both Dashboard.vue (line 129) and MyWork.vue (line 156) define: todayMidnight() {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d
},In Vue 2, a computed property with no reactive dependencies is computed once and cached indefinitely. If a user opens the dashboard before midnight and keeps the browser tab open, Recommendation: Either store |
|
[WARNING] Logic duplicated between The following are copy-pasted identically across both files:
This creates a maintenance burden: a bug fix or API change must be applied in two places. Recommend extracting a Not blocking for MVP, but worth tracking for the next iteration. |
|
[WARNING]
**Status**: ideaNow that the change is implemented and a PR is open, this should be updated to |
{
"reviewer": "Juan Claude van Damme — Hydra Code Reviewer, Conduction B.V.",
"pr": "ConductionNL/planix#17",
"reviewed_at": "2026-04-04",
"verdict": "CHANGES_REQUESTED",
"lint": "PASS",
"tests": "N/A — no JS test framework present; PHP tests unaffected by frontend-only change",
"summary": "Implementation covers the MVP scope (live KPI cards, Recent projects, Due this week, My Work view with priority grouping) and ESLint is clean. Two critical issues must be fixed before merge: the fetchData error paths are completely unhandled (silent failures on API errors), and the EUPL-1.2 license header in MyWork.vue is duplicated at file level. Five warnings are noted — most are acceptable for MVP but the stale todayMidnight computed and missing server-side project filter should be tracked.",
"findings": [
{
"id": "F-01",
"severity": "CRITICAL",
"file": "src/views/Dashboard.vue",
"line": 175,
"title": "Unhandled promise rejection in fetchData()",
"description": "mounted() calls this.fetchData() without .catch(). The async method has try/finally but no catch. API failures silently produce an empty view with no user feedback."
},
{
"id": "F-02",
"severity": "CRITICAL",
"file": "src/views/MyWork.vue",
"line": 196,
"title": "Unhandled promise rejection in fetchData() (same issue)",
"description": "Same pattern as F-01 in MyWork.vue."
},
{
"id": "F-03",
"severity": "CRITICAL",
"file": "src/views/MyWork.vue",
"line": "1-2",
"title": "EUPL-1.2 license header duplicated at file level",
"description": "License comment appears before <template> (lines 1-2) and again correctly inside <script> (lines 122-123). Remove the file-level occurrence."
},
{
"id": "F-04",
"severity": "WARNING",
"file": "src/views/Dashboard.vue",
"line": 1,
"title": "Missing EUPL-1.2 license header on substantially rewritten file",
"description": "Dashboard.vue was nearly fully rewritten but has no license header. Other pre-existing views also lack it — codebase-wide gap, not introduced here."
},
{
"id": "F-05",
"severity": "WARNING",
"file": "src/views/MyWork.vue",
"line": 242,
"title": "goToTask navigates to ProjectBoard instead of task detail",
"description": "Spec scenario 'Navigate to task detail from My Work' requires CnDetailPage navigation. CnDetailPage does not exist yet; pragmatic fallback to ProjectBoard is understandable but should be tracked as deferred."
},
{
"id": "F-06",
"severity": "WARNING",
"file": "src/views/Dashboard.vue",
"line": 189,
"title": "All projects fetched without server-side member filter",
"description": "fetchCollection(PROJECT_SCHEMA, {}) loads all projects and filters client-side. Could be slow on large instances. Same issue in MyWork.vue line 211."
},
{
"id": "F-07",
"severity": "WARNING",
"file": "src/views/Dashboard.vue",
"line": 129,
"title": "todayMidnight computed property will go stale past midnight",
"description": "Computed with no reactive deps is cached indefinitely. Same issue in MyWork.vue line 156. Use a data property set on mount instead."
},
{
"id": "F-08",
"severity": "WARNING",
"file": "src/views/Dashboard.vue + src/views/MyWork.vue",
"line": "multiple",
"title": "fetchData, projectTitle, formatDueDate, constants duplicated between views",
"description": "Extract to a shared composable or Pinia action for maintainability."
},
{
"id": "F-09",
"severity": "WARNING",
"file": "openspec/changes/spec/specs/dashboard-my-work.md",
"line": 3,
"title": "Spec status still set to 'idea'",
"description": "Should be updated to 'in-progress' now that a PR is open."
}
],
"critical_count": 3,
"warning_count": 6,
"suggestion_count": 0,
"must_fix_before_merge": ["F-01", "F-02", "F-03"]
} |
Security Finding — WARNING: Client-side-only project membership enforcementFile: Both views fetch the full project collection without a server-side membership filter: objectStore.fetchCollection(PROJECT_SCHEMA, {}) // no filter — all projectsClient-side filtering is then applied: // Dashboard.vue line 152-153
.filter((p) => Array.isArray(p.members) && p.members.includes(uid))Risk: If OpenRegister does not enforce membership-based access control at the collection level, any authenticated Nextcloud user who opens Planix can enumerate every project's Client-side filtering must complement server-side authorisation, not replace it. The dashboard computed property only hides non-member projects from the rendered list; the raw data is still fetched and held in Recommendation: Pass a server-side filter so that OpenRegister only returns projects the requesting user is a member of: objectStore.fetchCollection(PROJECT_SCHEMA, { members: uid })If the OpenRegister API does not yet support this query parameter, this must be tracked as a follow-up before GA and the backend team should confirm whether the collection endpoint already enforces user-scoped access control. Severity: WARNING |
Security Finding — WARNING: Unvalidated server value injected into CSS class nameFile: The :class="'my-work__priority-dot--' + (task.priority || 'normal')"Risk: If a malicious or misconfigured data source populates
The same pattern also appears as Recommendation: Validate the priority value against an allowlist before using it as a class discriminator: const VALID_PRIORITIES = new Set(['urgent', 'high', 'normal', 'low'])
// in a computed or method:
safeClass(priority) {
return VALID_PRIORITIES.has(priority) ? priority : 'normal'
}Then: Severity: WARNING |
Security Finding — WARNING: Silent error swallowing masks permission failuresFile: Both async fetchData() {
this.loading = true
try {
// API calls…
} finally {
this.loading = false
}
}Risk: When the API call fails — whether due to a network error, a 403 Forbidden, or a 401 Unauthorized — the exception is silently consumed.
Recommendation: Add a } catch (err) {
console.error('planix: fetchData failed', err)
this.error = err
} finally {
this.loading = false
}Severity: WARNING |
Security Finding — INFO: SPDX license header absent from Dashboard.vueFile:
Per Conduction Common Ground conventions, every new or changed file must carry the EUPL-1.2 header. Recommendation: Add the header as the first two lines of Severity: INFO (compliance, not a security vulnerability) |
{
"reviewer": "Clyde Barcode — Hydra Security Reviewer",
"pr": "https://github.com/ConductionNL/planix/pull/17",
"reviewed_at": "2026-04-04",
"sast": {
"semgrep": { "findings": 0, "rulesets": ["p/security-audit", "p/secrets", "p/owasp-top-ten"] },
"gitleaks": { "findings": 0 },
"trivy": { "status": "not_present" }
},
"manual_findings": [
{
"id": "SEC-001",
"severity": "WARNING",
"title": "Client-side-only project membership enforcement",
"files": ["src/views/Dashboard.vue:189", "src/views/MyWork.vue:212"],
"summary": "fetchCollection(PROJECT_SCHEMA, {}) fetches all projects; membership is filtered client-side only. If OpenRegister does not enforce user-scoped access at the API level, any authenticated user can enumerate all project metadata."
},
{
"id": "SEC-002",
"severity": "WARNING",
"title": "Unvalidated server value in CSS class name",
"files": ["src/views/MyWork.vue:41", "src/views/MyWork.vue:70", "src/views/MyWork.vue:99"],
"summary": "task.priority from the API is concatenated directly into a CSS class string. A space-containing value would inject additional class names. Allowlist validation required."
},
{
"id": "SEC-003",
"severity": "WARNING",
"title": "Silent error swallowing in fetchData()",
"files": ["src/views/Dashboard.vue:174", "src/views/MyWork.vue:196"],
"summary": "try/finally without catch silently discards API errors including 401/403 responses, rendering an empty state indistinguishable from a permission denial."
},
{
"id": "SEC-004",
"severity": "INFO",
"title": "Missing SPDX license header in Dashboard.vue",
"files": ["src/views/Dashboard.vue:1"],
"summary": "Substantially rewritten file lacks the required EUPL-1.2 / Conduction copyright header per Common Ground conventions."
}
],
"verdict": "APPROVE_WITH_WARNINGS",
"rationale": "No CRITICAL or HIGH findings. No secrets detected. No OWASP Top-10 vulnerabilities identified by automated tooling or manual review. Three WARNINGs require follow-up before GA: server-side project scoping must be confirmed or implemented, CSS class injection should be mitigated with an allowlist, and error handling must surface authentication failures to the user. One INFO item (license header) is a compliance gap only."
} |
…te license header F-01/F-02: Add try/catch in fetchData() in both Dashboard.vue and MyWork.vue. On API failure (network error, 401, 500) the catch block logs the error and surfaces it to the user via showError() from @nextcloud/dialogs, preventing a silent empty view that is indistinguishable from having no data. F-03: Remove the duplicate EUPL-1.2 license comment at file level in MyWork.vue (lines 1-2, before <template>). The canonical location inside <script> remains. File-level comments before <template> in a Vue SFC are non-standard and confuse IDE tooling and some Vue parsers.
|
Hydra Builder — Fix iteration 1 Fixed CRITICAL findings:
Remaining WARNINGs (not addressed — informational):
Quality checks: |
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
rubenvdlinde
left a comment
There was a problem hiding this comment.
Hydra Code Reviewer — Review Pass
Reviewed against spec at openspec/changes/spec/, ADRs at openspec/architecture/, and CLAUDE.md conventions.
CI: All automated checks pass (lint, eslint, stylelint, phpcs, phpstan, psalm). Task checklist complete.
Overall: Good implementation of the dashboard MVP — clean Vue patterns, NL Design System components used correctly, error handling present, parallel data fetching via Promise.all. Three issues require changes before merge (2 CRITICAL, 1 WARNING). See inline comments.
src/views/MyWork.vue
Outdated
| if (due.getTime() === tomorrow.getTime()) return t('planix', 'Tomorrow') | ||
| return due.toLocaleDateString() | ||
| }, | ||
| goToTask(task) { |
There was a problem hiding this comment.
[CRITICAL] goToTask navigates to ProjectBoard instead of task detail
The spec requires:
When the user clicks the task title → navigate to the task detail view (CnDetailPage). The browser back button MUST return to My Work.
Current implementation pushes to { name: 'ProjectBoard', params: { id: task.project } } — the kanban board for the whole project, not the specific task. Additionally, when task.project is falsy the click silently does nothing — the user gets no feedback.
No dedicated task-detail route exists in the router yet. The correct fix:
- Add a
/tasks/:idroute backed by a task detail component (CnDetailPage) — preferred per spec - Until that route exists, disable the click on the title and add a TODO comment so the behaviour is explicit and not misleading
At minimum, remove the silent no-op: if task.project is falsy, show an error toast or simply not render the click handler.
| } | ||
| }, | ||
| computed: { | ||
| todayMidnight() { |
There was a problem hiding this comment.
[CRITICAL] KPI filter query parameter is passed but never consumed
Dashboard.vue navigates to MyWork with { query: { filter: 'open' | 'overdue' | 'in_progress' | 'completed_today' } }. The spec acceptance criterion states:
KPI cards are clickable and navigate to My Work with the corresponding filter applied
But MyWork.vue never reads this.$route.query.filter, so all four KPI cards land on the same unfiltered view. The query param is wired in the URL but has zero effect.
Fix: read the query param in mounted / as a computed prop and use it to either (a) scroll to the matching group section, or (b) show only the relevant group when a filter is active. A minimal approach is to highlight the matching group title and auto-scroll to it.
| v-for="task in overdueTasks" | ||
| :key="task.id" | ||
| class="my-work__task-item"> | ||
| <span |
There was a problem hiding this comment.
[WARNING] Task rows are missing the status indicator required by the spec
The spec acceptance criteria states:
Tasks in My Work show: project name (badge), title, due date, status indicator, priority dot
Each task row renders: priority dot · title · project badge · due date. The current task status (Open / In Progress) is not shown. Quick status update is correctly deferred (out of scope per design.md), but displaying the read-only current status is still in scope.
Suggested fix: add a small NcBadge or styled <span> displaying task.status alongside the due date, consistent with the my-work__project-badge styling already in place.
| const due = new Date(task.dueDate) | ||
| return due >= today && due < weekEnd | ||
| }) | ||
| .sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)) |
There was a problem hiding this comment.
[WARNING] All projects fetched without server-side membership filter
objectStore.fetchCollection(PROJECT_SCHEMA, {})This fetches every project in the register and filters client-side by p.members.includes(uid). In larger deployments this (a) transfers unnecessary data to the browser and (b) exposes project titles/metadata of projects the user is not a member of before the client-side filter runs.
If the OpenRegister store supports a members filter parameter, pass it here: { members: uid }. If not, this is acceptable for MVP but should be tracked as a follow-up.
Hydra Code Reviewer — Final Verdict{
"verdict": "request-changes",
"pr": "ConductionNL/planix#17",
"commit": "d10d2ab65081c43b3372eeaa29acf8e1205012ca",
"reviewed_at": "2026-04-04",
"spec": "openspec/changes/spec/design.md",
"ci": {
"lint": "pass",
"eslint": "pass",
"stylelint": "pass",
"phpcs": "pass",
"phpstan": "pass",
"psalm": "pass",
"phpmd": "pending",
"task_list_completed": "pass"
},
"findings": [
{
"id": "F-001",
"severity": "CRITICAL",
"file": "src/views/MyWork.vue",
"line": 244,
"title": "goToTask navigates to ProjectBoard instead of task detail",
"description": "Clicking a task title pushes to { name: 'ProjectBoard', params: { id: task.project } } — the entire project kanban board, not the individual task detail (CnDetailPage). When task.project is falsy the click silently does nothing. Spec requires navigate to task detail view; back button must return to My Work.",
"spec_ref": "openspec/changes/spec/specs/dashboard-my-work.md#scenario-navigate-to-task-detail-from-my-work"
},
{
"id": "F-002",
"severity": "CRITICAL",
"file": "src/views/MyWork.vue",
"line": 155,
"title": "KPI filter query parameter passed but never consumed",
"description": "Dashboard.vue navigates to MyWork with ?filter=open|overdue|in_progress|completed_today but MyWork.vue never reads this.$route.query.filter. All four KPI cards produce identical unfiltered views. Spec acceptance criteria: 'KPI cards are clickable and navigate to My Work with the corresponding filter applied'.",
"spec_ref": "openspec/changes/spec/specs/dashboard-my-work.md#acceptance-criteria"
},
{
"id": "F-003",
"severity": "WARNING",
"file": "src/views/MyWork.vue",
"line": 37,
"title": "Status indicator missing from task rows",
"description": "Each task row shows: priority dot, title, project badge, due date. The spec requires a status indicator (read-only current status) as well. Quick status update is correctly out of scope, but the read-only indicator is in scope per acceptance criteria.",
"spec_ref": "openspec/changes/spec/specs/dashboard-my-work.md#acceptance-criteria"
},
{
"id": "F-004",
"severity": "WARNING",
"file": "src/views/Dashboard.vue",
"line": 168,
"title": "All projects fetched without server-side membership filter",
"description": "fetchCollection(PROJECT_SCHEMA, {}) loads every project; membership filtering is client-side only. This exposes all project titles/metadata to the browser before filtering, and does not scale. If the OpenRegister store supports a members filter param, it should be passed.",
"spec_ref": null
}
],
"summary": {
"critical": 2,
"warning": 2,
"suggestion": 0,
"must_fix_before_merge": ["F-001", "F-002"]
}
} |
rubenvdlinde
left a comment
There was a problem hiding this comment.
[WARNING] Unbounded project fetch — potential IDOR / data over-exposure
fetchCollection(PROJECT_SCHEMA, {}) is called with an empty filter, loading all projects into the component's this.projects array. The membership guard (p.members.includes(uid)) is applied only client-side in the recentProjects computed property — non-member project data is still present in memory and in any network-layer logging.
If the OpenRegister backend does not enforce row-level access control (i.e. does not restrict the /objects endpoint to projects the current user is a member of), then every authenticated Planix user can silently read the titles, members, and metadata of all projects.
Recommendation: pass the user-scoped filter server-side:
objectStore.fetchCollection(PROJECT_SCHEMA, { members: uid })This applies in both Dashboard.vue and MyWork.vue. If the OpenRegister API does not support a members filter, raise an RFI to add that filter before this feature ships.
Affected lines: src/views/Dashboard.vue (fetchCollection call), src/views/MyWork.vue (fetchCollection call).
rubenvdlinde
left a comment
There was a problem hiding this comment.
[INFO] Unauthenticated-user fallback produces empty UID string
Both Dashboard.vue and MyWork.vue use:
const uid = getCurrentUser()?.uid || ''When getCurrentUser() returns null (no active Nextcloud session), uid silently becomes ''. The subsequent call fetchCollection(TASK_SCHEMA, { assignedTo: '' }) sends a query with an empty assignedTo value. Depending on how OpenRegister interprets an empty string filter, this might return all tasks or no tasks — neither is the intended behaviour.
Recommendation: add an early-exit guard before the API calls:
const uid = getCurrentUser()?.uid
if (!uid) return // not authenticated — skip fetchThis prevents a spurious API call and makes the intent explicit. It is unlikely to trigger in production (Nextcloud always authenticates before rendering app pages), but it is defensive best practice for Nextcloud apps.
rubenvdlinde
left a comment
There was a problem hiding this comment.
[INFO] No pagination on task fetch — unbounded collection size
fetchCollection(TASK_SCHEMA, { assignedTo: uid }) fetches all tasks assigned to the current user in a single request with no limit or page parameter. For users with a large number of tasks this will:
- Transfer a large payload over the network on every dashboard/MyWork load
- Hold the entire task list in component memory for the session's lifetime
This is a performance and resource-exhaustion concern rather than a confidentiality risk, but under a DoS threat model an adversary with a legitimate account could create thousands of tasks to inflate their own response. Consider adding a reasonable limit (e.g. 200) or implementing cursor-based pagination.
Affected lines: src/views/Dashboard.vue and src/views/MyWork.vue — fetchCollection(TASK_SCHEMA, ...) calls.
|
Hydra Security Review — Clyde Barcode Automated Scan Summary
All automated scans clean. Manual Review Findings
No XSS risk found: all user-supplied data is rendered via Vue 2 Verdict{
"verdict": "APPROVE_WITH_WARNING",
"pr": "https://github.com/ConductionNL/planix/pull/17",
"reviewed_by": "Clyde Barcode — Hydra Security Reviewer",
"reviewed_at": "2026-04-04",
"commit": "d10d2ab65081c43b3372eeaa29acf8e1205012ca",
"automated_scans": {
"semgrep": { "status": "clean", "findings": 0 },
"gitleaks": { "status": "clean", "findings": 0 },
"trivy": { "status": "not_present" }
},
"manual_findings": [
{
"id": "SEC-001",
"severity": "WARNING",
"category": "IDOR / Data Over-Exposure",
"files": ["src/views/Dashboard.vue", "src/views/MyWork.vue"],
"summary": "fetchCollection(PROJECT_SCHEMA, {}) fetches all projects; membership filter is client-side only. Requires OpenRegister to enforce row-level access control server-side.",
"recommendation": "Pass server-side filter: fetchCollection(PROJECT_SCHEMA, { members: uid })"
},
{
"id": "SEC-002",
"severity": "INFO",
"category": "Defensive Coding",
"files": ["src/views/Dashboard.vue", "src/views/MyWork.vue"],
"summary": "getCurrentUser()?.uid || '' sends empty assignedTo query when no session exists.",
"recommendation": "Early-exit guard: if (!uid) return"
},
{
"id": "SEC-003",
"severity": "INFO",
"category": "Resource Exhaustion",
"files": ["src/views/Dashboard.vue", "src/views/MyWork.vue"],
"summary": "No pagination on task or project fetches. Unbounded payload size.",
"recommendation": "Add limit parameter (e.g. 200) or cursor-based pagination"
}
],
"block_merge": false,
"notes": "SEC-001 should be resolved before GA if OpenRegister does not provide implicit per-user scoping. Verify with the OpenRegister team whether /objects endpoints already scope results to the authenticated user's accessible records. If they do, SEC-001 is mitigated at the infrastructure layer and can be closed."
} |
{
"reviewer": "Hydra Code Reviewer",
"pr": "ConductionNL/planix#17",
"reviewed_at": "2026-04-04",
"verdict": "requested-changes",
"lint": "pass",
"tests": "no-test-suite",
"findings": [
{
"id": "F-001",
"severity": "CRITICAL",
"file": "src/views/MyWork.vue",
"lines": "173-188",
"title": "`overdue` filter not handled in `allTasks` computed",
"summary": "Dashboard 'Overdue' KPI navigates to /my-work?filter=overdue but allTasks has no branch for this case; falls through to showing all non-done tasks."
},
{
"id": "W-001",
"severity": "WARNING",
"file": "src/views/Dashboard.vue + src/views/MyWork.vue",
"title": "`fetchData` duplicated verbatim in both components",
"summary": "Identical API calls in two components; should use shared composable or confirm object store caches."
},
{
"id": "W-002",
"severity": "WARNING",
"file": "openspec/changes/spec/design.md + tasks.md",
"title": "Prior 'Admin Settings MVP' spec overwritten by this change",
"summary": "Using the generic 'spec' change slot silently destroyed the previous spec. Hydra Builder should use a dedicated named directory per change."
},
{
"id": "W-003",
"severity": "WARNING",
"file": "openspec/changes/spec/specs/dashboard-my-work.md",
"title": "Spec file has Status: idea and unresolved acceptance criteria",
"summary": "Status was never updated from 'idea'; two acceptance criteria remain unchecked without an explicit deferral note."
},
{
"id": "W-004",
"severity": "WARNING",
"file": "src/views/Dashboard.vue",
"title": "`recentProjects` fetches all projects without server-side membership filter",
"summary": "fetchCollection(PROJECT_SCHEMA, {}) retrieves all projects; client-side membership filter may over-fetch or expose data."
},
{
"id": "W-005",
"severity": "WARNING",
"file": "src/views/MyWork.vue",
"lines": "11-22",
"title": "Empty state uses NcEmptyContent instead of CnEmptyState per spec",
"summary": "tasks.md acceptance criteria specifies CnEmptyState; implementation uses NcEmptyContent from @nextcloud/vue."
},
{
"id": "S-001",
"severity": "SUGGESTION",
"file": "src/views/Dashboard.vue + src/views/MyWork.vue",
"title": "formatDueDate and todayMidnight duplicated across both components",
"summary": "Extract to shared utility or composable."
},
{
"id": "S-002",
"severity": "SUGGESTION",
"file": "src/views/Dashboard.vue + src/views/MyWork.vue",
"title": "todayMidnight computed not reactive to day-boundary changes",
"summary": "Date is frozen at first render; stale groupings if app open past midnight."
}
],
"summary": "One CRITICAL bug: the 'overdue' filter value from the Dashboard KPI card is not handled in MyWork.vue's allTasks computed, so clicking the Overdue KPI shows all open tasks instead of only overdue ones. ESLint passes cleanly (0 errors, 0 warnings). No test suite is present for the JS layer — manual acceptance testing required. Fix F-001 before merge; the WARNINGs are recommended improvements."
} |
rubenvdlinde
left a comment
There was a problem hiding this comment.
Hydra Security Review — PR #17 Dashboard & My Work MVP
Reviewer: Clyde Barcode, Hydra Security Reviewer, Conduction B.V.
Date: 2026-04-04
SAST Results
| Tool | Findings |
|---|---|
| Semgrep (p/security-audit, p/secrets, p/owasp-top-ten) | 0 |
| Gitleaks | 0 |
| Trivy | N/A (not present) |
Manual Review Summary
| Severity | Count |
|---|---|
| CRITICAL | 1 |
| WARNING | 1 |
| INFORMATIONAL | 1 |
See inline comments for details.
Non-Findings (Ruled Out)
- XSS: All user-controlled data rendered via Vue
{{ }}mustache (auto-escaping). Nov-htmlin any changed file. - Open redirect:
goToProject()andgoToMyWork()use Vue Router named routes — no raw URL construction. - Query parameter injection:
?filterused only as a safe map-key lookup; never reflected into HTML oreval. - Hardcoded secrets/credentials: None detected by Gitleaks or manual review.
| objectStore.fetchCollection(TASK_SCHEMA, { assignedTo: uid }), | ||
| objectStore.fetchCollection(PROJECT_SCHEMA, {}), | ||
| ]) | ||
|
|
There was a problem hiding this comment.
[SECURITY — CRITICAL] SEC-001: Client-side-only access control for project collection
fetchCollection(PROJECT_SCHEMA, {}) fetches all projects from OpenRegister with no server-side filter. Membership access control is only applied client-side in the recentProjects computed property:
.filter((p) => Array.isArray(p.members) && p.members.includes(uid))This is a broken access control pattern (OWASP A01:2021 / CWE-285). If OpenRegister returns projects beyond the user's membership — which this client-side guard implies — then every authenticated user's browser silently receives all project titles and member lists regardless of authorisation. A user can inspect the raw network response in DevTools.
Note the contrast: the task query is already filtered server-side with { assignedTo: uid }. Apply the same pattern here.
Fix:
objectStore.fetchCollection(PROJECT_SCHEMA, { members: uid })and remove the client-side .filter(p => p.members.includes(uid)) in recentProjects. Same fix needed in MyWork.vue (line 237).
src/views/MyWork.vue
Outdated
|
|
||
| const [tasks, projects] = await Promise.all([ | ||
| objectStore.fetchCollection(TASK_SCHEMA, { assignedTo: uid }), | ||
| objectStore.fetchCollection(PROJECT_SCHEMA, {}), |
There was a problem hiding this comment.
[SECURITY — CRITICAL] SEC-001 (duplicate): Client-side-only access control for project collection
Same issue as Dashboard.vue line 192. fetchCollection(PROJECT_SCHEMA, {}) fetches all projects; access control is client-side only. Fix by passing { members: uid } to the server-side query.
| this.tasks = tasks || [] | ||
| this.projects = projects || [] | ||
| } catch (error) { | ||
| console.error('planix: fetchData failed', error) |
There was a problem hiding this comment.
[SECURITY — WARNING] SEC-002: Full error object logged to browser console
console.error('planix: fetchData failed', error) serialises the full error object to the browser console. OpenRegister API errors may include internal server paths, response structures, or stack traces that aid privilege-escalation research.
Although only the logged-in user can read their own console, limiting the logged surface is good hygiene (CWE-209 / OWASP A05:2021 Security Misconfiguration).
Recommendation:
console.error('planix: fetchData failed', error?.message ?? error)Same applies to MyWork.vue line 243.
| import { CnConfigurationCard, CnKpiGrid, CnStatsBlock } from '@conduction/nextcloud-vue' | ||
| import AccountGroupOutline from 'vue-material-design-icons/AccountGroupOutline.vue' | ||
| import CalendarClock from 'vue-material-design-icons/CalendarClock.vue' | ||
| import { CnConfigurationCard, CnKpiGrid, CnStatsBlock, useObjectStore } from '@conduction/nextcloud-vue' |
There was a problem hiding this comment.
[INFORMATIONAL] SEC-003: Missing EUPL-1.2 license header
Per Conduction Common Ground conventions, every modified file must carry the EUPL-1.2 SPDX header. MyWork.vue correctly includes it (lines 121–122); Dashboard.vue does not.
Add at the top of the <script> block:
// SPDX-License-Identifier: EUPL-1.2
// Copyright (C) 2026 Conduction B.V.
{
"tool": "hydra-security-reviewer",
"reviewer": "clyde-barcode",
"pr": "ConductionNL/planix#17",
"date": "2026-04-04",
"verdict": "BLOCK",
"sast": {
"semgrep": { "ruleset": ["p/security-audit", "p/secrets", "p/owasp-top-ten"], "findings": 0 },
"gitleaks": { "findings": 0 },
"trivy": { "status": "not-present" }
},
"findings": [
{
"id": "SEC-001",
"severity": "CRITICAL",
"title": "Client-side-only access control for project collection",
"cwe": "CWE-285",
"owasp": "A01:2021 Broken Access Control",
"files": [
"src/views/Dashboard.vue:192",
"src/views/MyWork.vue:237"
],
"description": "fetchCollection(PROJECT_SCHEMA, {}) fetches all projects with no server-side filter. Membership access control is applied client-side only in the recentProjects computed property. If OpenRegister returns projects beyond the current user's membership, all project titles and member lists are transmitted to every authenticated user's browser and accessible via DevTools. The task query already uses server-side filtering ({ assignedTo: uid }), making this inconsistency a clear oversight.",
"recommendation": "Pass { members: uid } to fetchCollection for the project collection and remove the client-side .filter(p => p.members.includes(uid)) guard."
},
{
"id": "SEC-002",
"severity": "WARNING",
"title": "Full error object logged to browser console",
"cwe": "CWE-209",
"owasp": "A05:2021 Security Misconfiguration",
"files": [
"src/views/Dashboard.vue:196",
"src/views/MyWork.vue:243"
],
"description": "console.error logs the full error object which may include internal API endpoint paths, OpenRegister response bodies, and stack traces visible to any user with DevTools access.",
"recommendation": "Log error.message only: console.error('planix: fetchData failed', error?.message ?? error)"
},
{
"id": "SEC-003",
"severity": "INFORMATIONAL",
"title": "Missing EUPL-1.2 license header in Dashboard.vue script block",
"files": ["src/views/Dashboard.vue:97"],
"description": "Dashboard.vue script block lacks the mandatory EUPL-1.2 SPDX header. MyWork.vue correctly includes it (lines 121-122).",
"recommendation": "Add // SPDX-License-Identifier: EUPL-1.2 and // Copyright (C) 2026 Conduction B.V. at the top of the <script> block."
}
],
"non_findings": [
"XSS: All user-controlled data (task.title, project.title, task.priority, etc.) rendered via Vue mustache interpolation — auto-escaped. No v-html anywhere in the diff.",
"Open redirect: goToProject() and goToMyWork() use Vue Router named routes; no raw URL string construction.",
"Query parameter injection: ?filter used as map key lookup with fixed label map; unknown keys return empty string; never reflected into HTML or eval.",
"Hardcoded secrets or credentials: none detected by Gitleaks or manual review.",
"CSS injection via task.priority: :class binding is sanitised by Vue's attribute handling; no CSS or script injection possible."
]
} |
…ction (SEC-001)
Replace fetchCollection(PROJECT_SCHEMA, {}) with fetchCollection(PROJECT_SCHEMA, { members: uid })
in both Dashboard.vue and MyWork.vue so that only projects the current user is a member of are
returned by OpenRegister. This moves access control from the client (where raw data was still
visible in network responses) to the server side, consistent with the existing assignedTo filter
on the task collection. Remove the now-redundant client-side .filter(p => p.members.includes(uid))
from the recentProjects computed property in Dashboard.vue.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Hydra Builder — Fix iteration 3 Fixed CRITICAL findings:
Both files now use consistent server-side scoping for both task ( Remaining WARNINGs / INFOs (not addressed — informational):
|
rubenvdlinde
left a comment
There was a problem hiding this comment.
[CRITICAL] useObjectStore() called inside a method — deviates from established pattern and may break in Vue 2
In both Dashboard.vue and MyWork.vue, useObjectStore() is invoked inside the fetchData() async method:
async fetchData() {
this.loading = true
try {
const objectStore = useObjectStore()The established pattern in this codebase (see src/views/ProjectList.vue) is to expose stores via a computed property that is evaluated within component context:
computed: {
projectsStore() {
return useProjectsStore()
},
}In Vue 2 with @vue/composition-api, composables that rely on inject() (which Pinia stores and most Conduction composables do) must be called within a reactive context — setup(), a computed, or another composable. Calling useObjectStore() inside an async method invoked from mounted() bypasses the component instance context and will throw at runtime:
[Vue warn]: inject() can only be used inside setup() or functional components
Fix: Expose the store via a computed property (matching ProjectList.vue pattern) and reference this.objectStore inside fetchData():
computed: {
objectStore() {
return useObjectStore()
},
},This issue is present identically in both src/views/Dashboard.vue and src/views/MyWork.vue.
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
rubenvdlinde
left a comment
There was a problem hiding this comment.
[CRITICAL] filter=overdue falls through to default — "Showing: Overdue tasks" banner is misleading
In src/views/MyWork.vue, the allTasks computed property has two explicit if branches (completed_today, in_progress) and then a catch-all default. When the user clicks the Overdue KPI card, it navigates with ?filter=overdue. Neither explicit branch matches, so the default runs, returning all non-done tasks. The filter banner then displays "Showing: Overdue tasks" while all three task groups (Overdue / Due this week / Everything else) are still rendered.
Same issue for filter=open — no explicit branch.
Spec requirement: "KPI cards are clickable and navigate to My Work with the corresponding filter applied."
Fix: Add an explicit overdue branch that returns only tasks where dueDate < today && status !== 'done'. Add an explicit open branch too so future changes cannot silently misroute unknown filter values to the catch-all.
rubenvdlinde
left a comment
There was a problem hiding this comment.
[WARNING] EUPL-1.2 license header placed inside <script> block, not at file top
In src/views/MyWork.vue, the license comment appears as the first lines of the <script> section:
<script>
// SPDX-License-Identifier: EUPL-1.2
// Copyright (C) 2026 Conduction B.V.
import { useObjectStore } from '@conduction/nextcloud-vue'The CLAUDE.md convention states the header must appear on every new file. Standard practice for Vue SFCs is to place the SPDX comment at the very top of the file (before <template>), so license-scanning tools (e.g. reuse lint, FOSSA) can detect it correctly.
Dashboard.vue was substantially rewritten and still has no SPDX header at all.
Fix for MyWork.vue: Move the license comment to the first two lines of the file, before <template>.
Fix for Dashboard.vue: Add the SPDX header to the top of the file.
rubenvdlinde
left a comment
There was a problem hiding this comment.
[WARNING] fetchData() and projectTitle() duplicated verbatim in Dashboard.vue and MyWork.vue
Both views contain identical logic for:
- Store registration (
registerObjectTypeguard) - Parallel
fetchCollectioncalls for tasks and projects projectTitle(projectId)lookup helper- Error handling with
showError
Duplicated fetch logic means bugs (e.g. the useObjectStore() placement issue above) must be fixed in two places, and future changes to the data-fetching strategy (pagination, caching, refresh) will require parallel edits.
Recommendation: Extract into a shared composable (e.g. src/composables/useMyWorkData.js) that exposes { tasks, projects, loading, fetchData }. Both views then call this.myWorkData = useMyWorkData() from a computed or setup(). This also resolves the useObjectStore() placement concern in a single place.
rubenvdlinde
left a comment
There was a problem hiding this comment.
[WARNING] openspec/changes/spec/specs/dashboard-my-work.md still shows Status: idea
The spec file added in this PR has:
**Status**: ideaBut design.md correctly shows pr-created and tasks.md shows all tasks checked. The spec file status is stale and will mislead pipeline tooling or reviewers who rely on it to assess change readiness.
Fix: Update dashboard-my-work.md line 3 to **Status**: pr-created.
rubenvdlinde
left a comment
There was a problem hiding this comment.
[WARNING] Two spec acceptance criteria are unchecked but marked out of scope — discrepancy not documented
In openspec/changes/spec/specs/dashboard-my-work.md, the following acceptance criteria remain [ ]:
- [ ] Status can be updated inline from My Work without full navigation
- [ ] Clicking a task title in My Work navigates to task detail; back button returns to My Work
design.md correctly marks both as out of scope. However, the spec file is never updated to reflect the deferral — future reviewers will see open [ ] items and not know whether they were intentionally deferred or forgotten.
Additionally, MyWork.vue contains three identical <!-- TODO: navigate to task detail (CnDetailPage) once a /tasks/:id route is available --> comments with no linked issue or follow-up ticket.
Recommendation: Either check off deferred items with a note (e.g. [~] deferred — see design.md) or add a Deferred section to the spec. Create a tracking issue for the two deferred items and replace the TODO comments with the issue number.
rubenvdlinde
left a comment
There was a problem hiding this comment.
[SUGGESTION] projectTaskStats() called twice per project in template
In Dashboard.vue, the template calls projectTaskStats(project.id) twice per list item:
{{ projectTaskStats(project.id).done }}/{{ projectTaskStats(project.id).total }}Each call runs this.tasks.filter(...) from scratch. For 5 projects and potentially hundreds of tasks this is low impact, but it's unnecessary duplication. If the project list grows, this will double the filter passes.
Recommendation: Cache as a computed map (projectStatsMap), or call projectTaskStats once per item using v-for with a scoped variable, or return the formatted string from a single method call.
rubenvdlinde
left a comment
There was a problem hiding this comment.
{
"reviewer": "Juan Claude van Damme — Hydra Code Reviewer",
"pr": "#17",
"verdict": "CHANGES_REQUESTED",
"summary": "Dashboard & My Work MVP is a well-structured, readable implementation that correctly replaces placeholder data with live OpenRegister queries. The spec scope, routing, grouping logic, and CSS are all solid. Two CRITICAL issues block merge: the useObjectStore() placement violates the Vue 2 composition API contract (will throw at runtime) and the overdue/open filter values silently fall through to the catch-all in allTasks, making the KPI-to-MyWork navigation misleading and failing a spec acceptance criterion.",
"findings": [
{
"id": "F-01",
"severity": "CRITICAL",
"file": "src/views/Dashboard.vue, src/views/MyWork.vue",
"title": "useObjectStore() called inside a method — will throw in Vue 2",
"detail": "Calling useObjectStore() inside fetchData() (an async method invoked from mounted) bypasses the Vue 2 reactive context. The established codebase pattern is to expose stores via a computed property. Fix: add objectStore() { return useObjectStore() } computed and reference this.objectStore in fetchData()."
},
{
"id": "F-02",
"severity": "CRITICAL",
"file": "src/views/MyWork.vue",
"title": "filter=overdue and filter=open fall through to default in allTasks",
"detail": "Only completed_today and in_progress are handled explicitly. overdue and open silently use the catch-all, returning all non-done tasks while displaying a misleading 'Showing: Overdue tasks' banner. Fails spec criterion: KPI cards navigate to My Work with the corresponding filter applied."
},
{
"id": "F-03",
"severity": "WARNING",
"file": "src/views/MyWork.vue, src/views/Dashboard.vue",
"title": "EUPL-1.2 license header inside <script> block, missing from Dashboard.vue entirely",
"detail": "MyWork.vue places the SPDX comment inside <script> rather than at file top. Dashboard.vue (substantially rewritten) has no header at all. License scanners require the header at file top."
},
{
"id": "F-04",
"severity": "WARNING",
"file": "src/views/Dashboard.vue, src/views/MyWork.vue",
"title": "fetchData() and projectTitle() duplicated verbatim between both views",
"detail": "Identical fetch orchestration, store registration guard, error handling, and projectTitle helper appear in both components. Extract to a shared composable (e.g. src/composables/useMyWorkData.js)."
},
{
"id": "F-05",
"severity": "WARNING",
"file": "openspec/changes/spec/specs/dashboard-my-work.md",
"title": "Spec file Status still shows 'idea'",
"detail": "design.md is correctly set to pr-created. The spec file header still reads Status: idea — stale and misleading for pipeline tooling."
},
{
"id": "F-06",
"severity": "WARNING",
"file": "openspec/changes/spec/specs/dashboard-my-work.md, src/views/MyWork.vue",
"title": "Two spec acceptance criteria unchecked with no deferred tracking",
"detail": "Inline status update and task detail navigation are [ ] in the spec but marked out of scope in design.md. Three matching TODO comments in MyWork.vue have no issue reference. Document the deferral in the spec and link a tracking issue."
},
{
"id": "F-07",
"severity": "SUGGESTION",
"file": "src/views/Dashboard.vue",
"title": "projectTaskStats() called twice per project in template",
"detail": "Each call reruns tasks.filter() from scratch. Minor now (5 projects), but easily avoidable with a computed map or a single method call per item."
}
],
"blocking_count": 2,
"warning_count": 4,
"suggestion_count": 1,
"tests_run": "No JS test framework present in repo. ESLint reported 0 errors/0 warnings (per PR description — could not independently verify without running npm ci). PHP tests not applicable to this frontend-only change.",
"quality_gates": {
"eslint": "PASS (per PR description)",
"vitest_jest": "N/A — no JS test framework",
"spec_coverage": "PARTIAL — 2 acceptance criteria deferred without spec annotation",
"license_headers": "FAIL — MyWork.vue header misplaced; Dashboard.vue missing"
}
}
|
Hydra Security Reviewer — Clyde Barcode Automated Scanner Results
All automated scans clean. Manual review findings follow in separate comments. |
|
[WARNING] SEC-W01 — Missing EUPL-1.2 license header in File:
// SPDX-License-Identifier: EUPL-1.2
// Copyright (C) 2026 Conduction B.V.
Fix: Add the two SPDX comment lines immediately before the first Severity: WARNING — compliance/convention, not exploitable. |
|
[WARNING] SEC-W02 — Silent empty-UID fallback may produce unintended API queries Files:
If objectStore.fetchCollection(TASK_SCHEMA, { assignedTo: '' })
objectStore.fetchCollection(PROJECT_SCHEMA, { members: '' })Depending on how OpenRegister interprets an empty-string filter value, this could return no results (benign) or all records (data exposure). The SEC-001 fix from iteration 3 correctly moved to server-side membership filtering, but if the backend treats Recommended fix: Bail out early if no UID is available: const uid = getCurrentUser()?.uid
if (!uid) {
this.loading = false
return
}Severity: WARNING — theoretical exposure depends on backend filter semantics; Nextcloud normally guarantees an authenticated session before serving the SPA. |
|
[SUGGESTION] SEC-S01 — URL File:
However, accepting and reflecting arbitrary URL query strings without validation is a surface worth hardening. If the filter enumeration grows, an undocumented value could inadvertently match a new branch. Suggested improvement (not required for PASS): const VALID_FILTERS = ['open', 'overdue', 'in_progress', 'completed_today']
activeFilter() {
const f = this.$route.query?.filter
return VALID_FILTERS.includes(f) ? f : null
},Severity: SUGGESTION — no current exploitability, informational only. |
|
[NOTE] SEC-N01 — SEC-001 server-side membership filter correctly resolved in fix iteration 3 Confirming that the prior CRITICAL finding (project collection fetched without
Both |
{
"reviewer": "Clyde Barcode — Hydra Security Reviewer",
"pr": "https://github.com/ConductionNL/planix/pull/17",
"reviewed_at": "2026-04-04",
"scanners": {
"semgrep": { "version": "1.135.0", "rulesets": ["p/security-audit", "p/secrets", "p/owasp-top-ten"], "findings": 0 },
"gitleaks": { "findings": 0 },
"trivy": { "status": "not_present" }
},
"manual_findings": [
{
"id": "SEC-W01",
"severity": "WARNING",
"title": "Missing EUPL-1.2 license header in Dashboard.vue",
"file": "src/views/Dashboard.vue",
"line": 96,
"description": "EUPL-1.2 SPDX header absent from <script> block; required by Conduction Common Ground convention.",
"exploitable": false
},
{
"id": "SEC-W02",
"severity": "WARNING",
"title": "Silent empty-UID fallback may produce unintended API queries",
"files": ["src/views/Dashboard.vue:177", "src/views/MyWork.vue:226"],
"description": "getCurrentUser()?.uid || '' silently falls back to '' and still fires API requests. Backend filter semantics for empty-string values are untested.",
"exploitable": false,
"condition": "Requires getCurrentUser() to return null/undefined at runtime"
},
{
"id": "SEC-S01",
"severity": "SUGGESTION",
"title": "URL ?filter param accepted without allowlist validation",
"file": "src/views/MyWork.vue:161",
"description": "No allowlist check on route query param. Currently safe; hardening recommended.",
"exploitable": false
},
{
"id": "SEC-N01",
"severity": "NOTE",
"title": "SEC-001 server-side membership filter — RESOLVED",
"description": "Prior CRITICAL finding resolved in fix iteration 3 (commit 5eedbf1). Both collections now filter server-side by uid."
}
],
"critical_count": 0,
"warning_count": 2,
"suggestion_count": 1,
"verdict": "PASS",
"verdict_reason": "No CRITICAL findings. Two WARNINGs are non-exploitable compliance and defensive-coding issues. SEC-001 (prior CRITICAL) confirmed resolved. Safe to proceed to human review."
} |
|
Closing — old test PR. |
Summary
Replaces the placeholder sample data on the Planix dashboard with live KPI cards (Open, Overdue, In Progress, Completed Today) computed from OpenRegister task data assigned to the current user. Adds a 'Recent projects' panel with progress bars and a 'Due this week' task list. Introduces a new
/my-workroute backed byMyWork.vuethat groups and priority-sorts all tasks assigned to the current user, with an empty-state CTA to browse projects. No new backend endpoints are required — this is pure frontend aggregation over the existing Task and Project schemas.Spec Reference
https://github.com/ConductionNL/planix/blob/hydra/spec/openspec/changes/spec/design.md
Changes
src/views/Dashboard.vue— Rewrote from placeholder sample data to live data: fetches tasks and projects from OpenRegister on mount, computes four KPI cards, renders recent projects with progress bars and tasks due within 7 days with today/tomorrow date highlightingsrc/views/MyWork.vue— New view at/my-work; groups tasks into Overdue / Due this week / Everything else, sorted by priority within each group; shows project badge, title, due date, and priority dot per task; empty state uses NcEmptyContent with 'Browse projects' actionsrc/router/index.js— Adds/my-workroute pointing toMyWork.vuesrc/navigation/MainMenu.vue— Adds 'My Work' navigation item with BriefcaseOutline iconTest Coverage
No JavaScript test framework is present in this repository (tests/ contains PHP only). Manual acceptance testing required against a running Nextcloud + OpenRegister instance. All ESLint checks pass (
npm run lint— 0 errors, 0 warnings).