--- phase: 11-visual-design-responsive-layout-cleanup reviewed: 2026-06-17T00:00:00Z depth: standard files_reviewed: 13 files_reviewed_list: - frontend/vite.config.js - frontend/src/router/index.js - frontend/src/App.vue - frontend/src/layouts/AdminLayout.vue - frontend/src/components/layout/AppSidebar.vue - frontend/src/components/admin/AdminSidebar.vue - frontend/src/components/storage/StorageBrowser.vue - frontend/src/components/sharing/ShareModal.vue - frontend/src/components/cloud/CloudCredentialModal.vue - frontend/src/components/folders/FolderDeleteModal.vue - frontend/src/components/documents/DocumentPreviewModal.vue - frontend/src/views/DocumentView.vue - frontend/src/router/__tests__/router.guard.test.js findings: critical: 4 warning: 7 info: 4 total: 15 status: issues_found --- # Phase 11: Code Review Report **Reviewed:** 2026-06-17T00:00:00Z **Depth:** standard **Files Reviewed:** 13 **Status:** issues_found ## Summary This phase ships responsive drawer navigation, lazy-loading for routes, and modal polish. The core drawer implementation is structurally sound — drawer state is correctly owned by the layout components, backdrop is teleported to body to guarantee stacking, and route-change watchers auto-close the drawer. The router guard logic is correct and well-tested. `rollup-plugin-visualizer` is correctly placed in `devDependencies`. Four blocker-class issues were found: the iframe in `DocumentPreviewModal` renders arbitrary blobs without a `sandbox` attribute (XSS vector in the app's own origin); `CloudCredentialModal` can submit an empty server URL without any client-side validation; `suggestTopics()` writes errors into the wrong ref (`classifyError` instead of a dedicated `suggestError`), causing stale errors to bleed across unrelated actions; and the high-traffic routes `/`, `/topics`, `/document/:id`, and `/settings` lack explicit `meta` flags, making the auth guard work only by convention and leaving unauthenticated access untested by the new guard test suite. --- ## Structural Findings (fallow) None provided. --- ## Narrative Findings (AI reviewer) ## Critical Issues ### CR-01: iframe in DocumentPreviewModal has no sandbox attribute — XSS in app origin **File:** `frontend/src/components/documents/DocumentPreviewModal.vue:52-57` **Issue:** The preview iframe loads an object URL created from a blob fetched from the backend. No `sandbox` attribute is present on the ` ``` For PDF rendering `allow-scripts` is required by most PDF.js-based renderers. If the iframe does not need to communicate back to the parent or access cookies, do not add `allow-same-origin`. If only PDFs are previewed, the strictest safe value is `sandbox="allow-scripts"`. Evaluate whether `allow-same-origin` is actually needed before adding it, as that combination weakens the sandbox significantly. --- ### CR-02: CloudCredentialModal submits empty server URL with no client-side validation **File:** `frontend/src/components/cloud/CloudCredentialModal.vue:301-313` **Issue:** `submit()` reads `finalUrl = resolvedServerUrl.value` and immediately calls `api.connectWebDav(props.provider.key, finalUrl, ...)`. `resolvedServerUrl` returns `autoServerUrl.value` for the Nextcloud path, which evaluates to `''` when either `serverBase` or `username` is empty (line 219: `if (!serverBase.value || !username.value) return ''`). For the plain WebDAV path, `serverUrl` initializes to `''` and has no HTML `required` attribute — the form's `@submit.prevent` bypass prevents browser validation from firing. There is no guard in `submit()` that checks `!finalUrl`. An empty string is sent to the backend, which may attempt a connection to a relative path or log a misleading error. The user sees "Connection failed" with no actionable message about what field is missing. **Fix:** ```js async function submit() { connectError.value = '' const finalUrl = resolvedServerUrl.value if (!finalUrl) { connectError.value = 'Please enter the server URL before connecting.' return } if (!username.value.trim()) { connectError.value = 'Please enter a username.' return } saving.value = true try { await api.connectWebDav(props.provider.key, finalUrl, username.value, password.value) emit('connected') emit('close') } catch (e) { connectError.value = e.message || 'Connection failed. Please check your credentials.' } finally { saving.value = false } } ``` --- ### CR-03: `suggestTopics()` writes errors into `classifyError` — wrong ref, stale error leaks across actions **File:** `frontend/src/views/DocumentView.vue:246-257` **Issue:** The `suggestTopics` function (line 253) assigns exceptions to `classifyError.value` — the same ref used by `reclassify()`. Two concrete bugs result: 1. `suggestTopics()` never resets `classifyError` before running (unlike `reclassify()` which clears it at line 234), so a prior reclassify failure remains visible while the suggest request is in-flight. 2. A `suggestTopics` failure displays an error under the Topics header near the Re-classify button — the wrong location for a suggest-related message, misleading the user into thinking re-classification failed. 3. Running `reclassify()` after a `suggestTopics` failure will clear the suggest error (correct), but running `suggestTopics` after a `reclassify` failure will leave the reclassify error visible (incorrect). **Fix:** Add a separate `suggestError` ref and reset it at function entry: ```js const suggestError = ref(null) async function suggestTopics() { suggesting.value = true suggestError.value = null try { const result = await api.suggestTopics(doc.value.id) suggestions.value = result.suggested selectedSuggestions.value = [] } catch (e) { suggestError.value = e.message || 'Failed to fetch suggestions.' } finally { suggesting.value = false } } ``` Add `

{{ suggestError }}

` near the Suggest Topics button. --- ### CR-04: Routes `/`, `/topics`, `/document/:id`, `/settings` have no meta — auth guard relies on implicit convention, unauthenticated access untested **File:** `frontend/src/router/index.js:13-17` **Issue:** The five routes on lines 13–17 have no `meta` object. The navigation guard at line 96 uses `!to.meta.public` to decide whether to attempt a silent token refresh — this works because `undefined` is falsy. However: 1. Future guard branches that check `to.meta.requiresAuth` explicitly (mirroring the pattern on `/cloud`, `/folders/:folderId`, `/shared`) would silently skip these routes, potentially allowing unauthenticated access to the file manager and document pages. 2. The D-09 admin-redirect guard (line 113: `!isAdminRoute && !to.meta.public && isAdmin`) correctly fires on these routes only because of the same implicit convention. This is correct today but fragile. 3. The `router.guard.test.js` test suite covers unauthenticated redirect only for `/settings` (via the refresh-fails test at line 183) and does not include a negative test asserting that navigating to `/` or `/document/some-uuid` without a token redirects to `/login`. This means the primary auth protection on the file manager has no regression test. **Fix:** Add explicit meta to all five routes and add unauthenticated-redirect tests: ```js { path: '/', component: FileManagerView, meta: { requiresAuth: true } }, { path: '/topics', component: () => import('../views/TopicsView.vue'), meta: { requiresAuth: true } }, { path: '/topics/:name', component: () => import('../views/TopicsView.vue'), meta: { requiresAuth: true } }, { path: '/document/:id', component: () => import('../views/DocumentView.vue'), meta: { requiresAuth: true } }, { path: '/settings', component: () => import('../views/SettingsView.vue'), meta: { requiresAuth: true } }, ``` Add to `router.guard.test.js`: ```js it('unauthenticated user visiting / is redirected to /login', async () => { useAuthStore.mockReturnValue({ accessToken: null, user: null, refresh: vi.fn().mockRejectedValue(new Error('no session')), }) await router.push('/') expect(router.currentRoute.value.path).toBe('/login') }) ``` --- ## Warnings ### WR-01: AdminLayout has no Escape key handler for the mobile drawer **File:** `frontend/src/layouts/AdminLayout.vue:46-59` **Issue:** `App.vue` registers a `keydown` listener on `document` (lines 98–100) but that listener is not active when the admin layout is rendered — `App.vue` routes admin paths through a bare `` (line 3), bypassing the `div` that owns the keydown listener. `AdminLayout.vue` registers no keyboard event handler. An admin user who opens the mobile drawer on a phone cannot close it with Escape. The `CloudCredentialModal` uses `@keydown.escape.window` which confirms the escape pattern is understood; the drawer simply lacks it. **Fix:** ```js // Add to AdminLayout.vue