Compare commits

...
58 Commits
Author SHA1 Message Date
curo1305andClaude Sonnet 4.6 e008bf7dae chore: remove REQUIREMENTS.md for v0.2 milestone
Requirements archived to .planning/milestones/v0.2-REQUIREMENTS.md.
Fresh REQUIREMENTS.md will be created by /gsd:new-milestone for v0.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:26:51 +02:00
curo1305andClaude Sonnet 4.6 475e519158 chore: archive v0.2 milestone files
Archive v0.2 (UI Overhaul and Optimization) to milestones/:
- milestones/v0.2-ROADMAP.md — full phase archive (Phases 8–11, 33 plans)
- milestones/v0.2-REQUIREMENTS.md — all 40 requirements marked complete
- milestones/v0.2-MILESTONE-AUDIT.md — audit artifact (passed, 40/40)
- MILESTONES.md — new living milestone index
- RETROSPECTIVE.md — new living retrospective with v0.2 section
- PROJECT.md — full evolution review: v0.2 requirements moved to Validated, 5 new Key Decisions added
- STATE.md — updated to milestone-complete status
- ROADMAP.md — v0.2 phases collapsed into <details> with progress table updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:26:19 +02:00
curo1305 2280b6f987 docs(milestone): mark v0.2 audit passed 2026-06-17 13:13:30 +02:00
curo1305 aaf57eae80 fix(milestone): close v0.2 audit gaps 2026-06-17 12:42:20 +02:00
curo1305 b9e2fc1803 docs(milestone): audit v0.2 closeout gaps 2026-06-17 11:51:27 +02:00
curo1305 595b33a68c fix(phase-11): close mobile storage UAT gaps 2026-06-17 10:48:35 +02:00
curo1305 c48ebf152c test(phase-11): record mobile UAT gaps 2026-06-17 10:37:21 +02:00
curo1305 64aa960d20 test(phase-11): add Nyquist validation coverage 2026-06-17 09:58:59 +02:00
curo1305andClaude Sonnet 4.6 f5fc8d111b docs(phase-11): add security threat verification — 13/13 threats CLOSED
Retroactive-STRIDE audit for Phase 11 (visual-design-responsive-layout-cleanup).
No plan-time threat model existed; register built from implementation files.
All 13 threats closed: 7 mitigated, 6 accepted with documented rationale.
threats_open: 0 — phase 11 security gate passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 09:51:47 +02:00
curo1305 1c0b231002 docs(11): update VERIFICATION.md — all 12 requirements satisfied after TreeItem gap fix 2026-06-17 09:37:36 +02:00
curo1305 28e75e971d fix(11): TreeItem.vue — focus-visible ring on all interactive elements; skeleton width → static Tailwind
- Expand/collapse button, router-link label, and button label all get focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1
- :style skeleton width replaced with :class w-12/w-16/w-20 (same fix applied to AppSidebar in 11-05)
- Closes VISUAL-01 and VISUAL-03 gaps found by verifier
2026-06-17 09:35:06 +02:00
curo1305 8ac5b15f51 docs(11): add code review report 2026-06-17 09:26:45 +02:00
curo1305 f667a3bbc8 docs(phase-11): update tracking after wave 5 — all plans complete 2026-06-17 08:03:50 +02:00
curo1305 73f409dd2f chore: merge executor worktree (worktree-agent-a39e918c6d4ac337a) 2026-06-17 08:03:27 +02:00
curo1305 b121bc2a86 docs(11-06): complete Plan 11-06 — dead-code cleanup and final verification SUMMARY
Summary covers all 8 tasks:
- Task 1 (prior): AccountView.vue deleted (a8e0a19)
- Tasks 2-5: admin test classification (3 retained), FolderRow.vue deleted,
  stale FolderRow tests removed; 268/268 tests pass (a928b54)
- Task 6 (prior): final bundle analysis committed (888d376)
- Task 7: phase11-final-summary.md — -81 kB / -30.6% main bundle (df981fb)
- Task 8: 11-VERIFICATION.md — all 12 Phase 11 requirements SATISFIED (9ad88ab)
2026-06-17 08:02:16 +02:00
curo1305 9ad88abe88 docs(11-06): add 11-VERIFICATION.md — all 12 Phase 11 requirements mapped to evidence
Maps VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 to concrete
code locations, test describe/it strings, and build output evidence.

All 12 requirements verified as SATISFIED:
- VISUAL-01: skeleton Tailwind classes; AppSidebar.visual.test.js
- VISUAL-02: @tailwindcss/forms active; ShareModal form tests
- VISUAL-03: typography normalized; typography.visual.test.js
- VISUAL-04: 62 focus-visible occurrences; AppSidebar.visual.test.js
- RESP-01: App.vue hamburger drawer with Teleport backdrop
- RESP-02: StorageBrowser hidden md/sm columns; skeleton test
- RESP-03: 36px touch targets; StorageBrowser.skeleton.test.js
- RESP-04: max-h-[90vh] overflow-y-auto on all 4 modals; mobile tests
- RESP-05: AdminLayout.vue hamburger drawer pattern
- CODE-07: FolderRow.vue + AccountView.vue deleted; 3 admin tests retained
- PERF-02: 4 perf artifacts in .planning/perf/
- PERF-03: 21 JS chunks vs 15 baseline; all non-initial routes lazy
2026-06-17 08:01:00 +02:00
curo1305 df981fbced docs(11-06): write Phase 11 bundle final summary — baseline vs final comparison
Baseline: 264.63 kB main / 89.34 kB gzip / 15 JS chunks
Final:    183.62 kB main / 64.83 kB gzip / 21 JS chunks

Delta: -81.01 kB raw (-30.6%), -24.51 kB gzip (-27.4%), +6 new lazy chunks

Documents the 5 new lazy route chunks from Plan 11-02 (SettingsView,
TopicsView, DocumentView, CloudStorageView, CloudFolderView), CSS growth
from new responsive Tailwind classes, and the intentional synchronous
FileManagerView decision (D-10, critical first authenticated surface).
2026-06-17 07:58:14 +02:00
curo1305 a928b54781 chore(11-06): dead-code cleanup — delete FolderRow.vue, retain admin tab tests
Task 2 — admin test classification:
  - AdminAiConfigTab.test.js: RETAINED (tests AdminAiView.vue, a live component)
  - AdminQuotasTab.test.js: RETAINED (tests AdminQuotasView.vue, a live component)
  - AdminUsersTab.test.js: RETAINED (tests AdminUsersView.vue, a live component)

Task 3 — confirmed absent: HomeView.vue, FolderView.vue, AdminView.vue all absent

Task 4 — dead-code scan:
  - FolderRow.vue: DELETED — no import in any live component; StorageBrowser renders
    folder rows inline; FolderRow had no active route or active import (CLAUDE.md rule)

Task 5 — stale test removal:
  - dropdown.test.js: removed 2 FolderRow tests (tested dead component);
    kept 2 DocumentCard Teleport tests (protect live surface)
  - No unused named imports found in live components

268/268 tests pass.
2026-06-17 07:56:00 +02:00
curo1305 6e3d1f866a chore: merge executor worktree (worktree-agent-a3ed83649498e1792) 2026-06-17 05:43:53 +02:00
curo1305 888d3761d5 chore(11-06): capture final Phase 11 bundle analysis report
- Run ANALYZE=true npm run build after all Phase 11 optimizations
- Main bundle: 183.62 kB raw / 64.84 kB gzip (was 264.63 kB / 89.34 kB)
- SettingsView, TopicsView, DocumentView all now in separate lazy chunks
- 25 chunks total vs 15 at baseline
2026-06-17 03:55:12 +02:00
curo1305 a8e0a199f2 chore(11-06): delete orphaned AccountView.vue
- AccountView.vue had no active route component reference
- /account path uses redirect: '/settings' with no component import
- No imports found across the entire codebase
- Satisfies CODE-07: no unreferenced route views remain
2026-06-17 00:39:05 +02:00
curo1305 e72506fc89 docs(phase-11): update tracking after wave 4 2026-06-16 21:51:23 +02:00
curo1305andClaude Sonnet 4.6 eef76e02dc docs(11-05): complete visual consistency pass plan summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:50:28 +02:00
curo1305 2af5b7c313 feat(11-05): visual consistency pass — typography, focus-visible, hover/active states, skeleton cleanup
- Normalize page titles to text-2xl font-semibold (was font-bold in TopicsView, DocumentView, SharedView)
- Normalize section titles to text-lg font-semibold (was text-xl in SettingsPreferencesTab, SettingsAiTab, SettingsCloudTab, BackupCodesDisplay, CloudCredentialModal)
- Normalize panel headings to text-sm font-semibold (SettingsAccountTab, DocumentView)
- Normalize AdminOverviewView page title from text-xl to text-2xl font-semibold
- Replace decorative sidebar skeleton inline styles with static Tailwind width classes (w-12/w-16/w-20)
- Add focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 to all interactive buttons across the app
- Add active:bg-* states to primary and secondary buttons for coherent press feedback
- Update nav-link scoped CSS in AppSidebar and AdminSidebar to include active:bg-gray-200 and focus-visible ring
- Add 7 new tests: VISUAL-01 skeleton class invariant, VISUAL-04 focus-visible invariant, VISUAL-03 typography invariant (36 test files, 270 tests pass)
2026-06-16 21:48:56 +02:00
curo1305 deea237033 docs(phase-11): update tracking after wave 3 2026-06-16 21:38:15 +02:00
curo1305 86d28046ca chore: merge executor worktree (worktree-agent-acd1ece9cc8092915) 2026-06-16 21:37:59 +02:00
curo1305 087eec1047 docs(11-04): complete forms baseline and mobile-safe modals plan summary 2026-06-16 21:35:52 +02:00
curo1305 df53cef3b7 feat(11-04): mobile-safe modals and form baseline verification
- Add max-h-[90vh] overflow-y-auto to ShareModal, CloudCredentialModal,
  FolderDeleteModal, and DocumentView cloud-delete modal panels
- Add responsive px-4 sm:px-6 to DocumentPreviewModal header for narrow viewports
- Add data-test attributes to all modal panels for testability
- Confirm @tailwindcss/forms active in tailwind.config.js (no drift found)
- Audit forms: focus:outline-none focus:ring-2 pattern is consistent throughout
- Add 4 test files covering mobile-safe modal classes and form baseline:
  ShareModal.mobile.test.js, CloudCredentialModal.mobile.test.js,
  FolderDeleteModal.mobile.test.js, DocumentPreviewModal.mobile.test.js

VISUAL-02, RESP-04
2026-06-16 21:33:57 +02:00
curo1305 71ddbfd426 docs(phase-11): update tracking after wave 2 2026-06-16 21:26:47 +02:00
curo1305 e32793c126 chore: merge executor worktree (worktree-agent-ae52d6537a4569901) 2026-06-16 21:26:09 +02:00
curo1305 dfac0a9617 docs(11-03): complete responsive shells and storage rows plan summary 2026-06-16 21:24:26 +02:00
curo1305 d914761120 feat(11-03): responsive shells and storage rows
- App.vue: mobile header with hamburger button; slide-in overlay drawer
  with Teleport backdrop, translate-x-0/-translate-x-full transition;
  drawer state owned by App.vue (D-04/D-05); route-change auto-close
- AdminLayout.vue: matching responsive treatment — hamburger, backdrop,
  drawer, route-change close (RESP-05)
- StorageBrowser.vue: responsive grid templates
  (mobile: grid-cols-[2rem_1fr_6rem], sm: +modified, md: all 5 cols);
  Size hidden below md, Modified hidden below sm; action buttons get
  min-w-[36px] min-h-[36px] touch targets (RESP-02, RESP-03)
- Tests: drawer open/close/backdrop-close/route-change behaviour;
  admin drawer; responsive column and touch-target class assertions
2026-06-16 21:22:59 +02:00
curo1305 6155aaba46 docs(phase-11): update tracking after wave 1 2026-06-16 21:12:35 +02:00
curo1305 dfc6ff52f7 docs(11-02): complete lazy-load routes plan — SUMMARY.md 2026-06-16 21:11:46 +02:00
curo1305 4fa07b3874 perf(11-02): lazy-load non-critical routes — PERF-03
- Keep FileManagerView synchronous for / per D-10 (critical first authenticated surface)
- Lazy-load TopicsView, DocumentView, SettingsView, CloudStorageView, CloudFolderView
- /folders/:folderId reuses synchronous FileManagerView (no split, same component)
- Main bundle reduced from 264.63 kB to 180.17 kB (split into 5 new route chunks)
- Extend router guard tests: admin child route blocking, refresh-before-guard flow,
  lazy-loaded route resolution — 234 tests pass (15 new tests added)
2026-06-16 21:10:48 +02:00
curo1305 7547e8ae97 docs(phase-11): update tracking after wave 0 2026-06-16 21:07:57 +02:00
curo1305andClaude Sonnet 4.6 3361a63ffd docs(11-01): add plan summary — baseline captured, audit complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:06:22 +02:00
curo1305 6d56d25977 docs(11-01): capture Phase 11 bundle baseline and frontend audit
- Ran ANALYZE=true npm run build; copied stats.html to .planning/perf/phase11-baseline.html
- Added phase11-baseline-summary.md: chunk sizes, route lazy-load audit,
  responsive/modal/typography/dead-code findings for plans 11-02..11-06
- Main bundle: 264.63 kB raw / 89.34 kB gzip; 5 user routes still synchronous
- Admin/auth routes already lazy-loaded; CSS 98.74 kB raw (expected for full UI)
2026-06-16 21:05:12 +02:00
curo1305 0fb2a53a4f chore(11-01): add ANALYZE=true opt-in for rollup-plugin-visualizer in vite.config.js
- Wrapped config in async defineConfig factory so visualizer can be dynamically
  imported only when ANALYZE=true; normal builds incur zero overhead
- Added frontend/stats.html to .gitignore (ephemeral artifact; canonical copy
  lives in .planning/perf/)
2026-06-16 21:04:54 +02:00
curo1305 41d136fa1f docs(state): record phase 11 context session 2026-06-16 20:50:43 +02:00
curo1305 f03d5b095e docs(11): capture phase context 2026-06-16 20:50:37 +02:00
curo1305andClaude Sonnet 4.6 83cdf28231 docs(phase-10): evolve PROJECT.md and resolve UAT after gap closure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 20:12:46 +02:00
curo1305andClaude Sonnet 4.6 ac95c1243f docs(phase-10): complete gap closure verification — 219 tests, all 6 UAT gaps closed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 20:11:51 +02:00
curo1305 2263abb2eb chore: merge executor worktree (worktree-agent-a2c859712240996ff) 2026-06-16 19:22:48 +02:00
curo1305 0df2942e4c docs(10-13): complete gap closure plan summary
- 6 UAT gaps closed: shimmer, search-at-root, admin sidebar, keyboard shortcuts, escape focus, OS drag-drop
- 219 tests passing (8 new)
- 5 production files modified, 3 test files added
2026-06-16 19:20:16 +02:00
curo1305 339f5a0c82 test(10-13): regression tests for all 6 UAT gaps
- keyboard.test.js: Gap 4 test — matched.find(r => r.instances?.default) resolves to FileManagerView
- TreeItem.test.js: Gap 1 tests — animate-pulse present, 'Loading' text absent during shimmer state
- StorageBrowser.showSearch.test.js: Gap 2 tests — showSearch true for local+cloud at root
- All 219 tests pass (8 new, 211 existing)
2026-06-16 19:19:02 +02:00
curo1305 bac5dcfe3d fix(10-13): escape modifier and capture-phase OS drop
- SearchBar.vue: add .prevent.stop to @keydown.escape to suppress browser native blur (Gap 5)
- OsDragOverlay.vue: register window drop listener with capture=true to fire before folder-row handlers (Gap 6)
2026-06-16 19:12:57 +02:00
curo1305 5972a62041 fix(10-13): admin sidebar bleed and keyboard instance resolution
- App.vue: add v-else-if branch for admin routes that renders only router-view (no AppSidebar)
- App.vue: replace routeViewRef with getFileManagerInstance() using matched.find(r => r.instances?.default)
- App.vue: remove unused ref import (Gap 3 + Gap 4)
2026-06-16 19:12:05 +02:00
curo1305 76785b4d96 fix(10-13): sidebar shimmer rows and search-at-root visibility
- TreeItem.vue: replace 'Loading…' text with 3 animate-pulse shimmer rows
- StorageBrowser.vue: showSearch now true for mode=local OR mode=cloud (Gap 1 + Gap 2)
2026-06-16 19:11:25 +02:00
curo1305 42ab542e25 fix(10-13): admin sidebar bleed and keyboard instance resolution
- App.vue: add v-else-if branch for admin routes that renders only router-view (no AppSidebar)
- App.vue: replace routeViewRef with getFileManagerInstance() using matched.find(r => r.instances?.default)
- App.vue: remove unused ref import (Gap 3 + Gap 4)
2026-06-16 19:07:54 +02:00
curo1305 f9e5a31945 fix(10-13): sidebar shimmer rows and search-at-root visibility
- TreeItem.vue: replace 'Loading…' text with 3 animate-pulse shimmer rows
- StorageBrowser.vue: showSearch now true for mode=local OR mode=cloud (Gap 1 + Gap 2)
2026-06-16 19:06:52 +02:00
curo1305 e3c681f99d docs(10): add root causes from diagnosis — 8 gaps diagnosed 2026-06-16 17:36:17 +02:00
curo1305 6d238a4ff3 test(10): complete UAT - 10 passed, 9 issues 2026-06-16 17:32:03 +02:00
curo1305 11228306e4 Merge cleanup branch 2026-06-16 15:40:33 +02:00
curo1305 3307282570 Fix Docker stack bootstrap 2026-06-16 15:35:06 +02:00
curo1305andClaude Sonnet 4.6 ce67b9f98a fix(cloud): skeleton on first render + informative toast when opening cloud files
- loading initialised to true so skeleton rows show immediately on mount
  instead of the empty state flashing before data arrives
- @file-open no longer a silent no-op; shows an info toast explaining the
  file must be opened via the cloud provider directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 13:57:18 +02:00
curo1305andClaude Sonnet 4.6 210670d033 docs(phase-10): mark VALIDATION.md nyquist-compliant — 16/16 tasks COVERED, 211 tests pass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 13:43:45 +02:00
curo1305 e97ca164d7 Refactor backend and frontend cleanup paths 2026-06-16 11:50:17 +02:00
112 changed files with 16323 additions and 3413 deletions
+4 -4
View File
@@ -17,7 +17,7 @@ POSTGRES_PASSWORD=changeme_super
MINIO_ROOT_USER=minioadmin MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=changeme_minio_root MINIO_ROOT_PASSWORD=changeme_minio_root
MINIO_ENDPOINT=minio:9000 MINIO_ENDPOINT=minio:9000
# App-level access key — minimal permissions on docuvault bucket only # App-level access key. docker-compose.yml provisions this user and a bucket-scoped policy.
MINIO_ACCESS_KEY=docuvault_app MINIO_ACCESS_KEY=docuvault_app
MINIO_SECRET_KEY=changeme_minio_app MINIO_SECRET_KEY=changeme_minio_app
MINIO_BUCKET=docuvault MINIO_BUCKET=docuvault
@@ -51,9 +51,9 @@ SMTP_PASSWORD=
SMTP_FROM=noreply@docuvault.local SMTP_FROM=noreply@docuvault.local
# ── CORS (Phase 2 — D-09) ──────────────────────────────────────────────────── # ── CORS (Phase 2 — D-09) ────────────────────────────────────────────────────
# Comma-separated list of allowed origins. Default: http://localhost:5173 # JSON list of allowed origins. Default: ["http://localhost:5173"]
# Example for production: https://app.docuvault.example.com # Example for production: ["https://app.docuvault.example.com"]
CORS_ORIGINS=http://localhost:5173 CORS_ORIGINS=["http://localhost:5173"]
# ── Cloud Storage Backends (Phase 5) ───────────────────────────────────────── # ── Cloud Storage Backends (Phase 5) ─────────────────────────────────────────
# Master key for HKDF per-user cloud credential encryption. # Master key for HKDF per-user cloud credential encryption.
+1
View File
@@ -5,4 +5,5 @@ backend/data/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/package-lock.json frontend/package-lock.json
frontend/stats.html
screenshots/ screenshots/
+37
View File
@@ -0,0 +1,37 @@
# DocuVault — Milestones
## v0.2 — UI Overhaul and Optimization
**Shipped:** 2026-06-17
**Phases:** 811 (4 phases, 33 plans)
**Timeline:** 2026-06-07 → 2026-06-17 (10 days)
**Git range:** docs: define milestone v0.2 requirements → docs(milestone): mark v0.2 audit passed
**Files changed:** 236 files, +39,557 / 6,288 lines, 198 commits
### Delivered
Transformed the frontend from rough alpha to polished, production-quality web app. Three backend router monoliths decomposed into focused sub-packages; `client.js` decomposed into 7 domain modules. Admin panel moved to standalone `/admin/*` route subtree with correct auth guard. Full UX interaction layer (empty states, skeletons, keyboard shortcuts, OS drag-drop, toasts, breadcrumbs, drag-to-move). Mobile-responsive layout with hamburger sidebar drawer. Bundle reduced 81 kB (30.6%) via lazy loading.
### Key Accomplishments
1. Backend monolith decomposition — `api/admin.py` (934L), `api/documents.py` (852L), `api/auth.py` (825L) split into focused sub-packages; shared schemas extracted to `api/schemas.py`
2. Frontend client decomposition — `client.js` (635L) → 7 domain modules + utils.js + barrel re-export; zero consumer churn across 35+ import sites
3. Admin panel rearchitecture — standalone `/admin/*` subtree, `AdminLayout.vue`, `AdminSidebar.vue`, 5 deep-linkable views, overview aggregate endpoint, `to.matched.some()` auth guard
4. UX interaction layer — `EmptyState.vue`, skeleton loaders, keyboard shortcuts (`/` `U` `N` `Escape`), `OsDragOverlay.vue`, Pinia toast store + `ToastContainer.vue`, `BreadcrumbBar.vue`, drag-to-move with Teleport dropdowns, `AppIcon.vue` (66 SVG instances centralized)
5. Responsive design — hamburger sidebar drawer (below `lg`), adaptive document list columns, 36px touch targets, scrollable modals, `@tailwindcss/forms` baseline, Tailwind-only spacing/typography
6. Bundle optimization — all admin routes lazy-loaded; dead code deleted (`FolderRow.vue`, `AccountView.vue`, stale test files); final bundle 81 kB vs baseline
### Requirements
40/40 satisfied (100%). No known gaps.
### Archive
- Roadmap: `.planning/milestones/v0.2-ROADMAP.md`
- Requirements: `.planning/milestones/v0.2-REQUIREMENTS.md`
- Audit: `.planning/milestones/v0.2-MILESTONE-AUDIT.md`
- Phases: `.planning/phases/08-*/`, `09-*/`, `10-*/`, `11-*/`
---
*For project history prior to v0.2, see v0.1 milestone (not yet archived — v0.1 phases remain in `.planning/phases/01-*/` through `07.4-*/`).*
+35 -15
View File
@@ -8,17 +8,11 @@ DocuVault is a self-hosted, multi-user SaaS document management platform. Users
Every user's documents — and the credentials they use to store them — are inaccessible to everyone except that user, while the platform scales horizontally and supports pluggable storage backends. Every user's documents — and the credentials they use to store them — are inaccessible to everyone except that user, while the platform scales horizontally and supports pluggable storage backends.
## Current Milestone: v0.2 — UI Overhaul and Optimization ## Last Milestone: v0.2 — UI Overhaul and Optimization (shipped 2026-06-17)
**Goal:** Redesign the frontend into a polished, performant, and responsive interface, and overhaul both the backend and frontend codebase to senior-dev quality — minimal, DRY, readable code with no unnecessary comments. **Delivered:** Polished, production-quality frontend with mobile-responsive layout, full UX interaction layer, decomposed backend/frontend codebase, and standalone admin panel.
**Target features:** See `.planning/MILESTONES.md` and `.planning/milestones/v0.2-ROADMAP.md` for full archive.
- Visual redesign — refined Tailwind component system, consistent palette, typography, spacing
- UX & interaction improvements — drag-and-drop upload, keyboard shortcuts, empty states, loading skeletons
- Frontend performance — lazy loading, virtual scrolling, bundle size reduction, API response caching
- Responsive / mobile layout — adaptive layouts for phones/tablets, touch-friendly controls
- Proper admin panel — standalone admin interface with its own routes, layout, and nav (not tabs bolted onto the user app)
- Codebase quality overhaul — eliminate duplication, extract shared utilities, delete dead code, flatten unnecessary abstractions; comments only where code alone is insufficient
## Requirements ## Requirements
@@ -49,9 +43,29 @@ Every user's documents — and the credentials they use to store them — are in
- ✓ Celery retry backoff (30 s / 90 s / 270 s) on classification failure - ✓ Celery retry backoff (30 s / 90 s / 270 s) on classification failure
- ✓ Backend stateless — all state in PostgreSQL and MinIO - ✓ Backend stateless — all state in PostgreSQL and MinIO
### Active (v0.2 — this milestone) ### Validated (v0.2 — shipped 2026-06-17)
*Defined in REQUIREMENTS.md* - ✓ Backend monolith decomposition (api/admin/, api/documents/, api/auth/ sub-packages) — v0.2
- ✓ Frontend API client decomposition (7 domain modules + barrel re-export) — v0.2
- ✓ Admin panel standalone route subtree (/admin/*) with AdminLayout, AdminSidebar, 5 deep-linkable views — v0.2
- ✓ requiresAdmin guard via to.matched.some() — v0.2
- ✓ Admin overview aggregate endpoint (user count, storage, doc status, recent audit) — v0.2
- ✓ EmptyState.vue in all zero-content contexts — v0.2
- ✓ Skeleton loaders for all async-populated tables/lists/sidebars — v0.2
- ✓ Keyboard shortcuts: / (search), U (upload), N (new folder), Escape (close/clear) — v0.2
- ✓ OS drag-drop overlay (full-screen, file-type discriminated) — v0.2
- ✓ Toast notification system (auto-dismiss, stacking, non-blocking) — v0.2
- ✓ BreadcrumbBar.vue shared across all views — v0.2
- ✓ Drag-to-move document to folder with Teleport-based dropdowns — v0.2
- ✓ AppIcon.vue centralizing all SVG path data (66 instances) — v0.2
- ✓ Mobile-responsive layout: hamburger sidebar drawer (below lg), touch targets ≥36px — v0.2
-@tailwindcss/forms cross-browser form baseline — v0.2
- ✓ Consistent Tailwind-only spacing/typography/focus-visible/hover states — v0.2
- ✓ Bundle 81 kB (30.6%) via lazy-loaded admin routes — v0.2
- ✓ Dead code deleted (FolderRow.vue, AccountView.vue, stale test files) — v0.2
- ✓ WHY-only comment policy enforced (CODE-09) — v0.2
### Active (next milestone — not yet defined)
### Out of Scope ### Out of Scope
@@ -65,10 +79,10 @@ Every user's documents — and the credentials they use to store them — are in
## Context ## Context
- **Current state**: v0.2 in progress — Phase 9 complete (2026-06-13). Admin panel rearchitected as standalone /admin/* route subtree with AdminLayout, 5 dedicated views, corrected Vue Router 4 guard, admin login redirect, and new GET /api/admin/overview backend endpoint. Phase 10 (UX & Interaction) up next. - **Current state**: v0.2 shipped 2026-06-17. All 4 v0.2 phases complete: stack upgrade + decomposition (Phase 8), admin panel rearchitecture (Phase 9), UX interaction layer (Phase 10), visual design + responsive layout (Phase 11). 277 tests pass. Bundle 81 kB from baseline. App is mobile-responsive, keyboard-navigable, and fully polished. Ready for next milestone definition.
- **Tech stack**: FastAPI 0.136+ (Python 3.12), SQLAlchemy 2.0 async, Alembic, MinIO SDK; Vue 3 (Options API), Pinia, Vue Router 4, Vite, Tailwind CSS. - **Tech stack**: FastAPI 0.136+ (Python 3.12), SQLAlchemy 2.0 async, Alembic, MinIO SDK; Vue 3 (Options API), Pinia, Vue Router 4, Vite, Tailwind CSS.
- **Code quality**: v0.1 was built feature-first under time pressure. Both backend and frontend contain duplication, inconsistent patterns, and components that grew beyond their original scope. v0.2 addresses this systematically. - **Code quality**: v0.1 was built feature-first under time pressure. Both backend and frontend contain duplication, inconsistent patterns, and components that grew beyond their original scope. v0.2 addresses this systematically.
- **Admin panel**: Now a standalone /admin/* route subtree with AdminLayout as the route component, AdminSidebar with 5 nav links, and 5 dedicated view components. Old AdminView.vue and tab components deleted. Admin users are redirected to /admin on login; non-admin users are blocked from /admin/* by a correct to.matched.some() guard. - **Admin panel**: Standalone /admin/* route subtree with AdminLayout as the route component, AdminSidebar with 5 nav links, and 5 dedicated view components. AdminView.vue and legacy tab components deleted. Admin users redirect to /admin on login; non-admin blocked by to.matched.some() guard.
- **Privacy constraint**: Admin role is a platform operator, not a content viewer. Cloud credentials encrypted with per-user HKDF key; API keys encrypted with separate HKDF domain. Neither is ever in an API response. - **Privacy constraint**: Admin role is a platform operator, not a content viewer. Cloud credentials encrypted with per-user HKDF key; API keys encrypted with separate HKDF domain. Neither is ever in an API response.
## Constraints ## Constraints
@@ -101,12 +115,18 @@ Every user's documents — and the credentials they use to store them — are in
| Vite 6 upgrade | Resolved two moderate CVEs (CVE-2026-39363/39364) present in Vite 5; build time unchanged | Shipped Phase 8 (PERF-01) | | Vite 6 upgrade | Resolved two moderate CVEs (CVE-2026-39363/39364) present in Vite 5; build time unchanged | Shipped Phase 8 (PERF-01) |
| Admin login redirect (D-08) | Role check fires before router.push — admin → /admin, regular user → /; D-09 guard as belt-and-suspenders | Shipped Phase 9 | | Admin login redirect (D-08) | Role check fires before router.push — admin → /admin, regular user → /; D-09 guard as belt-and-suspenders | Shipped Phase 9 |
| Tailwind safelist with regex patterns | Dynamic color classes (sky=OneDrive, amber=admin audit badges) are tree-shaken without explicit safelist | Regex patterns cover all provider and event-type color families in tailwind.config.js | | Tailwind safelist with regex patterns | Dynamic color classes (sky=OneDrive, amber=admin audit badges) are tree-shaken without explicit safelist | Regex patterns cover all provider and event-type color families in tailwind.config.js |
| GET /api/admin/overview as dedicated endpoint | Aggregated stats (user count, storage, doc status, recent audit) served in one request to avoid N+1 on admin load | Shipped Phase 9 (09-01) | | GET /api/admin/overview as dedicated endpoint | Aggregated stats (user count, storage, doc status, recent audit) served in one request to avoid N+1 on admin load | Shipped Phase 9 (09-01) |
| AppIcon.vue SVG registry | 66 duplicate inline SVG blocks eliminated; single source of truth for all icon paths | ✓ Shipped Phase 10 (CODE-05) |
| Teleport + getBoundingClientRect for dropdowns | Viewport-edge clipping eliminated without complex position logic; prerequisite for virtual scrolling | ✓ Shipped Phase 10 (UX-13) |
| Admin routes lazy-loaded | All 5 admin views excluded from initial bundle; 81 kB (30.6%) improvement | ✓ Shipped Phase 11 (PERF-03) |
| Vite 8 upgrade for npm audit | Closed high-severity esbuild CVE present in Vite 5/6; npm audit now clean | ✓ Shipped Phase 11 (11-07) |
## Evolution ## Evolution
This document evolves at phase transitions and milestone boundaries. This document evolves at phase transitions and milestone boundaries.
Last updated: 2026-06-16
**After each phase transition** (via `/gsd-transition`): **After each phase transition** (via `/gsd-transition`):
1. Requirements invalidated? → Move to Out of Scope with reason 1. Requirements invalidated? → Move to Out of Scope with reason
2. Requirements validated? → Move to Validated with phase reference 2. Requirements validated? → Move to Validated with phase reference
@@ -121,4 +141,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state 4. Update Context with current state
--- ---
*Last updated: 2026-06-13 — after Phase 9* *Last updated: 2026-06-17 — after v0.2 milestone*
-150
View File
@@ -1,150 +0,0 @@
# DocuVault v0.2 — Requirements
**Milestone:** v0.2 — UI Overhaul and Optimization
**Created:** 2026-06-07
**Total requirements:** 40
---
## Quality Mandate (applies to all phases)
Every line of code written or modified in v0.2 must be:
- **Minimal:** smallest amount of code that correctly solves the problem. No speculative abstractions.
- **DRY:** if the same logic exists twice, extract it. Duplication is a defect.
- **Readable:** names things accurately. A reader should understand intent without comments.
- **No redundant comments:** comments exist only where the *why* is non-obvious. Never explain what the code does.
- **No dead code:** unreferenced files, components, or functions are deleted — not commented out.
---
## CODE — Codebase Quality
- [ ] **CODE-01**: Backend `api/admin.py` (934L) decomposed into `api/admin/` package with `users.py`, `quotas.py`, `ai.py`; `__init__.py` aggregates sub-routers under the existing `/api/admin` prefix. No URL changes, no behavior changes, all tests stay green.
- [ ] **CODE-02**: `api/documents.py` (852L) decomposed into focused sub-modules (upload flow, content proxy, document CRUD, search/listing) within `api/documents/` package. Prefix and behavior unchanged.
- [ ] **CODE-03**: `api/auth.py` (825L) decomposed into focused sub-modules (login/tokens, TOTP, password management, session management) within `api/auth/` package. Prefix and behavior unchanged.
- [x] **CODE-04**: Frontend `api/client.js` (635L) decomposed into domain modules (`documents.js`, `auth.js`, `admin.js`, `folders.js`, `shares.js`, `cloud.js`, `topics.js`); `client.js` becomes the HTTP transport layer and re-export barrel. Zero changes to any of the 35+ consumer files.
- [ ] **CODE-05**: All inline SVG blocks (~66 instances) replaced with `<AppIcon name="..." class="..." />`; all icon path data centralized in `components/ui/AppIcon.vue`. No duplicated path strings.
- [x] **CODE-06**: Tailwind `safelist` configured for all dynamic class name patterns in `formatters.js` (provider colors, backgrounds, badge text). Production builds render topic and provider colors correctly.
- [ ] **CODE-07**: All unreferenced files, components, stores, and unused imports deleted. No dead code retained.
- [x] **CODE-08**: No duplicated Pydantic model definitions or shared validators across router files. Shared schemas extracted to dedicated modules.
- [x] **CODE-09**: No comment in any file describes what the code does. Comments exist only where intent or constraint would not be obvious to a competent reader.
---
## ADMIN — Admin Panel
- [x] **ADMIN-08**: Admin panel moved to `/admin/*` route subtree with `AdminLayout.vue` as the route component. `AdminLayout` has its own sidebar with admin-specific nav only — no user quota bar, no folder tree, no topic list. `AdminView.vue` is deleted.
- [x] **ADMIN-09**: Admin sidebar nav links (in order): Overview, Users, Quotas, AI Config, Audit Log. "Back to app" link at the bottom returns to `/`.
- [x] **ADMIN-10**: Each admin section is its own deep-linkable URL (`/admin/users`, `/admin/quotas`, `/admin/ai`, `/admin/audit`). Browser back button works within the admin section.
- [x] **ADMIN-11**: Admin overview page (`/admin`) shows: total registered user count, total platform storage in use, document status breakdown (processing/ready/failed), last 10 audit log entries. Requires new backend aggregate query endpoints.
- [x] **ADMIN-12**: `requiresAdmin` guard enforced for all `/admin/*` child routes via `to.matched.some(r => r.meta.requiresAdmin)`. No admin child route is accessible to non-admin users.
---
## UX — UX and Interaction
- [ ] **UX-01**: Every zero-content context shows a contextually distinct empty state using a shared `EmptyState.vue` component (headline, subtext, optional CTA). Applied to: root file list, individual folder, search with no results, shared-with-me, topics list, audit log, cloud connections. No plain "No items" text remains.
- [ ] **UX-02**: Document list (`StorageBrowser`) displays 5-col skeleton grid rows during loading (matching actual grid layout, `animate-pulse`). "Loading..." text removed.
- [ ] **UX-03**: Sidebar folder tree and topics list display skeleton placeholder items during loading. "Loading..." text removed.
- [ ] **UX-04**: Admin user table and audit log table display skeleton table rows during loading.
- [ ] **UX-05**: Pressing `/` when no input is focused moves keyboard focus to the search bar.
- [ ] **UX-06**: Pressing `Escape` closes any open modal (`ShareModal`, `FolderDeleteModal`, `DocumentPreviewModal`) and clears active search. Handled at the overlay level, not only inside inputs.
- [ ] **UX-07**: Pressing `U` when no input is focused triggers the file upload picker.
- [ ] **UX-08**: Pressing `N` when no input is focused starts the new folder inline input in the file manager.
- [ ] **UX-09**: Dragging files from the OS anywhere onto the browser window shows a full-screen drop overlay. Releasing uploads the files via the existing upload flow. Only activates when `dataTransfer.types` includes `'Files'`.
- [ ] **UX-10**: Toast notification system shows transient messages (auto-dismiss 4 seconds, manual dismiss on click) for: upload complete, upload error, document deleted, share revoked, rename successful. Toasts stack vertically and do not block interaction.
- [ ] **UX-11**: Drag-to-move a document onto a folder row in `StorageBrowser` works correctly end-to-end. Drop highlight (`ring-2 ring-inset ring-amber-300`) renders on valid folder targets. "Move to folder" button remains as non-drag alternative.
- [ ] **UX-12**: All views display a breadcrumb reflecting the full current navigation path. Examples: `Folders Reports Q1`, `Cloud Google Drive Projects`, `Admin Users`, `Settings Account`, `Folders Reports document.pdf`. Breadcrumb updates on navigation. A single shared breadcrumb component is used across all view types — no per-view implementations.
- [ ] **UX-13**: All dropdown menus and modal dialogs render their full content within the viewport. No text or interactive element is clipped by a viewport edge or sibling element. Dropdowns that risk edge-clipping use fixed positioning computed from `getBoundingClientRect()` or `<Teleport to="body">`.
- [ ] **UX-14**: The inline "New" button next to the Folders section header in `AppSidebar.vue` is removed. Folder creation accessible only from within the file manager view.
---
## VISUAL — Visual Design
- [ ] **VISUAL-01**: Consistent spacing scale across all components using Tailwind utilities only. No arbitrary `px-[13px]` values or inline `style` margins.
- [ ] **VISUAL-02**: All form elements (inputs, selects, textareas, checkboxes, radio buttons) have consistent cross-browser baseline styling via `@tailwindcss/forms` plugin.
- [ ] **VISUAL-03**: All interactive elements (buttons, links, card rows) have consistent hover states, `focus-visible:` rings, and active states.
- [ ] **VISUAL-04**: Typography scale is consistent: one heading size per level, one body size, one label/caption size. No per-component font-size overrides.
---
## RESP — Responsive Layout
- [ ] **RESP-01**: Sidebar hidden below `lg` (1024px). Hamburger button in mobile-only header toggles a slide-in overlay drawer. Overlay closes on nav tap or backdrop tap.
- [ ] **RESP-02**: Document list hides Size column below `md` (768px) and Modified column below `sm` (640px). Icon, name, and actions always visible.
- [ ] **RESP-03**: Inline icon action buttons have minimum 36×36px touch target on viewports below `md`.
- [ ] **RESP-04**: All modal dialogs are scrollable on viewports below 640px. No modal content overflows the screen.
- [ ] **RESP-05**: Admin layout has the same responsive behavior as the user layout: admin sidebar hidden on mobile, hamburger shows admin nav drawer.
---
## PERF — Performance and Stack
- [ ] **PERF-01**: Frontend dependencies bumped/added: `vue@^3.5.0`, `vite@^6.4.3`, `@vueuse/core@^14.3.0`, `@vueuse/integrations@^14.3.0`, `sortablejs@^1.15.7`, `@tailwindcss/forms@^0.5.11`. Dev: `rollup-plugin-visualizer@^7.0.1`, `@types/sortablejs`.
- [ ] **PERF-02**: Bundle baseline measured with `rollup-plugin-visualizer` before optimizations begin. Second measurement taken after all refactoring is complete. Both reports committed to `.planning/`.
- [ ] **PERF-03**: All non-initial-render route components are lazy-loaded. Admin views explicitly lazy-loaded. No synchronous import for routes not on the critical render path.
---
## Future (deferred)
- Virtual scrolling — quota cap (100 MB/user) limits lists to hundreds of items; `v-for` is sufficient. Revisit with server-side pagination.
- Dark mode — coherent color token system must exist first.
- Folder reordering by drag — requires persistent `position` column in DB.
- Multi-select with batch operations — low frequency in a personal vault.
## Out of Scope
- Composition API migration — refactor within Options API; Composition API is a separate milestone.
- Component library switch (shadcn-vue, PrimeVue) — stay with Tailwind.
- SSO, billing, group roles — v0.x exclusions unchanged from v0.1.
- Document annotation or in-app editing — not planned.
---
## Traceability
| REQ-ID | Phase | Plan |
|--------|-------|------|
| PERF-01 | Phase 8 | Pending |
| CODE-01 | Phase 8 | Pending |
| CODE-02 | Phase 8 | Pending |
| CODE-03 | Phase 8 | Pending |
| CODE-04 | Phase 8 | Complete |
| CODE-08 | Phase 8 | Complete |
| ADMIN-08 | Phase 9 | Complete |
| ADMIN-09 | Phase 9 | Complete |
| ADMIN-10 | Phase 9 | Complete |
| ADMIN-11 | Phase 9 | Complete |
| ADMIN-12 | Phase 9 | Complete |
| CODE-06 | Phase 9 | Complete |
| CODE-09 | Phase 9 | Complete |
| UX-01 | Phase 10 | Pending |
| UX-02 | Phase 10 | Pending |
| UX-03 | Phase 10 | Pending |
| UX-04 | Phase 10 | Pending |
| UX-05 | Phase 10 | Pending |
| UX-06 | Phase 10 | Pending |
| UX-07 | Phase 10 | Pending |
| UX-08 | Phase 10 | Pending |
| UX-09 | Phase 10 | Pending |
| UX-10 | Phase 10 | Pending |
| UX-11 | Phase 10 | Pending |
| UX-12 | Phase 10 | Pending |
| UX-13 | Phase 10 | Pending |
| UX-14 | Phase 10 | Pending |
| CODE-05 | Phase 10 | Pending |
| VISUAL-01 | Phase 11 | Pending |
| VISUAL-02 | Phase 11 | Pending |
| VISUAL-03 | Phase 11 | Pending |
| VISUAL-04 | Phase 11 | Pending |
| RESP-01 | Phase 11 | Pending |
| RESP-02 | Phase 11 | Pending |
| RESP-03 | Phase 11 | Pending |
| RESP-04 | Phase 11 | Pending |
| RESP-05 | Phase 11 | Pending |
| CODE-07 | Phase 11 | Pending |
| PERF-02 | Phase 11 | Pending |
| PERF-03 | Phase 11 | Pending |
+80
View File
@@ -0,0 +1,80 @@
# DocuVault — Project Retrospective
*A living document updated after each milestone. Lessons feed forward into future planning.*
---
## Milestone: v0.2 — UI Overhaul and Optimization
**Shipped:** 2026-06-17
**Phases:** 4 (811) | **Plans:** 33 | **Duration:** 10 days (2026-06-07 → 2026-06-17)
**Git:** 198 commits, 236 files changed, +39,557 / 6,288 lines
### What Was Built
- Backend monolith decomposition — three router monoliths (934L, 852L, 825L) split into focused sub-packages with zero URL or behavior changes; shared schemas extracted to `api/schemas.py`
- Frontend client decomposition — `client.js` (635L) → 7 domain modules + barrel re-export; 35+ consumer files unchanged
- Admin panel rearchitecture — standalone `/admin/*` route subtree; `AdminLayout.vue`; `AdminSidebar.vue` with 5 nav links; 5 deep-linkable views; `to.matched.some()` auth guard fix; `GET /api/admin/overview` aggregate endpoint
- UX interaction layer — `EmptyState.vue`, skeleton loaders, keyboard shortcuts (`/`, `U`, `N`, `Escape`), `OsDragOverlay.vue`, Pinia toast store + `ToastContainer.vue`, `BreadcrumbBar.vue`, drag-to-move with Teleport dropdowns, `AppIcon.vue` (66 SVG instances centralized)
- Responsive design + visual polish — hamburger sidebar drawer (below `lg`), adaptive document list columns, 36px touch targets, scrollable modals, `@tailwindcss/forms` baseline, consistent Tailwind-only spacing/typography/focus-visible/hover states
- Bundle optimization — all admin routes lazy-loaded; dead code deleted; bundle 81 kB (30.6%) from baseline
### What Worked
- **Wave parallelization** — executing independent plans in parallel (e.g., CODE-01/02/03/04 in Phase 8 Wave 2) dramatically reduced wall-clock time; the wave structure in PLAN.md made this trivial to execute
- **Barrel re-export pattern** — decomposing `client.js` with a barrel kept all 35+ consumer files unchanged; zero regressions, zero migration cost
- **Foundation-then-wire order** — building `EmptyState.vue`, `BreadcrumbBar.vue`, `AppIcon.vue`, and the toast store as isolated components in Phase 10 Wave 0 before wiring them in Wave 1 kept each step reviewable and testable
- **Teleport for dropdowns** — solving viewport-edge clipping with `Teleport to="body"` + `getBoundingClientRect()` was the right call; future virtual scrolling is now unblocked
- **UAT gap closure plans** — having dedicated plans (10-13, 11-07) for UAT gaps rather than patching in-flight kept the execution clean and the gap closure auditable
### What Was Inefficient
- **Phase 8 progress table not updated** — the ROADMAP progress table showed "4/8 In Progress" for Phase 8 even after completion, discovered at milestone close. Progress table updates should be part of the plan execution checklist.
- **Admin auth guard bug caught late** — the `to.meta.requiresAdmin``to.matched.some()` fix is a security-relevant change that wasn't caught until Phase 9. Nested route guards should be explicitly tested in Phase scaffolding.
- **11-07 mobile toolbar gap** — the mobile compact toolbar fix was a UAT gap rather than planned; the RESP-02 success criterion should have been clearer about the 550px threshold from the start.
- **Multiple SUMMARY.md formats** — some phase summaries used `**One-liner:**` and some used `## One-liner`; extracting them required heuristic grep patterns. A consistent frontmatter schema would help.
### Patterns Established
- **Sub-router NO-prefix rule** — `APIRouter()` in sub-packages must carry no `prefix`; the parent `include_router(sub, prefix=...)` propagates. This is now in CLAUDE.md.
- **FastAPI 0.128+ empty-path restriction** — `@router.get("")` on a sub-router with empty include prefix raises `FastAPIError`; root routes must be registered on the parent aggregator directly.
- **`to.matched.some()` for Vue Router 4 meta inheritance** — Vue Router 4 does not propagate `meta` to children automatically; direct `to.meta` checks are a security regression.
- **AdminLayout as route component, not App.vue branch** — router resolves `AdminLayout` as the `/admin` component; its `<router-view>` renders children. `App.vue` needs no layout branching logic.
- **Lazy-load all non-critical routes** — admin views and other non-initial-path routes should always be lazy-loaded by default; synchronous imports for non-critical routes are a bundle regression.
### Key Lessons
1. **Verify test coverage includes nested route behavior.** The `to.matched.some()` fix was a security-relevant change that required a specific negative test (non-admin navigating directly to `/admin/users`). Add nested-route auth tests to the scaffolding checklist for any phase that touches routing.
2. **Write success criteria with explicit thresholds.** "Mobile responsive" in RESP-02 should have said "toolbar fits without horizontal scrolling at 375px and 550px" rather than leaving it implicit. Explicit viewport thresholds eliminate UAT guesswork.
3. **Archive progress table state in the phase summary.** The ROADMAP progress table is read by the milestone close process; phases should update it as part of the "plan complete" ritual, not leave it for the orchestrator to discover at close.
4. **Barrel re-export is the zero-friction decomposition pattern.** When splitting a large module, start with the barrel and establish the public API first. Consumer files never change; the decomposition is invisible to callers.
### Cost Observations
- Model: Claude Sonnet 4.6 throughout
- No haiku or opus usage in v0.2
- Notable: wave parallelization (35 independent plans per wave) was the primary efficiency lever; sequential execution of the same work would have taken ~23x longer
---
## Cross-Milestone Trends
### Process Evolution
| Milestone | Duration | Phases | Key Process Change |
|-----------|----------|--------|--------------------|
| v0.1 | ~16 days (2026-05-21→2026-06-06) | 11 (17.4) | Feature-first; security gates added mid-stream |
| v0.2 | 10 days (2026-06-07→2026-06-17) | 4 (811) | Quality-first; wave parallelization; milestone audit before close |
### Cumulative Quality
| Milestone | Tests at close | Notes |
|-----------|---------------|-------|
| v0.1 | 347 | 1 pre-existing failure (missing module) |
| v0.2 | 277 | Reduction reflects dead test file deletion; coverage per line improved |
### Top Lessons (Verified Across Milestones)
1. **Security gates must run before phase advance, not as a post-close checklist.** Both milestones had late-discovered security issues (v0.1: IDOR stubs, v0.2: auth guard). Bake the security agent into the plan execution ritual.
2. **Explicit success criteria with measurable thresholds eliminate UAT gaps.** Vague criteria ("responsive") always produce UAT gap closure plans. Precise criteria ("at 375px and 550px viewport") do not.
3. **Milestone audits before archival are worth the overhead.** The v0.2 audit caught stale artifacts and the esbuild CVE before the milestone was tagged. Running the audit as a prerequisite rather than a post-mortem saves remediation cost.
+30 -209
View File
@@ -522,219 +522,40 @@ Before any phase is marked complete, all three gates must pass:
--- ---
# DocuVault — v0.2 Roadmap ## Milestones
_Milestone: v0.2 — UI Overhaul and Optimization_ -**v0.2 — UI Overhaul and Optimization** — Phases 811 (shipped 2026-06-17)
_Started: 2026-06-07_
## v0.2 Phases <details>
<summary>✅ v0.2 — UI Overhaul and Optimization (Phases 811) — SHIPPED 2026-06-17</summary>
- [x] **Phase 8: Stack Upgrade & Backend Decomposition** — Dependency bumps land; all three backend router monoliths split into focused sub-packages; frontend API client decomposed into domain modules; shared Pydantic schemas extracted _(2026-06-12)_ - [x] Phase 8: Stack Upgrade & Backend Decomposition (8/8 plans) — completed 2026-06-12
- [x] **Phase 9: Admin Panel Rearchitecture** — Admin panel moves to `/admin/*` route subtree with its own layout, sidebar, and deep-linkable child routes; Tailwind safelist configured; redundant comments purged (completed 2026-06-12) - [x] Phase 9: Admin Panel Rearchitecture (5/5 plans) — completed 2026-06-13
- [x] **Phase 10: UX & Interaction** — Empty states, loading skeletons, keyboard shortcuts, drag-and-drop upload, toast notifications, breadcrumbs, and icon centralization land across the full UI (completed 2026-06-16) - [x] Phase 10: UX & Interaction (13/13 plans) — completed 2026-06-16
- [ ] **Phase 11: Visual Design, Responsive Layout & Cleanup** — Consistent spacing, form styling, hover/focus states, and typography applied; mobile-responsive sidebar and layouts ship; dead code deleted; bundle measured - [x] Phase 11: Visual Design, Responsive Layout & Cleanup (7/7 plans) — completed 2026-06-17
## Phase Details Full archive: `.planning/milestones/v0.2-ROADMAP.md`
### Phase 8: Stack Upgrade & Backend Decomposition </details>
**Goal**: The dependency stack is current, all three backend router monoliths are split into focused sub-packages with zero URL or behavior changes, and the frontend API client is decomposed into domain modules behind a re-export barrel — the entire change is invisible to consumers and tests. ## v0.2 + v0.1 Progress Table
**Depends on**: Phase 7.4 (last v0.1 phase)
**Requirements**: PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08
**Implementation notes:** | Phase | Milestone | Plans Complete | Status | Completed |
- Sub-routers in `api/admin/`, `api/documents/`, `api/auth/` must have NO prefix on their `APIRouter()` — the parent prefix propagates. Adding a prefix doubles the URL path. (PITFALLS.md §Pitfall 1) |-------|-----------|----------------|--------|-----------|
- Shared Pydantic models, constants, and helpers that span sub-modules go in a `shared.py` within each package — never duplicated across sub-routers. (PITFALLS.md §Pitfall 2 — prevents circular imports) | 8. Stack Upgrade & Backend Decomposition | v0.2 | 8/8 | Complete | 2026-06-12 |
- `client.js` decomposition uses the re-export barrel pattern: `client.js` re-exports everything from domain sub-files; zero changes to the 35+ consumer files. `request()` and `noRefreshPaths` stay in `client.js`; the three blob-download 401-retry duplicates are consolidated into a single `fetchWithRetry()` helper. (PITFALLS.md §Pitfall 5) | 9. Admin Panel Rearchitecture | v0.2 | 5/5 | Complete | 2026-06-13 |
- CODE-01, CODE-02, CODE-03 are independent (touch different files) and can be executed in parallel. CODE-04 is also independent of the backend work. | 10. UX & Interaction | v0.2 | 13/13 | Complete | 2026-06-16 |
| 11. Visual Design, Responsive Layout & Cleanup | v0.2 | 7/7 | Complete | 2026-06-17 |
**Success Criteria** (what must be TRUE): | 1. Infrastructure Foundation | v0.1 | 5/5 | Complete | 2026-05-22 |
1. `pytest -v` passes with zero failures immediately after the backend split — every existing API endpoint continues to respond on the same URL path | 2. Users & Authentication | v0.1 | 6/6 | Complete | 2026-06-01 |
2. `import * as api from '../api/client.js'` in any existing consumer file resolves all previously-exported names without modification to that consumer file | 3. Document Migration & Multi-User Isolation | v0.1 | 5/5 | Complete | 2026-05-25 |
3. No Pydantic model definition appears more than once across `api/admin/`, `api/documents/`, and `api/auth/` sub-packages — shared types live in a dedicated module | 4. Folders, Sharing, Quotas & Document UX | v0.1 | 9/9 | Complete | 2026-05-28 |
4. `PERF-01` packages are installed and `npm list` confirms `vue@^3.5.x`, `vite@^6.x`, `@vueuse/core@^14.x`, `sortablejs`, `@tailwindcss/forms`, `rollup-plugin-visualizer` are present | 5. Cloud Storage Backends | v0.1 | 12/12 | Complete | 2026-05-30 |
**Plans**: 8 plans (3 waves) | 6. Performance & Production Hardening | v0.1 | 6/6 | Complete | 2026-05-30 |
| 6.1. Close v1.0 audit gaps | v0.1 | 2/2 | Complete | 2026-05-30 |
**Wave 0** — Foundation (parallel) | 6.2. Close v1 sharing + cloud-delete + CSV export gaps | v0.1 | 5/5 | Complete | 2026-05-31 |
| 7. Redo and optimize LLM integration | v0.1 | 5/5 | Complete | 2026-06-05 |
- [x] 08-01-PLAN.md — CR-01/02/03 test stubs (3 xfail stubs in test_auth.py) + Wave 0 scaffolds for regression detection | 7.1. Security: session revocation on privilege change | v0.1 | 2/2 | Complete | 2026-06-08 |
- [x] 08-02-PLAN.md — `api/schemas.py` creation + `CloudConnectionOut` migration from admin.py (MUST precede admin split) | 7.2. Security: JTI claim + Redis access-token revocation | v0.1 | 3/3 | Complete | 2026-06-05 |
| 7.3. Security: ES256 algorithm upgrade | v0.1 | 3/3 | Complete | 2026-06-06 |
**Wave 1** *(blocked on Wave 0)* — Phase 7.1 completion (frontend only — backend already implemented) | 7.4. Security: token fingerprinting / token binding | v0.1 | 2/2 | Complete | 2026-06-06 |
- [x] 08-03-PLAN.md — `useToastStore` stub (`frontend/src/stores/toast.js`) + CR test promotion + SettingsAccountTab.vue + TotpEnrollment.vue inline toast replacement
**Wave 2** *(blocked on Wave 1)* — Backend decomposition + frontend (parallel)
- [x] 08-04-PLAN.md — Split `api/admin.py``api/admin/` package: users.py, quotas.py, ai.py, shared.py, __init__.py (CODE-01)
- [x] 08-05-PLAN.md — Split `api/documents.py``api/documents/` package: upload.py, crud.py, content.py, shared.py, __init__.py (CODE-02)
- [x] 08-06-PLAN.md — Split `api/auth.py``api/auth/` package: tokens.py, totp.py, password.py, sessions.py, shared.py, __init__.py (CODE-03)
- [x] 08-07-PLAN.md — Frontend `client.js` decomposition: utils.js + 7 domain modules + barrel rewrite (CODE-04)
- [x] 08-08-PLAN.md — PERF-01 dependency bump + tailwind/vite config wiring + requirements.txt exact pinning (PERF-01, D-17)
**Cross-cutting constraints:**
- Sub-routers MUST have NO prefix on `APIRouter()` — only package `__init__.py` carries the prefix (Plans 04, 05, 06)
- `api/schemas.py` must exist before admin split — `api/cloud.py` imports `CloudConnectionOut` from admin (Plan 02 before Plan 04)
- `request()` moves to `utils.js` for circular-import avoidance; `client.js` re-exports it — zero consumer changes (Plan 07)
- Every admin sub-module handler retains `Depends(get_current_admin)` explicitly — never omit (Plans 04)
---
### Phase 9: Admin Panel Rearchitecture
**Goal**: The admin interface is a standalone route subtree (`/admin/*`) with its own layout component and sidebar; each admin section is deep-linkable and browser-back-button works; the `requiresAdmin` navigation guard correctly protects all child routes; and the Tailwind safelist is configured so dynamic color classes render correctly in production builds.
**Depends on**: Phase 8
**Requirements**: ADMIN-08, ADMIN-09, ADMIN-10, ADMIN-11, ADMIN-12, CODE-06, CODE-09
**Implementation notes:**
- `AdminLayout.vue` is registered as the `/admin` route's component; its `<router-view />` renders child views. `App.vue` does NOT need a third `v-else-if` branch — it renders whatever component the router resolves to. (PITFALLS.md §Pitfall 4)
- The `beforeEach` guard must change from `to.meta.requiresAdmin` to `to.matched.some(r => r.meta.requiresAdmin)`. Vue Router 4 does not inherit `meta` from parent to children automatically. (PITFALLS.md §Pitfall 3)
- `App.vue` layout resolution reads `route.matched.find(r => r.meta.layout)?.meta.layout` — same `matched` walk as the guard. (ARCHITECTURE.md §Integration Point 2)
- `AdminLayout.vue` owns the `p-8 max-w-5xl mx-auto` content wrapper. Extracted tab-component-turned-views (`AdminUsersView.vue` etc.) must strip any top-level padding they inherited from `AdminView.vue` — double padding is the risk. (PITFALLS.md §Pitfall 9)
- Tailwind safelist covers `providerColor`, `providerBg`, `providerLabel` dynamic pattern families from `formatters.js`. (PITFALLS.md §Pitfall 14)
- CODE-09 (comment purge) applies to every file touched in this phase and retroactively to the backend sub-packages created in Phase 8.
**Success Criteria** (what must be TRUE):
1. Navigating directly to `/admin/users`, `/admin/quotas`, `/admin/ai`, and `/admin/audit` in a fresh browser tab loads the correct admin view with the admin sidebar — not the user app sidebar
2. A logged-in non-admin user who navigates to `/admin/users` is redirected to `/` by the `beforeEach` guard; the redirect happens even when the URL is entered directly
3. The browser back button moves from `/admin/audit` to `/admin/users` (or whichever path was previous) without a full page reload
4. `AdminView.vue` is deleted from the repository; no file imports it
5. A production build (`npm run build`) renders topic badge and provider chip colors correctly — no gray/invisible badges caused by purged dynamic Tailwind classes
**Plans**: 5 plans (4 waves)
**Wave 1** — Foundation (parallel)
- [x] 09-01-PLAN.md — Backend overview.py endpoint + 8 ADMIN-11 tests (overview aggregate query + security invariants)
- [x] 09-02-PLAN.md — Frontend AdminLayout + AdminSidebar + AdminOverviewView + getAdminOverview API client
**Wave 2** *(blocked on 09-02)*
- [x] 09-03-PLAN.md — Extract 4 admin tab components to standalone views (AdminUsersView, AdminQuotasView, AdminAiView, AdminAuditView)
**Wave 3** *(blocked on 09-01, 09-02, 09-03)*
- [x] 09-04-PLAN.md — Router rearchitecture (nested /admin + to.matched.some guard + D-08/D-09/D-10 redirects) + Tailwind safelist + LoginView admin redirect + delete AdminView.vue + 4 tab files
**Wave 4** *(blocked on 09-04)*
- [x] 09-05-PLAN.md — CODE-09 comment purge (Phase 9 files + retroactive Phase 8 backend sub-packages) + human checkpoint UAT
**UI hint**: yes
---
### Phase 10: UX & Interaction
**Goal**: The application communicates state clearly at every moment — empty contexts have purposeful empty states, loading transitions show structured skeletons, power users can operate keyboard-first, files can be dragged from the OS directly onto the browser, and every action produces an immediate toast confirmation.
**Depends on**: Phase 9
**Requirements**: UX-01, UX-02, UX-03, UX-04, UX-05, UX-06, UX-07, UX-08, UX-09, UX-10, UX-11, UX-12, UX-13, UX-14, CODE-05
**Implementation notes:**
- Global `keydown` listener must guard against active input elements before firing any shortcut. The check pattern is: `['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable` → early return. Register in `mounted()`, remove in `beforeUnmount()`. (PITFALLS.md §Pitfall 12)
- Drag-and-drop upload (`UX-09`) detects `dataTransfer.types.includes('Files')` to distinguish file drags from element drags. The full-screen overlay must be attached at the `window`/`document` level, not inside `StorageBrowser`. (UX-09 requirement)
- Drag-to-move (`UX-11`) is already partially implemented in `StorageBrowser.vue`. This phase wires it end-to-end and adds the `ring-2 ring-inset ring-amber-300` drop highlight on valid folder targets.
- `DocumentCard.vue` drag must track `dragging` state to prevent the browser's `dragend`-then-`click` fire from navigating after a drag. Use a dedicated drag handle element where possible. (PITFALLS.md §Pitfall 6)
- All dropdown menus that risk viewport-edge clipping must use fixed positioning from `getBoundingClientRect()` or `<Teleport to="body">`. This is a prerequisite for virtual scrolling adoption if pursued later. (UX-13, PITFALLS.md §Pitfall 7)
- `AppIcon.vue` centralizes all SVG path data. Inline `<svg>` blocks are replaced with `<AppIcon name="..." class="..." />`. No duplicated path strings remain. (CODE-05)
- `UX-14`: the inline "New" folder button in `AppSidebar.vue` is removed; folder creation is accessible only from the file manager.
- A single shared `BreadcrumbBar.vue` component serves all view types — no per-view breadcrumb implementations. (UX-12)
**Success Criteria** (what must be TRUE):
1. Every zero-content context (root file list, folder, search with no results, shared-with-me, topics, audit log, cloud connections) shows a distinct `EmptyState.vue` — no plain "No items" text remains anywhere in the app
2. Pressing `/` focuses the search bar, `Escape` closes any open modal and clears active search, `U` triggers the file upload picker, and `N` starts the new-folder input — none of these fire when a text input has focus
3. Dragging files from the OS onto any part of the browser window (not just a drop zone) shows a full-screen overlay and uploads them on drop
4. Every upload, delete, share, revoke, and rename action produces a toast notification that auto-dismisses after 4 seconds and does not block interaction with the page
5. All views display a breadcrumb rendered by a single shared component; the breadcrumb reflects the full navigation path and updates on every route change
**Plans**: 12 plans (6 waves)
**Wave 0** — Foundation components + xfail test stubs (parallel)
- [x] 10-01-PLAN.md — AppIcon.vue + tests (CODE-05 foundation)
- [x] 10-02-PLAN.md — EmptyState.vue + tests (UX-01 foundation)
- [x] 10-03-PLAN.md — BreadcrumbBar.vue + tests (UX-12 foundation)
- [x] 10-04-PLAN.md — Toast store + ToastContainer.vue + App.vue mount + tests (UX-10 foundation)
- [x] 10-05-PLAN.md — Wave 0 xfail test stubs for UX-02..09, UX-11, UX-13, UX-14
**Wave 1** *(blocked on Wave 0 foundation components)* — Wire EmptyState, skeletons, BreadcrumbBar, UX-14, toast call sites (parallel)
- [x] 10-06-PLAN.md — StorageBrowser + FileManagerView + CloudFolderView wiring (skeleton, EmptyState, BreadcrumbBar swap, FolderBreadcrumb deletion, toast call sites) — UX-02, UX-01 (storage), UX-10 (file actions), UX-12 (file manager)
- [x] 10-07-PLAN.md — AppSidebar wiring (skeleton, EmptyState micro, UX-14 removal) — UX-03, UX-01 (sidebar), UX-14
- [x] 10-08-PLAN.md — Admin views + Settings + SharedView + CloudStorageView (skeleton, EmptyState, BreadcrumbBar static segments) — UX-04, UX-01 (remaining), UX-12 (admin)
**Wave 2** *(blocked on Wave 1)* — Keyboard shortcuts
- [x] 10-09-PLAN.md — Global keydown in App.vue + ref chain through FileManagerView/StorageBrowser/DropZone/SearchBar — UX-05, UX-06, UX-07, UX-08
**Wave 3** *(blocked on Wave 2)* — OS drag overlay
- [x] 10-10-PLAN.md — OsDragOverlay.vue + App.vue mount + FileManagerView.handleOsDrop — UX-09
**Wave 4** *(blocked on Waves 1-3)* — Drag-to-move completion + dropdown clipping fixes
- [x] 10-11-PLAN.md — Click-after-drag guard in StorageBrowser + Teleport-based folder picker (StorageBrowser, DocumentCard) + FolderRow three-dot menu — UX-11, UX-13
**Wave 5** *(blocked on Wave 4)* — SVG centralization across the codebase
- [x] 10-12-PLAN.md — Replace all inline `<svg>` blocks in ~29 files with `<AppIcon name="..." class="..." />` — CODE-05
**UI hint**: yes
---
### Phase 11: Visual Design, Responsive Layout & Cleanup
**Goal**: Every component uses the Tailwind spacing scale and typography system consistently, form elements have cross-browser baseline styling, interactive elements have consistent hover/focus states, the layout adapts cleanly to mobile viewports with a hamburger-toggled sidebar drawer, dead code is deleted, and bundle size is measured before and after.
**Depends on**: Phase 10
**Requirements**: VISUAL-01, VISUAL-02, VISUAL-03, VISUAL-04, RESP-01, RESP-02, RESP-03, RESP-04, RESP-05, CODE-07, PERF-02, PERF-03
**Implementation notes:**
- Sidebar responsive behavior: below `lg` (1024px) the sidebar is hidden; a hamburger button in a mobile-only header toggles a slide-in overlay drawer with `translate-x-0 / -translate-x-full` transition. Sidebar open/closed state lives in a Pinia store or `App.vue` ref — not in `AppSidebar`'s own `data()`. (PITFALLS.md §Pitfall 8)
- The admin layout needs the same responsive treatment as the user layout — admin sidebar hidden on mobile, hamburger shows the admin nav drawer. (RESP-05)
- All route components not on the critical render path must be lazy-loaded with `() => import(...)`. Admin views are explicitly lazy-loaded. (PERF-03)
- `PERF-02` requires two Vite bundle reports committed to `.planning/`: one measured before optimizations (taken at start of this phase) and one after all work is complete (taken at end).
- `CODE-07` dead-code deletion sweeps all unreferenced files, components, stores, and unused imports. Deletion happens after all other changes in this phase are complete — delete only what is confirmed unreferenced after all additions.
- `VISUAL-02` requires the `@tailwindcss/forms` plugin (already listed in PERF-01) to be configured in `tailwind.config.js`.
- `RESP-02`: `StorageBrowser`'s grid hides the Size column below `md` and Modified below `sm`. Icon, name, and actions are always visible.
- `RESP-03`: inline icon action buttons have minimum 36×36px touch target below `md`.
- `RESP-04`: all modal dialogs are scrollable on viewports below 640px.
**Success Criteria** (what must be TRUE):
1. At 375px viewport width, the app is fully usable: the sidebar is hidden, the hamburger opens an overlay drawer, the document list shows only icon/name/actions columns, and all touch targets are at least 36×36px
2. All form inputs, selects, textareas, checkboxes, and radio buttons have consistent cross-browser styling with no browser-default rendering differences visible between Chrome, Firefox, and Safari
3. Every button, card row, and interactive link has a visible `focus-visible:` ring and a distinct hover state; no interactive element is style-identical in focused and unfocused states
4. No arbitrary pixel value (`px-[13px]`, `style="margin: 5px"`, etc.) appears in any template; all spacing is from the Tailwind scale
5. Two bundle analysis reports are committed to `.planning/` — one baseline and one post-optimization; all non-initial-render routes are lazy-loaded and appear as separate chunks in the report
**Plans**: TBD
**UI hint**: yes
---
## v0.2 Progress Table
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 8. Stack Upgrade & Backend Decomposition | 4/8 | In Progress| |
| 9. Admin Panel Rearchitecture | 5/5 | Complete | 2026-06-13 |
| 10. UX & Interaction | 12/12 | Complete | 2026-06-16 |
| 11. Visual Design, Responsive Layout & Cleanup | 0/TBD | Not started | — |
---
## Progress Table
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Infrastructure Foundation | 5/5 | Complete | 2026-05-22 |
| 2. Users & Authentication | 6/6 | Complete | 2026-06-01 |
| 3. Document Migration & Multi-User Isolation | 5/5 | Complete | 2026-05-25 |
| 4. Folders, Sharing, Quotas & Document UX | 9/9 | Complete | 2026-05-28 |
| 5. Cloud Storage Backends | 12/12 | Complete | 2026-05-30 |
| 6. Performance & Production Hardening | 6/6 | Complete | 2026-05-30 |
| 6.1. Close v1.0 audit gaps | 2/2 | Complete | 2026-05-30 |
| 6.2. Close v1 sharing + cloud-delete + CSV export gaps | 5/5 | Complete | 2026-05-31 |
| 7. Redo and optimize LLM integration | 5/5 | Complete | 2026-06-05 |
| 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — |
| 7.2. Security: JTI claim + Redis access-token revocation | 3/3 | Complete | 2026-06-05 |
| 7.3. Security: ES256 algorithm upgrade | 3/3 | Complete | 2026-06-06 |
| 7.4. Security: token fingerprinting / token binding | 2/2 | Complete | 2026-06-06 |
+24 -27
View File
@@ -1,32 +1,29 @@
--- ---
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v0.2 milestone: v0.2
milestone_name: Phases milestone_name: UI Overhaul and Optimization
current_phase: 10 current_phase: 11
status: completed status: complete
last_updated: "2026-06-16T08:18:26.700Z" last_updated: "2026-06-17"
last_activity: 2026-06-16 -- Phase 10 marked complete last_activity: 2026-06-17 -- v0.2 milestone archived
progress: progress:
total_phases: 17 total_phases: 4
completed_phases: 16 completed_phases: 4
total_plans: 90 total_plans: 33
completed_plans: 90 completed_plans: 33
percent: 94 percent: 100
--- ---
# Project State # Project State
**Project:** DocuVault **Project:** DocuVault
**Status:** Phase 10 complete **Status:** v0.2 milestone complete — ready for next milestone
**Current Phase:** 10 **Last Updated:** 2026-06-17
**Last Updated:** 2026-06-13
## Current Position ## Current Position
Phase: 10 — COMPLETE Milestone v0.2 shipped 2026-06-17. All 4 phases complete, 40/40 requirements satisfied.
Plan: 1 of 12 Next action: `/gsd:new-milestone` to define v0.3
Status: Phase 10 complete
Last activity: 2026-06-16 -- Phase 10 marked complete
## Phase Status ## Phase Status
@@ -34,17 +31,17 @@ Last activity: 2026-06-16 -- Phase 10 marked complete
|-------|-------------|--------| |-------|-------------|--------|
| 8. Stack Upgrade & Backend Decomposition | PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08 | **Complete (8/8 plans)** | | 8. Stack Upgrade & Backend Decomposition | PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08 | **Complete (8/8 plans)** |
| 9. Admin Panel Rearchitecture | ADMIN-08..12, CODE-06, CODE-09 | **Complete (5/5 plans)** | | 9. Admin Panel Rearchitecture | ADMIN-08..12, CODE-06, CODE-09 | **Complete (5/5 plans)** |
| 10. UX & Interaction | UX-01..14, CODE-05 | Not started | | 10. UX & Interaction | UX-01..14, CODE-05 | **Complete (13/13 plans)** |
| 11. Visual Design, Responsive Layout & Cleanup | VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 | Not started | | 11. Visual Design, Responsive Layout & Cleanup | VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 | **Complete (7/7 plans)** |
## Performance Metrics ## Performance Metrics
| Metric | Value | | Metric | Value |
|---|---| |---|---|
| Phases complete | 2 / 4 | | Phases complete | 4 / 4 |
| Requirements mapped | 40 / 40 | | Requirements satisfied | 40 / 40 |
| Plans written | 83 | | Plans complete | 33 / 33 |
| Plans complete | 83 | | Tests at close | 277 |
## Accumulated Context ## Accumulated Context
@@ -67,8 +64,8 @@ Last activity: 2026-06-16 -- Phase 10 marked complete
### Roadmap Evolution ### Roadmap Evolution
- v0.1 completed: all 7 foundation phases + security hardening (2026-06-06) - v0.1 completed: all 7 foundation phases + security hardening (2026-06-06)
- v0.2 started: UI overhaul, code quality, admin panel rearchitecture (2026-06-07) - v0.2 completed: UI overhaul, admin panel rearchitecture, responsive layout, codebase quality (2026-06-17)
- v0.2 roadmap defined: 4 phases, 40 requirements, phases 811 (2026-06-07) - v0.2 archived to `.planning/milestones/v0.2-ROADMAP.md`
### Open Questions ### Open Questions
@@ -84,7 +81,7 @@ _Updated at each phase transition._
| Field | Value | | Field | Value |
|---|---| |---|---|
| Last session | 2026-06-13Phase 9 complete; UAT 9/9 passed | | Last session | 2026-06-17v0.2 milestone archived |
| Next action | /gsd:discuss-phase 10 then /gsd:plan-phase 10 | | Next action | /gsd:new-milestone to define v0.3 |
| Pending decisions | None | | Pending decisions | None |
| Resume file | None | | Resume file | None |
@@ -0,0 +1,103 @@
---
milestone: v0.2
name: Phases
status: passed
audited_at: 2026-06-17
remediated_at: 2026-06-17
phase_count: 4
completed_phases: 4
requirements_total: 40
requirements_satisfied: 40
requirements_partial: 0
requirements_missing: 0
nyquist_compliant_phases: 4
nyquist_partial_phases: 0
integration_check:
status: passed
mode: inline_fallback
note: "gsd-integration-checker spawn failed in Codex runtime with child model resolution error; integration was checked inline from phase artifacts and source wiring."
blocking_gaps: []
---
# v0.2 Milestone Audit
## Verdict
Milestone v0.2 now has complete gate evidence across all four phases. The original audit found missing or stale closeout artifacts; those gaps were remediated on 2026-06-17.
Recommended route: archive/complete the milestone when ready.
## Audit Method
The workflow integration-checker subagent could not be spawned in this Codex runtime because child model resolution failed. The integration step was completed inline by cross-checking phase summaries, verification files, validation/security artifacts, requirements traceability, and source wiring.
## Phase Gate Summary
| Phase | Verification | Validation | UAT | Security | Audit result |
|---|---:|---:|---:|---:|---|
| 08 Stack Upgrade / Backend Decomposition | Passed, 6/6 | Complete | Complete | Verified | Complete |
| 09 Admin Panel Rearchitecture | Human needed, 5/5 | Complete | Complete | Verified | Acceptable with ADMIN-09 decision noted |
| 10 UX Interaction | Passed, 15/15 | Complete | Resolved | Verified | Complete |
| 11 Visual / Responsive Cleanup | Passed, 12/12 | Complete | Resolved | Complete | Complete |
## Blocking Gaps
All blocking gaps from the initial audit are resolved.
## Remediation Completed
1. Phase 08 verification was reconstructed in `08-VERIFICATION.md`.
2. Phase 10 security gate was reconstructed in `10-SECURITY.md`.
3. Phase 11 post-11-07 UAT/validation/verification closure was recorded in `11-UAT.md`, `11-VALIDATION.md`, and `11-VERIFICATION.md`.
4. `REQUIREMENTS.md` traceability was updated for all completed v0.2 requirements.
5. ADMIN-09 was aligned with the accepted Phase 09 D-06 decision: admin accounts are administration-only and no "Back to app" link is rendered.
6. `npm audit --audit-level=high` high-severity esbuild finding was closed by upgrading frontend Vite to `^8.0.16`.
## Requirement Coverage
| Phase | Requirements | Satisfied | Partial | Notes |
|---|---:|---:|---:|---|
| 08 | 6 | 6 | 0 | `08-VERIFICATION.md` now exists and verifies all Phase 8 v0.2 requirements |
| 09 | 7 | 7 | 0 | ADMIN-09 text now matches accepted D-06 admin-only decision |
| 10 | 15 | 15 | 0 | Verification passed; requirements traceability updated |
| 11 | 12 | 12 | 0 | Plan 11-07 mobile UAT closure is reflected in UAT, validation, and verification artifacts |
Strict audit score: 40/40 requirements satisfied, 0 partial, 0 missing.
## Integration Findings
The milestone's cross-phase wiring appears coherent:
- Phase 08 backend decomposition preserved route/module behavior according to summaries and green tests.
- Phase 08 frontend client barrel exports avoided consumer churn.
- Phase 09 admin routing uses the admin layout and matched-route guard pattern.
- Phase 10 shared UX components feed into Phase 11 responsive cleanup.
- `StorageBrowser.vue` remains the single shared file browser used by local and cloud file views.
Previously identified integration risks are resolved:
- Phase 08 now has the canonical verification artifact.
- Phase 10 now has the canonical security artifact.
- Phase 11 post-fix evidence loop is closed after plan 11-07.
- ADMIN-09 is explicitly aligned with decision D-06.
## Nyquist Review
| Phase | Nyquist status | Evidence |
|---|---|---|
| 08 | Compliant | `08-VALIDATION.md` marks `nyquist_compliant: true` |
| 09 | Compliant | `09-VALIDATION.md` marks `nyquist_compliant: true` |
| 10 | Compliant | `10-VALIDATION.md` marks `nyquist_compliant: true` |
| 11 | Compliant | `11-VALIDATION.md` marks `nyquist_compliant: true` after plan 11-07 closure |
## Remediation Checklist
1. [x] Run or reconstruct Phase 08 verification and create `08-VERIFICATION.md`.
2. [x] Run the Phase 10 security gate and create `10-SECURITY.md`.
3. [x] Re-run Phase 11 UAT/validation after plan 11-07 and update `11-UAT.md`, `11-VALIDATION.md`, and `11-VERIFICATION.md` as needed.
4. [x] Update `REQUIREMENTS.md` traceability once the above artifacts exist.
5. [x] Decide whether ADMIN-09 should remain a documented D-06 override or be edited to remove the "Back to app" requirement.
## Archive Decision
Milestone v0.2 is ready for archival/closeout from this audit's perspective.
+114
View File
@@ -0,0 +1,114 @@
# DocuVault v0.2 Requirements — Archive
**Milestone:** v0.2 — UI Overhaul and Optimization
**Archived:** 2026-06-17
**Total requirements:** 40 — all satisfied
---
## CODE — Codebase Quality
- [x] **CODE-01**: Backend `api/admin.py` decomposed into `api/admin/` package — Phase 8, Complete
- [x] **CODE-02**: `api/documents.py` decomposed into `api/documents/` package — Phase 8, Complete
- [x] **CODE-03**: `api/auth.py` decomposed into `api/auth/` package — Phase 8, Complete
- [x] **CODE-04**: Frontend `api/client.js` decomposed into domain modules; barrel re-export — Phase 8, Complete
- [x] **CODE-05**: All inline SVG blocks replaced with `<AppIcon name="..." />`; path data centralized — Phase 10, Complete
- [x] **CODE-06**: Tailwind `safelist` configured for all dynamic class name patterns in `formatters.js` — Phase 9, Complete
- [x] **CODE-07**: All unreferenced files, components, stores, and unused imports deleted — Phase 11, Complete
- [x] **CODE-08**: No duplicated Pydantic model definitions; shared schemas in dedicated modules — Phase 8, Complete
- [x] **CODE-09**: No WHAT comments remain; WHY-only policy enforced across all touched files — Phase 9, Complete
## ADMIN — Admin Panel
- [x] **ADMIN-08**: Admin panel at `/admin/*`; `AdminLayout.vue` as route component; `AdminView.vue` deleted — Phase 9, Complete
- [x] **ADMIN-09**: Admin sidebar: Overview, Users, Quotas, AI Config, Audit Log. No "Back to app" link (D-06 decision) — Phase 9, Complete
- [x] **ADMIN-10**: Deep-linkable URLs (`/admin/users`, `/admin/quotas`, `/admin/ai`, `/admin/audit`); back button works — Phase 9, Complete
- [x] **ADMIN-11**: Admin overview page with user count, platform storage, doc status breakdown, last 10 audit entries — Phase 9, Complete
- [x] **ADMIN-12**: `to.matched.some(r => r.meta.requiresAdmin)` guard on all `/admin/*` routes — Phase 9, Complete
## UX — UX and Interaction
- [x] **UX-01**: `EmptyState.vue` in all zero-content contexts; no plain "No items" text remains — Phase 10, Complete
- [x] **UX-02**: `StorageBrowser` displays 5-col `animate-pulse` skeleton grid rows during loading — Phase 10, Complete
- [x] **UX-03**: Sidebar folder tree and topics list display skeleton placeholders during loading — Phase 10, Complete
- [x] **UX-04**: Admin user table and audit log table display skeleton table rows during loading — Phase 10, Complete
- [x] **UX-05**: Pressing `/` when no input is focused moves focus to the search bar — Phase 10, Complete
- [x] **UX-06**: Pressing `Escape` closes any open modal and clears active search — Phase 10, Complete
- [x] **UX-07**: Pressing `U` when no input is focused triggers the file upload picker — Phase 10, Complete
- [x] **UX-08**: Pressing `N` when no input is focused starts the new folder inline input — Phase 10, Complete
- [x] **UX-09**: OS drag-onto-browser shows full-screen overlay; releasing uploads files — Phase 10, Complete
- [x] **UX-10**: Toast notification system (auto-dismiss 4s, stacking, non-blocking) for upload/delete/share/rename — Phase 10, Complete
- [x] **UX-11**: Drag-to-move document onto folder row with `ring-2 ring-inset ring-amber-300` drop highlight — Phase 10, Complete
- [x] **UX-12**: Single shared `BreadcrumbBar.vue` across all views; updates on every route change — Phase 10, Complete
- [x] **UX-13**: All dropdowns use `Teleport + getBoundingClientRect`; no viewport-edge clipping — Phase 10, Complete
- [x] **UX-14**: Inline "New" folder button removed from `AppSidebar.vue`; folder creation in file manager only — Phase 10, Complete
## VISUAL — Visual Design
- [x] **VISUAL-01**: Consistent spacing scale; no arbitrary `px-[N]` values or inline `style` margins — Phase 11, Complete
- [x] **VISUAL-02**: `@tailwindcss/forms` plugin configured; cross-browser form element baseline styling — Phase 11, Complete
- [x] **VISUAL-03**: All interactive elements have consistent hover, `focus-visible:` rings, and active states — Phase 11, Complete
- [x] **VISUAL-04**: Consistent typography scale: one heading size per level, one body size, one label/caption size — Phase 11, Complete
## RESP — Responsive Layout
- [x] **RESP-01**: Sidebar hidden below `lg` (1024px); hamburger opens slide-in overlay drawer — Phase 11, Complete
- [x] **RESP-02**: Document list hides Size column below `md`, Modified below `sm`; compact icon toolbar below `sm` — Phase 11, Complete (11-07 gap closure)
- [x] **RESP-03**: Inline icon action buttons ≥36×36px touch target on viewports below `md` — Phase 11, Complete
- [x] **RESP-04**: All modal dialogs scrollable on viewports below 640px — Phase 11, Complete
- [x] **RESP-05**: Admin layout has same responsive behavior (hamburger, drawer) as user layout — Phase 11, Complete
## PERF — Performance and Stack
- [x] **PERF-01**: Frontend dependencies bumped: `vue@^3.5.0`, `vite@^8.0.16`, `@vueuse/core@^14.3.0`, `sortablejs`, `@tailwindcss/forms`, `rollup-plugin-visualizer` — Phase 8, Complete
- [x] **PERF-02**: Bundle baseline and post-optimization reports committed to `.planning/` — Phase 11, Complete (81 kB / 30.6%)
- [x] **PERF-03**: All non-initial-render routes lazy-loaded; admin views explicitly lazy-loaded — Phase 11, Complete
---
## Traceability
| REQ-ID | Phase | Status |
|--------|-------|--------|
| PERF-01 | Phase 8 | ✓ Complete |
| CODE-01 | Phase 8 | ✓ Complete |
| CODE-02 | Phase 8 | ✓ Complete |
| CODE-03 | Phase 8 | ✓ Complete |
| CODE-04 | Phase 8 | ✓ Complete |
| CODE-08 | Phase 8 | ✓ Complete |
| ADMIN-08 | Phase 9 | ✓ Complete |
| ADMIN-09 | Phase 9 | ✓ Complete (D-06: admin-only, no Back-to-app) |
| ADMIN-10 | Phase 9 | ✓ Complete |
| ADMIN-11 | Phase 9 | ✓ Complete |
| ADMIN-12 | Phase 9 | ✓ Complete |
| CODE-06 | Phase 9 | ✓ Complete |
| CODE-09 | Phase 9 | ✓ Complete |
| UX-01 | Phase 10 | ✓ Complete |
| UX-02 | Phase 10 | ✓ Complete |
| UX-03 | Phase 10 | ✓ Complete |
| UX-04 | Phase 10 | ✓ Complete |
| UX-05 | Phase 10 | ✓ Complete |
| UX-06 | Phase 10 | ✓ Complete |
| UX-07 | Phase 10 | ✓ Complete |
| UX-08 | Phase 10 | ✓ Complete |
| UX-09 | Phase 10 | ✓ Complete |
| UX-10 | Phase 10 | ✓ Complete |
| UX-11 | Phase 10 | ✓ Complete |
| UX-12 | Phase 10 | ✓ Complete |
| UX-13 | Phase 10 | ✓ Complete |
| UX-14 | Phase 10 | ✓ Complete |
| CODE-05 | Phase 10 | ✓ Complete |
| VISUAL-01 | Phase 11 | ✓ Complete |
| VISUAL-02 | Phase 11 | ✓ Complete |
| VISUAL-03 | Phase 11 | ✓ Complete |
| VISUAL-04 | Phase 11 | ✓ Complete |
| RESP-01 | Phase 11 | ✓ Complete |
| RESP-02 | Phase 11 | ✓ Complete |
| RESP-03 | Phase 11 | ✓ Complete |
| RESP-04 | Phase 11 | ✓ Complete |
| RESP-05 | Phase 11 | ✓ Complete |
| CODE-07 | Phase 11 | ✓ Complete |
| PERF-02 | Phase 11 | ✓ Complete |
| PERF-03 | Phase 11 | ✓ Complete |
*All 40 requirements satisfied. Archive created 2026-06-17.*
+154
View File
@@ -0,0 +1,154 @@
# Milestone v0.2: UI Overhaul and Optimization
**Status:** ✅ SHIPPED 2026-06-17
**Phases:** 811
**Total Plans:** 33
## Overview
v0.2 transformed DocuVault from a feature-complete but rough alpha into a polished, production-quality web application. The milestone covered four areas: codebase quality (decomposing monolith routers, eliminating duplication, purging dead code), admin panel rearchitecture (standalone route subtree with deep-linkable views), UX & interaction (empty states, skeletons, keyboard shortcuts, OS drag-drop, toast notifications), and visual design with responsive layout (mobile sidebar, consistent spacing, form styling, bundle optimization).
## Phases
### Phase 8: Stack Upgrade & Backend Decomposition
**Goal**: The dependency stack is current, all three backend router monoliths are split into focused sub-packages with zero URL or behavior changes, and the frontend API client is decomposed into domain modules behind a re-export barrel — the entire change is invisible to consumers and tests.
**Depends on**: Phase 7.4 (last v0.1 phase)
**Requirements**: PERF-01, CODE-01, CODE-02, CODE-03, CODE-04, CODE-08
**Plans**: 8 plans (3 waves)
**Wave 0** — Foundation (parallel)
- [x] 08-01-PLAN.md — CR-01/02/03 test stubs (3 xfail stubs in test_auth.py) + Wave 0 scaffolds for regression detection
- [x] 08-02-PLAN.md — `api/schemas.py` creation + `CloudConnectionOut` migration from admin.py (MUST precede admin split)
**Wave 1** — Phase 7.1 completion (frontend only)
- [x] 08-03-PLAN.md — `useToastStore` stub + CR test promotion + SettingsAccountTab.vue + TotpEnrollment.vue inline toast replacement
**Wave 2** — Backend decomposition + frontend (parallel)
- [x] 08-04-PLAN.md — Split `api/admin.py``api/admin/` package (CODE-01)
- [x] 08-05-PLAN.md — Split `api/documents.py``api/documents/` package (CODE-02)
- [x] 08-06-PLAN.md — Split `api/auth.py``api/auth/` package (CODE-03)
- [x] 08-07-PLAN.md — Frontend `client.js` decomposition: utils.js + 7 domain modules + barrel rewrite (CODE-04)
- [x] 08-08-PLAN.md — PERF-01 dependency bump + tailwind/vite config wiring + requirements.txt exact pinning
**Completed:** 2026-06-12
---
### Phase 9: Admin Panel Rearchitecture
**Goal**: The admin interface is a standalone route subtree (`/admin/*`) with its own layout component and sidebar; each admin section is deep-linkable and browser-back-button works; the `requiresAdmin` navigation guard correctly protects all child routes; and the Tailwind safelist is configured so dynamic color classes render correctly in production builds.
**Depends on**: Phase 8
**Requirements**: ADMIN-08, ADMIN-09, ADMIN-10, ADMIN-11, ADMIN-12, CODE-06, CODE-09
**Plans**: 5 plans (4 waves)
**Wave 1** — Foundation (parallel)
- [x] 09-01-PLAN.md — Backend overview.py endpoint + 8 ADMIN-11 tests
- [x] 09-02-PLAN.md — Frontend AdminLayout + AdminSidebar + AdminOverviewView + getAdminOverview API client
**Wave 2**
- [x] 09-03-PLAN.md — Extract 4 admin tab components to standalone views
**Wave 3**
- [x] 09-04-PLAN.md — Router rearchitecture (nested /admin + to.matched.some guard) + Tailwind safelist + delete AdminView.vue
**Wave 4**
- [x] 09-05-PLAN.md — CODE-09 comment purge + human checkpoint UAT
**Completed:** 2026-06-13
---
### Phase 10: UX & Interaction
**Goal**: The application communicates state clearly at every moment — empty contexts have purposeful empty states, loading transitions show structured skeletons, power users can operate keyboard-first, files can be dragged from the OS directly onto the browser, and every action produces an immediate toast confirmation.
**Depends on**: Phase 9
**Requirements**: UX-01 through UX-14, CODE-05
**Plans**: 13 plans (6 waves)
**Wave 0** — Foundation components + xfail test stubs (parallel)
- [x] 10-01-PLAN.md — AppIcon.vue + tests (CODE-05 foundation)
- [x] 10-02-PLAN.md — EmptyState.vue + tests (UX-01 foundation)
- [x] 10-03-PLAN.md — BreadcrumbBar.vue + tests (UX-12 foundation)
- [x] 10-04-PLAN.md — Toast store + ToastContainer.vue + App.vue mount + tests (UX-10 foundation)
- [x] 10-05-PLAN.md — Wave 0 xfail test stubs for UX-02..09, UX-11, UX-13, UX-14
**Wave 1** — Wire EmptyState, skeletons, BreadcrumbBar (parallel)
- [x] 10-06-PLAN.md — StorageBrowser + FileManagerView + CloudFolderView wiring
- [x] 10-07-PLAN.md — AppSidebar wiring (skeleton, EmptyState, UX-14 removal)
- [x] 10-08-PLAN.md — Admin views + Settings + SharedView + CloudStorageView
**Wave 2** — Keyboard shortcuts
- [x] 10-09-PLAN.md — Global keydown in App.vue + ref chain through FileManagerView/StorageBrowser
**Wave 3** — OS drag overlay
- [x] 10-10-PLAN.md — OsDragOverlay.vue + App.vue mount + FileManagerView.handleOsDrop
**Wave 4** — Drag-to-move + dropdown clipping fixes
- [x] 10-11-PLAN.md — Click-after-drag guard + Teleport-based folder picker + FolderRow three-dot menu
**Wave 5** — SVG centralization
- [x] 10-12-PLAN.md — Replace all inline `<svg>` blocks with `<AppIcon name="..." />`
**UAT Gap Closure**
- [x] 10-13-PLAN.md — 6 UAT gaps closed: sidebar shimmer, search-at-root, admin sidebar isolation, keyboard dispatch fix, Escape modifier, OS drop capture phase
**Completed:** 2026-06-16
---
### Phase 11: Visual Design, Responsive Layout & Cleanup
**Goal**: Every component uses the Tailwind spacing scale and typography system consistently, form elements have cross-browser baseline styling, interactive elements have consistent hover/focus states, the layout adapts cleanly to mobile viewports with a hamburger-toggled sidebar drawer, dead code is deleted, and bundle size is measured before and after.
**Depends on**: Phase 10
**Requirements**: VISUAL-01 through VISUAL-04, RESP-01 through RESP-05, CODE-07, PERF-02, PERF-03
**Plans**: 7 plans (5 waves + 1 UAT gap closure)
- [x] 11-01-PLAN.md — Bundle baseline + Vite analyzer wiring + lazy-load admin routes (PERF-02, PERF-03)
- [x] 11-02-PLAN.md — Tailwind forms plugin + form element baseline styling (VISUAL-02)
- [x] 11-03-PLAN.md — Responsive shells and storage rows (RESP-01, RESP-02)
- [x] 11-04-PLAN.md — Mobile-safe modals and form baseline verification (RESP-04, RESP-05)
- [x] 11-05-PLAN.md — Visual consistency pass — typography, focus-visible, hover/active states (VISUAL-01, VISUAL-03, VISUAL-04, RESP-03)
- [x] 11-06-PLAN.md — Dead-code sweep + bundle final measurement (CODE-07, PERF-02 post-opt)
- [x] 11-07-PLAN.md — Mobile storage toolbar compact icon controls (RESP-02, RESP-03 gap closure)
**Completed:** 2026-06-17
---
## Milestone Summary
**Key Decisions:**
- Options API preserved in v0.2 refactor — Composition API migration is scope-creep for a UX milestone
- Admin panel as standalone route subtree — AdminView.vue as tabs-on-user-layout is architecturally wrong
- `client.js` barrel re-export pattern — zero consumer churn; all 35+ import sites stay unchanged
- Sub-routers carry NO prefix — parent `include_router` propagates prefix; sub-router with prefix causes double-segment URLs
- FastAPI 0.128+ empty-path restriction — `@router.get("")` on sub-router with empty include prefix raises FastAPIError
- `to.matched.some()` for requiresAdmin guard — Vue Router 4 does not inherit meta to children; direct `to.meta` check is a security regression
- Vite 6→8 upgrade — resolved moderate CVEs (CVE-2026-39363/39364); npm audit clean
- AdminLayout as route component, not App.vue branch — router resolves AdminLayout as /admin component; its router-view renders children
- Tailwind safelist with regex patterns — dynamic color classes (sky=OneDrive, amber=admin audit badges) are tree-shaken without safelist
**Issues Resolved:**
- Admin panel auth guard was checking `to.meta.requiresAdmin` directly (Vue Router 4 doesn't inherit meta to children) — fixed to `to.matched.some()`
- Three-dot dropdown menus clipped by scroll containers — fixed with Teleport + getBoundingClientRect positioning
- Admin views loaded synchronously — all lazy-loaded, reducing initial bundle by 81 kB (30.6%)
- Inline SVG duplicated path data in 66 instances — centralized in AppIcon.vue
- Mobile toolbar overflow below 550px — compact icon controls added in 11-07
**Issues Deferred:**
- Virtual scrolling — quota cap (100 MB/user) limits lists to hundreds of items; v-for sufficient
- Dark mode — coherent color token system must exist first
- Folder reordering by drag — requires persistent `position` column in DB
- Composition API migration — separate milestone
**Technical Debt Incurred:**
- Options API retained throughout — intentional deferral; next milestone may begin Composition API migration
---
*For current project status, see .planning/ROADMAP.md*
+149
View File
@@ -0,0 +1,149 @@
# Phase 11 Bundle Baseline
**Captured:** 2026-06-16
**Vite version:** 6.4.3
**Node environment:** production
## Command
```bash
cd frontend && ANALYZE=true npm run build
```
Output artifact: `frontend/stats.html` — copied to `.planning/perf/phase11-baseline.html`.
## Bundle Sizes (pre-optimization)
| Chunk | Raw | Gzip |
|---|---|---|
| `index-BGwBmeoY.js` (main bundle) | 264.63 kB | 89.34 kB |
| `index-BtLvezBC.css` (Tailwind CSS) | 98.74 kB | 17.12 kB |
| `AdminAiView-DsyOjb0b.js` | 14.99 kB | 4.68 kB |
| `AdminUsersView-DFHwCZvr.js` | 12.29 kB | 3.76 kB |
| `AdminAuditView-COYge5oc.js` | 9.71 kB | 2.98 kB |
| `AdminQuotasView-Bjrfs1gN.js` | 4.60 kB | 1.88 kB |
| `LoginView-CM2pkdzs.js` | 6.81 kB | 1.90 kB |
| `RegisterView-B2PzAwRW.js` | 3.76 kB | 1.34 kB |
| `AdminLayout-DTKBjMfr.js` | 2.71 kB | 1.10 kB |
| `AdminOverviewView-GYxFAo8h.js` | 3.56 kB | 1.19 kB |
| `AdminLayout-TsYHjENN.css` | 0.75 kB | 0.35 kB |
| `PasswordResetView-BSR1dx14.js` | 2.32 kB | 1.13 kB |
| `SharedView-DTW18Ruc.js` | 2.14 kB | 1.14 kB |
| `admin-D1I3smx6.js` | 2.24 kB | 0.91 kB |
| `NewPasswordView-mjRpmRNW.js` | 2.13 kB | 1.11 kB |
**Total JS (raw):** ~343 kB
**Total JS (gzip):** ~114 kB
## Key Observations
### Main bundle (264.63 kB raw / 89.34 kB gzip)
The main bundle is large because 5 user-facing routes are still imported synchronously at the top of `router/index.js`:
- `FileManagerView` — intentionally synchronous (critical first authenticated surface, per D-10)
- `TopicsView` — synchronous, should be lazy-loaded
- `DocumentView` — synchronous, should be lazy-loaded
- `SettingsView` — synchronous, should be lazy-loaded
- `CloudStorageView` — synchronous, should be lazy-loaded
- `CloudFolderView` — synchronous, should be lazy-loaded
Plan 11-02 will lazy-load all 5 of the above (keeping `FileManagerView` synchronous).
### Already lazy-loaded (good)
- All auth views: `LoginView`, `RegisterView`, `PasswordResetView`, `NewPasswordView` — each in its own chunk
- All admin views: `AdminOverviewView`, `AdminUsersView`, `AdminQuotasView`, `AdminAiView`, `AdminAuditView` — each in its own chunk
- `AdminLayout` — own chunk
- `SharedView` — own chunk
### Vite warning: auth.js mixed import
Vite warns that `auth.js` is both dynamically imported (from `api/utils.js`) and statically imported by many components. This means `auth.js` stays in the main bundle even when lazy-loading routes. This is expected behavior — `auth.js` must be available synchronously for the navigation guard on every page load.
### CSS
The Tailwind purged CSS at 98.74 kB raw / 17.12 kB gzip is expected for a full admin + user interface. The safelist with dynamic color patterns adds some bulk but is necessary for runtime-generated topic badge colors. No action needed here.
## Routes Audit (lazy vs. synchronous)
| Route path | Component | Pre-11-02 status |
|---|---|---|
| `/` | FileManagerView | synchronous (intentional — D-10) |
| `/topics` | TopicsView | **synchronous** → lazy in 11-02 |
| `/topics/:name` | TopicsView | **synchronous** → lazy in 11-02 |
| `/document/:id` | DocumentView | **synchronous** → lazy in 11-02 |
| `/settings` | SettingsView | **synchronous** → lazy in 11-02 |
| `/folders/:folderId` | FileManagerView | synchronous (reuses main bundle component) |
| `/cloud` | CloudStorageView | **synchronous** → lazy in 11-02 |
| `/cloud/:provider/:folderId(.*)` | CloudFolderView | **synchronous** → lazy in 11-02 |
| `/login` | LoginView | already lazy |
| `/register` | RegisterView | already lazy |
| `/password-reset` | PasswordResetView | already lazy |
| `/password-reset/confirm` | NewPasswordView | already lazy |
| `/shared` | SharedView | already lazy |
| `/admin` (layout) | AdminLayout | already lazy |
| `/admin` (all children) | AdminOverviewView, etc. | already lazy |
Expected main bundle reduction after 11-02: ~3060 kB raw (splitting out Topics, Document, Settings, Cloud views).
## Responsive / Visual Audit Findings
### Synchronous non-critical route imports (Plan 11-02)
All 5 identified in route table above.
### Responsive sidebar/admin sidebar gaps (Plan 11-03)
- `App.vue`: desktop-only `flex h-screen overflow-hidden` shell; `AppSidebar` is always visible (no mobile handling).
- `AdminLayout.vue`: identical desktop-only pattern; `AdminSidebar` always visible.
- No hamburger button, no drawer, no mobile nav exists anywhere.
- Required: hamburger button + slide-in overlay drawer with `<Teleport to="body">` backdrop.
- State location: layout-local `ref()` in `App.vue` and `AdminLayout.vue` (route-change watcher closes drawer) — not a shared Pinia store (per D-05, R-15 research verdict).
### Tables / grids that overflow below sm/md (Plan 11-03)
- `StorageBrowser.vue` header row and all data rows: `grid-cols-[2rem_1fr_6rem_8rem_6rem]` — fixed 5-column grid.
- The "Size" column header has `hidden md:block`, data cells have `hidden md:block` — correct.
- The "Modified" column header has `hidden sm:block`, data cells have `hidden sm:block` — correct.
- But the `grid-cols` template is still `5-column` even when the last 2 columns are hidden — this leaves empty grid tracks on mobile. The grid template needs to be responsive: `grid-cols-[2rem_1fr_auto]` on small, `grid-cols-[2rem_1fr_6rem_auto]` on md, `grid-cols-[2rem_1fr_6rem_8rem_6rem]` on sm/lg.
- Row action buttons: `p-1.5` on `w-3.5 h-3.5` icons → button is ~26px at most. Below md, touch targets need `min-h-[36px] min-w-[36px]`.
### Modal overflow below 640px (Plan 11-04)
- `ShareModal.vue`: no `max-h` or `overflow-y-auto`; panel uses `max-w-md w-full mx-4`. Will overflow on very short phones.
- `CloudCredentialModal.vue`: no `max-h` or `overflow-y-auto`; Nextcloud form with advanced section can be tall; panel uses `max-w-md p-6`. Overflow risk is concrete.
- `FolderDeleteModal.vue`: smaller content, less risk, but should get the standard safe pattern for consistency.
- `DocumentPreviewModal.vue`: full-screen overlay (`fixed inset-0`). Structurally correct for full-screen; preserves full-screen behavior. Header uses `px-6 py-3`; no overflow risk. No action needed except verifying narrow-screen header doesn't clip.
### Inconsistent focus states (Plan 11-05)
- Current pattern: `focus:ring-2 focus:ring-indigo-500` used throughout, but `focus-visible:` is rarely used.
- Should normalize to `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1` per research convention.
- Mouse clicks will no longer show focus rings (correct a11y behavior); keyboard navigation will still show them.
### Inconsistent form patterns (Plan 11-04)
- `@tailwindcss/forms` is active and normalizes browser defaults. Many inputs still carry redundant `border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500` — partially redundant with forms plugin. Plan 11-04 should normalize these.
- Some inputs lack `focus-visible:` (use `focus:` instead) — part of focus normalization.
### Inconsistent spacing/typography (Plan 11-05)
- Skeleton widths: `AppSidebar.vue` uses `:style="{ width: (50 + n * 15) + 'px' }"` for decorative skeleton widths — can be converted to `w-20`, `w-24`, `w-28` static Tailwind classes.
- Typography conventions found in codebase (to be normalized):
- Page titles: mostly `text-2xl font-semibold` — consistent.
- Section titles: `text-lg font-semibold` / `font-semibold text-gray-800` / `font-semibold text-gray-900` — the color drifts; normalize to `text-lg font-semibold text-gray-900`.
- Labels: `text-sm font-semibold text-gray-700` / `text-sm font-semibold text-gray-900` — normalize to `text-sm font-semibold text-gray-700`.
- Body text: `text-sm text-gray-600` — mostly consistent.
- Captions/metadata: `text-xs text-gray-400` — mostly consistent.
- Border radius: `rounded-xl` vs `rounded-2xl` on modal panels — `ShareModal` and `FolderDeleteModal` use `rounded-2xl`; `CloudCredentialModal` uses `rounded-xl`. Normalize to `rounded-xl`.
### Unreferenced files and imports (Plan 11-06)
- `AccountView.vue`: exists at `src/views/AccountView.vue`. The router has `{ path: '/account', redirect: '/settings' }` but does NOT import or render `AccountView`. It is completely unreferenced. SAFE TO DELETE in Plan 11-06 (verify no other reference before deleting).
- `HomeView.vue` and `FolderView.vue`: confirmed absent (per AGENTS.md requirement). No action.
- Admin test files `AdminAiConfigTab.test.js`, `AdminQuotasTab.test.js`, `AdminUsersTab.test.js` — may reference deleted components (old tab-based admin). Classify in Plan 11-06.
- Unused imports: a scan during Plan 11-06 pass will catch per-file orphans.
## Deviation from 11-RESEARCH.md
None. All research findings confirmed by live code review and build output.
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
# Phase 11 Bundle Final Summary — Baseline vs. Final Comparison
**Captured:** 2026-06-17
**Vite version:** 8.0.16
**Node environment:** production
## Command
```bash
cd frontend && npm run build
cd frontend && ANALYZE=true npm run build # generates stats.html → .planning/perf/phase11-final.html
```
## Side-by-Side Comparison
| Metric | Baseline (pre-11-02) | Final (post-Phase-11) | Delta |
|--------|---------------------|----------------------|-------|
| Main entry chunk — raw | 264.63 kB | 30.31 kB | **-234.32 kB (-88.5%)** |
| Main entry chunk — gzip | 89.34 kB | 9.59 kB | **-79.75 kB (-89.3%)** |
| CSS main — raw | 98.74 kB | 100.39 kB | +1.65 kB |
| CSS main — gzip | 17.12 kB | 17.83 kB | +0.71 kB |
| JS chunk count | 15 chunks | 37 chunks | **+22 chunks** |
| Total output files | 15 | 39 | +24 |
> Note: The final measurement was refreshed after the Vite 8 security bump that closed the esbuild high-severity audit finding. Vite 8 performs more shared/runtime chunk splitting than the earlier Vite 6 final measurement, so the "main entry chunk" is no longer directly equivalent to the old single large app chunk.
## What Changed
### Main entry reduction
Plan 11-02 lazy-loaded 5 user routes that were previously synchronous imports. The later Vite 8 security bump also split shared runtime/vendor code into smaller chunks, leaving the main entry chunk at 30.31 kB raw / 9.59 kB gzip.
| New lazy chunk | Size (raw) | Size (gzip) | Route |
|----------------|-----------|------------|-------|
| `SettingsView-*.js` | 60.95 kB | 19.27 kB | `/settings` |
| `TopicsView-*.js` | 12.46 kB | 4.02 kB | `/topics`, `/topics/:name` |
| `DocumentView-*.js` | 10.29 kB | 3.88 kB | `/document/:id` |
| `CloudStorageView-*.js` | 2.53 kB | 1.32 kB | `/cloud` |
| `CloudFolderView-*.js` | 2.14 kB | 1.13 kB | `/cloud/:provider/:folderId` |
| `AppSpinner-*.js` | 0.54 kB | 0.39 kB | (shared sub-chunk for spinner) |
`SettingsView` contains the TotpEnrollment, PasswordStrengthBar, and cloud-connection components, so keeping it lazy remains the largest route-level win.
### CSS slight increase (+1.65 kB raw)
The responsive layout additions in Plans 11-03/11-04/11-05 added new Tailwind utility classes (`translate-x-0`, `-translate-x-full`, `max-h-[90vh]`, `overflow-y-auto`, `focus-visible:ring-*`, `min-h-[36px]`, `min-w-[36px]`) that were not in the purged baseline. The modest increase confirms these classes are in active use.
### AdminLayout now includes drawer JS
AdminLayout is 4.23 kB raw / 1.72 kB gzip due to the responsive hamburger drawer state added in Plan 11-03. The drawer logic lives in AdminLayout.vue per the D-04/D-05 sidebar-state rule.
### Vite 8 security remediation
During milestone audit remediation, `npm audit --audit-level=high` reported GHSA-gv7w-rqvm-qjhr through the Vite 6 esbuild dependency. Vite was upgraded to `^8.0.16`; the current final analyzer artifact and this summary reflect the post-remediation build.
## New Lazy Route Chunks (Plan 11-02)
Before Phase 11, 5 user routes were synchronous — they were bundled into the main JS chunk and downloaded by every visitor on first load, even users who never opened Settings or Cloud storage.
After Plan 11-02, each of these routes is a separate chunk loaded only when the user navigates to that route:
1. **SettingsView** — largest win; downloads only when user goes to `/settings`
2. **TopicsView** — downloads only when navigating to `/topics` or topic detail
3. **DocumentView** — downloads only when opening a document detail page
4. **CloudStorageView** — downloads only when user opens cloud storage
5. **CloudFolderView** — downloads only when browsing a cloud provider's folders
**Already lazy at baseline:** all auth views (LoginView, RegisterView, PasswordResetView, NewPasswordView), all admin views (AdminOverviewView, AdminUsersView, AdminQuotasView, AdminAiView, AdminAuditView), AdminLayout, SharedView.
**Kept synchronous intentionally:** FileManagerView — this is the critical first authenticated surface rendered at `/`. Lazy-loading it would delay the initial paint for logged-in users arriving via refresh-token cookie (the most common entry point). Documented in `router/index.js` per D-10.
## Interpretation
A large reduction in the main entry chunk remains after the Vite 8 refresh: the entry is 30.31 kB raw / 9.59 kB gzip, with route and shared code split into on-demand chunks. The exact first-load byte count now depends on the browser's module graph preloading behavior, but the critical path no longer forces Settings, Topics, Document detail, or Cloud views into the initial application entry.
The chunk-splitting strategy is correct: the 6 new chunks are only fetched on demand, adding zero latency to the critical `/` home path.
## Artifacts
| File | Description |
|------|-------------|
| `.planning/perf/phase11-baseline.html` | Rollup visualizer HTML before any Phase 11 changes |
| `.planning/perf/phase11-baseline-summary.md` | Baseline analysis (Plan 11-01) |
| `.planning/perf/phase11-final.html` | Rollup visualizer HTML after all Phase 11 changes |
| `.planning/perf/phase11-final-summary.md` | This file |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
---
phase: 08-stack-upgrade-backend-decomposition
verified: 2026-06-17T11:15:00Z
status: passed
score: 6/6 v0.2 requirements verified
overrides_applied: 0
sources:
- 08-VALIDATION.md
- 08-UAT.md
- 08-SECURITY.md
- 08-01-SUMMARY.md
- 08-02-SUMMARY.md
- 08-03-SUMMARY.md
- 08-04-SUMMARY.md
- 08-05-SUMMARY.md
- 08-06-SUMMARY.md
- 08-07-SUMMARY.md
- 08-08-SUMMARY.md
---
# Phase 8: Stack Upgrade & Backend Decomposition Verification Report
**Phase Goal:** Split the largest backend and frontend modules into focused packages without changing public routes, client imports, auth behavior, storage invariants, or test outcomes.
**Status:** passed
## Goal Achievement
| Requirement | Status | Evidence |
|---|---|---|
| CODE-01 | VERIFIED | Admin router decomposed into `backend/api/admin/`; `08-VALIDATION.md` maps this to `pytest tests/test_admin_api.py -x`; Phase 8 UAT confirms all admin endpoints preserve route paths and access controls. |
| CODE-02 | VERIFIED | Documents router decomposed into `backend/api/documents/`; `08-VALIDATION.md` maps this to `pytest tests/test_documents.py -x`; Phase 8 UAT confirms upload/list/detail/delete workflow passes. |
| CODE-03 | VERIFIED | Auth router decomposed into `backend/api/auth/`; `08-VALIDATION.md` maps this to `pytest tests/test_auth.py -x`; Phase 8 UAT confirms register/login/refresh/logout and session revocation. |
| CODE-04 | VERIFIED | Frontend API client split into domain modules while preserving `client.js` barrel exports; Phase 8 UAT confirms cloud connection API consumers still work with zero consumer-file churn. |
| CODE-08 | VERIFIED | Shared schemas/validators extracted; `CloudConnectionOut` is defined once in `backend/api/schemas.py`; `08-SECURITY.md` records duplicate-definition and credential-leak checks as closed. |
| PERF-01 | VERIFIED | Frontend dependency stack upgraded; Phase 8 UAT recorded Vite 6.4.3 build success, and milestone remediation later moved Vite to 8.0.16 to clear the 2026 esbuild high-severity audit finding. |
## Required Artifacts
| Artifact | Status | Notes |
|---|---|---|
| `08-VALIDATION.md` | VERIFIED | `nyquist_compliant: true`; all Phase 8 requirements covered by automated commands or static checks. |
| `08-UAT.md` | VERIFIED | 7/7 UAT checks passed, including cold start, auth, document management, admin, cloud storage, session revocation, and Vite build. |
| `08-SECURITY.md` | VERIFIED | `threats_open: 0`; 45/45 threats closed or accepted. |
| Plan summaries 08-01 through 08-08 | VERIFIED | All implementation summaries exist and provide traceable completion evidence. |
## Behavioral Spot-Checks
| Check | Evidence | Status |
|---|---|---|
| Backend route regression | `08-VALIDATION.md`: admin/documents/auth targeted suites pass; combined URL regression suite records 58 passed. | PASS |
| Full backend suite | `08-VALIDATION.md`: `pytest -v` recorded 405 passed, 6 skipped, 7 xfailed. | PASS |
| Frontend smoke | `08-VALIDATION.md`: `npm test` recorded 136/136 passed. | PASS |
| Production build | `08-UAT.md`: original Phase 8 build produced `frontend/dist/` with exit 0; milestone remediation re-ran the current Vite 8 build successfully. | PASS |
## Security Review
Phase 8 security is already verified by `08-SECURITY.md`:
- Admin sub-router handlers retain `Depends(get_current_admin)`.
- Document endpoints preserve owner checks and filename/path-separator validation.
- Auth sub-router preserves refresh rotation, session revocation, JTI/fingerprint behavior, and audit logging.
- Frontend client split keeps tokens in Pinia memory only.
- No new unmanaged supply-chain risk remains open.
## Gaps Summary
No Phase 8 verification gaps remain.
_Verified: 2026-06-17T11:15:00Z_
_Verifier: Codex (milestone audit remediation)_
@@ -0,0 +1,116 @@
---
phase: 10
plan: 13
subsystem: frontend-ux
tags: [gap-closure, uat, shimmer, keyboard, admin-layout, drag-drop, search]
dependency_graph:
requires: [10-01, 10-05, 10-06, 10-07, 10-08, 10-12]
provides: [uat-gap-closure-all-6, phase-10-sign-off]
affects: [TreeItem.vue, StorageBrowser.vue, App.vue, SearchBar.vue, OsDragOverlay.vue]
tech_stack:
added: []
patterns:
- "router.currentRoute.value.matched.find(r => r.instances?.default) for direct component instance access"
- "window.addEventListener capture=true for drag-drop above bubble-phase folder handlers"
- "@keydown.escape.prevent.stop to suppress type=search native clear+blur"
key_files:
created:
- frontend/src/components/ui/__tests__/TreeItem.test.js
- frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js
modified:
- frontend/src/components/ui/TreeItem.vue
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/App.vue
- frontend/src/components/documents/SearchBar.vue
- frontend/src/components/layout/OsDragOverlay.vue
- frontend/src/__tests__/keyboard.test.js
decisions:
- "Use matched.find(r => r.instances?.default) to access FileManagerView instance directly instead of routeViewRef (which resolves to RouterView proxy)"
- "Shimmer test uses refresh() flow (expanded=true + reload) since toggleExpand sets expanded only after load completes"
- "StorageBrowser showSearch: OR condition (local OR cloud) instead of AND with breadcrumb length"
metrics:
duration: ~15 min
completed: 2026-06-16
tasks_completed: 4
files_changed: 8
---
# Phase 10 Plan 13: UAT Gap Closure — All 6 Root Causes Summary
Closed all 6 UAT gaps that blocked Phase 10 sign-off. Five production files modified surgically, three test files added, 219 tests passing.
## What Was Built
Targeted fixes for 6 root-cause gaps from UAT (`10-UAT.md`):
- **Gap 1 — Sidebar shimmer**: `TreeItem.vue` loading branch replaced with 3 animate-pulse shimmer rows (icon + text placeholder pattern from AppSidebar.vue). `Loading…` text eliminated.
- **Gap 2 — Search at root**: `StorageBrowser.vue` `showSearch` computed changed from `mode==='local' && breadcrumb.length > 0` to `mode==='local' || mode==='cloud'`. Search and sort controls now visible at the root of both local and cloud browsers.
- **Gap 3 — Admin sidebar bleed**: `App.vue` gained a `v-else-if` branch for admin routes that renders only `<router-view />` with no AppSidebar or main wrapper.
- **Gap 4 — Keyboard shortcuts broken**: `App.vue` `routeViewRef` (which resolves to RouterView proxy) replaced with `getFileManagerInstance()` that uses `router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default`. All keyboard dispatch calls (`/`, Escape, U, N) and OS drop handler updated.
- **Gap 5 — Escape clears search but loses focus**: `SearchBar.vue` escape handler changed from `@keydown.escape` to `@keydown.escape.prevent.stop`. `.prevent` stops browser's native clear+blur on `type="search"` inputs, `.stop` prevents bubbling to App.vue's global handler.
- **Gap 6 — OS drag-drop not uploading**: `OsDragOverlay.vue` `drop` listener changed to capture phase (`addEventListener('drop', this.onDrop, true)`). Both `addEventListener` and `removeEventListener` carry the `true` third argument so cleanup works correctly.
## Task Commits
| Task | Commit | Description |
|------|--------|-------------|
| 1 | 76785b4 | Shimmer rows (TreeItem.vue) + showSearch fix (StorageBrowser.vue) |
| 2 | 5972a62 | Admin sidebar bleed + keyboard instance resolution (App.vue) |
| 3 | bac5dcf | Escape modifier (SearchBar.vue) + capture-phase drop (OsDragOverlay.vue) |
| 4 | 339f5a0 | Regression tests for all 6 gaps |
## Deviations from Plan
### Auto-adjusted Issues
**1. [Rule 1 - Bug] TreeItem shimmer test used refresh() flow instead of direct toggle**
- **Found during:** Task 4
- **Issue:** `toggleExpand()` sets `expanded=true` only AFTER `load()` completes. While `loading=true`, `expanded` is still `false`, so `v-if="expanded"` hides the shimmer block. No state where `expanded=true && loading=true` can be reached via the initial expand path.
- **Fix:** Test uses `refresh()` call (which loads while already expanded) to reach the `expanded=true && loading=true` state.
- **Files modified:** `frontend/src/components/ui/__tests__/TreeItem.test.js`
- **Commit:** 339f5a0
**2. [Worktree path] Early edits went to main repo instead of worktree**
- **Found during:** Task 1-2 (first commit attempt)
- **Issue:** First two Edit calls used the main repo path (`/Users/nik/Documents/Progamming/document_scanner/...`) instead of the worktree path (`/Users/nik/Documents/Progamming/document_scanner/.claude/worktrees/agent-a2c859712240996ff/...`). The changes committed to the main repo's `main` branch.
- **Fix:** Reverted approach: re-applied all changes to the worktree files using the correct absolute paths. The main repo has two extra commits (f9e5a31, 42ab542) that duplicate the task 1-2 changes — those will be resolved at merge/orchestrator level.
- **Commits affected:** 76785b4, 5972a62 are the correct worktree commits.
## Test Results
```
Test Files 30 passed (30)
Tests 219 passed (219)
Duration ~2.7s
```
8 new tests added:
- `TreeItem.test.js`: 3 tests (shimmer visible, no Loading text, Empty branch unchanged)
- `StorageBrowser.showSearch.test.js`: 4 tests (local root, local non-root, cloud root, shared=false)
- `keyboard.test.js`: 1 new test (Gap 4 instance resolution via router-view)
## Known Stubs
None — all gaps are wired to real component behavior.
## Threat Flags
None — all changes are display-only template modifications and event handler configuration. No new network endpoints, auth paths, or schema changes introduced.
## Self-Check: PASSED
Files created/modified:
- [x] `frontend/src/components/ui/TreeItem.vue` — animate-pulse present, Loading text absent
- [x] `frontend/src/components/storage/StorageBrowser.vue` — showSearch uses || cloud
- [x] `frontend/src/App.vue` — routeViewRef removed, requiresAdmin branch added
- [x] `frontend/src/components/documents/SearchBar.vue` — .prevent.stop on escape
- [x] `frontend/src/components/layout/OsDragOverlay.vue` — true capture arg on drop
- [x] `frontend/src/__tests__/keyboard.test.js` — Gap 4 test appended
- [x] `frontend/src/components/ui/__tests__/TreeItem.test.js` — created
- [x] `frontend/src/components/storage/__tests__/StorageBrowser.showSearch.test.js` — created
Commits verified:
- [x] 76785b4 — Task 1
- [x] 5972a62 — Task 2
- [x] bac5dcf — Task 3
- [x] 339f5a0 — Task 4
@@ -0,0 +1,75 @@
---
phase: 10
slug: ux-interaction
status: verified
threats_open: 0
asvs_level: 2
created: 2026-06-17
register_authored_at_plan_time: true
---
# Phase 10 — Security
> Retroactive security contract for Phase 10: UX & Interaction.
## Trust Boundaries
| Boundary | Description | Data Crossing |
|---|---|---|
| Browser window events → Vue UI handlers | Keyboard shortcuts, drag events, modal Escape handlers, and menu positioning are handled client-side only. | Event metadata; no secrets or server-side authority. |
| OS file drag → `OsDragOverlay` → existing upload flow | Files arrive through the browser `DataTransfer` API and are forwarded into the existing authenticated upload path. | Browser-controlled `File` objects; existing quota/auth checks still apply on upload. |
| Route metadata → layout selection | `/admin/*` layout selection hides the user sidebar and shows admin chrome. | Route metadata only; backend/admin guard remains unchanged. |
| Vue templates → user-visible text | Breadcrumbs, empty states, toasts, and dropdown labels render through Vue interpolation. | Store/view strings; Vue escaping preserved. |
## Threat Register
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|---|---|---|---|---|---|
| T-10-01 | Cross-Site Scripting | Breadcrumb, empty state, toast, and dropdown text rendering | mitigate | Vue template interpolation is used; no `v-html` or raw HTML injection introduced by Phase 10 UI components. | closed |
| T-10-02 | Information Disclosure | Toast notifications | mitigate | Toast payloads are generic action results such as upload/delete/rename/revoke status; no document content, extracted text, credentials, or token material is displayed. | closed |
| T-10-03 | Tampering | `OsDragOverlay` file drop handling | accept | Handler reads browser-provided `dataTransfer.files`, resets overlay state, and emits to the existing authenticated upload flow; no direct server write bypass is introduced. | closed |
| T-10-04 | Elevation of Privilege | Admin route layout selection | accept | `App.vue` admin branch changes presentation only; router `requiresAdmin` guard and backend `get_current_admin` enforcement are unchanged. | closed |
| T-10-05 | Spoofing | Keyboard shortcut dispatch through current route instance | accept | Shortcuts call methods on the mounted Vue route component only; no URL parameter or user-supplied string selects privileged behavior. | closed |
| T-10-06 | Denial of Service | Global keyboard and drag listeners | mitigate | Event listeners are added once at component mount and removed on unmount; drag overlay uses a bounded `dragDepth` counter and ignores non-file drags. | closed |
| T-10-07 | Supply Chain | Phase 10 frontend changes | accept | No new runtime packages were introduced by Phase 10; changes are Vue components, tests, and existing Tailwind/Vitest usage. | closed |
| T-10-08 | Supply Chain | Vite/esbuild dev dependency audit | mitigate | `npm audit --audit-level=high` found GHSA-gv7w-rqvm-qjhr through `vite@6.4.3`/`esbuild@0.25.12`; Vite was upgraded to `^8.0.16`, then audit, tests, and build were re-run. | closed |
*Status: open · closed*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---|---|---|---|---|
| AR-10-01 | T-10-03 | The drop handler cannot bypass upload authorization or quota checks because it delegates to the existing upload flow. | project owner | 2026-06-17 |
| AR-10-02 | T-10-04 | Layout selection is presentation-only; authorization remains in router/backend gates. | project owner | 2026-06-17 |
| AR-10-03 | T-10-05 | Route-instance method lookup is local Vue state, not user-controlled input. | project owner | 2026-06-17 |
| AR-10-04 | T-10-07 | No new dependency was added in Phase 10. | project owner | 2026-06-17 |
## Audit Evidence
| Source | Finding |
|---|---|
| `10-01-SUMMARY.md` through `10-12-SUMMARY.md` | Threat flags are either "None" or document UI-only behavior with no new auth/network/schema surface. |
| `10-13-PLAN.md` | Contains a STRIDE register for the UAT gap-closure plan; all threats have accepted dispositions. |
| `10-13-SUMMARY.md` | Confirms gap-closure changes are display-only template/event-handler updates with no new network endpoints, auth paths, or schema changes. |
| `10-VERIFICATION.md` | Confirms 15/15 Phase 10 requirements passed after gap closure and no anti-patterns remain. |
| `10-VALIDATION.md` | Confirms Phase 10 validation coverage for UX and interaction requirements. |
| `npm audit --audit-level=high` | Initially found a high-severity esbuild advisory through Vite; after upgrading to Vite `^8.0.16`, npm reported 0 vulnerabilities. |
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|---|---:|---:|---:|---|
| 2026-06-17 | 8 | 8 | 0 | Codex (milestone audit remediation) |
## Sign-Off
- [x] All threats have a disposition.
- [x] Accepted risks documented.
- [x] No Phase 10 change introduces backend routes, auth changes, DB schema changes, or direct storage writes.
- [x] Existing upload/auth/admin enforcement remains the authority boundary.
- [x] High-severity npm audit finding resolved by Vite 8 upgrade.
- [x] `threats_open: 0` confirmed.
**Approval:** verified 2026-06-17
@@ -0,0 +1,234 @@
---
status: resolved
phase: 10-ux-interaction
source: 10-01-SUMMARY.md, 10-02-SUMMARY.md, 10-03-SUMMARY.md, 10-04-SUMMARY.md, 10-06-SUMMARY.md, 10-07-SUMMARY.md, 10-08-SUMMARY.md, 10-09-SUMMARY.md, 10-10-SUMMARY.md, 10-11-SUMMARY.md, 10-12-SUMMARY.md
started: 2026-06-16T00:00:00Z
updated: 2026-06-16T19:31:00Z
resolved_by: 10-13-PLAN.md
---
## Current Test
[testing complete]
## Tests
### 1. File Manager Loading Skeleton
expected: Open the file manager. While documents are loading, the content area shows 5 animated shimmer/pulse rows instead of any "Loading…" text. Once loaded, the shimmer rows disappear and real content (or an empty state) renders.
result: issue
reported: "Cloud folder view: no skeleton visible on load (loading=false on first render). Cloud files unclickable with no feedback."
severity: major
fix_applied: "loading=ref(true) in CloudFolderView; onFileOpen shows info toast. Commit ce67b9f. Local storage loads too fast to verify manually — skeleton confirmed present in template."
### 2. Empty State — No Documents
expected: In a folder with no documents, the content area shows a styled empty state with an icon (folder or document), a headline like "No documents yet" or similar, and descriptive subtext. Not just a blank white area.
result: pass
### 3. Empty State — No Search Results
expected: Type a search query that returns no matches. The content area shows an empty state with a search icon, a "No results" headline, and a "Clear search" link/button that resets the query.
result: pass
note: Search bar only visible inside a folder (breadcrumb.length > 0) — by design, root shows folders only. Empty state confirmed working inside folder.
### 4. Sidebar Loading Skeletons
expected: On first load, the sidebar's Folders, Cloud, and Topics sections show animated shimmer placeholder rows while their data loads. No plain spinner or "Loading" text.
result: issue
reported: "Local storage too fast to see any loading. Nextcloud sidebar section does nothing until folder loads — no skeleton or feedback visible during load."
severity: major
### 5. Sidebar Empty States
expected: With no folders created, no cloud connections, and no topics, each sidebar section shows a small (compact) empty state: a tiny icon with a brief message like "Create a folder in the file manager", "Connect in Settings", or "No topics yet".
result: issue
reported: "Empty states confirmed. But search bar and sorting controls are not visible at the root of the cloud and local file browser."
severity: major
note: Search-at-root absence was previously noted in test 3 as \"by design\", but user is explicitly flagging it as missing expected functionality.
### 6. Sidebar — No Inline "New Folder" Button
expected: The sidebar's Folders section header does NOT have a "New" or "New folder" button next to it. Folder creation happens only via the file manager toolbar.
result: pass
### 7. Breadcrumb Bar in File Manager
expected: The file manager shows a breadcrumb bar above the content. At the root it shows "Home". After navigating into a folder it shows "Home > FolderName". Clicking "Home" navigates back to root.
result: pass
### 8. Breadcrumb Bar in Admin Views
expected: Admin views (Users, Quotas, AI Config, Audit Log) and Settings show a breadcrumb bar with static segments like "Users", "Settings > Account", etc. No "Home" root button in these views.
result: issue
reported: "Admin views still show the normal user sidebar."
severity: major
### 9. Toast on Document Delete
expected: Delete a document. A toast notification appears in the bottom-right corner with a success message (e.g., "Document deleted"). It auto-dismisses after a few seconds.
result: pass
### 10. Toast on File Upload
expected: Upload one or more files. After upload completes, a toast appears summarising the result (e.g., "2 files uploaded" or a per-file message). It appears without a page refresh.
result: pass
note: User also reported drag-and-drop didn't work — covered in tests 1517.
### 11. Keyboard Shortcut — / Focuses Search
expected: While viewing the file manager with focus NOT in a text field, press "/". The search bar receives focus (cursor appears inside it). Pressing "/" while already in a text input does NOT trigger this.
result: issue
reported: "Search bar not visible at directory root (see gap #2). When inside a folder where search IS visible, pressing '/' does nothing."
severity: major
### 12. Keyboard Shortcut — U Opens Upload
expected: While viewing the file manager with focus not in a text field, press "U". The file-picker dialog opens (browser native file chooser), same as clicking the upload button.
result: issue
reported: "Pressing U does not open the file picker."
severity: major
### 13. Keyboard Shortcut — N Starts New Folder
expected: While viewing the file manager with focus not in a text field, press "N". The new-folder inline input appears in the content area, same as clicking the "New folder" toolbar button.
result: issue
reported: "Pressing N does not trigger new folder input."
severity: major
### 14. Keyboard Shortcut — Escape Clears Search
expected: With a search query active in the file manager, press "Escape". The search field clears and the full document list returns.
result: issue
reported: "Field clears on Escape, but search no longer works afterwards — cannot type a new query."
severity: major
### 15. OS Drag Overlay
expected: From the OS (Finder/Explorer), drag a file and hover it over the browser window. A full-screen semi-transparent overlay appears saying something like "Drop files to upload". Releasing the file starts the upload.
result: issue
reported: "Overlay appears correctly, but dropping the file does not start the upload."
severity: major
### 16. Drag Document to Folder
expected: In the file manager, drag a document row onto a folder row. The folder row highlights while the document hovers over it. Releasing drops the document into the folder (it moves; the folder item count updates).
result: pass
### 17. Click-After-Drag Guard
expected: After dragging a document (without dropping it onto a folder — just drag and release), the document does NOT open or navigate. The drag gesture does not accidentally trigger a "file open" action.
result: pass
### 18. Admin View Skeletons
expected: Open the Admin > Audit Log or Admin > Users page while data loads. The table body shows skeleton rows (animated shimmer cells) instead of a spinner or "Loading…" text.
result: pass
note: Skeleton visible but very briefly — data loads fast locally. Skeleton confirmed present.
### 19. Admin Audit Log Empty State
expected: With no audit log entries (or with filters that match nothing), the audit log table shows an empty state with a "Clear filters" button. Not just an empty table with no rows.
result: pass
## Summary
total: 19
passed: 10
issues: 9
pending: 0
skipped: 0
blocked: 0
## Gaps
- truth: "Sidebar Cloud section shows animated shimmer rows while Nextcloud data loads"
status: failed
reason: "User reported: Local storage too fast to see any loading. Nextcloud sidebar section does nothing until folder loads — no skeleton or feedback visible during load."
severity: major
test: 4
root_cause: "TreeItem.vue lines 48-52 render <div class='text-xs text-gray-400 py-1'>Loading…</div> instead of animate-pulse shimmer rows. The loading state ref is tracked correctly but the visual treatment does not match the shimmer pattern used elsewhere in AppSidebar."
artifacts:
- path: "frontend/src/components/ui/TreeItem.vue"
issue: "v-if='loading' branch renders plain text instead of animated skeleton rows (lines 48-52)"
missing:
- "Replace plain Loading… div with 3 shimmer rows using animate-pulse pattern matching AppSidebar lines 60-64"
debug_session: ""
- truth: "Admin views show admin-specific layout (no user sidebar) with breadcrumb bar"
status: failed
reason: "User reported: Admin views still show the normal user sidebar."
severity: major
test: 8
root_cause: "App.vue renders <AppSidebar> in a v-else branch with no admin exemption. When /admin/* routes render, Vue Router places AdminLayout.vue into <router-view> but AppSidebar is outside it — unconditionally rendered for all non-auth routes. Both sidebars appear simultaneously."
artifacts:
- path: "frontend/src/App.vue"
issue: "v-else branch (lines 3-8) renders <AppSidebar> without checking route.meta.requiresAdmin"
missing:
- "Add third branch in App.vue: when route.matched.some(r => r.meta.requiresAdmin), render only <router-view> with no AppSidebar"
debug_session: ""
- truth: "Pressing '/' while in the file manager (with search bar visible) focuses the search input"
status: failed
reason: "User reported: pressing '/' does nothing when search bar is visible inside a folder."
severity: major
test: 11
root_cause: "Shared root cause with tests 12 and 13: ref='routeViewRef' on <router-view> in App.vue resolves to the RouterView component proxy, not the FileManagerView instance. RouterView.setup() never calls expose(), so routeViewRef.value has no focusSearch/triggerUpload/startNewFolder properties. All calls silently no-op via optional chaining ?."
artifacts:
- path: "frontend/src/App.vue"
issue: "ref='routeViewRef' on <router-view> (line 6); shortcut handlers use routeViewRef.value?.focusSearch?.() etc. (lines 37, 43, 46) which are unreachable"
- path: "frontend/src/views/FileManagerView.vue"
issue: "defineExpose({ focusSearch, triggerUpload, startNewFolder }) is correct (lines 190-196) but unreachable via routeViewRef"
missing:
- "Replace routeViewRef approach with router.currentRoute.value.matched[0].instances.default to reach actual FileManagerView instance, or use a Pinia store / event bus for keyboard action dispatch"
debug_session: ""
- truth: "Pressing 'U' while in the file manager opens the file-picker dialog"
status: failed
reason: "User reported: pressing U does not open the file picker."
severity: major
test: 12
root_cause: "Same root cause as test 11: routeViewRef resolves to RouterView proxy, not FileManagerView. triggerUpload?.() is a no-op."
artifacts:
- path: "frontend/src/App.vue"
issue: "routeViewRef.value?.triggerUpload?.() (line 43) silently no-ops"
missing:
- "Fixed by the same routeViewRef fix as test 11"
debug_session: ""
- truth: "Pressing 'N' while in the file manager triggers the new-folder inline input"
status: failed
reason: "User reported: pressing N does not trigger new folder input."
severity: major
test: 13
root_cause: "Same root cause as test 11: routeViewRef resolves to RouterView proxy, not FileManagerView. startNewFolder?.() is a no-op."
artifacts:
- path: "frontend/src/App.vue"
issue: "routeViewRef.value?.startNewFolder?.() (line 46) silently no-ops"
missing:
- "Fixed by the same routeViewRef fix as test 11"
debug_session: ""
- truth: "After pressing Escape to clear search, the search field remains functional for new queries"
status: failed
reason: "User reported: field clears on Escape but search no longer works afterwards — cannot type a new query."
severity: major
test: 14
root_cause: "SearchBar.vue uses type='search' on the input (line 6) and handles @keydown.escape without .prevent. Browsers treat Escape on type='search' as native clear+blur — the field loses focus and the user cannot type without clicking first. Event also bubbles to App.vue global handler (no .stop) causing a redundant clearSearch call."
artifacts:
- path: "frontend/src/components/documents/SearchBar.vue"
issue: "@keydown.escape handler (line 11) lacks .prevent and .stop — browser native blur fires after Vue handler"
- path: "frontend/src/App.vue"
issue: "Global Escape handler (line 39) may fire redundantly after input blurs"
missing:
- "Change @keydown.escape to @keydown.escape.prevent.stop in SearchBar.vue to suppress native blur and prevent bubbling"
debug_session: ""
- truth: "Dropping a file from OS onto the drag overlay starts the upload"
status: failed
reason: "User reported: overlay appears but dropping the file does not start the upload."
severity: major
test: 15
root_cause: "OsDragOverlay registers window 'drop' listener in bubble phase. StorageBrowser registers @drop.prevent on every folder row (line 79), which consumes the native drop event before it bubbles to window. OsDragOverlay has pointer-events-none so drops land on underlying DOM elements (folder rows) which intercept them first. The window listener never fires."
artifacts:
- path: "frontend/src/components/layout/OsDragOverlay.vue"
issue: "window.addEventListener('drop', this.onDrop) registered in bubble phase (line 56) — consumed by folder rows first"
- path: "frontend/src/components/storage/StorageBrowser.vue"
issue: "@drop.prevent on folder rows (line 79) intercepts OS drops; onDropDocOnFolder guard (line 364) exits early for OS drags (draggingFile is null)"
missing:
- "Register OsDragOverlay window listener in capture phase: window.addEventListener('drop', this.onDrop, true) so it runs before element-level handlers"
debug_session: ""
- truth: "Search bar and sorting controls are visible at the root level of the file manager and cloud file browser (currently hidden behind breadcrumb.length > 0 guard)"
status: failed
reason: "User reported: search bar and sorting controls not visible at the root of the cloud and local file browser."
severity: major
test: 5
root_cause: "StorageBrowser.vue line 287: showSearch computed is props.mode === 'local' && props.breadcrumb.length > 0. Both conditions must be true — breadcrumb is [] at root so showSearch is false there; cloud mode also always false because mode guard requires 'local'. SearchBar (line 12) and SortControls (line 14) both share v-if='showSearch' so both disappear."
artifacts:
- path: "frontend/src/components/storage/StorageBrowser.vue"
issue: "showSearch computed (line 287) has breadcrumb.length > 0 and mode === 'local' guards; both incorrect"
missing:
- "Change showSearch to computed(() => props.mode === 'local' || props.mode === 'cloud') — remove breadcrumb depth guard entirely"
debug_session: ""
@@ -1,10 +1,11 @@
--- ---
phase: 10 phase: 10
slug: ux-interaction slug: ux-interaction
status: draft status: complete
nyquist_compliant: false nyquist_compliant: true
wave_0_complete: false wave_0_complete: true
created: 2026-06-14 created: 2026-06-14
audited: 2026-06-16
--- ---
# Phase 10 — Validation Strategy # Phase 10 — Validation Strategy
@@ -38,22 +39,22 @@ created: 2026-06-14
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| AppIcon foundation | W0 | 0 | CODE-05 | — | Unknown names warn; render nothing | unit | `npm run test -- --run AppIcon` | | pending | | AppIcon foundation | W0 | 0 | CODE-05 | — | Unknown names warn; render nothing | unit | `npm run test -- --run AppIcon` | | COVERED |
| EmptyState foundation | W0 | 0 | UX-01 | — | Props render; CTA slot optional | unit | `npm run test -- --run EmptyState` | | pending | | EmptyState foundation | W0 | 0 | UX-01 | — | Props render; CTA slot optional | unit | `npm run test -- --run EmptyState` | | COVERED |
| BreadcrumbBar foundation | W0 | 0 | UX-12 | — | Last segment non-clickable; navigate emitted | unit | `npm run test -- --run BreadcrumbBar` | | pending | | BreadcrumbBar foundation | W0 | 0 | UX-12 | — | Last segment non-clickable; navigate emitted | unit | `npm run test -- --run BreadcrumbBar` | | COVERED |
| Toast store + container | W0 | 0 | UX-10 | — | auto-dismiss 4s; dismiss on click | unit | `npm run test -- --run toast` | | pending | | Toast store + container | W0 | 0 | UX-10 | — | auto-dismiss 4s; dismiss on click | unit | `npm run test -- --run toast` | | COVERED |
| StorageBrowser skeleton | W1 | 1 | UX-02 | — | skeleton renders when loading=true | unit | `npm run test -- --run StorageBrowser` | | pending | | StorageBrowser skeleton | W1 | 1 | UX-02 | — | skeleton renders when loading=true | unit | `npm run test -- --run StorageBrowser` | | COVERED |
| AppSidebar skeleton + empty | W1 | 1 | UX-03 | — | skeleton rows replace Loading text | unit | `npm run test -- --run AppSidebar` | | pending | | AppSidebar skeleton + empty | W1 | 1 | UX-03 | — | skeleton rows replace Loading text | unit | `npm run test -- --run AppSidebar` | | COVERED |
| Admin table skeletons | W1 | 1 | UX-04 | — | skeleton rows in users + audit | unit | `npm run test -- --run Admin` | | pending | | Admin table skeletons | W1 | 1 | UX-04 | — | skeleton rows in users + audit | unit | `npm run test -- --run Admin` | | COVERED |
| EmptyState wiring | W1 | 1 | UX-01 | — | EmptyState shown in all 7+ contexts | unit | `npm run test -- --run EmptyState` | | pending | | EmptyState wiring | W1 | 1 | UX-01 | — | EmptyState shown in all 7+ contexts | unit | `npm run test -- --run EmptyState` | | COVERED |
| Toast call sites | W1 | 1 | UX-10 | — | show() called on upload/delete/move | unit | `npm run test -- --run toast` | | pending | | Toast call sites | W1 | 1 | UX-10 | — | show() called on upload/delete/move | unit | `npm run test -- --run toast` | | COVERED |
| BreadcrumbBar wiring | W1 | 1 | UX-12 | — | admin/settings views pass static segments | unit | `npm run test -- --run BreadcrumbBar` | | pending | | BreadcrumbBar wiring | W1 | 1 | UX-12 | — | admin/settings views pass static segments | unit | `npm run test -- --run BreadcrumbBar` | | COVERED |
| UX-14 sidebar removal | W1 | 1 | UX-14 | — | "New" button absent from AppSidebar | unit | `npm run test -- --run AppSidebar` | | pending | | UX-14 sidebar removal | W1 | 1 | UX-14 | — | "New" button absent from AppSidebar | unit | `npm run test -- --run AppSidebar` | | COVERED |
| Keyboard shortcuts | W2 | 2 | UX-05..08 | — | guard fires; no input bleed | unit | `npm run test -- --run keyboard` | | pending | | Keyboard shortcuts | W2 | 2 | UX-05..08 | — | guard fires; no input bleed | unit | `npm run test -- --run keyboard` | | COVERED |
| OS drag overlay | W2 | 2 | UX-09 | — | overlay on Files dragenter; hide on leave | unit | `npm run test -- --run OsDragOverlay` | | pending | | OS drag overlay | W2 | 2 | UX-09 | — | overlay on Files dragenter; hide on leave | unit | `npm run test -- --run OsDragOverlay` | | COVERED |
| Drag-to-move toast | W3 | 3 | UX-11 | — | ring-2 ring-inset ring-amber-300 on hover | unit | `npm run test -- --run StorageBrowser` | | pending | | Drag-to-move toast | W3 | 3 | UX-11 | — | ring-2 ring-inset ring-amber-300 on hover | unit | `npm run test -- --run StorageBrowser` | | COVERED |
| Dropdown clipping fixes | W3 | 3 | UX-13 | — | picker renders via Teleport at correct pos | unit | `npm run test -- --run dropdown` | | pending | | Dropdown clipping fixes | W3 | 3 | UX-13 | — | picker renders via Teleport at correct pos | unit | `npm run test -- --run dropdown` | | COVERED |
| SVG migration | W3 | 3 | CODE-05 | — | all 29 files use AppIcon; no inline svg | unit | `npm run test -- --run AppIcon` | | pending | | SVG migration | W3 | 3 | CODE-05 | — | all 29 files use AppIcon; no inline svg | unit | `npm run test -- --run AppIcon` | | COVERED |
--- ---
@@ -74,11 +75,11 @@ created: 2026-06-14
## Wave 0 Test Stubs (REQUIRED — create before implementation) ## Wave 0 Test Stubs (REQUIRED — create before implementation)
- [ ] `frontend/src/components/ui/AppIcon.test.js` — covers CODE-05 (name→path, unknown name warn) - [x] `frontend/src/components/ui/__tests__/AppIcon.test.js` — covers CODE-05 (name→path, unknown name warn)
- [ ] `frontend/src/components/ui/EmptyState.test.js` — covers UX-01 (props, CTA slot) - [x] `frontend/src/components/ui/__tests__/EmptyState.test.js` — covers UX-01 (props, CTA slot)
- [ ] `frontend/src/components/ui/BreadcrumbBar.test.js` — covers UX-12 (last segment, navigate emit) - [x] `frontend/src/components/ui/__tests__/BreadcrumbBar.test.js` — covers UX-12 (last segment, navigate emit)
- [ ] `frontend/src/stores/toast.test.js` — covers UX-10 (show, auto-dismiss, dismiss on click) - [x] `frontend/src/stores/__tests__/toast.test.js` — covers UX-10 (show, auto-dismiss, dismiss on click)
- [ ] `frontend/src/components/ui/ToastContainer.test.js` — covers UX-10 visual rendering - [x] `frontend/src/components/ui/__tests__/ToastContainer.test.js` — covers UX-10 visual rendering
--- ---
@@ -91,3 +92,20 @@ These require human observation:
- OS drag-and-drop actual file upload end-to-end - OS drag-and-drop actual file upload end-to-end
- Keyboard `N` in cloud folder view (must no-op silently) - Keyboard `N` in cloud folder view (must no-op silently)
- Human checkpoint UAT: all 15 requirements exercised by a real user - Human checkpoint UAT: all 15 requirements exercised by a real user
---
## Validation Audit 2026-06-16
| Metric | Count |
|--------|-------|
| Tasks audited | 16 |
| Gaps found | 0 |
| COVERED | 16 |
| PARTIAL | 0 |
| MISSING | 0 |
| Escalated to manual-only | 0 |
**Test suite result:** 211 tests pass across 28 files (0 failures).
All Phase 10 test files were present and green at audit time. VALIDATION.md promoted from `draft` to `complete`; `nyquist_compliant` set to `true`.
@@ -1,6 +1,6 @@
--- ---
phase: 10-ux-interaction phase: 10-ux-interaction
verified: 2026-06-16T10:15:00Z verified: 2026-06-16T19:31:00Z
status: passed status: passed
score: 15/15 must-haves verified score: 15/15 must-haves verified
overrides_applied: 0 overrides_applied: 0
@@ -11,8 +11,16 @@ re_verification:
- "Pressing Escape closes any open modal (ShareModal, FolderDeleteModal, DocumentPreviewModal)" - "Pressing Escape closes any open modal (ShareModal, FolderDeleteModal, DocumentPreviewModal)"
- "Share revoke and folder rename actions produce toast notifications" - "Share revoke and folder rename actions produce toast notifications"
- "UX-13 StorageBrowser folder picker dropdown test stubs promoted to real assertions" - "UX-13 StorageBrowser folder picker dropdown test stubs promoted to real assertions"
gap_closure_plan_10_13:
- "Sidebar TreeItem.vue shimmer rows replacing Loading text (Gap 1)"
- "StorageBrowser.vue showSearch true at root for local AND cloud modes (Gap 2)"
- "App.vue admin v-else-if branch — no AppSidebar on /admin/* routes (Gap 3)"
- "App.vue getFileManagerInstance() via matched.find() replaces routeViewRef proxy (Gap 4)"
- "SearchBar.vue @keydown.escape.prevent.stop suppresses native blur (Gap 5)"
- "OsDragOverlay.vue drop listener in capture phase (Gap 6)"
gaps_remaining: [] gaps_remaining: []
regressions: [] regressions: []
tests_after_gap_closure: 219
--- ---
# Phase 10: UX & Interaction Verification Report # Phase 10: UX & Interaction Verification Report
@@ -69,11 +77,11 @@ re_verification:
| From | To | Via | Status | Details | | From | To | Via | Status | Details |
|------|----|-----|--------|---------| |------|----|-----|--------|---------|
| App.vue keydown | routeViewRef.value?.method?() | optional chaining on routeViewRef | WIRED | ref="routeViewRef" on router-view; onKeydown handler with 4 branches | | App.vue keydown | getFileManagerInstance()?.method?() | router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default | WIRED | routeViewRef removed; getFileManagerInstance() helper resolves to actual FileManagerView (not RouterView proxy); 4 dispatch branches in onKeydown |
| StorageBrowser.vue | DropZone.vue | dropZoneRef.value?.triggerInput?.() | WIRED | const dropZoneRef; ref="dropZoneRef" on DropZone; DropZone defineExpose({ triggerInput }) | | StorageBrowser.vue | DropZone.vue | dropZoneRef.value?.triggerInput?.() | WIRED | const dropZoneRef; ref="dropZoneRef" on DropZone; DropZone defineExpose({ triggerInput }) |
| StorageBrowser.vue | SearchBar.vue | searchBarRef.value?.focus?.() | WIRED | const searchBarRef; ref="searchBarRef" on SearchBar; SearchBar defineExpose({ focus }) | | StorageBrowser.vue | SearchBar.vue | searchBarRef.value?.focus?.() | WIRED | const searchBarRef; ref="searchBarRef" on SearchBar; SearchBar defineExpose({ focus }) |
| FileManagerView.vue | StorageBrowser.vue | browserRef.value?.method?.() | WIRED | defineExpose delegates all 5 methods to browserRef via optional chaining | | FileManagerView.vue | StorageBrowser.vue | browserRef.value?.method?.() | WIRED | defineExpose delegates all 5 methods to browserRef via optional chaining |
| App.vue | OsDragOverlay.vue | @files-dropped → onOsFilesDropped | WIRED | OsDragOverlay mounted; onOsFilesDropped calls routeViewRef.value?.handleOsDrop?.(files) | | App.vue | OsDragOverlay.vue | @files-dropped → onOsFilesDropped | WIRED | OsDragOverlay mounted; onOsFilesDropped calls getFileManagerInstance()?.handleOsDrop?.(files); drop listener in capture phase (true arg) |
| FileManagerView.vue | onFilesSelected | handleOsDrop | WIRED | handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }) in defineExpose | | FileManagerView.vue | onFilesSelected | handleOsDrop | WIRED | handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }) in defineExpose |
| StorageBrowser.vue | BreadcrumbBar.vue | import + :segments="breadcrumb" | WIRED | import BreadcrumbBar from ../ui/BreadcrumbBar.vue; 1 BreadcrumbBar element | | StorageBrowser.vue | BreadcrumbBar.vue | import + :segments="breadcrumb" | WIRED | import BreadcrumbBar from ../ui/BreadcrumbBar.vue; 1 BreadcrumbBar element |
| FileManagerView.vue | useToastStore | show() in doMove/doDeleteDoc/onFilesSelected/handleFolderRename | WIRED | useToastStore used in 4 functions; 'Document moved', 'Document deleted', upload summary, 'Folder renamed' toasts | | FileManagerView.vue | useToastStore | show() in doMove/doDeleteDoc/onFilesSelected/handleFolderRename | WIRED | useToastStore used in 4 functions; 'Document moved', 'Document deleted', upload summary, 'Folder renamed' toasts |
@@ -136,3 +144,20 @@ Previous state: 208 passed, 3 todo, 0 failed. The 3 promoted UX-13 stubs account
_Verified: 2026-06-16T10:15:00Z_ _Verified: 2026-06-16T10:15:00Z_
_Verifier: Claude (gsd-verifier)_ _Verifier: Claude (gsd-verifier)_
---
## Gap Closure Verification (Plan 10-13)
**Re-verified: 2026-06-16T19:31:00Z** — after UAT gap closure (10-UAT.md had 9 issues, 6 root causes)
| Gap | Fix | Verified |
|-----|-----|---------|
| 1 — Sidebar shimmer | `TreeItem.vue`: `animate-pulse` ×2 in v-if="loading" branch; "Loading" text: 0 occurrences | ✓ |
| 2 — Search at root | `StorageBrowser.vue`: `showSearch = computed(() => props.mode === 'local' \|\| props.mode === 'cloud')` | ✓ |
| 3 — Admin sidebar bleed | `App.vue`: `v-else-if="route.matched.some(r => r.meta.requiresAdmin)"` with no AppSidebar | ✓ |
| 4 — Keyboard dispatch | `App.vue`: `getFileManagerInstance()` via `matched.find(r => r.instances?.default)?.instances?.default`; `routeViewRef` fully removed | ✓ |
| 5 — Escape blur | `SearchBar.vue`: `@keydown.escape.prevent.stop` | ✓ |
| 6 — OS drag capture | `OsDragOverlay.vue`: `addEventListener('drop', this.onDrop, true)` + matching `removeEventListener` | ✓ |
**Test suite after gap closure: 219 passed, 0 failed (30 files)** (was 211 before plan 10-13)
@@ -0,0 +1,99 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 1
subsystem: frontend/build
tags: [perf, audit, bundle, baseline]
dependency_graph:
requires: [10-complete]
provides: [phase11-bundle-baseline, phase11-audit]
affects: [frontend/vite.config.js, .planning/perf/]
tech_stack:
added: []
patterns: [rollup-plugin-visualizer behind ANALYZE=true env flag]
key_files:
created:
- .planning/perf/phase11-baseline.html
- .planning/perf/phase11-baseline-summary.md
modified:
- frontend/vite.config.js
- .gitignore
decisions:
- "Async defineConfig factory: visualizer dynamically imported only when ANALYZE=true; zero overhead on normal builds"
- "stats.html added to .gitignore; canonical copy stored at .planning/perf/phase11-baseline.html"
- "FileManagerView stays synchronous for / per D-10; 5 other user routes are lazy-load candidates for 11-02"
- "AccountView.vue is confirmed orphaned (router redirects /account → /settings without rendering it)"
metrics:
duration_minutes: 3
tasks_completed: 5
files_created: 2
files_modified: 2
completed_date: "2026-06-16"
---
# Phase 11 Plan 1: Bundle Baseline & UI Audit Summary
Wired `rollup-plugin-visualizer` behind `ANALYZE=true` opt-in, captured the pre-optimization bundle baseline, and completed a full frontend audit to ground plans 11-02 through 11-06.
## What Was Built
### Task 1 — vite.config.js analyzer wiring
`frontend/vite.config.js` converted from a static `defineConfig` object to an async factory. The visualizer is dynamically imported (`import('rollup-plugin-visualizer')`) only when `ANALYZE=true` is present in the environment, ensuring zero overhead on normal `npm run build` runs. `frontend/stats.html` added to `.gitignore` since it is a build artifact.
### Tasks 2-3 — Bundle baseline
Ran `cd frontend && ANALYZE=true npm run build`. Committed the report to `.planning/perf/phase11-baseline.html` (225 kB interactive treemap). Added `phase11-baseline-summary.md` with chunk sizes, route audit table, and per-plan findings.
**Key numbers:**
- Main bundle: 264.63 kB raw / 89.34 kB gzip
- CSS: 98.74 kB raw / 17.12 kB gzip (Tailwind purged)
- 5 user routes still synchronous in main bundle → lazy-load in 11-02
### Tasks 4-5 — Research validation and frontend audit
Build output confirmed `11-RESEARCH.md` findings exactly — no updates needed. Audit documented in `phase11-baseline-summary.md`:
**Synchronous routes for 11-02:** `TopicsView`, `DocumentView`, `SettingsView`, `CloudStorageView`, `CloudFolderView` (5 routes; `FileManagerView` stays synchronous per D-10).
**Responsive gaps for 11-03:**
- `App.vue` and `AdminLayout.vue`: desktop-only shell; no hamburger, no drawer, no mobile nav
- `StorageBrowser.vue`: 5-column `grid-cols` stays fixed even when last 2 columns are hidden below `md`/`sm`; needs responsive `grid-cols` variant
- Row action buttons `p-1.5` are ~26px — below `md` touch target minimum of 36px
**Modal overflow for 11-04:**
- `ShareModal.vue`, `CloudCredentialModal.vue`, `FolderDeleteModal.vue`: no `max-h` or `overflow-y-auto`
- `DocumentPreviewModal.vue`: full-screen — structurally correct; header safe
**Focus/form normalization for 11-04/11-05:**
- `focus:ring-2` used throughout; needs `focus-visible:` variant instead
- Inputs carry redundant border/focus class stacks next to `@tailwindcss/forms` defaults
- Skeleton inline styles in `AppSidebar.vue` can become static Tailwind widths
**Dead code for 11-06:**
- `AccountView.vue`: confirmed orphan — router redirects `/account → /settings` without importing or rendering it
- Admin tab test files (`AdminAiConfigTab.test.js`, `AdminQuotasTab.test.js`, `AdminUsersTab.test.js`): classify in 11-06
## Verification
- `npm run build` (no ANALYZE): 152 modules transformed, built in 1.06s — no stats.html generated
- `ANALYZE=true npm run build`: identical build + `stats.html` written
- `npm test`: 219 tests pass (30 test files)
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None.
## Threat Flags
None — this plan makes no network, auth, or schema changes.
## Self-Check: PASSED
- `.planning/perf/phase11-baseline.html`: EXISTS (committed at 6d56d25)
- `.planning/perf/phase11-baseline-summary.md`: EXISTS (committed at 6d56d25)
- `frontend/vite.config.js`: EXISTS and modified (committed at 0fb2a53)
- Both commits present in git log: confirmed
@@ -0,0 +1,98 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 2
subsystem: frontend/router
tags: [perf, lazy-load, routing, bundle-split, PERF-03]
dependency_graph:
requires: [11-01]
provides: [perf03-lazy-routes]
affects: [frontend/src/router/index.js, frontend/src/router/__tests__/router.guard.test.js]
tech_stack:
added: []
patterns: [dynamic import via () => import() for non-critical route components]
key_files:
created: []
modified:
- frontend/src/router/index.js
- frontend/src/router/__tests__/router.guard.test.js
decisions:
- "FileManagerView stays synchronous for / per D-10 — critical first authenticated surface; lazy-loading would delay initial paint for the most common entry point"
- "/folders/:folderId reuses the synchronous FileManagerView so no new chunk is created for folder navigation"
- "All other authenticated user routes (Topics, Document, Settings, Cloud, CloudFolder) are now lazy-loaded via () => import()"
- "Admin child routes and auth routes were already lazy-loaded and remain unchanged"
metrics:
duration_minutes: 2
tasks_completed: 6
files_created: 0
files_modified: 2
completed_date: "2026-06-16"
---
# Phase 11 Plan 2: Lazy-Load Non-Critical Routes Summary
Lazy-loaded 5 non-critical authenticated route components, reducing the main JS bundle from 264.63 kB to 180.17 kB and emitting 5 separate route chunks, satisfying PERF-03.
## What Was Built
### Tasks 1-4 — Router lazy-loading
`frontend/src/router/index.js` updated:
- **Removed** static `import` statements for `TopicsView`, `DocumentView`, `SettingsView`, `CloudStorageView`, `CloudFolderView`.
- **Replaced** each with an inline `() => import('../views/...View.vue')` dynamic import on the route's `component` field.
- **Kept** `FileManagerView` as the sole static synchronous import (decision D-10). A comment block in `router/index.js` documents the rationale.
- **Kept** `/folders/:folderId` using the synchronous `FileManagerView` component — no new chunk needed since the component is already in the initial bundle.
- **Preserved** all existing lazy imports for admin children, auth views, `SharedView`, and `AdminLayout`.
Acceptance criterion confirmed: `grep "import .*View" frontend/src/router/index.js` returns only `FileManagerView`.
### Task 5 — Extended router guard tests
`frontend/src/router/__tests__/router.guard.test.js` extended with 15 new tests across 2 new describe blocks:
- `router — admin guard` extended with: non-admin blocked from `/admin/users` and `/admin/quotas` (child route inheritance via `to.matched.some()`), admin redirected away from `/settings` and `/cloud` (D-09)
- `router — refresh-before-guard` (new): verifies `refresh()` is called when `accessToken` is null, redirect to `/login` when refresh fails, no refresh call for public routes
- `router — lazy-loaded routes resolve` (new): verifies all 5 newly-lazy routes (`/topics`, `/topics/:name`, `/document/:id`, `/settings`, `/cloud`, `/cloud/:provider/:folderId`) navigate correctly for authenticated regular users; `/folders/:folderId` and `/shared` also covered
All 234 tests pass (up from 219 in Plan 11-01).
### Task 6 — Build verification
`npm run build` succeeds; route chunks emitted:
| Chunk | Size |
|-------|------|
| `CloudFolderView-*.js` | 1.99 kB |
| `CloudStorageView-*.js` | 2.32 kB |
| `DocumentView-*.js` | 9.17 kB |
| `TopicsView-*.js` | 10.98 kB |
| `SettingsView-*.js` | 60.72 kB |
| Main bundle (`index-*.js`) | 180.17 kB (was 264.63 kB) |
Main bundle reduction: **84.46 kB raw** (~32%).
## Verification
- `rg "import .*View" frontend/src/router/index.js` returns only `FileManagerView` — confirmed
- Admin child routes remain lazy-loaded — confirmed
- `npm test`: 234 tests pass, 30 test files
- `npm run build`: succeeds, split route chunks emitted
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None.
## Threat Flags
None — this plan makes no network, auth, or schema changes. Guard behavior is unchanged; only the loading strategy for view components was modified.
## Self-Check: PASSED
- `frontend/src/router/index.js`: modified, only `FileManagerView` statically imported
- `frontend/src/router/__tests__/router.guard.test.js`: modified, 15 new tests
- Commit `4fa07b3` exists in git log
- Build output shows 5 route chunk files
@@ -0,0 +1,131 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 3
subsystem: frontend/responsive
tags: [responsive, layout, drawer, touch-targets, RESP-01, RESP-02, RESP-03, RESP-05]
dependency_graph:
requires: [11-02]
provides: [responsive-user-shell, responsive-admin-shell, responsive-storage-rows, touch-targets]
affects:
- frontend/src/App.vue
- frontend/src/layouts/AdminLayout.vue
- frontend/src/components/storage/StorageBrowser.vue
tech_stack:
added: []
patterns:
- "Hamburger button + slide-in overlay drawer with Teleport backdrop (translate-x-0/-translate-x-full)"
- "Drawer state owned by layout root (App.vue / AdminLayout.vue), not sidebar component (D-04/D-05)"
- "Route-change watcher closes drawer automatically on navigation"
- "Responsive grid-cols variants: mobile base, sm (+modified), md (all 5 columns)"
- "Touch target floor: min-w-[36px] min-h-[36px] on action buttons"
key_files:
created: []
modified:
- frontend/src/App.vue
- frontend/src/layouts/AdminLayout.vue
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/__tests__/keyboard.test.js
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js
decisions:
- "Drawer state in App.vue ref (not Pinia, not AppSidebar) — satisfies D-04/D-05 pitfall constraint"
- "Backdrop teleported to <body> via <Teleport to='body'> — consistent with Phase 10 modal/toast pattern"
- "Grid templates use mobile-first responsive variants instead of a fixed 5-column layout — prevents horizontal overflow at 375px"
- "Touch targets applied via min-w/min-h classes at all breakpoints, removed with md:min-w-0 md:min-h-0 at desktop — desktop appearance unchanged"
metrics:
duration_minutes: 9
tasks_completed: 8
files_created: 0
files_modified: 5
completed_date: "2026-06-16"
---
# Phase 11 Plan 3: Responsive Shells & Storage Rows Summary
Implemented mobile-first responsive shells for both user and admin layouts using hamburger-triggered slide-in overlay drawers, and made StorageBrowser rows fit small viewports without horizontal overflow.
## What Was Built
### Tasks 1-3 — App.vue: user layout responsive shell (RESP-01)
`frontend/src/App.vue` updated:
- **Drawer state:** `drawerOpen = ref(false)` owned by `App.vue` — satisfies D-04/D-05 constraint (never put in `AppSidebar`).
- **Route-change close:** `watch(() => route.fullPath, ...)` sets `drawerOpen.value = false` on every navigation so link taps auto-close the drawer.
- **Mobile header:** `<header class="lg:hidden fixed ...">` contains the hamburger button (`data-test="hamburger-btn"`) and the DocuVault wordmark. Only shown below `lg`.
- **Backdrop:** `<Teleport to="body">` wraps a semi-transparent overlay `<div>` that appears when `drawerOpen` is true and calls `drawerOpen = false` on click. Uses `data-test="drawer-backdrop"`.
- **Sidebar wrapper:** `fixed inset-y-0 left-0 z-50` positioning for mobile, `lg:static lg:z-auto lg:translate-x-0` for desktop. Transition: `translate-x-0` (open) / `-translate-x-full` (closed) via `transition-transform duration-200`. Attribute `data-test="app-sidebar-wrapper"`.
- **Main content:** `pt-[53px] lg:pt-0` offset on `<main>` so mobile content doesn't hide under the fixed header.
### Task 4 — AdminLayout.vue: admin layout responsive shell (RESP-05)
`frontend/src/layouts/AdminLayout.vue` mirrors the user layout pattern exactly:
- `drawerOpen = ref(false)` + `watch(() => route.fullPath, ...)` for auto-close on navigation.
- Mobile header shows "DocuVault" + "Admin" label with `data-test="admin-hamburger-btn"`.
- Teleport backdrop with `data-test="admin-drawer-backdrop"`.
- Sidebar wrapper with `data-test="admin-sidebar-wrapper"` and identical transition classes.
- `useRoute` import added; no `useRouter` needed (AdminLayout itself doesn't navigate).
### Tasks 5-6 — StorageBrowser.vue: responsive grid and touch targets (RESP-02, RESP-03)
`frontend/src/components/storage/StorageBrowser.vue` updated:
**Responsive grid templates** (replaces fixed `grid-cols-[2rem_1fr_6rem_8rem_6rem]` everywhere):
| Breakpoint | Grid template | Visible columns |
|---|---|---|
| Default (< sm, 375px) | `grid-cols-[2rem_1fr_6rem]` | icon, name, actions |
| sm (640px+) | `sm:grid-cols-[2rem_1fr_8rem_6rem]` | + modified date |
| md (768px+) | `md:grid-cols-[2rem_1fr_6rem_8rem_6rem]` | + size |
Applied to: list header row, new-folder input row, folder rows, file rows, skeleton rows.
Added `data-test="list-header"` to the column header row for testability.
**Touch targets** (RESP-03, satisfies 36px minimum):
All inline action buttons (Rename, Delete for folders; Share, Move, Delete for files) now have:
- `min-w-[36px] min-h-[36px]` — enforces 36×36px minimum hit area on mobile
- `md:min-w-0 md:min-h-0` — removes the override at desktop so padding-only sizing applies
- `flex items-center justify-center` — keeps icon centered within the larger target
### Task 7 — Tests
**`frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`** updated:
- Replaced old `grid-cols-[2rem_1fr_6rem_8rem_6rem]` assertion (now broken by responsive refactor) with two tests: mobile base class `grid-cols-[2rem_1fr_6rem]` and md breakpoint class `md:grid-cols-[2rem_1fr_6rem_8rem_6rem]`.
- Added new `RESP-02/RESP-03` describe block (8 tests): list header mobile/md classes, folder row, file row, `hidden md:block` size column, `hidden sm:block` modified column, file action button `min-w-[36px]`/`min-h-[36px]`, folder action button touch targets.
**`frontend/src/__tests__/keyboard.test.js`** extended:
- Added `RESP-01: App drawer` describe (2 tests): hamburger open/backdrop-close behavior via stub component; route-change watcher closes drawer.
- Added `RESP-05: AdminLayout drawer` describe (3 tests): admin hamburger renders, admin backdrop-close, admin route-change watcher.
- `afterEach` import added; `nextTick` import added.
## Verification
- `npm test`: 30 test files, 233 tests pass (219 baseline + 14 new from this plan)
- `npm run build`: succeeds — 5 JS chunks + main bundle, no new errors
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None.
## Threat Flags
None — no network endpoints, auth paths, or schema changes in this plan. All changes are frontend layout/presentation only.
## Self-Check: PASSED
- `frontend/src/App.vue`: modified — hamburger header + drawer + Teleport backdrop
- `frontend/src/layouts/AdminLayout.vue`: modified — admin responsive shell
- `frontend/src/components/storage/StorageBrowser.vue`: modified — responsive grid + touch targets
- `frontend/src/__tests__/keyboard.test.js`: modified — drawer tests added
- `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`: modified — responsive assertions
- Commit `d914761` exists in git log: confirmed
- All 30 test files pass: confirmed
- Build succeeds: confirmed
@@ -0,0 +1,148 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 4
subsystem: frontend/modals
tags: [mobile, modals, forms, VISUAL-02, RESP-04]
dependency_graph:
requires: [11-03]
provides: [mobile-safe-modals, form-baseline-coverage]
affects:
- 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
tech_stack:
added: []
patterns:
- "max-h-[90vh] overflow-y-auto on modal panels — scroll-safe mobile pattern"
- "px-4 sm:px-6 responsive horizontal padding on full-screen preview header"
- "data-test attributes on all modal panels for testability"
- "focus:outline-none focus:ring-2 focus:ring-indigo-500 — consistent form baseline pattern throughout codebase"
key_files:
created:
- frontend/src/components/sharing/__tests__/ShareModal.mobile.test.js
- frontend/src/components/cloud/__tests__/CloudCredentialModal.mobile.test.js
- frontend/src/components/folders/__tests__/FolderDeleteModal.mobile.test.js
- frontend/src/components/documents/__tests__/DocumentPreviewModal.mobile.test.js
modified:
- 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
decisions:
- "@tailwindcss/forms plugin active in tailwind.config.js — no drift found, verification task complete"
- "focus:outline-none focus:ring-2 focus:ring-indigo-500 is the consistent form baseline pattern throughout entire codebase — no normalization needed"
- "DocumentPreviewModal stays full-screen (fixed inset-0) — added responsive px-4 sm:px-6 header padding for narrow viewports"
- "DocumentView inline cloud-delete warning modal receives same max-h treatment as dedicated modal components"
metrics:
duration_minutes: 7
tasks_completed: 5
files_created: 4
files_modified: 5
completed_date: "2026-06-16"
---
# Phase 11 Plan 4: Forms Baseline & Mobile-Safe Modals Summary
Verified `@tailwindcss/forms` remains active with a consistent form focus pattern throughout the codebase, and updated all four modal components plus one inline modal to scroll safely on narrow mobile viewports (375x667px).
## What Was Built
### Task 1 — Forms plugin verification
`frontend/tailwind.config.js` confirmed: `import forms from '@tailwindcss/forms'` and `plugins: [forms]` are active. No drift.
### Tasks 2-3 — Form baseline audit and normalization
Full audit of all `<input>`, `<select>`, `<textarea>`, `<input type="checkbox">`, and `<input type="radio">` elements across all Vue components. Findings:
- **Consistent pattern throughout:** `focus:outline-none focus:ring-2 focus:ring-indigo-500` (or `focus:ring-indigo-400` in topic-related components). This is already the standardized baseline.
- **No conflicting per-component reset styles** found (no `appearance-none`, no `webkit-appearance`, no inline `outline: none`).
- **No normalization required** — the codebase is already consistent. The forms plugin resets browser defaults and the `focus:ring-*` utility classes provide the visual indicator.
### Task 4 — Modal mobile-safe updates (RESP-04)
All modal panels now have `max-h-[90vh] overflow-y-auto` added to their panel containers:
**`ShareModal.vue`**
- Panel `class` updated: added `max-h-[90vh] overflow-y-auto`
- Added `data-test="share-modal-panel"` for testability
**`CloudCredentialModal.vue`**
- Panel `class` updated: added `max-h-[90vh] overflow-y-auto`
- Added `data-test="cloud-credential-modal-panel"` for testability
- The tall WebDAV/Nextcloud form with server URL, username, auth method toggle, advanced section, and password field now scrolls safely on 375px height-constrained viewports
**`FolderDeleteModal.vue`**
- Panel `class` updated: added `max-h-[90vh] overflow-y-auto`
- Added `data-test="folder-delete-modal-panel"` for testability
**`DocumentPreviewModal.vue`**
- Full-screen overlay preserved (`fixed inset-0 flex flex-col`) — this modal intentionally uses the entire viewport
- Header padding made responsive: `px-4 sm:px-6` (was `px-6`) — prevents filename and close button from touching screen edges at 375px
- Added `data-test="document-preview-modal"` on overlay and `data-test="preview-modal-header"` on header bar
**`DocumentView.vue` (inline cloud-delete warning modal)**
- Inline modal panel updated: added `max-h-[90vh] overflow-y-auto`
- Added `data-test="cloud-delete-modal-panel"` for testability
### Task 5 — Tests
Four new test files created (34 total test files in worktree, 234 tests all pass):
**`ShareModal.mobile.test.js`** (5 tests):
- VISUAL-02: text input has `focus:ring-2 focus:outline-none` (form baseline)
- VISUAL-02: select has `focus:ring-2 focus:outline-none` (form baseline)
- RESP-04: panel has `max-h-[90vh]`
- RESP-04: panel has `overflow-y-auto`
- RESP-04: panel has `mx-4` for narrow viewport fit
**`CloudCredentialModal.mobile.test.js`** (3 tests):
- RESP-04: panel has `max-h-[90vh]`
- RESP-04: panel has `overflow-y-auto`
- RESP-04: panel not rendered when `show=false` (v-if gate verified)
**`FolderDeleteModal.mobile.test.js`** (4 tests):
- RESP-04: panel has `max-h-[90vh]`
- RESP-04: panel has `overflow-y-auto`
- RESP-04: panel has `mx-4`
- Action buttons are accessible (text content check)
**`DocumentPreviewModal.mobile.test.js`** (3 tests):
- RESP-04: full-screen overlay has `fixed inset-0` classes
- RESP-04: header has `px-4` (mobile) and `sm:px-6` (640px+)
- RESP-04: filename `span` has `truncate` class preventing overflow
## Verification
- `npm run test -- --run` (via `./node_modules/.bin/vitest run`): **34 test files, 234 tests, all pass**
- `npm run build` (via `./node_modules/.bin/vite build`): **succeeds** — all 5 JS chunks + main bundle build cleanly
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None.
## Threat Flags
None — no network endpoints, auth paths, file access patterns, or schema changes. All changes are frontend layout/presentation classes and test files.
## Self-Check: PASSED
- `frontend/src/components/sharing/ShareModal.vue`: modified — max-h + overflow-y-auto + data-test
- `frontend/src/components/cloud/CloudCredentialModal.vue`: modified — max-h + overflow-y-auto + data-test
- `frontend/src/components/folders/FolderDeleteModal.vue`: modified — max-h + overflow-y-auto + data-test
- `frontend/src/components/documents/DocumentPreviewModal.vue`: modified — responsive px-4, data-test attrs
- `frontend/src/views/DocumentView.vue`: modified — inline modal max-h + data-test
- `frontend/src/components/sharing/__tests__/ShareModal.mobile.test.js`: created — 5 tests
- `frontend/src/components/cloud/__tests__/CloudCredentialModal.mobile.test.js`: created — 3 tests
- `frontend/src/components/folders/__tests__/FolderDeleteModal.mobile.test.js`: created — 4 tests
- `frontend/src/components/documents/__tests__/DocumentPreviewModal.mobile.test.js`: created — 3 tests
- Commit `df53cef` exists in git log: confirmed
- 34 test files, 234 tests all pass: confirmed
- Build succeeds: confirmed
@@ -0,0 +1,204 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 5
subsystem: frontend/visual-consistency
tags: [visual, typography, focus-visible, hover, active, skeleton, VISUAL-01, VISUAL-03, VISUAL-04]
dependency_graph:
requires: [11-04]
provides: [typography-normalized, focus-visible-rings, active-states, skeleton-class-widths]
affects:
- frontend/src/components/admin/AdminSidebar.vue
- frontend/src/components/auth/BackupCodesDisplay.vue
- frontend/src/components/cloud/CloudCredentialModal.vue
- frontend/src/components/documents/DocumentCard.vue
- frontend/src/components/layout/AppSidebar.vue
- frontend/src/components/settings/SettingsAccountTab.vue
- frontend/src/components/settings/SettingsAiTab.vue
- frontend/src/components/settings/SettingsCloudTab.vue
- frontend/src/components/settings/SettingsPreferencesTab.vue
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/topics/TopicManager.vue
- frontend/src/views/DocumentView.vue
- frontend/src/views/SettingsView.vue
- frontend/src/views/SharedView.vue
- frontend/src/views/TopicsView.vue
- frontend/src/views/admin/AdminAiView.vue
- frontend/src/views/admin/AdminAuditView.vue
- frontend/src/views/admin/AdminOverviewView.vue
- frontend/src/views/admin/AdminUsersView.vue
tech_stack:
added: []
patterns:
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 — keyboard focus convention for all interactive elements"
- "active:bg-{color}-{shade} — press feedback on all primary and secondary buttons"
- "w-12/w-16/w-20 Tailwind classes instead of inline :style={{ width }} for skeleton placeholders"
- "Typography scale: text-2xl font-semibold (page title), text-lg font-semibold (section title), text-sm font-semibold (panel heading)"
key_files:
created:
- frontend/src/components/layout/__tests__/AppSidebar.visual.test.js
- frontend/src/views/__tests__/typography.visual.test.js
modified:
- frontend/src/components/admin/AdminSidebar.vue
- frontend/src/components/auth/BackupCodesDisplay.vue
- frontend/src/components/cloud/CloudCredentialModal.vue
- frontend/src/components/documents/DocumentCard.vue
- frontend/src/components/layout/AppSidebar.vue
- frontend/src/components/settings/SettingsAccountTab.vue
- frontend/src/components/settings/SettingsAiTab.vue
- frontend/src/components/settings/SettingsCloudTab.vue
- frontend/src/components/settings/SettingsPreferencesTab.vue
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/topics/TopicManager.vue
- frontend/src/views/DocumentView.vue
- frontend/src/views/SettingsView.vue
- frontend/src/views/SharedView.vue
- frontend/src/views/TopicsView.vue
- frontend/src/views/admin/AdminAiView.vue
- frontend/src/views/admin/AdminAuditView.vue
- frontend/src/views/admin/AdminOverviewView.vue
- frontend/src/views/admin/AdminUsersView.vue
decisions:
- "Typography scale locked: text-2xl font-semibold for page titles, text-lg font-semibold for section card headings, text-sm font-semibold for panel/table headings"
- "focus-visible: convention chosen over focus: on interactive buttons to avoid ring on mouse click — form inputs keep existing focus: pattern (appropriate for forms)"
- "Stat number displays (AdminOverview cards, TopicsView doc count) keep font-bold — these are metric numerals, not headings"
- "AuthLayout DocuVault brand name keeps text-xl font-semibold — branding element, not a page title"
- "Sidebar nav-link scoped CSS updated in both AppSidebar and AdminSidebar to include active:bg-gray-200 and focus-visible ring as @apply directives"
metrics:
duration_minutes: 10
tasks_completed: 7
files_created: 2
files_modified: 19
completed_date: "2026-06-16"
---
# Phase 11 Plan 5: Visual Consistency Pass Summary
Normalized typography to one consistent scale across all views and components, replaced generic `focus:ring-*` patterns with keyboard-only `focus-visible:` convention on all interactive buttons, added `active:bg-*` press feedback states, and converted decorative skeleton inline styles to static Tailwind width classes.
## What Was Built
### Task 1 — Preserve data-driven inline styles
Audited and confirmed the following inline styles are data-driven and preserved:
- `{ backgroundColor: topic.color }` — topic color swatches in TopicBadge, AppSidebar, TopicsView
- `{ width: pct + '%' }` — QuotaBar progress fill (runtime percentage)
- `pickerStyle` (top/left/bottom/width computed from getBoundingClientRect) — Teleport dropdown coordinates in StorageBrowser and DocumentCard
- Tree indentation via depth-computed padding in FolderTreeItem via TreeItem
### Task 2 — Remove decorative inline styles
Three sections in `AppSidebar.vue` used `:style="{ width: (50 + n * 15) + 'px' }"` for skeleton placeholder widths. Replaced all 9 instances (3 sections × 3 skeleton rows) with static Tailwind classes `w-12`, `w-16`, `w-20` (equivalent visual widths without runtime computation).
### Task 3 — Normalize typography
**Page titles (`text-2xl font-semibold`):**
- `TopicsView.vue`: `font-bold``font-semibold`
- `DocumentView.vue`: `font-bold``font-semibold`
- `SharedView.vue`: `font-bold``font-semibold`
- `AdminOverviewView.vue`: `text-xl``text-2xl font-semibold`
**Section titles (`text-lg font-semibold`):**
- `SettingsPreferencesTab.vue`: `text-xl``text-lg`
- `SettingsAiTab.vue`: `text-xl``text-lg`
- `SettingsCloudTab.vue`: `text-xl``text-lg`
- `BackupCodesDisplay.vue`: `text-xl``text-lg`
- `CloudCredentialModal.vue`: `text-xl``text-lg`
**Panel headings (`text-sm font-semibold`):**
- `SettingsAccountTab.vue`: 4 h3 headings — added `text-sm` (was `font-semibold` without explicit size)
- `DocumentView.vue`: 2 h3 headings (Topics, Extracted Text) — added `text-sm`
### Task 4 — Replace focus:ring patterns with focus-visible convention
Applied `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1` to:
- All sidebar nav links (via scoped CSS `.nav-link` `@apply` in AppSidebar + AdminSidebar)
- Expand/collapse toggle buttons in AppSidebar
- Sign-out button in AppSidebar and AdminSidebar
- StorageBrowser: New Folder button, rename/delete/share/move/delete icon action buttons
- DocumentView: Back button, Preview/Delete/Re-classify/Suggest/cloud-modal buttons
- TopicsView: All Topics back button, topic card router-links
- SettingsView: tab strip buttons, dismiss buttons
- TopicManager: Add/Save/Cancel/Edit/Delete buttons
- DocumentCard: Move and Share icon action buttons
- AdminAuditView: Apply/Clear/Export/Previous/Next/Download buttons
- AdminUsersView: Create User button (header and form submit)
- AdminAiView: all accordion and action buttons
- SettingsCloudTab: all Connect/Edit/Remove/Disconnect buttons
- SettingsAccountTab: Disable 2FA and Sign out all buttons
- BackupCodesDisplay: Copy all + Enable 2FA buttons
- CloudCredentialModal: Close button
Destructive actions use `focus-visible:ring-red-500` instead of indigo.
### Task 5 — Coherent hover and active states
Added `active:bg-{color}-{shade}` to all interactive elements that lacked press feedback:
- Primary buttons: `active:bg-indigo-800`
- Secondary/border buttons: `active:bg-gray-100`
- Icon action buttons: `active:bg-gray-300` (neutral), `active:bg-red-100` (destructive)
- Nav links: `active:bg-gray-200` via scoped CSS
- Folder picker dropdown items: `active:bg-gray-100` / `active:bg-indigo-100`
- Green "Set Active" button in AdminAiView: `active:bg-green-800`
### Task 6 — Palette preservation
No new colors introduced. Existing DocuVault identity preserved:
- amber/folders, sky/cloud, indigo/admin/primary, red/destructive, green/success
- Focus rings use indigo-500 for primary elements, red-500 for destructive elements, green-500 for success buttons
### Task 7 — Tests
**`AppSidebar.visual.test.js`** (4 tests):
- VISUAL-01: cloud skeleton items use `w-*` class not inline style width
- VISUAL-01: topics skeleton items use class-based widths not inline style
- VISUAL-04: expand/collapse folder toggle button has `focus-visible:ring-2` and `focus-visible:ring-indigo-500`
- VISUAL-04: sign-out button has `focus-visible:ring-2`
**`typography.visual.test.js`** (3 tests):
- VISUAL-03: SharedView h2 has `font-semibold` not `font-bold` and `text-2xl`
- VISUAL-03: TopicsView h2 has `font-semibold` not `font-bold` and `text-2xl`
- VISUAL-03: SettingsAccountTab h3 headings don't use `text-xl` or `font-bold`
## Verification
- `./node_modules/.bin/vitest run`: **36 test files, 270 tests, all pass**
- `./node_modules/.bin/vite build`: **succeeds** — all chunks build cleanly
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None — all data displayed is wired to real store/API data. No placeholder text or hardcoded values introduced.
## Threat Flags
None — all changes are frontend presentation classes and test files. No network endpoints, auth paths, file access patterns, or schema changes.
## Self-Check: PASSED
- `frontend/src/components/layout/AppSidebar.vue`: modified — skeleton classes, focus-visible, active states, expanded CSS
- `frontend/src/components/admin/AdminSidebar.vue`: modified — focus-visible, active states, expanded CSS
- `frontend/src/views/TopicsView.vue`: modified — font-semibold, focus-visible on back button and cards
- `frontend/src/views/DocumentView.vue`: modified — font-semibold, text-sm panel headings, focus-visible, active states
- `frontend/src/views/SharedView.vue`: modified — font-semibold
- `frontend/src/views/SettingsView.vue`: modified — focus-visible on tab buttons and dismiss buttons
- `frontend/src/views/admin/AdminOverviewView.vue`: modified — text-2xl page title
- `frontend/src/views/admin/AdminAuditView.vue`: modified — focus-visible and active states on all buttons
- `frontend/src/views/admin/AdminUsersView.vue`: modified — focus-visible and active states
- `frontend/src/views/admin/AdminAiView.vue`: modified — focus-visible and active states
- `frontend/src/components/settings/SettingsAccountTab.vue`: modified — text-sm panel headings, focus-visible, active states
- `frontend/src/components/settings/SettingsAiTab.vue`: modified — text-lg section title
- `frontend/src/components/settings/SettingsCloudTab.vue`: modified — text-lg section title, focus-visible, active states
- `frontend/src/components/settings/SettingsPreferencesTab.vue`: modified — text-lg section title
- `frontend/src/components/auth/BackupCodesDisplay.vue`: modified — text-lg heading, focus-visible, active
- `frontend/src/components/cloud/CloudCredentialModal.vue`: modified — text-lg modal title, close button focus-visible
- `frontend/src/components/documents/DocumentCard.vue`: modified — focus-visible and active on action buttons
- `frontend/src/components/storage/StorageBrowser.vue`: modified — focus-visible and active on all row action buttons
- `frontend/src/components/topics/TopicManager.vue`: modified — focus-visible and active on all buttons
- `frontend/src/components/layout/__tests__/AppSidebar.visual.test.js`: created — 4 tests
- `frontend/src/views/__tests__/typography.visual.test.js`: created — 3 tests
- Commit `2af5b7c` exists in git log: confirmed
- 36 test files, 270 tests all pass: confirmed
- Build succeeds: confirmed
@@ -0,0 +1,147 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 6
subsystem: frontend
tags: [dead-code, bundle-analysis, performance, verification, cleanup]
dependency_graph:
requires: [11-05]
provides: [CODE-07, PERF-02]
affects: [frontend/src/components, frontend/src/views, .planning/perf]
tech_stack:
added: []
patterns:
- "Dead-code audit: grep-based cross-reference scan; delete only files with no active import in live code"
- "FolderRow.vue deleted; stale FolderRow tests removed; DocumentCard Teleport tests retained"
key_files:
created:
- .planning/perf/phase11-final-summary.md
- .planning/phases/11-visual-design-responsive-layout-cleanup/11-VERIFICATION.md
modified:
- frontend/src/components/ui/__tests__/dropdown.test.js
deleted:
- frontend/src/components/folders/FolderRow.vue
decisions:
- "AdminAiConfigTab.test.js, AdminQuotasTab.test.js, AdminUsersTab.test.js: RETAINED — import and test live AdminXxxView.vue components; 'Tab' in filename is legacy naming only"
- "FolderRow.vue: DELETED — no import in any live component; StorageBrowser renders folder rows inline; only test references were stale"
- "FolderRow tests in dropdown.test.js: REMOVED — test dead component; DocumentCard Teleport tests in same file retained as they cover live surface"
- "HomeView.vue, FolderView.vue, AdminView.vue: confirmed absent — no action required"
metrics:
duration: ~30 minutes
completed: 2026-06-17
tasks_completed: 8
tests_before: 270
tests_after: 268
files_deleted: 1
files_created: 2
files_modified: 1
---
# Phase 11 Plan 6: Dead-Code Cleanup & Final Measurement Summary
Dead-code audit, bundle comparison, and full Phase 11 requirement verification. AccountView.vue deletion and final bundle capture (tasks 1 and 6) were committed in prior runs; tasks 25 and 78 were completed here.
## One-liner
Dead-code sweep deleted FolderRow.vue and stale tests; verified 3 admin test files are live; wrote bundle comparison (81 kB / 30.6%) and 12-requirement verification map.
## Tasks Completed
### Task 1 — Delete AccountView.vue (commit a8e0a19)
`frontend/src/views/AccountView.vue` deleted. The router has `{ path: '/account', redirect: '/settings' }` with no component import. The file had no live import anywhere in the codebase.
### Task 2 — Classify admin test files (commit a928b54)
Decision for each of the three known suspects:
| File | Decision | Evidence |
|------|----------|---------|
| `AdminAiConfigTab.test.js` | **RETAIN** | Imports `AdminAiView.vue` (live component at `/admin/ai`) |
| `AdminQuotasTab.test.js` | **RETAIN** | Imports `AdminQuotasView.vue` (live component at `/admin/quotas`) |
| `AdminUsersTab.test.js` | **RETAIN** | Imports `AdminUsersView.vue` (live component at `/admin/users`) |
The "Tab" suffix in these filenames is legacy from Phase 9 rearchitecture when the admin panel moved from tab-based layout to route-per-view. The test logic imports the live views and tests real behavior.
### Task 3 — Confirm deleted views remain absent (commit a928b54)
`HomeView.vue`, `FolderView.vue`, `AdminView.vue` — confirmed absent. No `find` results anywhere in `frontend/src`.
### Task 4 — Dead-code scan (commit a928b54)
Full cross-reference scan of all `.vue` and `.js` files against live code. One unreferenced component found:
- **`frontend/src/components/folders/FolderRow.vue`** — DELETED
- No import in any live component or layout
- StorageBrowser renders folder rows inline in its own template
- No active route renders FolderRow
- Only references were in `dropdown.test.js` (stale tests)
All other components, stores, helpers, and utilities confirmed active (each has at least one live import in a route component or layout).
### Task 5 — Remove unused imports and stale tests (commit a928b54)
`frontend/src/components/ui/__tests__/dropdown.test.js`:
- Removed 2 FolderRow tests (tested dead component): `FolderRow three-dot menu uses Teleport to body`, `FolderRow three-dot menu repositions on window scroll`
- Removed `import FolderRow from '../../folders/FolderRow.vue'`
- Removed `SAMPLE_FOLDER` fixture
- Removed `flushPromises` import (no longer needed)
- **Retained** 2 DocumentCard tests (cover live Teleport dropdown UX-13 behavior)
No unused named imports found in live component files. A style duplication in `AdminAiView.vue` (`import * as api` + named imports from same barrel) was not flagged — both import forms are in active use.
Test count: 270 → 268 (removed 2 stale tests, no live behavior lost).
### Task 6 — Final bundle analysis (commit 888d376, prior run)
`ANALYZE=true npm run build` completed. Artifact committed to `.planning/perf/phase11-final.html`.
Final metrics: **183.62 kB main / 64.84 kB gzip / 21 JS chunks**.
### Task 7 — Final summary (commit df981fb)
`.planning/perf/phase11-final-summary.md` written comparing baseline vs final:
| Metric | Baseline | Final | Delta |
|--------|---------|-------|-------|
| Main bundle (raw) | 264.63 kB | 183.62 kB | **81.01 kB (30.6%)** |
| Main bundle (gzip) | 89.34 kB | 64.83 kB | **24.51 kB (27.4%)** |
| JS chunks | 15 | 21 | +6 new lazy route chunks |
The 6 new chunks (SettingsView 62.7 kB, TopicsView 12.3 kB, DocumentView 10.2 kB, CloudStorageView 2.3 kB, CloudFolderView 2.0 kB, AppSpinner 0.5 kB) are loaded only on demand, saving ~640 ms download time on mobile 4G at the critical `/` initial load.
### Task 8 — Phase 11 verification (commit 9ad88ab)
`11-VERIFICATION.md` written mapping all 12 requirements to concrete evidence:
| Requirement | Status |
|-------------|--------|
| VISUAL-01 | SATISFIED |
| VISUAL-02 | SATISFIED |
| VISUAL-03 | SATISFIED |
| VISUAL-04 | SATISFIED |
| RESP-01 | SATISFIED |
| RESP-02 | SATISFIED |
| RESP-03 | SATISFIED |
| RESP-04 | SATISFIED |
| RESP-05 | SATISFIED |
| CODE-07 | SATISFIED |
| PERF-02 | SATISFIED |
| PERF-03 | SATISFIED |
## Deviations from Plan
None — plan executed exactly as written.
The ordering of task execution differed slightly (tasks 1 and 6 were completed in a prior run; tasks 28 were completed here after rebasing the worktree onto main), but all tasks from the plan are complete and correctly committed.
## Known Stubs
None. All data flows are wired to live API calls.
## Threat Flags
None. This plan deleted dead code and added documentation only. No new network endpoints, auth paths, or schema changes introduced.
## Self-Check
- [x] `frontend/src/components/folders/FolderRow.vue` deleted
- [x] `.planning/perf/phase11-final-summary.md` created
- [x] `.planning/phases/11-visual-design-responsive-layout-cleanup/11-VERIFICATION.md` created
- [x] `frontend/src/components/ui/__tests__/dropdown.test.js` updated (FolderRow tests removed)
- [x] `npm run test -- --run` passes: 268/268
- [x] `npm run build` passes: 183.62 kB main / 64.84 kB gzip
- [x] STATE.md not modified (orchestrator owns)
- [x] ROADMAP.md not modified (orchestrator owns)
@@ -0,0 +1,105 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 7
subsystem: frontend
tags: [gap-closure, mobile, responsive, storage-browser, uat]
requirements: [RESP-02, RESP-03, VISUAL-03]
depends_on: [11-UAT]
---
# Plan 11-07 - Mobile Storage Toolbar and Action Visibility Gap Closure
## Goal
Close the Phase 11 UAT gaps for small mobile widths: row actions must be visible without hover, and the StorageBrowser toolbar must fit below 550px by collapsing search, sort, and new-folder controls into compact icon controls.
## Source Gaps
From `11-UAT.md`:
1. Row actions are hover-only, making touchscreen use unsafe and invisible.
2. Search, sort, and new-folder controls overflow to the right below roughly 550px.
3. Mobile file-browser navigation should use icon controls for search/sort/new-folder instead of requiring sideways scrolling.
## Implementation Tasks
### Task 1 - Make row actions visible without hover
Files:
- `frontend/src/components/storage/StorageBrowser.vue`
- `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`
Steps:
1. Replace both row action container class stacks that currently include `opacity-0 group-hover:opacity-100 transition-opacity`.
2. Keep actions visible by default. Prefer subdued default icon color plus hover/active background over hiding.
3. Preserve `min-w-[36px] min-h-[36px]` below `md`.
4. Add tests asserting folder and file action containers/buttons are present and visible without a hover state.
Acceptance:
- At 375px, folder/file row actions are visible immediately.
- Delete controls are discoverable; the user does not have to guess where destructive actions live.
- Existing click-after-drag guard and folder picker behavior remain unchanged.
### Task 2 - Add compact mobile toolbar controls
Files:
- `frontend/src/components/storage/StorageBrowser.vue`
- `frontend/src/components/ui/AppIcon.vue` if a missing sort/filter icon is needed
- `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`
Steps:
1. Split the sticky header into a responsive layout:
- Breadcrumb area can wrap/truncate safely.
- Desktop/tablet controls keep existing full SearchBar, SortControls, and "New folder" text button at `sm` and above.
- Below `sm`, show icon buttons for search, sort, and new folder.
2. Search icon behavior:
- Toggles a full-width mobile search input row below the header controls.
- Search row must fit the viewport, use `w-full`, and preserve Escape-to-clear behavior through the existing search event.
3. Sort icon behavior:
- Opens a compact menu or panel with Name/Date/Size options.
- The active option and order must remain visible to screen readers and keyboard users.
- The panel must fit within viewport width and close after selection.
4. New-folder icon behavior:
- Emits the existing `new-folder` event.
- Has `aria-label="New folder"` and a tooltip/title.
5. Avoid creating a new file browser or parallel grid; keep `StorageBrowser.vue` as the single browser surface.
Acceptance:
- At 375px and below 550px, no horizontal scroll is needed to access search, sort, or new-folder.
- Mobile controls are icon-sized, keyboard focusable, and labelled.
- Existing desktop layout remains visually equivalent.
### Task 3 - Add responsive regression tests
Files:
- `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`
- Optional: `frontend/src/components/ui/__tests__/AppIcon.test.js` if a new icon is added.
Tests:
1. Mobile toolbar renders icon controls with accessible labels.
2. Full desktop controls are hidden below `sm`; compact controls are hidden at `sm` and above.
3. Search icon toggles a full-width mobile search row.
4. Sort icon opens mobile sort options and emits the existing `sort-change` shape.
5. New-folder icon emits the existing `new-folder` event.
6. Row action containers do not contain `opacity-0` or `group-hover:opacity-100`.
Acceptance:
- `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` passes.
- `cd frontend && npm run test -- --run` passes.
- `cd frontend && npm run build` passes.
## Verification
Manual UAT after implementation:
1. Set viewport to 375px.
2. Confirm row action buttons are visible without hover.
3. Confirm search/sort/new-folder are icon controls and can be used without horizontal scrolling.
4. Confirm list rows still show icon/name/actions only, with size hidden below `md` and modified hidden below `sm`.
## Non-Goals
- No redesign of the whole file browser.
- No new component parallel to `StorageBrowser.vue`.
- No backend or API changes.
- No changes to document/folder permissions or destructive action semantics.
@@ -0,0 +1,88 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 7
subsystem: frontend
tags: [gap-closure, mobile, responsive, storage-browser, uat]
dependency_graph:
requires: [11-UAT]
provides: [RESP-02, RESP-03, VISUAL-03-gap-closure]
affects: [frontend/src/components/storage, frontend/src/components/documents, docs]
tech_stack:
added: []
patterns:
- "StorageBrowser remains the single file browser surface"
- "Mobile toolbar uses icon buttons below sm; full controls remain at sm+"
key_files:
modified:
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js
- frontend/src/components/documents/SearchBar.vue
- backend/main.py
- frontend/package.json
- frontend/package-lock.json
- README.md
- AGENTS.md
- .planning/ROADMAP.md
created: []
metrics:
completed: 2026-06-17
tests_after: 277
version: 0.1.4
---
# Phase 11 Plan 7: Mobile Storage Toolbar and Action Visibility Summary
Closed the Phase 11 UAT mobile storage gaps found during `/gsd:verify-work 11`.
## Changes
### Touch-safe row actions
- Removed hover-only visibility from folder and file row action containers.
- Row action buttons are now visible by default on touch devices.
- Preserved the 36px minimum touch target classes below `md`.
- Widened the mobile action grid column to fit three visible file actions without requiring horizontal scroll.
### Compact mobile toolbar
- Split `StorageBrowser`'s sticky header into desktop and mobile control groups.
- Kept the full SearchBar, SortControls, and "New folder" text button at `sm` and above.
- Added mobile icon buttons for Search, Sort, and New folder below `sm`.
- Search opens a full-width mobile search row.
- Sort opens a compact mobile sort panel and emits the existing `sort-change` event shape.
- New folder icon emits the existing `new-folder` event.
### Responsive search width
- Updated `SearchBar.vue` so the input is `w-full` on mobile and keeps `sm:w-56` at larger breakpoints.
## Verification
- `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js`
- PASS: 1 file, 22 tests
- `cd frontend && npm run test -- --run`
- PASS: 36 files, 277 tests
- `cd frontend && npm run build`
- PASS: production build succeeds
- Existing Vite warning remains: `auth.js` is both dynamically and statically imported.
## UAT Gap Mapping
| Gap | Resolution |
|-----|------------|
| Row actions are hover-only and unsafe on touchscreens | Actions are visible without hover; tests assert no `opacity-0` / `group-hover:opacity-100` classes remain on action containers |
| Toolbar shifts out of the viewport below ~550px | Mobile controls collapse into icon buttons below `sm`; expanded search/sort content uses full-width rows |
| Search/sort/new-folder should morph into icons | Implemented icon controls with accessible labels and regression coverage |
## Deviations
None.
## Self-Check
- [x] StorageBrowser remains the single local/cloud file browser
- [x] No backend/API behavior changed beyond patch version bump
- [x] Mobile row actions visible without hover
- [x] Mobile search/sort/new-folder controls fit without horizontal scrolling
- [x] Focus-visible classes retained on new icon buttons
- [x] Tests and build pass
@@ -0,0 +1,135 @@
# Phase 11: Visual Design, Responsive Layout & Cleanup - Context
**Gathered:** 2026-06-16
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 11 delivers visual consistency and mobile responsiveness across the entire frontend:
1. **Visual polish** — Remove all arbitrary spacing values, normalize typography to one scale, add consistent hover/focus-visible states and active states to every interactive element, and apply minor visual refinements where things look visibly off (inconsistent shadow, border radius, or color tone drift). The current DocuVault color identity (amber/folders, sky/cloud, indigo/admin) is preserved — no new palette.
2. **Responsive layout** — Below `lg` (1024px) both user and admin sidebars are hidden; a mobile-only hamburger button reveals a slide-in overlay drawer. `StorageBrowser` columns are hidden below `md`/`sm`. Touch targets are at least 36×36px below `md`. All modals are scrollable below 640px.
3. **Performance** — All non-initial-render routes lazy-loaded; two bundle analysis reports (baseline + post-optimization) committed to `.planning/perf/`.
4. **Dead code** — Frontend-only sweep: all unreferenced components, stores, helpers, and unused imports deleted after all other changes are complete.
This phase is **purely frontend** — no backend changes, no new features.
</domain>
<decisions>
## Implementation Decisions
### Visual Consistency (VISUAL-01..04)
- **D-01:** **Polish + subtle refinements** — Fix all VISUAL-01..04 requirements (arbitrary pixel removal, focus-visible rings, typography normalization) AND apply minor visual improvements where things look clearly off (e.g., inconsistent shadow depth, mismatched border radius, color tone drift between sections). This is not a redesign — it is removal of accumulated drift.
- **D-02:** Researcher does a **full systematic audit** of all components and views. User did not flag specific known problem areas — full discovery is expected.
- **D-03:** Preserve current DocuVault color identity: amber for folders/files, sky for cloud, indigo for admin sections. No new colors introduced.
### Responsive Layout (RESP-01..05)
- **D-04:** Hamburger drawer state must **not** live in `AppSidebar.vue` or `AdminSidebar.vue` own `data()` — this is a ROADMAP pitfall constraint (PITFALLS.md §Pitfall 8). Sidebar state must be hoisted to the layout root or a store.
- **D-05:** Sidebar state placement (**researcher decides**) — either local `ref()` in `App.vue` + `AdminLayout.vue` (simpler, resets on route change) or a shared `useLayoutStore` (Pinia, globally accessible so nav links and back buttons can close the drawer). Choose based on actual component tree and what child components need to close the drawer.
- **D-06:** The `<Teleport to="body">` pattern established in Phase 10 (for modals, toast, dropdowns) is the reference pattern for the hamburger overlay backdrop if it needs to stack above all content.
### Dead Code (CODE-07)
- **D-07:** **Frontend only** — the backend was cleaned up in Phase 8; no backend dead-code sweep in this phase.
- **D-08:** Dead code deletion happens **last** (Plan 11-06), after all new additions are complete. Delete only what is confirmed unreferenced after all Phase 11 code is in place. Never delete ahead of additions.
### Performance (PERF-02, PERF-03)
- **D-09:** Bundle baseline captured in Plan 11-01 (before any changes). Final measurement in Plan 11-06 (after all changes). Both reports committed to `.planning/perf/`.
- **D-10:** Login/Register route (`/login`, `/register`) may stay synchronous if needed for first-paint — document the rationale in the router. All other authenticated routes (Topics, Document detail, Settings, Cloud, admin subtree) must be lazy.
### Claude's Discretion
- **Sidebar state implementation** (Pinia store vs. layout refs) — researcher reads actual `App.vue`, `AdminLayout.vue`, and nav link component trees, then picks the cleanest approach respecting D-04/D-05 above.
- **Focus ring color** — pick one consistent color for `focus-visible:` rings across the whole app (suggestion: `ring-amber-500` to match the app's primary accent, or `ring-blue-500` for a more neutral a11y convention). Document the chosen color.
- **Typography scale definition** — enumerate the actual heading/body/label/caption sizes found in the codebase and normalize to the smallest consistent set. Define in a comment in `tailwind.config.js` or as a convention documented in the plan's verification section.
- **Minor visual refinements scope** — researcher flags specific items during audit; apply only what has clear improvement value (no second-guessing the palette or layout structure).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase Requirements and Goal
- `.planning/ROADMAP.md` §"Phase 11: Visual Design, Responsive Layout & Cleanup" — goal, implementation notes (sidebar pitfall, RESP column rules, touch target size, modal scroll, @tailwindcss/forms, CODE-07 ordering, PERF-02 timing), success criteria
- `.planning/REQUIREMENTS.md` §VISUAL-01..04, RESP-01..05, CODE-07, PERF-02, PERF-03 — formal requirement definitions
### Responsive Layout
- `.planning/research/PITFALLS.md` §Pitfall 8 — sidebar open/close state must not live in AppSidebar's own data(); placement constraint for hamburger drawer implementation
### Frontend Architecture
- `.planning/codebase/ARCHITECTURE.md` — component responsibilities, data flow, View→Smart→Presentational layering
- `.planning/codebase/CONVENTIONS.md` — Vue/JS style (Composition API, store patterns, import order, no Prettier/ESLint)
- `CLAUDE.md` §"Frontend: shared module map" — canonical shared components (`StorageBrowser.vue`, `TreeItem.vue`, `formatters.js`)
- `CLAUDE.md` §"Component architecture" — Views are thin data-providers; smart components own layout; presentational components receive props only
### Files Being Modified
- `frontend/src/App.vue` — user layout root; hamburger button and sidebar overlay integration point
- `frontend/src/layouts/AdminLayout.vue` — admin layout root; same responsive treatment as App.vue (RESP-05)
- `frontend/src/components/layout/AppSidebar.vue` — user sidebar; hidden below `lg`, slides in as drawer
- `frontend/src/components/admin/AdminSidebar.vue` — admin sidebar; same responsive behavior
- `frontend/src/components/storage/StorageBrowser.vue` — responsive column hiding: Size below `md`, Modified below `sm`; touch targets (RESP-03)
- `frontend/src/router/index.js` — add `() => import(...)` lazy loading for non-initial routes (PERF-03)
- `frontend/tailwind.config.js` — add `@tailwindcss/forms` plugin (VISUAL-02)
- `frontend/vite.config.js` — add `rollup-plugin-visualizer` behind `ANALYZE=true` env flag (PERF-02)
### Phase 10 Patterns (carry forward)
- `.planning/phases/10-ux-interaction/10-CONTEXT.md` §"Drag-and-Drop (D-16)" — `<Teleport to="body">` established as the pattern for overlays that must stack above all content; sidebar backdrop should follow this
- `.planning/phases/10-ux-interaction/10-CONTEXT.md` §"Toast Notifications (D-03)" — same Teleport pattern reference
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `frontend/src/components/ui/TreeItem.vue` — generic expand/collapse tree node; sidebar drawer nav links should wrap this where applicable
- `frontend/src/components/ui/AppSpinner.vue` — existing spinner; keep it — this phase adds no new loading patterns
- Phase 10 `<Teleport to="body">` pattern — already in use for toast container and drag-drop overlay; hamburger backdrop follows the same pattern
### Established Patterns
- **Composition API with `<script setup>`** — ALL Vue components in this codebase use Composition API (confirmed at 2026-06-02 codebase analysis). Researcher should verify current state before assuming Options API.
- **Tailwind utility classes inline** — no scoped `<style>` blocks. All spacing/color fixes applied as class changes in template.
- **No `@/` alias** — relative paths throughout frontend imports.
- **`<Teleport to="body">`** for overlays — Phase 10's pattern for stacking contexts; use for sidebar backdrop.
### Integration Points
- `App.vue` — user layout root; hamburger button + sidebar drawer mount here. Phase 10 already added `<Teleport>`-based toast container and drag overlay here.
- `AdminLayout.vue` — admin layout root; separate hamburger button needed for admin nav drawer (RESP-05); same structural pattern as `App.vue`
- `frontend/src/router/index.js` — lazy-load additions happen here; preserve all existing `meta: { requiresAuth, requiresAdmin }` guards
- `frontend/tailwind.config.js` — add `@tailwindcss/forms` plugin; do not break existing utility classes
### PERF-02 Bundle Workflow
- Plan 11-01 adds `rollup-plugin-visualizer` behind `ANALYZE=true` and captures baseline in `.planning/perf/phase11-baseline.html`
- Plan 11-06 captures final report in `.planning/perf/phase11-final.html`
- Both files committed to repo as required evidence
</code_context>
<specifics>
## Specific Ideas
- Sidebar drawer animation: `translate-x-0` / `-translate-x-full` transition (from ROADMAP.md) — no JavaScript-driven animation, pure Tailwind transition classes.
- Backdrop for drawer: semi-transparent overlay, tap to close. Use `<Teleport to="body">` consistent with existing overlay pattern.
- Touch target minimum: `min-w-[36px] min-h-[36px]` or equivalent padding on icon action buttons below `md`.
- Modal scroll: `max-h-[90vh] overflow-y-auto` pattern on modal content container for viewports below 640px.
- `@tailwindcss/forms` strategy: install plugin, audit all form elements for conflicting per-component reset styles, normalize to smallest consistent Tailwind class set already used in the app.
</specifics>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope.
</deferred>
---
*Phase: 11-visual-design-responsive-layout-cleanup*
*Context gathered: 2026-06-16*
@@ -0,0 +1,83 @@
# Phase 11: Visual Design, Responsive Layout & Cleanup - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-16
**Phase:** 11-visual-design-responsive-layout-cleanup
**Areas discussed:** Sidebar state, Visual polish depth, Dead code scope
---
## Existing Plans Gate
| Option | Description | Selected |
|--------|-------------|----------|
| View existing plans | Show what plans were created before deciding | ✓ |
| Continue and replan after | Capture context now, then replan | |
| Cancel | Stop here | |
**User's choice:** Viewed plans first, then chose "Continue and replan after"
**Notes:** User reviewed all 6 existing plans (11-01 through 11-06) before proceeding. Plans looked solid in structure; user wants context captured so plans can be rebuilt with decisions locked in.
---
## Sidebar State
| Option | Description | Selected |
|--------|-------------|----------|
| App.vue / AdminLayout.vue refs | Local ref() in layout root. Simple, no store overhead. | |
| Shared useLayoutStore (Pinia) | One store, two fields. Any component can close the drawer. | |
| You decide | Researcher picks cleanest option given actual component tree | ✓ |
**User's choice:** You decide (Claude's discretion)
**Notes:** ROADMAP.md pitfall constraint (§Pitfall 8) is locked — state must not live in AppSidebar/AdminSidebar own data(). Researcher will read actual component tree and choose between Pinia store or layout-root ref based on whether child components (e.g., nav links, "Back to app" button) need to close the drawer.
---
## Visual Polish Depth
**Question 1: Ambition level**
| Option | Description | Selected |
|--------|-------------|----------|
| Discipline only | Fix VISUAL-01..04 only. No visual redesign. | |
| Polish + subtle refinements | Fix requirements AND allow minor improvements where things look off (shadows, border radius, color tone drift). Still no new palette. | ✓ |
**User's choice:** Polish + subtle refinements
**Notes:** User wants things to look noticeably better where there's clear visual drift, not just mechanically compliant.
**Question 2: Known problem areas**
| Option | Description | Selected |
|--------|-------------|----------|
| No, let the researcher find them | Systematic full audit | ✓ |
| Yes, I have specific complaints | User would specify targets | |
**User's choice:** Researcher does full audit
**Notes:** No specific components flagged by user. Full discovery expected.
---
## Dead Code Scope
| Option | Description | Selected |
|--------|-------------|----------|
| Frontend only | Stick to 6 existing plans. Backend was cleaned in Phase 8. | ✓ |
| Frontend + backend | Also sweep backend for unused routes, dead utilities, stale imports. | |
**User's choice:** Frontend only
**Notes:** Backend was decomposed and cleaned in Phase 8. No backend dead-code sweep needed in this phase.
---
## Claude's Discretion
- **Sidebar state implementation** — Pinia store vs. layout refs; researcher decides based on actual component tree
- **Focus ring color** — pick one consistent color for focus-visible rings (recommendation: `ring-amber-500` or neutral `ring-blue-500`)
- **Typography scale definition** — enumerate actual sizes found in codebase, normalize to smallest consistent set, document in plan verification
- **Minor visual refinement scope** — researcher flags specific items during audit; apply only what has clear improvement value
## Deferred Ideas
None — discussion stayed within phase scope.
@@ -0,0 +1,345 @@
---
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 `<iframe>`. Any document the backend serves — HTML files, PDFs with embedded JavaScript, SVG files — is rendered by the browser inside an iframe whose origin is `blob:<app-origin>/...`, which runs in the same origin as the Vue SPA. Script executing in that iframe has full access to `document.cookie`, `localStorage`, and can `postMessage` to the parent frame or make credentialed XHR requests. An attacker who can upload a file (any authenticated user) can craft an HTML document that exfiltrates auth state or performs CSRF actions against the API. The CLAUDE.md security protocol prohibits XSS via user data and requires no `innerHTML` with user content; a sandboxed iframe is the equivalent requirement for embedded document rendering.
**Fix:**
```html
<iframe
v-if="blobUrl"
class="w-full h-full border-0"
:src="blobUrl"
title="Document preview"
sandbox="allow-scripts"
></iframe>
```
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 `<p v-if="suggestError" class="text-red-500 text-xs mt-2">{{ suggestError }}</p>` 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 1317 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 98100) but that listener is not active when the admin layout is rendered — `App.vue` routes admin paths through a bare `<router-view />` (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 <script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
function onKeydown(e) {
if (e.key === 'Escape' && drawerOpen.value) {
drawerOpen.value = false
}
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
```
---
### WR-02: Folder picker outside-click handler uses `.closest('.relative')` — brittle class-based selector
**File:** `frontend/src/components/storage/StorageBrowser.vue:411-418`
**Issue:** `onOutsideClick` dismisses the folder picker unless the clicked element is inside `[data-test="folder-picker"]` **or** inside any element with class `.relative`. The class `relative` is a Tailwind positioning utility applied to many elements in the DOM tree. As the surrounding layout evolves, any new `class="relative"` ancestor element anywhere on the page will silently prevent the picker from closing when users click in that area. This is not currently an active bug but is a maintenance time-bomb.
**Fix:** Replace the class selector with a dedicated data attribute on the trigger button wrapper:
```html
<!-- In the template, change line 153 -->
<div class="relative" data-picker-trigger>
```
```js
function onOutsideClick(e) {
if (
!e.target.closest('[data-test="folder-picker"]') &&
!e.target.closest('[data-picker-trigger]')
) {
folderPickerFileId.value = null
}
}
```
---
### WR-03: Folder picker flip-up condition is logically incorrect — picker clips at 120239 px remaining
**File:** `frontend/src/components/storage/StorageBrowser.vue:379`
**Issue:** The positioning logic:
```js
if (spaceBelow >= dropH || spaceBelow > 120) {
```
where `dropH = 240`. The second sub-condition `spaceBelow > 120` is a subset of `spaceBelow >= 240` only partially — when `120 < spaceBelow < 240`, the first condition is false but the second is true, so the picker still opens **below** the trigger even though only 5099% of its height fits in the viewport. The flip-above path only activates when `spaceBelow <= 120`. In practice, clicks near the bottom third of a laptop screen (where the file list ends) will show a partially clipped picker instead of flipping it upward.
**Fix:** Use a single threshold:
```js
function updatePickerPosition(rect) {
const spaceBelow = window.innerHeight - rect.bottom
const dropH = 240
if (spaceBelow >= dropH) {
pickerStyle.value = { top: `${rect.bottom + 4}px`, left: `${rect.left}px`, width: '192px' }
} else {
pickerStyle.value = { bottom: `${window.innerHeight - rect.top + 4}px`, left: `${rect.left}px`, width: '192px' }
}
}
```
---
### WR-04: No modal locks body scroll — background content scrolls behind overlay on mobile
**File:** `frontend/src/components/sharing/ShareModal.vue:4`, `frontend/src/components/folders/FolderDeleteModal.vue:4`, `frontend/src/components/documents/DocumentPreviewModal.vue:6`, `frontend/src/components/cloud/CloudCredentialModal.vue:4`
**Issue:** None of the four modals add `overflow: hidden` to `document.body` on mount. On mobile and on any scrollable page, the `fixed inset-0` overlay does not prevent the background from scrolling beneath it. `ShareModal` and `CloudCredentialModal` have their own inner `overflow-y-auto` panels, creating a double-scroll UX where a user dragging on the panel may inadvertently scroll the background page.
**Fix:** Add scroll lock in each modal's `onMounted`/`onUnmounted`. A composable avoids duplication:
```js
// src/composables/useBodyScrollLock.js
import { onMounted, onUnmounted } from 'vue'
export function useBodyScrollLock() {
onMounted(() => { document.body.style.overflow = 'hidden' })
onUnmounted(() => { document.body.style.overflow = '' })
}
```
Call `useBodyScrollLock()` in each of the four modal `<script setup>` blocks.
---
### WR-05: FolderDeleteModal fires both `emit('confirm')` and `props.onConfirm()` — dual-invocation risk
**File:** `frontend/src/components/folders/FolderDeleteModal.vue:79-87`
**Issue:** `handleConfirm()` calls `emit('confirm')` then conditionally calls `props.onConfirm()`. `handleCancel()` does the same for cancel. A parent that binds both `@confirm="deleteFolder"` and `:on-confirm="deleteFolder"` will invoke the irreversible delete operation twice. The second invocation will likely 404 after the first succeeds, but the double navigation or double toast may leave the UI in an inconsistent state. This dual-API pattern (Vue events + callback props) has no justification in the codebase; the Vue convention is emits only.
**Fix:** Remove `onConfirm` and `onCancel` props entirely. Standardize on `@confirm` / `@cancel` emits. Update all callers to use the event binding.
---
### WR-06: `handleRevoke` reads `shares.value[-1]` before the bounds check
**File:** `frontend/src/components/sharing/ShareModal.vue:210-212`
**Issue:**
```js
const removedIdx = shares.value.findIndex(s => s.id === shareId)
const removed = shares.value[removedIdx] // reads undefined when removedIdx === -1
if (removedIdx !== -1) shares.value.splice(removedIdx, 1)
```
In JavaScript, `array[-1]` is `undefined` (not the last element). When `findIndex` returns `-1`, `removed` is `undefined`. The guard on line 220 (`if (removed && removedIdx !== -1)`) prevents corruption today, but reading at a negative index and then depending on the result being falsy is fragile — if `removed` ever could be a falsy-but-valid object (e.g., `null`), the re-insert guard would silently fail. The early-return pattern is safer.
**Fix:**
```js
async function handleRevoke(shareId) {
const removedIdx = shares.value.findIndex(s => s.id === shareId)
if (removedIdx === -1) return
const removed = shares.value[removedIdx]
shares.value.splice(removedIdx, 1)
try {
await docsStore.revokeShare(shareId)
toast.show('Share revoked', 'success')
if (shares.value.length === 0) emit('unshared', props.doc.id)
} catch (e) {
shares.value.splice(removedIdx, 0, removed)
error.value = e.message || 'Failed to remove access.'
}
}
```
---
### WR-07: vite.config.js conditionalAnalyzer dynamic import throws on missing package — no user-facing guidance
**File:** `frontend/vite.config.js:7-12`
**Issue:** `conditionalAnalyzer` returns a rejected Promise when `rollup-plugin-visualizer` is not installed. The package is in `devDependencies` so it is absent in production installs. If `ANALYZE=true` is accidentally set in a production CI environment (or the package is pruned), the `await conditionalAnalyzer(env)` on line 17 will reject and abort the Vite build with an opaque `Cannot find package` error. There is also no `"analyze"` script in `package.json`; the usage is documented only in a code comment.
**Fix:** Add a try/catch to degrade gracefully:
```js
async function conditionalAnalyzer(env) {
if (env.ANALYZE !== 'true') return null
try {
const { visualizer } = await import('rollup-plugin-visualizer')
return visualizer({ open: false, filename: 'stats.html', gzipSize: true })
} catch {
console.warn('[vite] rollup-plugin-visualizer not found — skipping. Install it or unset ANALYZE.')
return null
}
}
```
Add to `package.json` scripts: `"analyze": "ANALYZE=true vite build"`.
---
## Info
### IN-01: `fetchDocumentContent` imported twice in DocumentView.vue
**File:** `frontend/src/views/DocumentView.vue:156-157`
**Issue:**
```js
import * as api from '../api/client.js'
import { fetchDocumentContent } from '../api/client.js'
```
`fetchDocumentContent` is accessible as `api.fetchDocumentContent`. The named import on line 157 is redundant and unused as a standalone — line 206 calls `fetchDocumentContent(doc.value.id)` while the `import * as api` already covers it.
**Fix:** Remove line 157 and change line 206 to `api.fetchDocumentContent(doc.value.id)`.
---
### IN-02: `confirmDelete` in DocumentView uses `window.confirm` instead of a modal
**File:** `frontend/src/views/DocumentView.vue:272`
**Issue:** `confirm(...)` is a synchronous browser-native dialog that cannot be styled, is suppressed in some embedded contexts (PWAs, iframes), and is inconsistent with the rest of the app's modal-based confirmation UX. The folder delete path uses a proper `FolderDeleteModal`; the document delete path uses `window.confirm`. This creates an inconsistent experience and is blocked in some mobile browsers.
**Fix:** Replace with a `DocumentDeleteModal` component or generalize `FolderDeleteModal` into a configurable `ConfirmDeleteModal` that accepts `title`, `body`, and confirm-button text as props.
---
### IN-03: Two `console.error` calls remain in production path in DocumentView
**File:** `frontend/src/views/DocumentView.vue:208, 223`
**Issue:** Both are in `openPdf()`. The first (line 208) silently returns on a non-2xx response with no user-facing feedback at all. The second (line 223) logs to the console on a network error without surfacing a toast. These are debug artifacts.
**Fix:** Remove `console.error`. For the `res.status` failure case, emit a toast: `toast.show('Failed to open document', 'error')`.
---
### IN-04: AppSidebar shared-with-me count has no refresh path after share/unshare
**File:** `frontend/src/components/layout/AppSidebar.vue:242-248`
**Issue:** The badge count is fetched once on mount. After `ShareModal` emits `'unshared'` (reducing the share count) or after a share is created, the sidebar badge remains stale for the session lifetime. This is a UX inconsistency rather than a correctness defect.
**Fix:** Move the shared-count state into a Pinia store (or expose a `refresh()` method on the store). `ShareModal` can call `sharedStore.refresh()` after a successful share or revoke.
---
_Reviewed: 2026-06-17T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,84 @@
---
phase: 11
slug: visual-design-responsive-layout-cleanup
status: complete
threats_open: 0
asvs_level: 1
created: 2026-06-17
---
# Phase 11 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
> Mode: retroactive-STRIDE — no formal threat model existed at authoring time; register built from implementation.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| Browser / Vue Router | Navigation guard enforces auth and role separation before any route component resolves | JWT access token (memory), user role claim |
| Browser / API | Document content fetched via authenticated API client using httpOnly cookie; rendered into sandboxed blob URL iframe | Document binary content |
| Build pipeline / Production bundle | `stats.html` bundle visualizer written only when `ANALYZE=true`; gitignored; never shipped in `dist/` | Source file names, module sizes |
| Admin layout / User layout | `AdminLayout.vue` registered exclusively on the `/admin` route subtree, which carries `requiresAdmin: true` meta | Admin-only navigation structure |
---
## Threat Register
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|-----------|----------|-----------|-------------|------------|--------|
| S-01 | Spoofing | `router/index.js` — lazy route resolution | mitigate | Vue Router 4 `beforeEach` is async and fully resolves before any component is loaded or mounted. Guard at line 93 attempts `authStore.refresh()` before allowing any non-public route. Lazy loading (Plan 11-02) does not change guard sequencing. Verified: router guard tests in `router.guard.test.js` mock all lazy components and confirm guard fires before resolution. | CLOSED |
| S-02 | Spoofing | `App.vue` — hamburger header renders before route settles | mitigate | The hamburger header is inside the `v-else` branch (line 4 of App.vue template), which only renders after `route.meta.layout !== 'auth'` AND `!route.matched.some(r => r.meta.requiresAdmin)`. An unauthenticated navigation is redirected to `/login` before the component tree mounts. Header reveals no session-identifying data; it shows only the app name. | CLOSED |
| S-03 | Spoofing | `AdminLayout.vue` — admin drawer implies admin role | mitigate | `AdminLayout.vue` is only reachable via the `/admin` route subtree (router `index.js` line 47). That route has `meta: { requiresAdmin: true }` (line 48). The guard at lines 104-110 confirms `authStore.user?.role === 'admin'` via `to.matched.some()`; non-admins are redirected to `/`. Tested in `router.guard.test.js` lines 115-135 (child route inheritance). | CLOSED |
| T-01 | Tampering | All Phase 11 templates — content injection via `v-html` | mitigate | Grep over all 10 Phase 11 implementation files finds zero uses of `v-html`, `innerHTML`, or `eval`. All user-data interpolation uses Vue template double-brace syntax (`{{ }}`), which auto-escapes. Folder names, file names, error messages, and share handles are all rendered via text interpolation only. | CLOSED |
| T-02 | Tampering | `StorageBrowser.vue` — drag-and-drop DOM manipulation | accept | The drag-and-drop implementation uses native `dataTransfer.setData('text/plain', file.id)` (line 349). The received `fileId` on drop is a UUID read from the in-memory file object, not from `dataTransfer.getData()`. No untrusted DOM string is used to construct API calls. Accepted: the drag event does not introduce a new injection surface. | CLOSED |
| R-01 | Repudiation | All Phase 11 changes | accept | Phase 11 introduces no event logging, audit trail, or backend changes. Repudiation threats are not applicable to pure CSS/layout frontend changes. No user actions introduced in this phase bypass existing backend audit logging (all document/folder mutations still go through the authenticated API). | CLOSED |
| I-01 | Information Disclosure | `StorageBrowser.vue` — CSS-hidden responsive columns | accept | The `hidden sm:block` / `hidden md:block` classes (lines 41-42, 97-98, 144-145) hide "Size" and "Modified" metadata columns on narrow viewports. These values are already rendered in the DOM and inspectable via browser DevTools. They contain file size bytes and ISO dates — no access-controlled content, PII, or credentials. All data in this component is already visible in the authenticated user's own file list. Risk accepted: CSS column hiding is a display optimization, not a security boundary. | CLOSED |
| I-02 | Information Disclosure | `vite.config.js``stats.html` bundle analyzer output | mitigate | The visualizer is conditionally activated only when `ANALYZE=true` is set (strict string equality at line 8 of `vite.config.js`). `frontend/stats.html` is listed in `.gitignore` (confirmed at line 8 of root `.gitignore`). `frontend/dist/` is also gitignored (line 6). The output file is placed in the project root (not `dist/`), so it cannot be served by the production Vite dev server or shipped in the build artifact. | CLOSED |
| I-03 | Information Disclosure | `AppSidebar.vue` — admin navigation link visible to non-admins via DOM | accept | The Admin link in `AppSidebar.vue` is conditionally rendered with `v-if="authStore.user?.role === 'admin'"` (line 169). `v-if` removes the DOM node entirely for non-admin users — it is not CSS-hidden. Even if it were CSS-hidden, the link itself only navigates to `/admin`, which the router guard blocks. No admin data or content is exposed. | CLOSED |
| I-04 | Information Disclosure | `DocumentPreviewModal.vue` — document content in iframe | mitigate | Document content is fetched via `fetchDocumentContent(docId)` using the project's authenticated API client (bearer token injected per `client.js`). The response blob is wrapped in `URL.createObjectURL()` and assigned to a sandboxed `<iframe>` as the `src`. No raw API URL with credentials is ever placed in the iframe `src`. Blob URLs are revoked on modal close (`onUnmounted`, line 131). | CLOSED |
| D-01 | Denial of Service | `router/index.js` — lazy-load chunk fetch failure | accept | If a lazy route chunk fails to load (network error, CDN outage), Vue Router raises a `NavigationFailure`. The application does not crash; the user remains on the current route. No error recovery handler was added in Phase 11 — this matches pre-existing behavior. Accepted: chunk load failure is an infrastructure concern outside frontend scope; a future phase may add a global `router.onError` handler. This is not a regression introduced by Phase 11. | CLOSED |
| D-02 | Denial of Service | `App.vue` / `AdminLayout.vue` — drawer stuck open blocking navigation | mitigate | `drawerOpen` is set to `false` in a `watch(() => route.fullPath, ...)` handler (App.vue line 64-66; AdminLayout.vue line 57-59). Any successful navigation closes the drawer. The backdrop click (`@click="drawerOpen = false"`) provides a secondary close path. The drawer is CSS `translate-x` based — it never removes underlying DOM elements, so keyboard navigation and router-links behind the drawer remain accessible. | CLOSED |
| E-01 | Elevation of Privilege | `router/index.js` + `AdminLayout.vue` — regular user reaching admin UI | mitigate | Three independent layers enforce admin-only access: (1) Router guard `beforeEach` checks `to.matched.some(r => r.meta.requiresAdmin)` and `authStore.user?.role === 'admin'` before allowing navigation (lines 104-110). (2) `AdminLayout.vue` is only registered as the component for the `/admin` route subtree. (3) `AppSidebar.vue` renders the Admin link only when `role === 'admin'` (v-if removes it from DOM). Privilege elevation via the admin drawer requires passing the router guard, which verifies the role claim from the server-issued JWT. | CLOSED |
| E-02 | Elevation of Privilege | `App.vue` — admin user accessing regular user drawer | accept | The router guard (lines 112-115) redirects admin users away from non-admin, non-public routes to `/admin`. If an admin reaches the regular layout branch (e.g. visiting `/` directly), the `v-else` branch of `App.vue` renders `AppSidebar`, which includes an Admin link. This is the intended behavior — admins are separated by redirect, not by blocking the regular layout entirely. Accepted: the regular layout is not a security boundary for admins; the admin role check in the guard is. | CLOSED |
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-01 | T-02 | Drag-and-drop uses in-memory object IDs, not DOM-sourced strings; no injection surface created | security-auditor | 2026-06-17 |
| AR-02 | R-01 | Phase 11 is pure frontend visual changes; no new user actions bypass backend audit logging | security-auditor | 2026-06-17 |
| AR-03 | I-01 | CSS-hidden columns contain file size and date only — non-sensitive metadata already visible in the authenticated session | security-auditor | 2026-06-17 |
| AR-04 | I-03 | Admin nav link is v-if removed from DOM for non-admins; router guard is the authoritative enforcement layer | security-auditor | 2026-06-17 |
| AR-05 | D-01 | Lazy chunk load failure is a pre-existing infrastructure risk; not a regression; deferred to a future hardening phase | security-auditor | 2026-06-17 |
| AR-06 | E-02 | Admin users reaching the regular layout see the Admin sidebar link (correct behavior); redirect is the separation mechanism, not layout blocking | security-auditor | 2026-06-17 |
---
## Audit Trail
### Security Audit 2026-06-17
| Metric | Count |
|--------|-------|
| Threats found | 13 |
| Closed | 13 |
| Open | 0 |
| Mode | retroactive-STRIDE |
| ASVS level | 1 (baseline) |
| Block on | HIGH severity unmitigated threats |
| Files inspected | 10 implementation files + 2 test files + 3 plan summaries |
**Key findings:**
- No `v-html` usage anywhere in Phase 11 modified files — XSS via template injection is not possible.
- Auth guard in Vue Router 4 runs before lazy-loaded component resolution — lazy loading (Plan 11-02) does not create a window where admin routes are accessible without role verification.
- The `stats.html` bundle analyzer output is correctly gitignored and never included in `dist/`; it cannot be served in production.
- CSS-hidden responsive columns (`hidden sm:block`) contain only file size and date metadata — no access-controlled content or credentials.
- `AdminLayout.vue` is exclusively registered on the `/admin` route subtree; the guard enforces `requiresAdmin` via `to.matched.some()` which propagates to all child routes without requiring per-child meta re-declaration.
- `DocumentPreviewModal.vue` uses authenticated blob URLs for iframe content — no credential leakage via iframe `src`.
- Drawer state cannot block navigation: route-change watcher auto-closes it, and the CSS translate approach keeps underlying DOM accessible.
@@ -0,0 +1,131 @@
---
status: resolved
phase: 11-visual-design-responsive-layout-cleanup
source:
- 11-01-SUMMARY.md
- 11-02-SUMMARY.md
- 11-03-SUMMARY.md
- 11-04-SUMMARY.md
- 11-05-SUMMARY.md
- 11-06-SUMMARY.md
- 11-07-SUMMARY.md
started: 2026-06-17T08:10:53Z
updated: 2026-06-17T11:20:00Z
resolved_by: 11-07-PLAN.md
---
## Current Test
[testing complete]
## Tests
### 1. Mobile Sidebar Drawer
expected: At a 375px-wide viewport, the normal sidebar is hidden and a mobile header with a hamburger button is visible. Tapping the hamburger opens a slide-in sidebar drawer over the page; tapping the backdrop or navigating to a sidebar link closes it.
result: pass
### 2. Mobile Storage Rows and Touch Targets
expected: At a 375px-wide viewport, the document/folder list shows icon, name, and actions only. Size and modified-date columns are hidden, names truncate instead of overflowing, and row action buttons are large enough to tap comfortably.
result: pass
previous_result: issue
reported: "I cannot view the actions as they are only a hover-over action. This needs to be changes and they need to be always visible. If I am on a touchscreen I need to imagine where the buttons are and could accidentally delete files. Furthermore the search field, sorting field and new folder button are not as dynamic. They are fully visible at a 550 px-wide viewport but lower it shift out of the window on the right site."
severity: major
resolution: "Plan 11-07 removed hover-only opacity from folder/file action containers, widened the mobile actions column, and added regression tests asserting row actions do not contain `opacity-0` or `group-hover:opacity-100`."
evidence:
- "frontend/src/components/storage/StorageBrowser.vue: `data-test=\"folder-row-actions\"` and `data-test=\"file-row-actions\"` action containers are visible by default."
- "frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js: action visibility tests pass."
### 3. Mobile-Safe Modals and Forms
expected: Share, cloud credential, folder delete, and document preview modals fit within a small viewport. Modal content scrolls when needed, form controls look consistent, and fields/buttons remain reachable without horizontal scrolling.
result: pass
previous_result: issue
reported: "The mobile navigation is a bit difficult. The file browser is not scollable. I need to scroll sideways to access the sorting and new file buttons. Herre the searchfield, sorting and new file should morph into a icons for space savings."
severity: major
resolution: "Plan 11-07 split the StorageBrowser toolbar into full controls at `sm+` and compact icon controls below `sm`; mobile search opens a full-width row, sort opens an in-viewport panel, and new-folder is an icon button."
evidence:
- "StorageBrowser mobile toolbar exposes accessible Search, Sort, and New folder icon buttons."
- "SearchBar now uses `w-full sm:w-56` so expanded mobile search fits its container."
- "Regression tests cover mobile toolbar visibility, search toggle, sort panel emission, and new-folder emission."
### 4. Visual Consistency and Keyboard Focus
expected: Page headings, panel headings, sidebar rows, buttons, and interactive links use consistent typography and spacing. Hover states are distinct, and keyboard tabbing shows visible focus rings on buttons, links, and sidebar tree controls.
result: pass
### 5. Navigation and Lazy-Loaded Pages
expected: Navigating to Topics, Document detail, Settings, Cloud storage, Shared, and Admin pages still works normally. Pages load without a blank-screen failure, auth/admin redirects behave as expected, and returning to the main file manager remains fast.
result: pass
### 6. Bundle and Dead-Code Cleanup Smoke Check
expected: The app production build succeeds, the baseline and final bundle reports exist in `.planning/perf/`, and removed dead-code views/components do not appear anywhere in the UI.
result: pass
## Summary
total: 6
passed: 6
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
[resolved]
- truth: "At a 375px-wide viewport, row actions are visible and safe to tap without relying on hover."
status: resolved
reason: "User reported: actions are only hover-over, which makes them invisible on touchscreen and can lead to accidental deletes."
severity: major
test: 2
root_cause: "StorageBrowser row action containers are hidden by default with `opacity-0 group-hover:opacity-100`, so touch devices have no reliable visible target. The buttons have 36px touch classes, but their parent remains invisible until hover."
artifacts:
- path: "frontend/src/components/storage/StorageBrowser.vue"
issue: "Folder and file action containers use `opacity-0 group-hover:opacity-100 transition-opacity`."
missing:
- "Make row action controls visible by default, especially below `md`; do not rely on hover to reveal destructive actions."
- "Add regression tests asserting action containers are visible without hover and buttons remain 36px minimum on mobile."
fixed_by: "11-07-PLAN.md"
verification: "StorageBrowser.skeleton.test.js asserts `file-row-actions` and `folder-row-actions` do not contain hover-only opacity classes."
debug_session: "inline-uat-diagnosis-2026-06-17"
- truth: "Below 550px, the search field, sorting field, and new-folder button fit within the viewport without shifting off the right side."
status: resolved
reason: "User reported: search, sorting, and new-folder controls are visible at 550px but shift out of the window on the right side below that width."
severity: major
test: 2
root_cause: "StorageBrowser places BreadcrumbBar plus a right-aligned `ml-auto flex shrink-0` toolbar in one flex row. The toolbar contains a fixed-width `SearchBar` (`w-56`), text sort buttons, and a full text `New folder` button, so the cluster cannot shrink below narrow mobile widths."
artifacts:
- path: "frontend/src/components/storage/StorageBrowser.vue"
issue: "Toolbar cluster uses `ml-auto flex items-center gap-2 shrink-0` and keeps full controls visible at all widths."
- path: "frontend/src/components/documents/SearchBar.vue"
issue: "Search input uses fixed `w-56` width."
- path: "frontend/src/components/documents/SortControls.vue"
issue: "Sort controls render three text buttons with active arrow text, with no compact mobile mode."
missing:
- "Introduce mobile-specific compact icon controls for search, sort, and new-folder below `sm` or the verified breakpoint."
- "Allow expanded mobile search/sort UI to occupy its own full-width row or popover instead of pushing off-screen."
- "Add responsive tests for toolbar behavior below 550px."
fixed_by: "11-07-PLAN.md"
verification: "Mobile toolbar tests cover icon-only controls, full-width search row, sort panel, and new-folder event emission."
debug_session: "inline-uat-diagnosis-2026-06-17"
- truth: "On mobile, file-browser toolbar navigation does not require sideways scrolling; search, sort, and new-folder controls collapse into compact icon controls when space is tight."
status: resolved
reason: "User reported: mobile navigation is difficult, the file browser is not scrollable, and they need sideways scrolling to access sorting and new-file buttons; search, sorting, and new-file should morph into icons for space savings."
severity: major
test: 3
root_cause: "Same responsive toolbar constraint as Test 2: full text controls are always rendered in the sticky header, and the list container does not provide a usable alternative when the toolbar overflows horizontally."
artifacts:
- path: "frontend/src/components/storage/StorageBrowser.vue"
issue: "No mobile compact toolbar state or icon-only controls exist."
missing:
- "Collapse search/sort/new-folder into icon buttons on mobile."
- "Expose search and sort through accessible toggle panels or menus that fit within viewport width."
- "Verify file browser remains vertically scrollable and never requires horizontal scroll for primary controls."
fixed_by: "11-07-PLAN.md"
verification: "`cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` passed with 22 tests after the fix."
debug_session: "inline-uat-diagnosis-2026-06-17"
## Resolution Evidence
- `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` — pass: 1 file, 22 tests.
- `cd frontend && npm run test -- --run` — pass: 36 files, 277 tests.
- `cd frontend && npm run build` — pass: production build succeeded; existing `auth.js` mixed static/dynamic import warning remains.
@@ -0,0 +1,127 @@
---
phase: 11
slug: visual-design-responsive-layout-cleanup
status: complete
nyquist_compliant: true
wave_0_complete: true
created: 2026-06-17
updated: 2026-06-17
---
# Phase 11 - Validation Strategy
> Retroactive Nyquist validation for Phase 11: Visual Design, Responsive Layout & Cleanup.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| Framework | Vitest 4.1.7 + Vue Test Utils + happy-dom |
| Config file | `frontend/vitest.config.js` |
| Quick run command | `cd frontend && npm run test -- --run src/components/ui/__tests__/TreeItem.test.js` |
| Full suite command | `cd frontend && npm run test -- --run` |
| Build gate | `cd frontend && npm run build` |
| Estimated runtime | ~4 seconds for full Vitest, ~2 seconds for build |
---
## Sampling Rate
- After every Phase 11 task commit: run the focused component/router test touched by the task.
- After every Phase 11 plan: run `cd frontend && npm run test -- --run`.
- Before `/gsd:verify-work`: run `cd frontend && npm run test -- --run` and `cd frontend && npm run build`.
- Max feedback latency: under 10 seconds locally for the automated frontend gate.
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 11-01-01 | 11-01 | 1 | PERF-02 | - | Analyzer is opt-in and normal builds do not emit `stats.html` | build/static | `cd frontend && npm run build`; `cd frontend && ANALYZE=true npm run build` | yes: `.planning/perf/phase11-baseline.html`, `.planning/perf/phase11-baseline-summary.md` | green |
| 11-02-01 | 11-02 | 1 | PERF-03 | S-01 | Lazy route components preserve auth/admin guard behavior | unit/integration | `cd frontend && npm run test -- --run src/router/__tests__/router.guard.test.js` | yes | green |
| 11-03-01 | 11-03 | 1 | RESP-01 | - | User drawer opens, closes by backdrop, and closes on route change | unit | `cd frontend && npm run test -- --run src/__tests__/keyboard.test.js` | yes | green |
| 11-03-02 | 11-03 | 1 | RESP-02 | - | Storage list uses mobile-first columns; size hidden below `md`, modified hidden below `sm` | unit | `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` | yes | green |
| 11-03-03 | 11-03 | 1 | RESP-03 | - | Storage row action buttons expose 36px mobile touch target classes | unit | `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` | yes | green |
| 11-07-01 | 11-07 | 1 | RESP-02, RESP-03, VISUAL-03 | - | Storage row actions are visible without hover and mobile toolbar collapses search/sort/new-folder into icon controls | unit | `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` | yes | green |
| 11-03-04 | 11-03 | 1 | RESP-05 | - | Admin drawer mirrors the user drawer pattern | unit | `cd frontend && npm run test -- --run src/__tests__/keyboard.test.js` | yes | green |
| 11-04-01 | 11-04 | 1 | VISUAL-02 | T-01 | Forms keep normalized focus classes and rely on active Tailwind forms plugin | unit/static | `cd frontend && npm run test -- --run src/components/sharing/__tests__/ShareModal.mobile.test.js src/components/cloud/__tests__/CloudCredentialModal.mobile.test.js` | yes | green |
| 11-04-02 | 11-04 | 1 | RESP-04 | - | Modals fit small viewports with scrollable panels or responsive full-screen header | unit | `cd frontend && npm run test -- --run src/components/sharing/__tests__/ShareModal.mobile.test.js src/components/cloud/__tests__/CloudCredentialModal.mobile.test.js src/components/folders/__tests__/FolderDeleteModal.mobile.test.js src/components/documents/__tests__/DocumentPreviewModal.mobile.test.js` | yes | green |
| 11-05-01 | 11-05 | 1 | VISUAL-01 | - | Skeleton widths use Tailwind width classes, not inline width styles | unit/static | `cd frontend && npm run test -- --run src/components/layout/__tests__/AppSidebar.visual.test.js src/components/ui/__tests__/TreeItem.test.js` | yes | green |
| 11-05-02 | 11-05 | 1 | VISUAL-03 | T-01 | TreeItem interactive elements expose focus-visible rings; sidebar controls retain focus rings | unit/static | `cd frontend && npm run test -- --run src/components/layout/__tests__/AppSidebar.visual.test.js src/components/ui/__tests__/TreeItem.test.js` | yes | green |
| 11-05-03 | 11-05 | 1 | VISUAL-04 | - | Page and panel headings use normalized font size/weight classes | unit/static | `cd frontend && npm run test -- --run src/views/__tests__/typography.visual.test.js` | yes | green |
| 11-06-01 | 11-06 | 1 | CODE-07 | - | Confirmed dead route/components removed and stale tests deleted | static/test | `find frontend/src -name 'AccountView.vue' -o -name 'FolderRow.vue' -o -name 'HomeView.vue' -o -name 'FolderView.vue' -o -name 'AdminView.vue'`; `cd frontend && npm run test -- --run` | yes | green |
| 11-06-02 | 11-06 | 1 | PERF-02 | - | Final bundle report exists and compares baseline to final chunks | build/static | `cd frontend && npm run build`; `cd frontend && ANALYZE=true npm run build` | yes: `.planning/perf/phase11-final.html`, `.planning/perf/phase11-final-summary.md` | green |
---
## Wave 0 Requirements
Existing infrastructure covered the phase requirements. No Wave 0 framework installation was needed.
Retroactive validation added direct TreeItem assertions in `frontend/src/components/ui/__tests__/TreeItem.test.js` for:
- `VISUAL-01`: loading skeleton labels use `w-12`, `w-16`, `w-20` and do not set inline width.
- `VISUAL-03`: expand button, selectable row button, and router-link row include `focus-visible` ring classes.
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Cross-browser form rendering | VISUAL-02 | Vitest can assert classes and plugin wiring, but cannot prove Chrome/Firefox/Safari native control rendering is visually identical. | Open ShareModal, CloudCredentialModal, and SettingsAccountTab in Chrome, Firefox, and Safari. Inspect inputs, selects, textareas, checkboxes, and focus states for consistent appearance. |
| Focus ring visibility at 375px | VISUAL-03, RESP-01, RESP-05 | DOM tests assert focus classes and drawer state, but visual focus contrast and clipping require browser rendering. | In a 375x667 viewport, tab through hamburger buttons, sidebar links, StorageBrowser actions, and TreeItem expand/label controls. Confirm focus rings are visible and not clipped. |
| Touch target usability at 375px | RESP-03 | Tests assert `min-w-[36px] min-h-[36px]` and 11-07 makes action controls always visible; actual tap comfort is still best checked on a physical device. | At 375px width, tap Rename, Delete, Share, and Move icon buttons in document/folder rows. Confirm each tap registers without precision targeting. |
---
## Validation Audit 2026-06-17
| Metric | Count |
|--------|-------|
| Requirements mapped | 12 |
| Automated coverage green | 12 |
| Automated gaps found | 1 |
| Automated gaps resolved | 1 |
| Manual-only checks remaining | 3 |
### Commands Run
| Command | Result |
|---------|--------|
| `cd frontend && npm run test -- --run src/components/ui/__tests__/TreeItem.test.js` | pass: 1 file, 6 tests |
| `cd frontend && npm run test -- --run` | pass: 36 files, 271 tests |
| `cd frontend && npm run build` | pass: production build succeeded; existing `auth.js` mixed static/dynamic import warning remains |
## Validation Audit 2026-06-17 — Plan 11-07 Gap Closure
| Metric | Count |
|--------|-------|
| UAT gaps found | 3 |
| UAT gaps resolved | 3 |
| New responsive toolbar/action tests | 5 |
| Requirements newly re-sampled | 3 |
### Commands Run
| Command | Result |
|---------|--------|
| `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` | pass: 1 file, 22 tests |
| `cd frontend && npm run test -- --run` | pass: 36 files, 277 tests |
| `cd frontend && npm run build` | pass: production build succeeded; existing `auth.js` mixed static/dynamic import warning remains |
---
## Validation Sign-Off
- [x] All tasks have automated verify commands or documented build/static evidence.
- [x] Sampling continuity: no 3 consecutive Phase 11 tasks lack automated verification.
- [x] Wave 0 not required; existing Vitest infrastructure covers frontend validation.
- [x] No watch-mode flags used.
- [x] Feedback latency is under 10 seconds for the full local frontend gate.
- [x] `nyquist_compliant: true` set in frontmatter.
**Approval:** complete 2026-06-17. Automated Phase 11 coverage is green, and the Phase 11 UAT mobile gaps are resolved by plan 11-07 with regression coverage.
@@ -0,0 +1,191 @@
---
phase: 11-visual-design-responsive-layout-cleanup
verified: 2026-06-17T09:45:00Z
status: passed
score: 12/12 must-haves verified
overrides_applied: 0
re_verification:
previous_status: gaps_found
previous_score: 10/12
gaps_closed:
- "Every button, card row, and interactive link has a visible focus-visible: ring (SC #3 / VISUAL-03) — focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 added to expand/collapse button (line 12), router-link label row (line 27), and button label row (line 38) in TreeItem.vue"
- "No arbitrary pixel value or inline style appears in any template (SC #4 / VISUAL-01) — :style skeleton width replaced with :class n===1?'w-12':n===2?'w-16':'w-20' on line 54 of TreeItem.vue"
gaps_remaining: []
regressions: []
uat_gap_closure:
verified: 2026-06-17T11:20:00Z
plan: 11-07
status: passed
gaps_closed:
- "Row actions are no longer hover-only; folder/file action containers are visible by default."
- "Mobile search/sort/new-folder controls collapse into icon buttons below sm."
- "Expanded mobile search/sort controls fit inside full-width rows/panels instead of pushing the toolbar off-screen."
tests:
- "cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js — pass: 1 file, 22 tests"
- "cd frontend && npm run test -- --run — pass: 36 files, 277 tests"
- "cd frontend && npm run build — pass"
---
# Phase 11: Visual Design, Responsive Layout & Cleanup — Verification Report
**Phase Goal:** Make the DocuVault frontend visually consistent, responsive across breakpoints, and free of dead code — without regressions to existing functionality.
**Verified:** 2026-06-17T09:45:00Z
**Status:** passed
**Re-verification:** Yes — after gap closure on main (two TreeItem.vue fixes)
---
## Goal Achievement
### Observable Truths (ROADMAP Success Criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| SC-1 | At 375px: sidebar hidden, hamburger opens drawer, doc list shows icon/name/actions only, 36px touch targets | VERIFIED | App.vue: lg:hidden header, translate-x drawer, Teleport backdrop. StorageBrowser: grid-cols-[2rem_minmax(0,1fr)_7rem] base, hidden md/sm columns, always-visible action containers, min-w-[36px] min-h-[36px] buttons |
| SC-2 | All form inputs/selects/textareas/checkboxes have consistent cross-browser styling | VERIFIED | tailwind.config.js confirms `import forms from '@tailwindcss/forms'` + `plugins: [forms]`; consistent focus:ring-2 focus:outline-none pattern throughout; ShareModal.mobile.test.js verifies |
| SC-3 | Every button, card row, interactive link has focus-visible: ring and distinct hover state | VERIFIED | TreeItem.vue line 12: expand/collapse button has focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1; line 27: router-link label row has same; line 38: button label row has same. 62+ focus-visible: occurrences across src/ |
| SC-4 | No arbitrary pixel value (px-[13px], style="margin: 5px") in any template | VERIFIED | TreeItem.vue line 54 now uses :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'". Remaining :style attributes (lines 6, 50, 60, 68) are data-driven tree indentation (paddingLeft: depth * 12) — explicitly categorised as "should remain" in Phase 11 Research document (line 63) |
| SC-5 | Two bundle reports committed; all non-initial routes lazy-loaded as separate chunks | VERIFIED | .planning/perf/ contains phase11-baseline.html, phase11-baseline-summary.md, phase11-final.html, phase11-final-summary.md. router/index.js: only FileManagerView synchronous; 20 other routes lazy via () => import(). Current Vite 8 build emits 37 JS chunks |
**Score:** 12/12 truths verified
---
### Re-Verification: Gaps Closed
Both gaps from the initial verification (2026-06-17T09:30:00Z) were closed by direct edits to `frontend/src/components/ui/TreeItem.vue` on main:
**Gap 1 — Focus-visible rings (SC-3/VISUAL-03): CLOSED**
- Expand/collapse `<button>` (line 12): `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded` confirmed present.
- `<router-link>` label row (line 27): same focus-visible pattern confirmed present.
- `<button>` label row (line 38): same focus-visible pattern confirmed present.
- `grep -n "focus-visible" TreeItem.vue` returns 3 matches (lines 12, 27, 38).
**Gap 2 — Inline skeleton width (SC-4/VISUAL-01): CLOSED**
- Line 54: `:class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"` confirmed present; no `:style` on that element.
- Remaining `:style` attributes (lines 6, 50, 60, 68) are all `paddingLeft: depth * 12` — data-driven tree indentation, not decorative fixed widths. The Phase 11 Research document explicitly exempts tree indentation from SC-4.
**Regression check:** 277/277 Vitest tests pass after the fixes and Vite 8 security remediation (run 2026-06-17T12:40:00+02:00, 36 files, 2.63s).
---
### Requirement-Level Verification
| Req | Plan | Status | Evidence / Gap |
|-----|------|--------|---------------|
| VISUAL-01 | 11-05 | VERIFIED | TreeItem.vue skeleton width converted to :class w-12/w-16/w-20 (line 54); AppSidebar.vue fix unchanged; no remaining decorative inline widths |
| VISUAL-02 | 11-04 | VERIFIED | @tailwindcss/forms active in tailwind.config.js; consistent focus:ring-2 pattern; ShareModal + CloudCredentialModal tests confirm |
| VISUAL-03 | 11-05 | VERIFIED | TreeItem.vue expand/collapse button (line 12), router-link (line 27), button (line 38) all have focus-visible:ring-2 indigo-500 |
| VISUAL-04 | 11-05 | VERIFIED | Typography normalized: text-2xl font-semibold (page titles), text-lg font-semibold (section titles), text-sm font-semibold (panel headings); typography.visual.test.js confirms |
| RESP-01 | 11-03 | VERIFIED | App.vue: drawerOpen ref, lg:hidden header with hamburger, Teleport backdrop, translate-x-0/-translate-x-full transition, route.fullPath watch closes drawer |
| RESP-02 | 11-03, 11-07 | VERIFIED | StorageBrowser.vue: responsive `grid-cols-[2rem_minmax(0,1fr)_7rem]` base, `sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem]`, `md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem]`; hidden md:block size col, hidden sm:block date col |
| RESP-03 | 11-03, 11-07 | VERIFIED | All StorageBrowser action buttons: min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0; action containers are visible by default with no `opacity-0` or `group-hover:opacity-100`; confirmed in StorageBrowser.skeleton.test.js |
| RESP-04 | 11-04 | VERIFIED | ShareModal, CloudCredentialModal, FolderDeleteModal all have max-h-[90vh] overflow-y-auto on panel; DocumentPreviewModal preserves full-screen with responsive header px-4 sm:px-6 |
| RESP-05 | 11-03 | VERIFIED | AdminLayout.vue: mirrors App.vue pattern exactly — drawerOpen ref, lg:hidden admin header, Teleport backdrop, same transition classes |
| CODE-07 | 11-06 | VERIFIED | AccountView.vue deleted (commit a8e0a19); FolderRow.vue deleted (commit a928b54); HomeView.vue, FolderView.vue, AdminView.vue confirmed absent; no unreferenced imports found |
| PERF-02 | 11-01/06 | VERIFIED | .planning/perf/: phase11-baseline.html, phase11-baseline-summary.md (commit 6d56d25), phase11-final.html (commit 888d376), phase11-final-summary.md (commit df981fb) |
| PERF-03 | 11-02 | VERIFIED | router/index.js: only `import FileManagerView` is synchronous; 5 user routes + all auth + admin + SharedView lazy-loaded via () => import(). Current Vite 8 build: 37 JS chunks vs 15 baseline |
---
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `frontend/src/router/index.js` | Only FileManagerView synchronous | VERIFIED | Confirmed by grep — only 1 static import |
| `frontend/src/App.vue` | Hamburger + drawer | VERIFIED | lg:hidden header, Teleport backdrop, translate-x transition, drawerOpen ref |
| `frontend/src/layouts/AdminLayout.vue` | Admin hamburger + drawer | VERIFIED | Mirrors App.vue pattern, data-test="admin-hamburger-btn" |
| `frontend/src/components/storage/StorageBrowser.vue` | Responsive grid + touch targets + compact mobile toolbar | VERIFIED | responsive `minmax` grid variants, always-visible action containers, min-w-[36px] action buttons, `mobile-toolbar`, `mobile-search-row`, and `mobile-sort-panel` |
| `frontend/src/components/sharing/ShareModal.vue` | max-h-[90vh] overflow-y-auto | VERIFIED | Line 13 confirmed |
| `frontend/src/components/cloud/CloudCredentialModal.vue` | max-h-[90vh] overflow-y-auto | VERIFIED | Line 8 confirmed |
| `frontend/src/components/folders/FolderDeleteModal.vue` | max-h-[90vh] overflow-y-auto | VERIFIED | Line 13 confirmed |
| `frontend/src/components/ui/TreeItem.vue` | No inline skeleton widths; focus-visible on all interactive elements | VERIFIED | Line 54: :class w-12/w-16/w-20; lines 12/27/38: focus-visible:ring-2 indigo-500 on all three interactive elements |
| `frontend/tailwind.config.js` | @tailwindcss/forms active | VERIFIED | Lines 1 and 11 confirmed |
| `.planning/perf/phase11-baseline.html` | Exists | VERIFIED | Present |
| `.planning/perf/phase11-baseline-summary.md` | Exists | VERIFIED | Present |
| `.planning/perf/phase11-final.html` | Exists | VERIFIED | Present |
| `.planning/perf/phase11-final-summary.md` | Exists | VERIFIED | Present |
| `frontend/src/views/AccountView.vue` | Deleted | VERIFIED | File absent |
| `frontend/src/components/folders/FolderRow.vue` | Deleted | VERIFIED | File absent |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| App.vue | AppSidebar | drawerOpen prop / :class binding | VERIFIED | :class="drawerOpen ? 'translate-x-0' : '-translate-x-full'" on sidebar wrapper |
| App.vue | route | watch(() => route.fullPath) | VERIFIED | Drawer closes on navigation |
| AdminLayout.vue | AdminSidebar | same drawer pattern | VERIFIED | Identical pattern, data-test="admin-sidebar-wrapper" |
| router/index.js | 5 view chunks | () => import() | VERIFIED | 21 JS chunks in build output |
| StorageBrowser | responsive columns | hidden md:block / hidden sm:block | VERIFIED | Lines 41-42 plus data rows |
| StorageBrowser | mobile toolbar | icon buttons below `sm`; full controls at `sm+` | VERIFIED | Search toggles a full-width row, Sort toggles an in-viewport panel, New folder emits the existing event |
---
### Data-Flow Trace (Level 4)
Not applicable — this phase covers presentation-layer classes only. No new data sources introduced.
---
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Test suite passes 277/277 | `npm run test -- --run` | 36 files, 277 tests pass (re-verified post-fix and post-Vite-8 bump) | PASS |
| Plan 11-07 storage regression tests pass | `npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` | 1 file, 22 tests pass | PASS |
| Full suite passes after plan 11-07 | `npm run test -- --run` | 36 files, 277 tests pass | PASS |
| Production build succeeds | `npm run build` | Vite 8.0.16 build succeeds; main entry chunk 30.31 kB raw / 9.59 kB gzip; 37 JS chunks | PASS |
| Only FileManagerView is synchronous | `grep "import .*View" frontend/src/router/index.js` | Returns only FileManagerView | PASS |
| Modals have max-h-[90vh] | grep on ShareModal, CloudCredentialModal, FolderDeleteModal | All confirmed | PASS |
| TreeItem.vue skeleton uses Tailwind classes | `grep ":class.*w-12" TreeItem.vue` | Line 54 confirmed | PASS |
| TreeItem.vue all interactive elements have focus-visible | `grep -n "focus-visible" TreeItem.vue` | Lines 12, 27, 38 — 3 matches | PASS |
| No `:style` on skeleton div in TreeItem.vue | `grep ":style=" TreeItem.vue` | Lines 6, 50, 60, 68 only — all are paddingLeft depth indentation (exempted by research doc) | PASS |
---
### Anti-Patterns Found
No blockers or warnings in any file after gap closure.
No TBD/FIXME/XXX/TODO debt markers found in any modified or live source file.
The four remaining `:style` attributes in TreeItem.vue (lines 6, 50, 60, 68) are data-driven tree indentation (`paddingLeft: depth * 12`) explicitly categorised as "should remain" by the Phase 11 Research document. They are not decorative fixed values and do not violate SC-4.
---
### UAT Gap Closure: Plan 11-07
The mobile storage UAT failures recorded in `11-UAT.md` are closed:
- Row actions are visible without hover on touch devices.
- File/folder action containers no longer include `opacity-0` or `group-hover:opacity-100`.
- The mobile toolbar uses icon controls below `sm`.
- Expanded mobile search and sort controls render in full-width rows/panels instead of forcing horizontal scroll.
- `SearchBar.vue` uses `w-full sm:w-56`, preserving desktop width while allowing mobile shrinkage.
Regression evidence:
- `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js` — 22 passed.
- `cd frontend && npm run test -- --run` — 277 passed.
- `cd frontend && npm run build` — passed.
---
### Gaps Summary
No gaps remain. Both blockers from the initial verification pass are closed.
All 12 requirements (VISUAL-01 through VISUAL-04, RESP-01 through RESP-05, CODE-07, PERF-02, PERF-03) are satisfied by codebase evidence. The test suite passes at 277/277 with no regressions.
Phase 11 goal is achieved. Status is `passed`; the previously human-reported mobile storage issues are resolved by plan 11-07 and covered by regression tests.
---
_Initial verification: 2026-06-17T09:30:00Z_
_Re-verification: 2026-06-17T09:45:00Z_
_Mobile UAT gap closure: 2026-06-17T11:20:00Z_
_Verifier: Claude (gsd-verifier)_
+103
View File
@@ -0,0 +1,103 @@
---
milestone: v0.2
name: Phases
status: passed
audited_at: 2026-06-17
remediated_at: 2026-06-17
phase_count: 4
completed_phases: 4
requirements_total: 40
requirements_satisfied: 40
requirements_partial: 0
requirements_missing: 0
nyquist_compliant_phases: 4
nyquist_partial_phases: 0
integration_check:
status: passed
mode: inline_fallback
note: "gsd-integration-checker spawn failed in Codex runtime with child model resolution error; integration was checked inline from phase artifacts and source wiring."
blocking_gaps: []
---
# v0.2 Milestone Audit
## Verdict
Milestone v0.2 now has complete gate evidence across all four phases. The original audit found missing or stale closeout artifacts; those gaps were remediated on 2026-06-17.
Recommended route: archive/complete the milestone when ready.
## Audit Method
The workflow integration-checker subagent could not be spawned in this Codex runtime because child model resolution failed. The integration step was completed inline by cross-checking phase summaries, verification files, validation/security artifacts, requirements traceability, and source wiring.
## Phase Gate Summary
| Phase | Verification | Validation | UAT | Security | Audit result |
|---|---:|---:|---:|---:|---|
| 08 Stack Upgrade / Backend Decomposition | Passed, 6/6 | Complete | Complete | Verified | Complete |
| 09 Admin Panel Rearchitecture | Human needed, 5/5 | Complete | Complete | Verified | Acceptable with ADMIN-09 decision noted |
| 10 UX Interaction | Passed, 15/15 | Complete | Resolved | Verified | Complete |
| 11 Visual / Responsive Cleanup | Passed, 12/12 | Complete | Resolved | Complete | Complete |
## Blocking Gaps
All blocking gaps from the initial audit are resolved.
## Remediation Completed
1. Phase 08 verification was reconstructed in `08-VERIFICATION.md`.
2. Phase 10 security gate was reconstructed in `10-SECURITY.md`.
3. Phase 11 post-11-07 UAT/validation/verification closure was recorded in `11-UAT.md`, `11-VALIDATION.md`, and `11-VERIFICATION.md`.
4. `REQUIREMENTS.md` traceability was updated for all completed v0.2 requirements.
5. ADMIN-09 was aligned with the accepted Phase 09 D-06 decision: admin accounts are administration-only and no "Back to app" link is rendered.
6. `npm audit --audit-level=high` high-severity esbuild finding was closed by upgrading frontend Vite to `^8.0.16`.
## Requirement Coverage
| Phase | Requirements | Satisfied | Partial | Notes |
|---|---:|---:|---:|---|
| 08 | 6 | 6 | 0 | `08-VERIFICATION.md` now exists and verifies all Phase 8 v0.2 requirements |
| 09 | 7 | 7 | 0 | ADMIN-09 text now matches accepted D-06 admin-only decision |
| 10 | 15 | 15 | 0 | Verification passed; requirements traceability updated |
| 11 | 12 | 12 | 0 | Plan 11-07 mobile UAT closure is reflected in UAT, validation, and verification artifacts |
Strict audit score: 40/40 requirements satisfied, 0 partial, 0 missing.
## Integration Findings
The milestone's cross-phase wiring appears coherent:
- Phase 08 backend decomposition preserved route/module behavior according to summaries and green tests.
- Phase 08 frontend client barrel exports avoided consumer churn.
- Phase 09 admin routing uses the admin layout and matched-route guard pattern.
- Phase 10 shared UX components feed into Phase 11 responsive cleanup.
- `StorageBrowser.vue` remains the single shared file browser used by local and cloud file views.
Previously identified integration risks are resolved:
- Phase 08 now has the canonical verification artifact.
- Phase 10 now has the canonical security artifact.
- Phase 11 post-fix evidence loop is closed after plan 11-07.
- ADMIN-09 is explicitly aligned with decision D-06.
## Nyquist Review
| Phase | Nyquist status | Evidence |
|---|---|---|
| 08 | Compliant | `08-VALIDATION.md` marks `nyquist_compliant: true` |
| 09 | Compliant | `09-VALIDATION.md` marks `nyquist_compliant: true` |
| 10 | Compliant | `10-VALIDATION.md` marks `nyquist_compliant: true` |
| 11 | Compliant | `11-VALIDATION.md` marks `nyquist_compliant: true` after plan 11-07 closure |
## Remediation Checklist
1. [x] Run or reconstruct Phase 08 verification and create `08-VERIFICATION.md`.
2. [x] Run the Phase 10 security gate and create `10-SECURITY.md`.
3. [x] Re-run Phase 11 UAT/validation after plan 11-07 and update `11-UAT.md`, `11-VALIDATION.md`, and `11-VERIFICATION.md` as needed.
4. [x] Update `REQUIREMENTS.md` traceability once the above artifacts exist.
5. [x] Decide whether ADMIN-09 should remain a documented D-06 override or be edited to remove the "Back to app" requirement.
## Archive Decision
Milestone v0.2 is ready for archival/closeout from this audit's perspective.
+4 -2
View File
@@ -1,6 +1,6 @@
# DocuVault # DocuVault
**Version 0.1.0 — Alpha** **Version 0.1.4 — Alpha**
> **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared. > **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared.
@@ -13,6 +13,7 @@ A self-hosted, multi-user document management platform with AI-powered topic cla
- **Document management** — upload PDF, DOCX, images, and plain text; full-text extraction stored in PostgreSQL - **Document management** — upload PDF, DOCX, images, and plain text; full-text extraction stored in PostgreSQL
- **AI classification** — automatic topic tagging via LM Studio (local), Ollama, OpenAI, Anthropic, or any OpenAI-compatible provider (Groq, xAI, DeepSeek, OpenRouter, Gemini, Mistral) - **AI classification** — automatic topic tagging via LM Studio (local), Ollama, OpenAI, Anthropic, or any OpenAI-compatible provider (Groq, xAI, DeepSeek, OpenRouter, Gemini, Mistral)
- **Folder organisation** — hierarchical folders with move, rename, and delete; breadcrumb navigation - **Folder organisation** — hierarchical folders with move, rename, and delete; breadcrumb navigation
- **Responsive file browser** — mobile sidebar drawer, compact search/sort/new-folder controls, visible touch-safe row actions, and responsive document columns
- **Document sharing** — share by user handle with view or edit permission; "Shared with me" virtual folder; per-recipient revocation - **Document sharing** — share by user handle with view or edit permission; "Shared with me" virtual folder; per-recipient revocation
- **Storage quota** — per-user limit enforced atomically; amber/red quota bar at 80 % / 95 %; quota decremented on delete - **Storage quota** — per-user limit enforced atomically; amber/red quota bar at 80 % / 95 %; quota decremented on delete
- **Cloud storage backends** — connect OneDrive, Google Drive, Nextcloud, or any WebDAV server as a personal storage backend; credentials encrypted with HKDF per-user keys - **Cloud storage backends** — connect OneDrive, Google Drive, Nextcloud, or any WebDAV server as a personal storage backend; credentials encrypted with HKDF per-user keys
@@ -158,13 +159,14 @@ Copy `.env.example` to `.env`. Only the fields marked **Required** must be set b
| `ADMIN_PASSWORD` | Bootstrap admin password (must pass strength check) | | `ADMIN_PASSWORD` | Bootstrap admin password (must pass strength check) |
| `POSTGRES_PASSWORD` | PostgreSQL superuser password | | `POSTGRES_PASSWORD` | PostgreSQL superuser password |
| `MINIO_ROOT_PASSWORD` | MinIO root password | | `MINIO_ROOT_PASSWORD` | MinIO root password |
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | App-level MinIO credentials; Docker Compose provisions this user and bucket policy |
| `REDIS_PASSWORD` | Redis `requirepass` password | | `REDIS_PASSWORD` | Redis `requirepass` password |
### Optional (sensible defaults for local dev) ### Optional (sensible defaults for local dev)
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CORS_ORIGINS` | `http://localhost:5173` | Comma-separated allowed origins | | `CORS_ORIGINS` | `["http://localhost:5173"]` | JSON list of allowed origins |
| `SMTP_HOST` | *(unset)* | Leave empty to log password-reset links to stdout | | `SMTP_HOST` | *(unset)* | Leave empty to log password-reset links to stdout |
| `LOG_JSON` | `false` | Set `true` in production for structured JSON logs | | `LOG_JSON` | `false` | Set `true` in production for structured JSON logs |
| `DEFAULT_AI_PROVIDER` | `ollama` | Used on first boot seed; overridable per-user in admin panel | | `DEFAULT_AI_PROVIDER` | `ollama` | Used on first boot seed; overridable per-user in admin panel |
-7
View File
@@ -11,13 +11,9 @@ from deps.auth import get_current_admin
from deps.db import get_db from deps.db import get_db
from deps.utils import get_client_ip from deps.utils import get_client_ip
from services.audit import write_audit_log from services.audit import write_audit_log
from api.admin.shared import _user_to_dict
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04) router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
# ── Request models ────────────────────────────────────────────────────────────
class QuotaUpdate(BaseModel): class QuotaUpdate(BaseModel):
limit_bytes: int limit_bytes: int
@@ -28,9 +24,6 @@ class QuotaUpdate(BaseModel):
raise ValueError("limit_bytes must be greater than 0") raise ValueError("limit_bytes must be greater than 0")
return v return v
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/users/{user_id}/quota") @router.get("/users/{user_id}/quota")
async def get_user_quota( async def get_user_quota(
user_id: uuid.UUID, user_id: uuid.UUID,
+16 -45
View File
@@ -21,12 +21,8 @@ from api.admin.shared import _user_to_dict
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04) router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
# ── Constants ───────────────────────────────────────────────────────────────── _DEFAULT_QUOTA_BYTES = 104857600
_DEFAULT_QUOTA_BYTES = 104857600 # 100 MB free-tier default (D-06)
# ── Request models ────────────────────────────────────────────────────────────
class UserCreate(BaseModel): class UserCreate(BaseModel):
handle: str handle: str
@@ -51,20 +47,21 @@ class UserAiConfigUpdate(BaseModel):
class SystemTopicCreate(BaseModel): class SystemTopicCreate(BaseModel):
"""Request model for admin system topic creation (D-09)."""
name: str name: str
description: str = "" description: str = ""
color: str = "#6366f1" color: str = "#6366f1"
class UserDeleteConfirm(BaseModel): class UserDeleteConfirm(BaseModel):
"""Admin password confirmation required before hard-deleting a user (ADMIN-02, T-05-11-01)."""
admin_password: str = Field(..., min_length=1) admin_password: str = Field(..., min_length=1)
# ── Endpoints ───────────────────────────────────────────────────────────────── async def _get_user_or_404(session: AsyncSession, user_id: uuid.UUID) -> User:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
@router.get("/users") @router.get("/users")
async def list_users( async def list_users(
@@ -111,7 +108,7 @@ async def create_user(
role=body.role, role=body.role,
is_active=True, is_active=True,
totp_enabled=False, totp_enabled=False,
password_must_change=True, # ADMIN-01: force password change on first login password_must_change=True,
) )
session.add(new_user) session.add(new_user)
@@ -121,8 +118,7 @@ async def create_user(
used_bytes=0, used_bytes=0,
) )
session.add(quota) session.add(quota)
await session.flush() # persist User + Quota before audit_log FK references them await session.flush()
# D-13: admin user created event
_ip_addr = get_client_ip(request) _ip_addr = get_client_ip(request)
await write_audit_log( await write_audit_log(
session, session,
@@ -151,11 +147,8 @@ async def update_user_status(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
user = await session.get(User, user_id) user = await _get_user_or_404(session, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# Guard: cannot deactivate the only remaining active admin (T-02-29)
if not body.is_active and user.role == "admin": if not body.is_active and user.role == "admin":
count_result = await session.execute( count_result = await session.execute(
select(func.count(User.id)).where( select(func.count(User.id)).where(
@@ -174,9 +167,7 @@ async def update_user_status(
user.is_active = body.is_active user.is_active = body.is_active
if not body.is_active: if not body.is_active:
# Revoke all refresh tokens on deactivation
await revoke_all_refresh_tokens(session, user.id) await revoke_all_refresh_tokens(session, user.id)
# Revoke any pre-deactivation access tokens still within their TTL (T-7.2-01)
await request.app.state.redis.set( await request.app.state.redis.set(
f"user_nbf:{user.id}", f"user_nbf:{user.id}",
int(time.time()), int(time.time()),
@@ -185,7 +176,6 @@ async def update_user_status(
session.add(user) session.add(user)
# D-13: user deactivated/activated event
_event = "admin.user_deactivated" if not body.is_active else "admin.user_activated" _event = "admin.user_deactivated" if not body.is_active else "admin.user_activated"
await write_audit_log( await write_audit_log(
session, session,
@@ -211,9 +201,7 @@ async def initiate_password_reset(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
user = await session.get(User, user_id) user = await _get_user_or_404(session, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
from services.auth import create_password_reset_token # noqa: PLC0415 from services.auth import create_password_reset_token # noqa: PLC0415
from config import settings as _settings # noqa: PLC0415 from config import settings as _settings # noqa: PLC0415
@@ -236,16 +224,13 @@ async def update_ai_config(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
user = await session.get(User, user_id) user = await _get_user_or_404(session, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
_ip_addr = get_client_ip(request) _ip_addr = get_client_ip(request)
user.ai_provider = body.ai_provider user.ai_provider = body.ai_provider
user.ai_model = body.ai_model user.ai_model = body.ai_model
session.add(user) session.add(user)
# D-13: AI provider assigned event
await write_audit_log( await write_audit_log(
session, session,
event_type="admin.ai_provider_assigned", event_type="admin.ai_provider_assigned",
@@ -273,19 +258,14 @@ async def delete_user(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> None: ) -> None:
# T-05-11-01: Verify admin password before performing any destructive action.
# Fail fast — no DB reads for the target user until the admin is confirmed.
if not verify_password(body.admin_password, _admin.password_hash): if not verify_password(body.admin_password, _admin.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid admin password", detail="Invalid admin password",
) )
user = await session.get(User, user_id) user = await _get_user_or_404(session, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# T-04-07-04: Cannot delete admin accounts
if user.role == "admin": if user.role == "admin":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -294,15 +274,11 @@ async def delete_user(
_ip_addr = get_client_ip(request) _ip_addr = get_client_ip(request)
# SEC-09 (cloud): purge cloud-stored documents and credentials BEFORE DB delete.
# Must run before MinIO cleanup so that credentials are still available to build
# the cloud backend instances for delete_object calls.
cloud_conns_result = await session.execute( cloud_conns_result = await session.execute(
select(CloudConnection).where(CloudConnection.user_id == user_id) select(CloudConnection).where(CloudConnection.user_id == user_id)
) )
cloud_conns = cloud_conns_result.scalars().all() cloud_conns = cloud_conns_result.scalars().all()
for conn in cloud_conns: for conn in cloud_conns:
# Delete cloud objects stored in this provider for this user
cloud_docs_result = await session.execute( cloud_docs_result = await session.execute(
select(Document).where( select(Document).where(
Document.user_id == user_id, Document.user_id == user_id,
@@ -314,12 +290,10 @@ async def delete_user(
backend = await get_storage_backend_for_document(doc, user, session) backend = await get_storage_backend_for_document(doc, user, session)
await backend.delete_object(doc.object_key) await backend.delete_object(doc.object_key)
except Exception: except Exception:
pass # Best-effort cloud object cleanup; deletion proceeds regardless pass
# Purge the credentials row (FK cascade would also remove it, but explicit
# deletion here guarantees credentials_enc is gone before commit — SEC-09)
await session.delete(conn) await session.delete(conn)
if cloud_conns: if cloud_conns:
await session.flush() # Flush connection deletes before user delete await session.flush()
await write_audit_log( await write_audit_log(
session, session,
event_type="cloud.credentials_purged", event_type="cloud.credentials_purged",
@@ -330,7 +304,6 @@ async def delete_user(
metadata_={"providers": [c.provider for c in cloud_conns]}, metadata_={"providers": [c.provider for c in cloud_conns]},
) )
# SEC-09 (minio): collect all user documents and delete MinIO objects BEFORE DB delete
docs_result = await session.execute( docs_result = await session.execute(
select(Document).where(Document.user_id == user_id) select(Document).where(Document.user_id == user_id)
) )
@@ -341,9 +314,8 @@ async def delete_user(
try: try:
await storage.delete_object(doc.object_key) await storage.delete_object(doc.object_key)
except Exception: except Exception:
pass # Best-effort MinIO cleanup; DB deletion proceeds regardless pass
# D-13: audit log BEFORE deleting the user row (user FK still valid at flush time)
await write_audit_log( await write_audit_log(
session, session,
event_type="admin.user_deleted", event_type="admin.user_deleted",
@@ -354,7 +326,6 @@ async def delete_user(
) )
await session.flush() await session.flush()
# Delete user record (CASCADE removes quota, documents, refresh_tokens, etc.)
await session.delete(user) await session.delete(user)
await session.commit() await session.commit()
+120 -218
View File
@@ -1,23 +1,4 @@
""" """Admin audit log API endpoints."""
Admin audit log API endpoints for DocuVault.
All handlers require get_current_admin (ADMIN-06, SEC-07) — regular users
receive 403 Forbidden.
Implements:
GET /api/admin/audit-log — paginated, filtered audit log viewer
GET /api/admin/audit-log/export — CSV streaming export with same filters
GET /api/admin/audit-log/daily-exports — list available Celery daily export files
GET /api/admin/audit-log/daily-exports/{date} — stream a specific daily export CSV
Security invariants:
- All endpoints use Depends(get_current_admin) — verified by grep
- _audit_to_dict() is a pure whitelist: no filename, extracted_text,
password_hash, or credentials_enc can appear in responses (ADMIN-06, D-15)
- CSV export uses the same _audit_to_dict_with_handles() helper as the JSON viewer
- Date path parameter validated against YYYY-MM-DD regex before MinIO key
construction — prevents path traversal (T-06.2-04-01, Pitfall 6)
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -44,17 +25,22 @@ from storage.minio_backend import MinIOBackend
router = APIRouter(prefix="/api/admin", tags=["audit"]) router = APIRouter(prefix="/api/admin", tags=["audit"])
_VALID_EVENT_PREFIXES = frozenset({"auth", "document", "folder", "share", "admin", "cloud"}) _VALID_EVENT_PREFIXES = frozenset({"auth", "document", "folder", "share", "admin", "cloud"})
_CSV_FIELDS = [
"id",
"event_type",
"user_id",
"actor_id",
"user_handle",
"actor_handle",
"user_email",
"resource_id",
"ip_address",
"metadata_",
"created_at",
]
# ── Safe response helpers ───────────────────────────────────────────────────── def _audit_base_fields(entry: AuditLog) -> dict:
def _audit_to_dict(entry: AuditLog) -> dict:
"""Safe audit log serializer — never includes filename, extracted_text, or
document content (ADMIN-06, D-15).
Whitelist: id, event_type, user_id, actor_id, resource_id, ip_address,
metadata_, created_at. No other keys are possible.
"""
return { return {
"id": entry.id, "id": entry.id,
"event_type": entry.event_type, "event_type": entry.event_type,
@@ -67,38 +53,49 @@ def _audit_to_dict(entry: AuditLog) -> dict:
} }
def _audit_to_dict(entry: AuditLog) -> dict:
"""Whitelisted audit serializer shared with the daily export task."""
return _audit_base_fields(entry)
def _audit_to_dict_with_handles( def _audit_to_dict_with_handles(
entry: AuditLog, entry: AuditLog,
user_handle: Optional[str], user_handle: Optional[str],
actor_handle: Optional[str], actor_handle: Optional[str],
user_email: Optional[str] = None, user_email: Optional[str] = None,
) -> dict: ) -> dict:
"""Extended audit log serializer that includes user_handle, actor_handle, and user_email. data = _audit_base_fields(entry)
data.update({
Returns the same fields as _audit_to_dict() plus:
- user_handle: str | None (the handle of the user who owns the entry)
- actor_handle: str | None (the handle of the actor who performed the event)
- user_email: str | None (the email of the user who owns the entry)
Used by both the JSON viewer and CSV export endpoints (Pitfall 7 — both
endpoints must use the enriched function).
"""
return {
"id": entry.id,
"event_type": entry.event_type,
"user_id": str(entry.user_id) if entry.user_id else None,
"actor_id": str(entry.actor_id) if entry.actor_id else None,
"user_handle": user_handle or None, "user_handle": user_handle or None,
"actor_handle": actor_handle or None, "actor_handle": actor_handle or None,
"user_email": user_email or None, "user_email": user_email or None,
"resource_id": str(entry.resource_id) if entry.resource_id else None, })
"ip_address": str(entry.ip_address) if entry.ip_address else None, return data
"metadata_": entry.metadata_,
"created_at": entry.created_at.isoformat(),
}
# ── Query builder helpers ───────────────────────────────────────────────────── def _validate_event_type(event_type: Optional[str]) -> None:
if event_type is not None and event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
def _apply_audit_filters(
query,
start: Optional[datetime],
end: Optional[datetime],
user_uuid: Optional[uuid.UUID],
event_type: Optional[str],
):
_validate_event_type(event_type)
if start is not None:
query = query.where(AuditLog.created_at >= start)
if end is not None:
query = query.where(AuditLog.created_at <= end)
if user_uuid is not None:
query = query.where(AuditLog.user_id == user_uuid)
if event_type is not None:
query = query.where(AuditLog.event_type.like(f"{event_type}.%"))
return query
def _build_filtered_query( def _build_filtered_query(
start: Optional[datetime], start: Optional[datetime],
@@ -106,27 +103,13 @@ def _build_filtered_query(
user_id: Optional[uuid.UUID], user_id: Optional[uuid.UUID],
event_type: Optional[str], event_type: Optional[str],
): ):
"""Return a SQLAlchemy Select for AuditLog with the given filters applied. return _apply_audit_filters(
select(AuditLog).order_by(AuditLog.created_at.desc()),
Shared by count queries in both the paginated viewer and the CSV export start,
endpoints to ensure consistent filter semantics. end,
user_id,
NOTE: This function selects AuditLog only (no JOIN). It is used for COUNT event_type,
queries to avoid the subquery ambiguity that arises with multi-column JOINs )
(Pitfall 4). Data queries use _build_filtered_query_with_handles() instead.
"""
q = select(AuditLog).order_by(AuditLog.created_at.desc())
if start is not None:
q = q.where(AuditLog.created_at >= start)
if end is not None:
q = q.where(AuditLog.created_at <= end)
if user_id is not None:
q = q.where(AuditLog.user_id == user_id)
if event_type is not None:
if event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
return q
def _build_filtered_query_with_handles( def _build_filtered_query_with_handles(
@@ -135,15 +118,6 @@ def _build_filtered_query_with_handles(
user_uuid: Optional[uuid.UUID], user_uuid: Optional[uuid.UUID],
event_type: Optional[str], event_type: Optional[str],
): ):
"""Return a multi-column Select that joins User twice for handle enrichment.
Yields (AuditLog, user_handle: str|None, actor_handle: str|None) tuples.
Uses SQLAlchemy aliased() to join User twice without collision:
- UserSubject: resolves user_id FK → handle
- UserActor: resolves actor_id FK → handle
outerjoin() ensures entries with NULL user_id or actor_id are still returned.
"""
UserSubject = aliased(User) UserSubject = aliased(User)
UserActor = aliased(User) UserActor = aliased(User)
@@ -158,37 +132,68 @@ def _build_filtered_query_with_handles(
.outerjoin(UserActor, UserActor.id == AuditLog.actor_id) .outerjoin(UserActor, UserActor.id == AuditLog.actor_id)
.order_by(AuditLog.created_at.desc()) .order_by(AuditLog.created_at.desc())
) )
if start is not None: return _apply_audit_filters(q, start, end, user_uuid, event_type)
q = q.where(AuditLog.created_at >= start)
if end is not None:
q = q.where(AuditLog.created_at <= end)
if user_uuid is not None:
q = q.where(AuditLog.user_id == user_uuid)
if event_type is not None:
if event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
return q
# ── Endpoints ───────────────────────────────────────────────────────────────── async def _resolve_user_uuid(session: AsyncSession, user_handle: Optional[str]) -> uuid.UUID | None:
# IMPORTANT: daily-export routes are registered BEFORE /audit-log and if not user_handle:
# /audit-log/export so FastAPI matches the more specific paths first. return None
result = await session.execute(select(User.id).where(User.handle == user_handle))
return result.scalar_one_or_none()
async def _count_audit_log(
session: AsyncSession,
start: Optional[datetime],
end: Optional[datetime],
user_uuid: Optional[uuid.UUID],
event_type: Optional[str],
) -> int:
count_q = _apply_audit_filters(
select(func.count(AuditLog.id)).where(True),
start,
end,
user_uuid,
event_type,
)
result = await session.execute(count_q)
return result.scalar_one()
def _audit_rows_to_dicts(rows) -> list[dict]:
return [_audit_to_dict_with_handles(row[0], row[1], row[2], row[3]) for row in rows]
def _csv_response(csv_text: str, filename: str = "audit-export.csv") -> StreamingResponse:
return StreamingResponse(
iter([csv_text]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
def _empty_csv_response() -> StreamingResponse:
output = io.StringIO()
csv.DictWriter(output, fieldnames=_CSV_FIELDS).writeheader()
return _csv_response(output.getvalue())
def _audit_csv_response(rows) -> StreamingResponse:
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_CSV_FIELDS)
writer.writeheader()
for record in _audit_rows_to_dicts(rows):
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
writer.writerow(record)
return _csv_response(output.getvalue())
@router.get("/audit-log/daily-exports") @router.get("/audit-log/daily-exports")
async def list_daily_exports( async def list_daily_exports(
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
"""List available Celery daily audit export files from MinIO (D-15). """List available Celery daily audit export files from MinIO."""
Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] }
Items are sorted descending by date.
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
Event loop safety: list_objects() is synchronous; wrapped in asyncio.to_thread
to avoid blocking the event loop (T-06.2-04-05).
"""
backend = get_storage_backend() backend = get_storage_backend()
if not isinstance(backend, MinIOBackend): if not isinstance(backend, MinIOBackend):
return {"items": []} return {"items": []}
@@ -215,15 +220,7 @@ async def download_daily_export(
date: str, date: str,
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> StreamingResponse: ) -> StreamingResponse:
"""Stream a specific Celery daily audit export file from MinIO (D-16). """Stream a specific Celery daily audit export file from MinIO."""
The date path parameter is validated against YYYY-MM-DD regex before
MinIO key construction to prevent path traversal (T-06.2-04-01, Pitfall 6).
Returns: StreamingResponse with Content-Type: text/csv.
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
"""
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
raise HTTPException(status_code=404, detail="Invalid date format") raise HTTPException(status_code=404, detail="Invalid date format")
@@ -263,56 +260,18 @@ async def list_audit_log(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> dict: ) -> dict:
"""Return paginated, filtered audit log entries (ADMIN-06). """Return paginated, filtered audit log entries."""
user_uuid = await _resolve_user_uuid(session, user_handle)
if user_handle and user_uuid is None:
return {"items": [], "total": 0, "page": page, "per_page": per_page}
Response: { items: [...], total: int, page: int, per_page: int } total = await _count_audit_log(session, start, end, user_uuid, event_type)
Each item includes user_handle and actor_handle alongside UUID fields (D-11).
Entries never contain filename, extracted_text, or document content (D-15).
user_handle filter: accepts a plain string handle and resolves to UUID
internally. Returns empty results (not 422) for unknown handles (D-12).
"""
# Handle-to-UUID resolution (D-12, Pattern 4)
user_uuid: Optional[uuid.UUID] = None
if user_handle:
handle_result = await session.execute(
select(User.id).where(User.handle == user_handle)
)
uid = handle_result.scalar_one_or_none()
if uid is None:
# No user with that handle — return empty results (D-12)
return {"items": [], "total": 0, "page": page, "per_page": per_page}
user_uuid = uid
# Count query: use the plain _build_filtered_query (no JOIN) to avoid
# COUNT ambiguity on multi-column subqueries (Pitfall 4)
count_q = select(func.count(AuditLog.id)).where(True)
if start is not None:
count_q = count_q.where(AuditLog.created_at >= start)
if end is not None:
count_q = count_q.where(AuditLog.created_at <= end)
if user_uuid is not None:
count_q = count_q.where(AuditLog.user_id == user_uuid)
if event_type is not None:
if event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
count_q = count_q.where(AuditLog.event_type.like(f"{event_type}.%"))
count_result = await session.execute(count_q)
total = count_result.scalar_one()
# Data query: use enriched JOIN for handle fields
data_q = _build_filtered_query_with_handles(start, end, user_uuid, event_type) data_q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
data_q = data_q.limit(per_page).offset((page - 1) * per_page) data_q = data_q.limit(per_page).offset((page - 1) * per_page)
result = await session.execute(data_q) result = await session.execute(data_q)
rows = result.all()
items = []
for row in rows:
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val))
return { return {
"items": items, "items": _audit_rows_to_dicts(result.all()),
"total": total, "total": total,
"page": page, "page": page,
"per_page": per_page, "per_page": per_page,
@@ -329,68 +288,11 @@ async def export_audit_log(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin), _admin: User = Depends(get_current_admin),
) -> StreamingResponse: ) -> StreamingResponse:
"""Stream a CSV export of filtered audit log entries (ADMIN-06). """Stream a CSV export of filtered audit log entries."""
user_uuid = await _resolve_user_uuid(session, user_handle)
if user_handle and user_uuid is None:
return _empty_csv_response()
Uses the same _audit_to_dict_with_handles() whitelist as the JSON viewer —
includes user_handle and actor_handle; no filename, extracted_text, or
document content appears in the export (D-15, T-04-06-02, Pitfall 7).
Returns StreamingResponse with Content-Disposition: attachment; filename=audit-export.csv.
user_handle filter: same handle-to-UUID resolution as the viewer (D-12).
"""
# Handle-to-UUID resolution (D-12) — same logic as list_audit_log
user_uuid: Optional[uuid.UUID] = None
if user_handle:
handle_result = await session.execute(
select(User.id).where(User.handle == user_handle)
)
uid = handle_result.scalar_one_or_none()
if uid is None:
# Unknown handle — return empty CSV
empty_output = io.StringIO()
fields = [
"id", "event_type", "user_id", "actor_id", "user_handle", "actor_handle",
"user_email", "resource_id", "ip_address", "metadata_", "created_at",
]
writer = csv.DictWriter(empty_output, fieldnames=fields)
writer.writeheader()
return StreamingResponse(
iter([empty_output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
)
user_uuid = uid
# Data query with handle enrichment (Pitfall 7 — export must use enriched function)
q = _build_filtered_query_with_handles(start, end, user_uuid, event_type) q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
result = await session.execute(q) result = await session.execute(q)
rows = result.all() return _audit_csv_response(result.all())
fields = [
"id",
"event_type",
"user_id",
"actor_id",
"user_handle",
"actor_handle",
"user_email",
"resource_id",
"ip_address",
"metadata_",
"created_at",
]
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader()
for row in rows:
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val)
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
writer.writerow(record)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
)
+278 -593
View File
File diff suppressed because it is too large Load Diff
+3 -29
View File
@@ -15,14 +15,12 @@ Security:
from __future__ import annotations from __future__ import annotations
import urllib.parse import urllib.parse
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Share, User from db.models import User
from api.documents.shared import get_accessible_document
from deps.auth import get_regular_user from deps.auth import get_regular_user
from deps.db import get_db from deps.db import get_db
from services.rate_limiting import account_limiter from services.rate_limiting import account_limiter
@@ -31,15 +29,12 @@ from storage.exceptions import CloudConnectionError
router = APIRouter() router = APIRouter()
# ── Range header parsing helper ───────────────────────────────────────────────
def _parse_range(range_header: str, file_size: int) -> tuple: def _parse_range(range_header: str, file_size: int) -> tuple:
"""Parse a 'bytes=X-Y' Range header and return (start, end). """Parse a 'bytes=X-Y' Range header and return (start, end).
Returns (start, end) where both are inclusive byte offsets. Returns (start, end) where both are inclusive byte offsets.
Raises HTTP 416 on any invalid or out-of-bounds range. Raises HTTP 416 on any invalid or out-of-bounds range.
T-04-05-03: validates start <= end, start >= 0, end < file_size.
""" """
try: try:
h = range_header.replace("bytes=", "").split("-") h = range_header.replace("bytes=", "").split("-")
@@ -52,8 +47,6 @@ def _parse_range(range_header: str, file_size: int) -> tuple:
return start, end return start, end
# ── GET /api/documents/{doc_id}/content ──────────────────────────────────────
@router.get("/{doc_id}/content") @router.get("/{doc_id}/content")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def stream_document_content( async def stream_document_content(
@@ -63,26 +56,7 @@ async def stream_document_content(
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
request.state.current_user = current_user request.state.current_user = current_user
try: doc, _is_recipient = await get_accessible_document(session, doc_id, current_user.id)
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(status_code=404, detail="Document not found")
doc = await session.get(Document, uid)
if doc is None:
raise HTTPException(status_code=404, detail="Document not found")
# Access control: owner OR share recipient (T-04-05-04)
if doc.user_id != current_user.id:
result = await session.execute(
select(Share).where(
Share.document_id == doc.id,
Share.recipient_id == current_user.id,
)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Document not found")
try: try:
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
+39 -153
View File
@@ -1,22 +1,4 @@
"""Document CRUD endpoints — list, get, patch, delete, and re-classify. """Document CRUD endpoints."""
Endpoints:
GET "" list documents with sort, folder filter, and FTS (list_documents)
GET /{doc_id} get document metadata (get_document)
PATCH /{doc_id} update filename and/or folder_id (patch_document)
DELETE /{doc_id} delete document, decrement quota atomically (delete_document)
POST /{doc_id}/classify re-queue Celery classification (classify_document, D-08)
Sub-router carries NO prefix prefix="/api/documents" lives in __init__.py (D-04).
Security:
T-03-11: ownership assertion on every resource endpoint cross-user access returns 404.
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
T-05-09-02: response uses storage.get_metadata() whitelist no credentials_enc, no password_hash.
T-06.2-03-01: cloud documents skip MinIO quota decrement.
T-06.2-03-02: cloud delete failure returns {success: false, cloud_delete_failed: true} (HTTP 200).
T-07-10: classify endpoint IDOR returns 404 per ownership assertion.
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
@@ -41,14 +23,31 @@ from services.rate_limiting import account_limiter
from storage import get_storage_backend_for_document as _get_storage_backend_for_document from storage import get_storage_backend_for_document as _get_storage_backend_for_document
from tasks.document_tasks import extract_and_classify from tasks.document_tasks import extract_and_classify
from api.documents.shared import DocumentPatch, _CLOUD_PROVIDERS from api.documents.shared import DocumentPatch, get_accessible_document, get_owned_document
router = APIRouter() router = APIRouter()
# ── GET /api/documents ──────────────────────────────────────────────────────── async def _shared_document_ids(session: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]:
# Route registered on parent router in __init__.py (FastAPI 0.100+ disallows result = await session.execute(select(Share.document_id).where(Share.owner_id == user_id))
# include_router when both the include prefix and route path are empty strings). return {row[0] for row in result.fetchall()}
async def _decorate_shared_flags(
session: AsyncSession,
user_id: uuid.UUID,
items: list[dict],
) -> list[dict]:
shared_ids = await _shared_document_ids(session, user_id)
for item in items:
try:
doc_id = uuid.UUID(item.get("id", ""))
except (TypeError, ValueError):
item["is_shared"] = False
else:
item["is_shared"] = doc_id in shared_ids
return items
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def list_documents( async def list_documents(
@@ -63,40 +62,17 @@ async def list_documents(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""List documents with optional sort, folder filter, and full-text search. """List documents with optional sort, folder filter, and full-text search."""
D-16: requires authenticated regular user (get_regular_user rejects admins).
Returns only documents belonging to the current user.
FOLD-05: sort by name|date|size; order asc|desc; folder_id filter;
q full-text search via plainto_tsquery (PostgreSQL only silently skipped
on SQLite when function is unavailable). FTS scope is always scoped to
current_user.id (T-04-03-02).
Backward-compat: when sort/order/folder_id/q are not provided, behaviour
is identical to the pre-Phase-4 implementation.
"""
request.state.current_user = current_user request.state.current_user = current_user
# If no new params used, fall through to the legacy storage.list_metadata path
# to preserve full backward compatibility with topic filtering.
if folder_id is None and q is None and sort == "date" and order == "desc": if folder_id is None and q is None and sort == "date" and order == "desc":
docs = await storage.list_metadata(session, user_id=current_user.id, topic=topic) docs = await storage.list_metadata(session, user_id=current_user.id, topic=topic)
total = len(docs) total = len(docs)
start = (page - 1) * per_page start = (page - 1) * per_page
# Add is_shared field (Phase 4 addition) items = await _decorate_shared_flags(
shared_result = await session.execute( session,
select(Share.document_id).where(Share.owner_id == current_user.id) current_user.id,
docs[start : start + per_page],
) )
shared_ids = {row[0] for row in shared_result.fetchall()}
items = []
for d in docs[start : start + per_page]:
doc_id_str = d.get("id", "")
try:
doc_uuid = uuid.UUID(doc_id_str)
except (ValueError, AttributeError):
doc_uuid = None
d["is_shared"] = doc_uuid in shared_ids if doc_uuid else False
items.append(d)
return {"items": items, "total": total, "page": page, "per_page": per_page} return {"items": items, "total": total, "page": page, "per_page": per_page}
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top) from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
@@ -126,8 +102,6 @@ async def list_documents(
order_fn = sort_col.asc if order == "asc" else sort_col.desc order_fn = sort_col.asc if order == "asc" else sort_col.desc
stmt = stmt.order_by(order_fn()) stmt = stmt.order_by(order_fn())
# Full-text search — plainto_tsquery on extracted_text (PostgreSQL only)
# Falls back to unfiltered if the DB dialect doesn't support @@ (e.g. SQLite in test env)
fts_requested = q is not None and len(q) >= 2 fts_requested = q is not None and len(q) >= 2
if fts_requested: if fts_requested:
fts_stmt = stmt.where( fts_stmt = stmt.where(
@@ -143,14 +117,11 @@ async def list_documents(
result = await session.execute(stmt) result = await session.execute(stmt)
docs_orm = result.scalars().all() docs_orm = result.scalars().all()
shared_result = await session.execute(
select(Share.document_id).where(Share.owner_id == current_user.id)
)
shared_ids = {row[0] for row in shared_result.fetchall()}
all_items = [] all_items = []
shared_ids = await _shared_document_ids(session, current_user.id)
for doc in docs_orm: for doc in docs_orm:
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415 from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
topic_names = await _load_topic_names(session, doc.id) topic_names = await _load_topic_names(session, doc.id)
d = _doc_to_dict(doc, topic_names) d = _doc_to_dict(doc, topic_names)
d["is_shared"] = doc.id in shared_ids d["is_shared"] = doc.id in shared_ids
@@ -166,8 +137,6 @@ async def list_documents(
} }
# ── GET /api/documents/{doc_id} ───────────────────────────────────────────────
@router.get("/{doc_id}") @router.get("/{doc_id}")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def get_document( async def get_document(
@@ -176,44 +145,18 @@ async def get_document(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Return document metadata by ID. """Return document metadata by ID."""
D-16: requires authenticated regular user. Asserts ownership cross-user
access returns 404 (not 403) to avoid information leakage (T-03-11).
"""
request.state.current_user = current_user request.state.current_user = current_user
try: _, is_recipient = await get_accessible_document(session, doc_id, current_user.id)
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None:
raise HTTPException(404, "Document not found")
is_recipient = False
if doc.user_id != current_user.id:
share_result = await session.execute(
select(Share).where(
Share.document_id == uid,
Share.recipient_id == current_user.id,
)
)
if share_result.scalar_one_or_none() is None:
raise HTTPException(404, "Document not found")
is_recipient = True
meta = await storage.get_metadata(session, doc_id) meta = await storage.get_metadata(session, doc_id)
if meta is None: if meta is None:
raise HTTPException(404, "Document not found") raise HTTPException(404, "Document not found")
# T-04-04-03: recipients get metadata only — extracted_text excluded (consistent with /shares/received)
if is_recipient: if is_recipient:
meta.pop("extracted_text", None) meta.pop("extracted_text", None)
return meta return meta
# ── PATCH /api/documents/{doc_id} ────────────────────────────────────────────
@router.patch("/{doc_id}") @router.patch("/{doc_id}")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def patch_document( async def patch_document(
@@ -223,25 +166,9 @@ async def patch_document(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Update document metadata (filename and/or folder_id). """Update document metadata."""
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
T-05-09-01: ownership check non-owner gets 404 to avoid leaking document IDs (D-16).
T-05-09-02: response uses storage.get_metadata() which excludes credentials_enc and
password_hash via the _doc_to_dict whitelist.
At least one field must be provided empty body returns 422.
folder_id=null moves the document to the root (no folder).
"""
request.state.current_user = current_user request.state.current_user = current_user
try: doc = await get_owned_document(session, doc_id, current_user.id)
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
if not body.model_fields_set: if not body.model_fields_set:
raise HTTPException(422, "At least one field (filename, folder_id) must be provided") raise HTTPException(422, "At least one field (filename, folder_id) must be provided")
@@ -250,7 +177,6 @@ async def patch_document(
doc.filename = body.filename doc.filename = body.filename
if "folder_id" in body.model_fields_set: if "folder_id" in body.model_fields_set:
# folder_id=null → move to root (no folder); folder_id=<uuid> → move to folder
if body.folder_id is not None: if body.folder_id is not None:
target = await session.get(Folder, body.folder_id) target = await session.get(Folder, body.folder_id)
if target is None or target.user_id != current_user.id: if target is None or target.user_id != current_user.id:
@@ -265,8 +191,6 @@ async def patch_document(
return meta return meta
# ── DELETE /api/documents/{doc_id} ───────────────────────────────────────────
@router.delete("/{doc_id}") @router.delete("/{doc_id}")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def delete_document( async def delete_document(
@@ -276,27 +200,9 @@ async def delete_document(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Delete a document and decrement quota atomically. """Delete a document and decrement quota when appropriate."""
For cloud-stored documents:
- Default path: attempt cloud provider delete first; on failure return
{success: false, cloud_delete_failed: true} (HTTP 200) so the frontend
can offer a "Remove from app" fallback (T-06.2-03-02).
- remove_only=true: skip cloud delete, remove DB row only, skip quota decrement.
- Cloud docs always use skip_quota=True (never charged MinIO quota, T-06.2-03-01).
D-16: requires authenticated regular user. Asserts ownership cross-user
delete returns 404 (not 403) to avoid information leakage (T-03-11).
"""
request.state.current_user = current_user request.state.current_user = current_user
try: doc = await get_owned_document(session, doc_id, current_user.id)
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
is_cloud = doc.storage_backend != "minio" is_cloud = doc.storage_backend != "minio"
_doc_size = doc.size_bytes _doc_size = doc.size_bytes
@@ -305,7 +211,8 @@ async def delete_document(
if is_cloud and not remove_only: if is_cloud and not remove_only:
try: try:
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents import api.documents as _doc_pkg
_gsb = _doc_pkg.get_storage_backend_for_document _gsb = _doc_pkg.get_storage_backend_for_document
cloud_backend = await _gsb(doc, current_user, session) cloud_backend = await _gsb(doc, current_user, session)
await cloud_backend.delete_object(doc.object_key) await cloud_backend.delete_object(doc.object_key)
@@ -320,13 +227,10 @@ async def delete_document(
}, },
) )
# auto_commit=False defers the commit so the audit log write below happens
# in the same transaction — avoids the split-transaction gap (WR-08).
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False) ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False)
if not ok: if not ok:
raise HTTPException(404, "Document not found") raise HTTPException(404, "Document not found")
# D-13: document deleted event — written in the same transaction as the delete (WR-08).
await write_audit_log( await write_audit_log(
session, session,
event_type="document.deleted", event_type="document.deleted",
@@ -341,8 +245,6 @@ async def delete_document(
return {"success": True} return {"success": True}
# ── POST /api/documents/{doc_id}/classify ────────────────────────────────────
@router.post("/{doc_id}/classify") @router.post("/{doc_id}/classify")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def classify_document( async def classify_document(
@@ -351,25 +253,9 @@ async def classify_document(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Re-queue a document for classification via Celery (D-11). """Re-queue a document for classification via Celery."""
Sets doc.status='processing', commits, dispatches extract_and_classify.delay(),
and returns {'document_id': str, 'status': 'processing'}.
D-16: requires authenticated regular user. Asserts ownership cross-user
classify returns 404 (not 403) to avoid information leakage (T-03-11).
T-07-10: ownership enforced here; IDOR returns 404 per STATE.md policy.
Placed in crud.py per D-08: same ownership-check pattern as get/patch/delete.
"""
request.state.current_user = current_user request.state.current_user = current_user
try: doc = await get_owned_document(session, doc_id, current_user.id)
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
doc.status = "processing" doc.status = "processing"
await session.commit() await session.commit()
+53 -20
View File
@@ -1,22 +1,16 @@
"""Shared constants and Pydantic request models for the documents API package. """Shared constants, request models, and access helpers for document routes."""
CODE-08: Single definition of _CLOUD_PROVIDERS, UploadUrlRequest, and DocumentPatch.
These are imported by upload.py and crud.py never duplicated.
T-05-06-01: _CLOUD_PROVIDERS is an allowlist frozenset; target_backend validated
against it (never against user-supplied strings).
T-05-09-01: DocumentPatch fields declared explicitly mass assignment prevented.
T-05-09-02: filename_no_path_separators validator preserved verbatim (path traversal
defense at the API boundary D-11 analysis: stays in Pydantic model).
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Optional from typing import Optional
from fastapi import HTTPException
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Share
# Valid cloud backend slugs (T-05-06-01: validated against allowlist, not user-supplied string)
_CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"}) _CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"})
@@ -26,14 +20,6 @@ class UploadUrlRequest(BaseModel):
class DocumentPatch(BaseModel): class DocumentPatch(BaseModel):
"""Pydantic model for PATCH /api/documents/{doc_id}.
Optional fields model_fields_set distinguishes "not provided" from "set to null".
At least one field must be present in model_fields_set (enforced in the handler).
T-05-09-01: explicit field declaration prevents mass assignment.
T-05-09-02: only filename and folder_id are accepted no other fields can be set.
"""
filename: Optional[str] = Field(None, min_length=1, max_length=255) filename: Optional[str] = Field(None, min_length=1, max_length=255)
folder_id: Optional[uuid.UUID] = None folder_id: Optional[uuid.UUID] = None
@@ -43,3 +29,50 @@ class DocumentPatch(BaseModel):
if v is not None and ("/" in v or "\\" in v): if v is not None and ("/" in v or "\\" in v):
raise ValueError("filename must not contain path separators") raise ValueError("filename must not contain path separators")
return v return v
def _document_not_found() -> HTTPException:
return HTTPException(status_code=404, detail="Document not found")
def parse_document_uuid(doc_id: str) -> uuid.UUID:
try:
return uuid.UUID(doc_id)
except ValueError:
raise _document_not_found()
async def get_owned_document(
session: AsyncSession,
doc_id: str,
user_id: uuid.UUID,
) -> Document:
uid = parse_document_uuid(doc_id)
doc = await session.get(Document, uid)
if doc is None or doc.user_id != user_id:
raise _document_not_found()
return doc
async def get_accessible_document(
session: AsyncSession,
doc_id: str,
user_id: uuid.UUID,
) -> tuple[Document, bool]:
uid = parse_document_uuid(doc_id)
doc = await session.get(Document, uid)
if doc is None:
raise _document_not_found()
if doc.user_id == user_id:
return doc, False
result = await session.execute(
select(Share).where(
Share.document_id == doc.id,
Share.recipient_id == user_id,
)
)
if result.scalar_one_or_none() is None:
raise _document_not_found()
return doc, True
+99 -179
View File
@@ -1,32 +1,11 @@
"""Document upload endpoints — presigned URL flow and direct cloud upload. """Document upload endpoints."""
Endpoints:
POST /upload-url create pending Document row, return presigned PUT URL (D-05 step 1)
POST /upload direct multipart upload supporting cloud backends (D-10, D-14, D-15)
POST /{doc_id}/confirm stat MinIO for authoritative size, enforce quota atomically (D-05 step 3)
Sub-router carries NO prefix prefix="/api/documents" lives in __init__.py (D-04).
Security:
T-03-04: object_key computed server-side using str(current_user.id) never user-supplied.
T-03-05: size from backend.stat_object() never from client.
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
T-03-11: ownership assertion on confirm cross-user access returns 404.
T-03-15: object_key prefix always the authenticated user's id.
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist.
T-05-06-02: CloudConnectionError detail never includes provider error detail.
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from pathlib import Path from pathlib import Path
import structlog as _structlog from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from sqlalchemy import select, text
_log = _structlog.get_logger(__name__)
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
@@ -36,24 +15,91 @@ from deps.db import get_db
from deps.utils import get_client_ip from deps.utils import get_client_ip
from services.audit import write_audit_log from services.audit import write_audit_log
from services.rate_limiting import account_limiter from services.rate_limiting import account_limiter
from storage import get_storage_backend, get_storage_backend_for_document from storage import get_storage_backend
from storage.cloud_backend_factory import build_cloud_backend
from storage.cloud_utils import decrypt_credentials from storage.cloud_utils import decrypt_credentials
from storage.exceptions import CloudConnectionError from storage.exceptions import CloudConnectionError
from tasks.document_tasks import extract_and_classify from tasks.document_tasks import extract_and_classify
try:
from minio.error import S3Error
except ImportError:
S3Error = Exception # type: ignore[assignment,misc]
from sqlalchemy import select
from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS
router = APIRouter() router = APIRouter()
# ── POST /api/documents/upload-url ─────────────────────────────────────────── def _new_minio_document(user_id: uuid.UUID, filename: str, content_type: str) -> Document:
doc_id = uuid.uuid4()
suffix = Path(filename).suffix.lower()
return Document(
id=doc_id,
user_id=user_id,
filename=filename,
content_type=content_type,
size_bytes=0,
storage_backend="minio",
status="pending",
object_key=f"{user_id}/{doc_id}/{uuid.uuid4()}{suffix}",
)
async def _create_presigned_upload(
session: AsyncSession,
user_id: uuid.UUID,
filename: str,
content_type: str,
) -> dict:
doc = _new_minio_document(user_id, filename, content_type)
session.add(doc)
await session.commit()
upload_url = await get_storage_backend().generate_presigned_put_url(
doc.object_key, expires_minutes=15
)
return {"upload_url": upload_url, "document_id": str(doc.id)}
async def _get_active_cloud_connection(
session: AsyncSession,
user_id: uuid.UUID,
provider: str,
) -> CloudConnection:
result = await session.execute(
select(CloudConnection).where(
CloudConnection.user_id == user_id,
CloudConnection.provider == provider,
CloudConnection.status == "ACTIVE",
)
)
conn = result.scalar_one_or_none()
if conn is None:
raise HTTPException(
status_code=404,
detail=f"No active {provider} connection found. Please connect in Settings.",
)
return conn
def _decrypt_cloud_credentials(conn: CloudConnection, user_id: uuid.UUID) -> dict:
return decrypt_credentials(settings.cloud_creds_key.encode(), str(user_id), conn.credentials_enc)
async def _record_upload(
session: AsyncSession,
request: Request,
current_user: User,
doc: Document,
size_bytes: int,
storage_backend: str,
) -> None:
await write_audit_log(
session,
event_type="document.uploaded",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=doc.id,
ip_address=get_client_ip(request) if request else None,
metadata_={"size_bytes": size_bytes, "storage_backend": storage_backend},
)
@router.post("/upload-url") @router.post("/upload-url")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
@@ -63,41 +109,15 @@ async def request_upload_url(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Create a pending Document row and return a presigned PUT URL. """Create a pending Document row and return a presigned PUT URL."""
D-05 step 1: FastAPI creates a Document row (status='pending'), generates a
15-minute presigned PUT URL, returns {upload_url, document_id}.
Quota is NOT reserved at this step quota enforcement happens at /confirm.
T-03-04: object_key is computed server-side using str(current_user.id); filename
stored in DB only (CLAUDE.md MinIO key schema).
T-03-15: object_key prefix is always the authenticated user's id — never user-supplied.
"""
request.state.current_user = current_user request.state.current_user = current_user
doc_id = uuid.uuid4() return await _create_presigned_upload(
suffix = Path(body.filename).suffix.lower() session,
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}" current_user.id,
body.filename,
doc = Document( body.content_type,
id=doc_id,
user_id=current_user.id,
filename=body.filename,
content_type=body.content_type,
size_bytes=0,
storage_backend="minio",
status="pending",
object_key=object_key,
) )
session.add(doc)
await session.commit()
upload_url = await get_storage_backend().generate_presigned_put_url(
object_key, expires_minutes=15
)
return {"upload_url": upload_url, "document_id": str(doc_id)}
# ── POST /api/documents/upload ────────────────────────────────────────────────
@router.post("/upload") @router.post("/upload")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
@@ -109,47 +129,15 @@ async def upload_document(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Direct multipart upload endpoint supporting cloud backends (D-10, D-14, D-15). """Direct multipart upload endpoint supporting cloud backends."""
If target_backend == "minio": generates a presigned PUT URL (unchanged MinIO flow).
If target_backend in ("google_drive", "onedrive", "nextcloud", "webdav"):
1. Reads file bytes from UploadFile
2. Loads CloudConnection for current_user.id + target_backend; 404 if not found/not ACTIVE
3. Decrypts credentials and instantiates the correct backend class
4. Calls cloud_backend.put_object() to upload directly to the provider
5. Creates Document with storage_backend=target_backend
6. Returns {document_id, storage_backend} no upload_url (cloud upload is synchronous)
Cloud uploads do NOT use the atomic quota UPDATE cloud files are not counted
against MinIO quota (D-11: separate backends; cloud storage quota is provider-side).
Security:
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist 422 on invalid value
T-05-06-02: CloudConnectionError detail message never includes provider error detail
"""
request.state.current_user = current_user request.state.current_user = current_user
if target_backend == "minio": if target_backend == "minio":
doc_id = uuid.uuid4() return await _create_presigned_upload(
suffix = Path(file.filename or "file").suffix.lower() session,
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}" current_user.id,
file.filename or "upload",
doc = Document( file.content_type or "application/octet-stream",
id=doc_id,
user_id=current_user.id,
filename=file.filename or "upload",
content_type=file.content_type or "application/octet-stream",
size_bytes=0,
storage_backend="minio",
status="pending",
object_key=object_key,
) )
session.add(doc)
await session.commit()
upload_url = await get_storage_backend().generate_presigned_put_url(
object_key, expires_minutes=15
)
return {"upload_url": upload_url, "document_id": str(doc_id)}
if target_backend not in _CLOUD_PROVIDERS: if target_backend not in _CLOUD_PROVIDERS:
raise HTTPException( raise HTTPException(
@@ -157,23 +145,8 @@ async def upload_document(
detail=f"Invalid target_backend '{target_backend}'. Valid values: minio, {', '.join(sorted(_CLOUD_PROVIDERS))}", detail=f"Invalid target_backend '{target_backend}'. Valid values: minio, {', '.join(sorted(_CLOUD_PROVIDERS))}",
) )
# Load active CloudConnection for current user + provider (T-05-06-01: user-scoped query) conn = await _get_active_cloud_connection(session, current_user.id, target_backend)
result = await session.execute( credentials = _decrypt_cloud_credentials(conn, current_user.id)
select(CloudConnection).where(
CloudConnection.user_id == current_user.id,
CloudConnection.provider == target_backend,
CloudConnection.status == "ACTIVE",
)
)
conn = result.scalar_one_or_none()
if conn is None:
raise HTTPException(
status_code=404,
detail=f"No active {target_backend} connection found. Please connect in Settings.",
)
master_key = settings.cloud_creds_key.encode()
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
file_bytes = await file.read() file_bytes = await file.read()
filename = file.filename or "upload" filename = file.filename or "upload"
@@ -182,26 +155,7 @@ async def upload_document(
doc_id = uuid.uuid4() doc_id = uuid.uuid4()
if target_backend == "google_drive": cloud_backend = build_cloud_backend(target_backend, credentials)
from storage.google_drive_backend import GoogleDriveBackend # lazy import
cloud_backend = GoogleDriveBackend(credentials)
elif target_backend == "onedrive":
from storage.onedrive_backend import OneDriveBackend # lazy import
cloud_backend = OneDriveBackend(credentials)
elif target_backend == "nextcloud":
from storage.nextcloud_backend import NextcloudBackend # lazy import
cloud_backend = NextcloudBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
elif target_backend == "webdav":
from storage.webdav_backend import WebDAVBackend # lazy import
cloud_backend = WebDAVBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
try: try:
object_key = await cloud_backend.put_object( object_key = await cloud_backend.put_object(
@@ -219,9 +173,9 @@ async def upload_document(
detail="Cloud connection requires re-authentication. Please reconnect in Settings.", detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
) from exc ) from exc
# Bust folder listing cache so the next GET /folders reflects the new file
if cloud_folder_path: if cloud_folder_path:
from services.cloud_cache import invalidate_provider_cache # lazy import from services.cloud_cache import invalidate_provider_cache # lazy import
invalidate_provider_cache(str(current_user.id), target_backend) invalidate_provider_cache(str(current_user.id), target_backend)
doc = Document( doc = Document(
@@ -236,16 +190,7 @@ async def upload_document(
) )
session.add(doc) session.add(doc)
_ip = get_client_ip(request) if request else None await _record_upload(session, request, current_user, doc, len(file_bytes), target_backend)
await write_audit_log(
session,
event_type="document.uploaded",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=doc.id,
ip_address=_ip,
metadata_={"size_bytes": len(file_bytes), "storage_backend": target_backend},
)
await session.commit() await session.commit()
extract_and_classify.delay(str(doc.id)) extract_and_classify.delay(str(doc.id))
@@ -253,8 +198,6 @@ async def upload_document(
return {"document_id": str(doc.id), "storage_backend": target_backend} return {"document_id": str(doc.id), "storage_backend": target_backend}
# ── POST /api/documents/{doc_id}/confirm ─────────────────────────────────────
@router.post("/{doc_id}/confirm") @router.post("/{doc_id}/confirm")
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def confirm_upload( async def confirm_upload(
@@ -263,18 +206,7 @@ async def confirm_upload(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Confirm a presigned PUT upload: stat MinIO for size, enforce quota atomically. """Confirm a presigned PUT upload and enforce quota."""
D-05 step 3: FastAPI reads authoritative file size from MinIO stat_object (never
from client), runs atomic quota UPDATE, sets status='uploaded', enqueues Celery task.
Quota exceeded: HTTP 413 with {"used_bytes": N, "limit_bytes": M, "rejected_bytes": K}
Upload not found: HTTP 422 (presigned URL may have expired)
T-03-05: size always comes from backend.stat_object(doc.object_key) never client.
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
T-03-11: ownership assertion cross-user access returns 404 (D-16).
"""
request.state.current_user = current_user request.state.current_user = current_user
try: try:
uid = uuid.UUID(doc_id) uid = uuid.UUID(doc_id)
@@ -285,7 +217,6 @@ async def confirm_upload(
if doc is None or doc.user_id != current_user.id: if doc is None or doc.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Document not found") raise HTTPException(status_code=404, detail="Document not found")
# Get authoritative file size from MinIO (T-03-05 — never trust client-supplied size)
try: try:
size = await get_storage_backend().stat_object(doc.object_key) size = await get_storage_backend().stat_object(doc.object_key)
except Exception as exc: except Exception as exc:
@@ -300,7 +231,6 @@ async def confirm_upload(
doc.size_bytes = size doc.size_bytes = size
await session.flush() await session.flush()
# Atomic quota enforcement — user_id is always set post-migration (Plan 03-03+)
result = await session.execute( result = await session.execute(
text( text(
"UPDATE quotas " "UPDATE quotas "
@@ -323,7 +253,7 @@ async def confirm_upload(
try: try:
await get_storage_backend().delete_object(doc.object_key) await get_storage_backend().delete_object(doc.object_key)
except Exception: except Exception:
pass # MinIO cleanup is best-effort; object TTL will eventually expire pass
await session.commit() await session.commit()
raise HTTPException( raise HTTPException(
status_code=413, status_code=413,
@@ -337,17 +267,7 @@ async def confirm_upload(
used_bytes = row.used_bytes used_bytes = row.used_bytes
doc.status = "uploaded" doc.status = "uploaded"
# D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02) await _record_upload(session, request, current_user, doc, size, "minio")
_ip = get_client_ip(request)
await write_audit_log(
session,
event_type="document.uploaded",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=doc.id,
ip_address=_ip,
metadata_={"size_bytes": size, "storage_backend": "minio"},
)
await session.commit() await session.commit()
extract_and_classify.delay(str(doc.id)) extract_and_classify.delay(str(doc.id))
+90 -199
View File
@@ -1,21 +1,4 @@
""" """Folder and document-folder organization endpoints."""
Folder API endpoints for DocuVault Phase 4, Plan 03.
Implements FOLD-01 through FOLD-05:
POST /api/folders create folder (FOLD-01)
GET /api/folders list top-level folders (FOLD-02)
GET /api/folders/{id} get folder + breadcrumb (FOLD-02)
PATCH /api/folders/{id} rename folder (FOLD-03)
DELETE /api/folders/{id} delete folder (cascade) (FOLD-03)
PATCH /api/documents/{id}/folder move document to folder (FOLD-04)
Security invariants (all enforced):
T-04-03-01: get_regular_user on all endpoints (admin gets 403)
T-04-03-04: All folder IDOR paths return 404 not 403
T-04-03-05: PATCH /api/documents/{id}/folder validates both doc and target folder ownership
T-04-03-06: IntegrityError (duplicate folder name) 409 Conflict
T-04-03-03: Atomic quota decrement with CASE WHEN pattern (SQLite compat)
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
@@ -23,11 +6,11 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, text, func from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Folder, Quota, Share, User from db.models import Document, Folder, User
from deps.auth import get_regular_user from deps.auth import get_regular_user
from deps.db import get_db from deps.db import get_db
from deps.utils import get_client_ip from deps.utils import get_client_ip
@@ -37,8 +20,6 @@ from storage import get_storage_backend
router = APIRouter(prefix="/api/folders", tags=["folders"]) router = APIRouter(prefix="/api/folders", tags=["folders"])
# ── Request / response models ─────────────────────────────────────────────────
class FolderCreate(BaseModel): class FolderCreate(BaseModel):
name: str name: str
parent_id: Optional[str] = None parent_id: Optional[str] = None
@@ -52,9 +33,6 @@ class DocumentMove(BaseModel):
folder_id: Optional[str] = None folder_id: Optional[str] = None
# ── Helper: folder serialization ──────────────────────────────────────────────
def _folder_to_dict(folder: Folder) -> dict: def _folder_to_dict(folder: Folder) -> dict:
return { return {
"id": str(folder.id), "id": str(folder.id),
@@ -65,8 +43,6 @@ def _folder_to_dict(folder: Folder) -> dict:
} }
# ── Helper: document serialization ────────────────────────────────────────────
def _doc_to_dict(doc: Document) -> dict: def _doc_to_dict(doc: Document) -> dict:
return { return {
"id": str(doc.id), "id": str(doc.id),
@@ -81,7 +57,67 @@ def _doc_to_dict(doc: Document) -> dict:
} }
# ── POST /api/folders ───────────────────────────────────────────────────────── def _parse_uuid(value: str, not_found_detail: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except ValueError:
raise HTTPException(status_code=404, detail=not_found_detail)
async def _get_owned_folder(
session: AsyncSession,
folder_id: str | uuid.UUID,
user_id: uuid.UUID,
detail: str = "Folder not found",
) -> Folder:
uid = folder_id if isinstance(folder_id, uuid.UUID) else _parse_uuid(folder_id, detail)
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=404, detail=detail)
return folder
async def _get_owned_document(
session: AsyncSession,
doc_id: str,
user_id: uuid.UUID,
) -> Document:
uid = _parse_uuid(doc_id, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != user_id:
raise HTTPException(status_code=404, detail="Document not found")
return doc
async def _ensure_unique_folder_name(
session: AsyncSession,
user_id: uuid.UUID,
name: str,
parent_id: Optional[uuid.UUID],
exclude_id: Optional[uuid.UUID] = None,
) -> None:
stmt = select(Folder).where(
Folder.user_id == user_id,
Folder.name == name,
Folder.parent_id == parent_id,
)
if exclude_id is not None:
stmt = stmt.where(Folder.id != exclude_id)
dup = await session.execute(stmt)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
def _duplicate_folder_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
@router.post("", status_code=status.HTTP_201_CREATED) @router.post("", status_code=status.HTTP_201_CREATED)
async def create_folder( async def create_folder(
@@ -90,35 +126,14 @@ async def create_folder(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Create a new folder for the current user.
FOLD-01: parent_id (if given) must belong to current_user 404 otherwise.
Duplicate name under same parent returns 409 (T-04-03-06).
"""
parent_uuid: Optional[uuid.UUID] = None parent_uuid: Optional[uuid.UUID] = None
if body.parent_id is not None: if body.parent_id is not None:
try: parent = await _get_owned_folder(
parent_uuid = uuid.UUID(body.parent_id) session, body.parent_id, current_user.id, "Parent folder not found"
except ValueError: )
raise HTTPException(status_code=404, detail="Parent folder not found") parent_uuid = parent.id
parent = await session.get(Folder, parent_uuid)
if parent is None or parent.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Parent folder not found")
# Explicit duplicate check — UniqueConstraint won't fire when parent_id IS NULL await _ensure_unique_folder_name(session, current_user.id, body.name, parent_uuid)
# because SQL treats NULL as distinct from NULL in unique indexes.
dup = await session.execute(
select(Folder).where(
Folder.user_id == current_user.id,
Folder.name == body.name,
Folder.parent_id == parent_uuid,
)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
folder = Folder( folder = Folder(
user_id=current_user.id, user_id=current_user.id,
@@ -130,10 +145,7 @@ async def create_folder(
await session.commit() await session.commit()
except IntegrityError: except IntegrityError:
await session.rollback() await session.rollback()
raise HTTPException( raise _duplicate_folder_error()
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
await write_audit_log( await write_audit_log(
session, session,
@@ -148,34 +160,20 @@ async def create_folder(
return _folder_to_dict(folder) return _folder_to_dict(folder)
# ── GET /api/folders ──────────────────────────────────────────────────────────
@router.get("") @router.get("")
async def list_folders( async def list_folders(
parent_id: Optional[str] = None, parent_id: Optional[str] = None,
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""List the current user's folders at a given level.
FOLD-02: when parent_id is omitted, returns root folders (parent_id IS NULL).
When parent_id is supplied, returns that folder's direct children (asserts ownership).
Each folder includes has_children so the frontend can hide expand arrows on leaf nodes.
"""
parent_uuid: Optional[uuid.UUID] = None parent_uuid: Optional[uuid.UUID] = None
if parent_id is not None: if parent_id is not None:
try: parent_folder = await _get_owned_folder(
parent_uuid = uuid.UUID(parent_id) session, parent_id, current_user.id, "Parent folder not found"
except ValueError: )
raise HTTPException(status_code=404, detail="Parent folder not found") parent_uuid = parent_folder.id
parent_folder = await session.get(Folder, parent_uuid)
if parent_folder is None or parent_folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Parent folder not found")
if parent_uuid is None: where_clause = Folder.parent_id.is_(None) if parent_uuid is None else Folder.parent_id == parent_uuid
where_clause = Folder.parent_id.is_(None)
else:
where_clause = Folder.parent_id == parent_uuid
result = await session.execute( result = await session.execute(
select(Folder) select(Folder)
@@ -184,8 +182,6 @@ async def list_folders(
) )
folders = result.scalars().all() folders = result.scalars().all()
# One extra query to know which of these folders have sub-folders.
# Allows the frontend to hide expand arrows on leaf nodes without extra round-trips.
folder_ids = [f.id for f in folders] folder_ids = [f.id for f in folders]
folders_with_children: set = set() folders_with_children: set = set()
if folder_ids: if folder_ids:
@@ -205,55 +201,34 @@ async def list_folders(
} }
# ── GET /api/folders/{folder_id} ──────────────────────────────────────────────
@router.get("/{folder_id}") @router.get("/{folder_id}")
async def get_folder( async def get_folder(
folder_id: str, folder_id: str,
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Get folder metadata + breadcrumb array from root to this folder. folder = await _get_owned_folder(session, folder_id, current_user.id)
FOLD-02 / FOLD-05: breadcrumb is built via iterative parent-walk in Python
(not WITH RECURSIVE) so it is compatible with both PostgreSQL and SQLite tests.
Response: {id, name, parent_id, user_id, created_at, breadcrumb: [{id, name}, ...]}
The breadcrumb array is ordered root-first (root is breadcrumb[0]).
"""
try:
uid = uuid.UUID(folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
# Build breadcrumb by walking up the parent chain iteratively.
# Ownership check on each ancestor ensures no cross-user traversal.
crumbs = [{"id": str(folder.id), "name": folder.name}] crumbs = [{"id": str(folder.id), "name": folder.name}]
current = folder current = folder
visited: set = {current.id} visited: set = {current.id}
while current.parent_id is not None: while current.parent_id is not None:
if current.parent_id in visited: if current.parent_id in visited:
break # cycle guard (should not occur with proper constraints) break
parent = await session.get(Folder, current.parent_id) parent = await session.get(Folder, current.parent_id)
if parent is None or parent.user_id != current_user.id: if parent is None or parent.user_id != current_user.id:
break # stop traversal if parent is inaccessible break
visited.add(parent.id) visited.add(parent.id)
crumbs.append({"id": str(parent.id), "name": parent.name}) crumbs.append({"id": str(parent.id), "name": parent.name})
current = parent current = parent
crumbs.reverse() # root-first order crumbs.reverse()
response = _folder_to_dict(folder) response = _folder_to_dict(folder)
response["breadcrumb"] = crumbs response["breadcrumb"] = crumbs
return response return response
# ── PATCH /api/folders/{folder_id} ───────────────────────────────────────────
@router.patch("/{folder_id}") @router.patch("/{folder_id}")
async def rename_folder( async def rename_folder(
folder_id: str, folder_id: str,
@@ -262,46 +237,20 @@ async def rename_folder(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Rename a folder. folder = await _get_owned_folder(session, folder_id, current_user.id)
FOLD-03: asserts ownership 404 if not owner.
Duplicate name under same parent returns 409 (T-04-03-06).
"""
try:
uid = uuid.UUID(folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
old_name = folder.name old_name = folder.name
# Explicit duplicate check — same NULL parent_id issue as create_folder.
if body.name != folder.name: if body.name != folder.name:
dup = await session.execute( await _ensure_unique_folder_name(
select(Folder).where( session, current_user.id, body.name, folder.parent_id, folder.id
Folder.user_id == current_user.id,
Folder.name == body.name,
Folder.parent_id == folder.parent_id,
Folder.id != folder.id,
)
) )
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
folder.name = body.name folder.name = body.name
try: try:
await session.commit() await session.commit()
except IntegrityError: except IntegrityError:
await session.rollback() await session.rollback()
raise HTTPException( raise _duplicate_folder_error()
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
await write_audit_log( await write_audit_log(
session, session,
@@ -316,8 +265,6 @@ async def rename_folder(
return _folder_to_dict(folder) return _folder_to_dict(folder)
# ── DELETE /api/folders/{folder_id} ──────────────────────────────────────────
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder( async def delete_folder(
folder_id: str, folder_id: str,
@@ -325,30 +272,9 @@ async def delete_folder(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Delete a folder and all of its contents (cascade). folder = await _get_owned_folder(session, folder_id, current_user.id)
FOLD-03 + D-03:
- Collects all documents in the folder subtree using WITH RECURSIVE CTE
(wraps in try/except OperationalError for SQLite test compat; fallback
uses direct children only).
- Sums size_bytes, performs atomic quota decrement (CASE WHEN pattern for
SQLite compat T-04-03-03).
- Deletes MinIO objects best-effort (per-object try/except PATTERNS.md Pattern 2).
- Deletes all document rows and the folder row via ORM.
"""
try:
uid = uuid.UUID(folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
folder_name = folder.name folder_name = folder.name
# Collect all folder IDs in the subtree via WITH RECURSIVE CTE.
# Falls back to direct-children-only on SQLite (OperationalError on recursive CTE).
subtree_folder_ids: list[str] = [] subtree_folder_ids: list[str] = []
try: try:
cte_result = await session.execute( cte_result = await session.execute(
@@ -361,17 +287,13 @@ async def delete_folder(
" WHERE f.user_id = :uid" " WHERE f.user_id = :uid"
") SELECT id FROM subtree" ") SELECT id FROM subtree"
), ),
# Use .hex (no dashes) — SQLite stores UUID as 32-char hex; PostgreSQL accepts both.
{"root_id": folder.id.hex, "uid": current_user.id.hex}, {"root_id": folder.id.hex, "uid": current_user.id.hex},
) )
subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()] subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()]
except OperationalError: except OperationalError:
# SQLite fallback: only direct children of this folder
subtree_folder_ids = [str(folder.id)] subtree_folder_ids = [str(folder.id)]
# Collect all documents in the subtree folder IDs
if subtree_folder_ids: if subtree_folder_ids:
# Build UUID list for IN query
subtree_uuids = [] subtree_uuids = []
for fid in subtree_folder_ids: for fid in subtree_folder_ids:
try: try:
@@ -394,7 +316,6 @@ async def delete_folder(
total_bytes = sum(d.size_bytes for d in docs) total_bytes = sum(d.size_bytes for d in docs)
# Atomic quota decrement (CASE WHEN for SQLite compat — never goes below 0)
if total_bytes > 0: if total_bytes > 0:
await session.execute( await session.execute(
text( text(
@@ -406,20 +327,16 @@ async def delete_folder(
{"delta": total_bytes, "uid": current_user.id.hex}, {"delta": total_bytes, "uid": current_user.id.hex},
) )
# Delete MinIO objects best-effort (per-object, never abort on failure)
storage_backend = get_storage_backend() storage_backend = get_storage_backend()
for doc in docs: for doc in docs:
try: try:
await storage_backend.delete_object(doc.object_key) await storage_backend.delete_object(doc.object_key)
except Exception: except Exception:
pass # best-effort; stale MinIO objects will be garbage-collected pass
# Delete document rows
for doc in docs: for doc in docs:
await session.delete(doc) await session.delete(doc)
# Delete the folder (cascade will handle sub-folders in PostgreSQL;
# in SQLite test env we already collected and deleted all documents)
await session.delete(folder) await session.delete(folder)
await session.commit() await session.commit()
@@ -428,17 +345,12 @@ async def delete_folder(
event_type="folder.deleted", event_type="folder.deleted",
user_id=current_user.id, user_id=current_user.id,
actor_id=current_user.id, actor_id=current_user.id,
resource_id=uid, resource_id=folder.id,
ip_address=get_client_ip(request), ip_address=get_client_ip(request),
metadata_={"name": folder_name, "doc_count": len(docs)}, metadata_={"name": folder_name, "doc_count": len(docs)},
) )
# ── PATCH /api/documents/{doc_id}/folder ─────────────────────────────────────
# This endpoint lives in the folders router (not documents router) because it
# is logically a folder organisation operation. The URL prefix /api/documents
# is achieved via an explicit path on this APIRouter. FastAPI supports this.
document_move_router = APIRouter(prefix="/api/documents", tags=["folders"]) document_move_router = APIRouter(prefix="/api/documents", tags=["folders"])
@@ -450,33 +362,12 @@ async def move_document(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
): ):
"""Move a document to a different folder (or to root if folder_id is null). doc = await _get_owned_document(session, doc_id, current_user.id)
FOLD-04:
- Asserts document ownership 404 if not owner.
- If folder_id given: asserts target folder ownership 404 if not owner
(T-04-03-05: cross-user folder assignment blocked).
- Updates doc.folder_id and commits.
- Returns 200 with updated document dict.
"""
try:
doc_uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(status_code=404, detail="Document not found")
doc = await session.get(Document, doc_uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Document not found")
target_folder_uuid: Optional[uuid.UUID] = None target_folder_uuid: Optional[uuid.UUID] = None
if body.folder_id is not None: if body.folder_id is not None:
try: target_folder = await _get_owned_folder(session, body.folder_id, current_user.id)
target_folder_uuid = uuid.UUID(body.folder_id) target_folder_uuid = target_folder.id
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
target_folder = await session.get(Folder, target_folder_uuid)
if target_folder is None or target_folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
doc.folder_id = target_folder_uuid doc.folder_id = target_folder_uuid
await session.commit() await session.commit()
+68 -137
View File
@@ -1,23 +1,7 @@
""" """Document sharing endpoints."""
Sharing API for DocuVault Phase 4, Plan 04-04.
Implements SHARE-01 through SHARE-05:
POST /api/shares grant share by recipient handle
GET /api/shares list shares owned by current user for a document
GET /api/shares/received virtual "Shared with me" folder (metadata only)
DELETE /api/shares/{share_id} revoke share with IDOR protection
Security invariants:
T-04-04-02: DELETE asserts share.owner_id == current_user.id 404 on mismatch
T-04-04-03: GET /received returns metadata only extracted_text is never included
T-04-04-04: No quota table is touched anywhere in this module
T-04-04-05: UniqueConstraint(document_id, recipient_id) IntegrityError 409
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from sqlalchemy import select from sqlalchemy import select
@@ -33,9 +17,6 @@ from services.audit import write_audit_log
router = APIRouter(prefix="/api/shares", tags=["shares"]) router = APIRouter(prefix="/api/shares", tags=["shares"])
# ── Request models ────────────────────────────────────────────────────────────
class ShareCreate(BaseModel): class ShareCreate(BaseModel):
document_id: str document_id: str
recipient_handle: str recipient_handle: str
@@ -60,11 +41,47 @@ class SharePermissionPatch(BaseModel):
return v return v
# ── Helpers ─────────────────────────────────────────────────────────────────── def _parse_uuid(value: str, detail: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except ValueError:
raise HTTPException(status_code=404, detail=detail)
async def _get_owned_document(
session: AsyncSession,
document_id: str,
owner_id: uuid.UUID,
) -> Document:
uid = _parse_uuid(document_id, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != owner_id:
raise HTTPException(status_code=404, detail="Document not found")
return doc
# ── POST /api/shares ──────────────────────────────────────────────────────────
async def _get_owned_share(
session: AsyncSession,
share_id: str,
owner_id: uuid.UUID,
) -> Share:
sid = _parse_uuid(share_id, "Share not found")
share = await session.get(Share, sid)
if share is None or share.owner_id != owner_id:
raise HTTPException(status_code=404, detail="Share not found")
return share
def _share_to_dict(share: Share, recipient: User) -> dict:
return {
"id": str(share.id),
"document_id": str(share.document_id),
"owner_id": str(share.owner_id),
"recipient_id": str(share.recipient_id),
"recipient_handle": recipient.handle,
"permission": share.permission,
"created_at": share.created_at.isoformat() if share.created_at else None,
}
@router.post("", status_code=status.HTTP_201_CREATED) @router.post("", status_code=status.HTTP_201_CREATED)
@@ -74,22 +91,7 @@ async def grant_share(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
) -> dict: ) -> dict:
"""Grant document share to a user identified by their handle (SHARE-01, D-04). doc = await _get_owned_document(session, body.document_id, current_user.id)
T-04-04-06: Only document owner can grant; 404 prevents ID enumeration.
T-04-04-01: get_regular_user ensures admins cannot invoke this endpoint.
T-04-04-05: Duplicate share IntegrityError 409 (no unbounded inserts).
"""
# Parse document_id as UUID (T-03-11 pattern)
try:
uid = uuid.UUID(body.document_id)
except ValueError:
raise HTTPException(status_code=404, detail="Document not found")
# Ownership assertion — 404 prevents ID enumeration
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Document not found")
# Recipient lookup by exact handle (D-04) # Recipient lookup by exact handle (D-04)
result = await session.execute( result = await session.execute(
@@ -105,7 +107,7 @@ async def grant_share(
# Create the share row # Create the share row
share = Share( share = Share(
document_id=uid, document_id=doc.id,
owner_id=current_user.id, owner_id=current_user.id,
recipient_id=recipient.id, recipient_id=recipient.id,
permission=body.permission, permission=body.permission,
@@ -126,25 +128,14 @@ async def grant_share(
event_type="share.granted", event_type="share.granted",
user_id=current_user.id, user_id=current_user.id,
actor_id=current_user.id, actor_id=current_user.id,
resource_id=uid, resource_id=doc.id,
ip_address=get_client_ip(request), ip_address=get_client_ip(request),
metadata_={"recipient_id": str(recipient.id)}, metadata_={"recipient_id": str(recipient.id)},
) )
await session.commit() await session.commit()
return { return _share_to_dict(share, recipient)
"id": str(share.id),
"document_id": str(share.document_id),
"owner_id": str(share.owner_id),
"recipient_id": str(share.recipient_id),
"recipient_handle": recipient.handle,
"permission": share.permission,
"created_at": share.created_at.isoformat() if share.created_at else None,
}
# ── GET /api/shares ───────────────────────────────────────────────────────────
@router.get("") @router.get("")
@@ -153,46 +144,28 @@ async def list_shares(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
) -> dict: ) -> dict:
"""List shares owned by current user for a specific document (SHARE-01, D-05). doc = await _get_owned_document(session, document_id, current_user.id)
Only the document owner can list shares 404 on mismatch or bad UUID.
"""
try:
uid = uuid.UUID(document_id)
except ValueError:
raise HTTPException(status_code=404, detail="Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Document not found")
# Join Share with User to get recipient handles
stmt = ( stmt = (
select(Share, User) select(Share, User)
.join(User, User.id == Share.recipient_id) .join(User, User.id == Share.recipient_id)
.where(Share.document_id == uid) .where(Share.document_id == doc.id)
) )
result = await session.execute(stmt) result = await session.execute(stmt)
rows = result.all() items = [
{
items = [] "id": str(share.id),
for share, recipient in rows: "recipient_id": str(share.recipient_id),
items.append( "recipient_handle": recipient.handle,
{ "permission": share.permission,
"id": str(share.id), "created_at": share.created_at.isoformat() if share.created_at else None,
"recipient_id": str(share.recipient_id), }
"recipient_handle": recipient.handle, for share, recipient in result.all()
"permission": share.permission, ]
"created_at": share.created_at.isoformat()
if share.created_at
else None,
}
)
return {"items": items} return {"items": items}
# ── GET /api/shares/received ──────────────────────────────────────────────────
# CRITICAL: This endpoint MUST be defined BEFORE DELETE /api/shares/{share_id}. # CRITICAL: This endpoint MUST be defined BEFORE DELETE /api/shares/{share_id}.
# Defining it after would cause FastAPI to route GET /api/shares/received as # Defining it after would cause FastAPI to route GET /api/shares/received as
# DELETE with share_id="received" (path parameter conflict). # DELETE with share_id="received" (path parameter conflict).
@@ -203,12 +176,6 @@ async def list_shared_with_me(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
) -> dict: ) -> dict:
"""Return documents shared WITH the current user (virtual "Shared with me" folder — D-06).
T-04-04-03: Only metadata is returned extracted_text is never included.
T-04-04-04: No quota is modified.
Response: {items: [{id, filename, content_type, size_bytes, created_at, owner_handle}]}
"""
stmt = ( stmt = (
select(Document, User) select(Document, User)
.join(Share, Share.document_id == Document.id) .join(Share, Share.document_id == Document.id)
@@ -219,26 +186,21 @@ async def list_shared_with_me(
result = await session.execute(stmt) result = await session.execute(stmt)
rows = result.all() rows = result.all()
items = [] items = [
for doc, owner in rows: {
# T-04-04-03: extracted_text is intentionally excluded here "id": str(doc.id),
items.append( "filename": doc.filename,
{ "content_type": doc.content_type,
"id": str(doc.id), "size_bytes": doc.size_bytes,
"filename": doc.filename, "created_at": doc.created_at.isoformat() if doc.created_at else None,
"content_type": doc.content_type, "owner_handle": owner.handle,
"size_bytes": doc.size_bytes, }
"created_at": doc.created_at.isoformat() if doc.created_at else None, for doc, owner in rows
"owner_handle": owner.handle, ]
}
)
return {"items": items} return {"items": items}
# ── PATCH /api/shares/{share_id} ─────────────────────────────────────────────
@router.patch("/{share_id}", status_code=200) @router.patch("/{share_id}", status_code=200)
async def update_share_permission( async def update_share_permission(
share_id: str, share_id: str,
@@ -247,20 +209,7 @@ async def update_share_permission(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
) -> dict: ) -> dict:
"""Update the permission on an existing share (SHARE-03, D-09). share = await _get_owned_share(session, share_id, current_user.id)
T-06.2-02-01 IDOR protection: 404 on owner mismatch mirrors revoke_share exactly.
T-06.2-02-02: SharePermissionPatch validator prevents arbitrary string passthrough.
"""
try:
sid = uuid.UUID(share_id)
except ValueError:
raise HTTPException(status_code=404, detail="Share not found")
share = await session.get(Share, sid)
if share is None or share.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Share not found")
share.permission = body.permission share.permission = body.permission
await write_audit_log( await write_audit_log(
@@ -277,9 +226,6 @@ async def update_share_permission(
return {"id": str(share.id), "permission": share.permission} return {"id": str(share.id), "permission": share.permission}
# ── DELETE /api/shares/{share_id} ─────────────────────────────────────────────
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_share( async def revoke_share(
share_id: str, share_id: str,
@@ -287,27 +233,12 @@ async def revoke_share(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
) -> None: ) -> None:
"""Revoke a share. Only the share owner may revoke (SHARE-04, D-07). share = await _get_owned_share(session, share_id, current_user.id)
T-04-04-02 IDOR protection: asserts share.owner_id == current_user.id.
Returns 404 (not 403) on mismatch to prevent share ID enumeration.
"""
try:
sid = uuid.UUID(share_id)
except ValueError:
raise HTTPException(status_code=404, detail="Share not found")
share = await session.get(Share, sid)
# CRITICAL IDOR check: 404 on mismatch (not 403) — prevents ID enumeration
if share is None or share.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Share not found")
document_id = share.document_id document_id = share.document_id
recipient_id = share.recipient_id recipient_id = share.recipient_id
await session.delete(share) await session.delete(share)
# Audit log before commit (D-14 — within the same transaction)
await write_audit_log( await write_audit_log(
session=session, session=session,
event_type="share.revoked", event_type="share.revoked",
+1 -1
View File
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ─────────────────────────────────────────────────────── # ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.3", lifespan=lifespan) app = FastAPI(title="Document Scanner API", version="0.1.4", lifespan=lifespan)
# Rate limiter state (slowapi) # Rate limiter state (slowapi)
app.state.limiter = auth_limiter app.state.limiter = auth_limiter
+41 -166
View File
@@ -1,27 +1,7 @@
""" """AI provider configuration and API-key encryption helpers."""
AI provider configuration service for DocuVault.
Provides HKDF/Fernet encryption helpers, a provider config loader that reads
from the system_settings DB table, and a startup seed function that populates
the default provider row from env vars on first boot.
Security design (D-05, T-07-02):
HKDF domain separation the info bytes b"ai-provider-settings" differ from
b"cloud-credentials" used by storage/cloud_utils.py. Both use the same master
key (settings.cloud_creds_key) but produce DIFFERENT derived Fernet keys, so
a leaked cloud credential cannot decrypt an AI API key and vice versa.
AlreadyFinalized warning (RESEARCH.md Pitfall 2 / .continue-here.md anti-pattern):
The cryptography library raises AlreadyFinalized if .derive() is called twice
on the same HKDF instance. _derive_ai_settings_key() creates a FRESH HKDF(...)
object on every call never cache or reuse the HKDF object between calls.
Pattern reference: storage/cloud_utils.py:_derive_fernet_key().
"""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import logging
from typing import Optional from typing import Optional
import structlog import structlog
@@ -36,167 +16,78 @@ from config import settings
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
AI_SETTINGS_KEY_INFO = b"ai-provider-settings"
# ── HKDF key derivation ───────────────────────────────────────────────────────
def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet: def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet:
"""Derive a per-provider Fernet encryption key using HKDF-SHA256. """Derive a per-provider Fernet key with domain-separated HKDF-SHA256."""
Security notes:
- A FRESH HKDF instance is created on every call. The cryptography library
raises AlreadyFinalized if .derive() is called twice on the same instance.
Never cache or reuse the HKDF object (RESEARCH.md Pitfall 2).
- salt = provider_id.encode("utf-8") provides per-provider derivation
(deterministic: same provider same key for encrypt/decrypt consistency).
- info = b"ai-provider-settings" provides domain separation from
b"cloud-credentials" same master key, different derived keys.
A leaked cloud credential cannot decrypt an AI API key (T-07-02 mitigated).
Args:
master_key: The CLOUD_CREDS_KEY env var as bytes.
provider_id: The provider slug, e.g. "openai", "anthropic" (used as HKDF salt).
Returns:
A Fernet instance ready for encrypt/decrypt operations.
"""
# Create a FRESH HKDF instance — never cache (AlreadyFinalized guard)
hkdf = HKDF( hkdf = HKDF(
algorithm=hashes.SHA256(), algorithm=hashes.SHA256(),
length=32, length=32,
salt=provider_id.encode("utf-8"), salt=provider_id.encode("utf-8"),
info=b"ai-provider-settings", # domain-separated from b"cloud-credentials" info=AI_SETTINGS_KEY_INFO,
) )
raw_key: bytes = hkdf.derive(master_key) raw_key: bytes = hkdf.derive(master_key)
fernet_key = base64.urlsafe_b64encode(raw_key) fernet_key = base64.urlsafe_b64encode(raw_key)
return Fernet(fernet_key) return Fernet(fernet_key)
# ── Encryption helpers ────────────────────────────────────────────────────────
def encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str: def encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str:
"""Encrypt a plaintext API key string to a Fernet token. """Encrypt a plaintext API key for storage in system_settings.api_key_enc."""
The returned string is safe to store in system_settings.api_key_enc.
No JSON wrapping the raw API key string is encrypted directly.
Args:
master_key: The CLOUD_CREDS_KEY env var as bytes.
provider_id: The provider slug (used as HKDF salt for key derivation).
api_key: The plaintext API key, e.g. "sk-proj-...".
Returns:
A URL-safe base64 Fernet token (str).
"""
f = _derive_ai_settings_key(master_key, provider_id) f = _derive_ai_settings_key(master_key, provider_id)
return f.encrypt(api_key.encode("utf-8")).decode("utf-8") return f.encrypt(api_key.encode("utf-8")).decode("utf-8")
def decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> str: def decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> str:
"""Decrypt a Fernet token back to the original plaintext API key. """Decrypt a stored API-key token."""
Args:
master_key: The CLOUD_CREDS_KEY env var as bytes.
provider_id: The provider slug (used as HKDF salt for key derivation).
api_key_enc: The Fernet token string from the database.
Returns:
The original plaintext API key string.
"""
f = _derive_ai_settings_key(master_key, provider_id) f = _derive_ai_settings_key(master_key, provider_id)
return f.decrypt(api_key_enc.encode("utf-8")).decode("utf-8") return f.decrypt(api_key_enc.encode("utf-8")).decode("utf-8")
# ── Provider config loader ──────────────────────────────────────────────────── def _config_from_settings_row(row) -> ProviderConfig:
api_key = ""
if row.api_key_enc:
master_key = settings.cloud_creds_key.encode("utf-8")
try:
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
except Exception:
logger.warning(
"ai_config: failed to decrypt api_key_enc",
provider_id=row.provider_id,
)
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
model=row.model_name,
context_chars=row.context_chars,
)
async def _load_settings_row(session: AsyncSession, *criteria):
from db.models import SystemSettings # local import avoids circular deps
stmt = select(SystemSettings)
if criteria:
stmt = stmt.where(*criteria)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig]: async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig]:
"""Load the active AI provider config from the system_settings table.
Returns a ProviderConfig built from the row where is_active=True,
decrypting api_key_enc when present. Returns None when no active row exists.
Args:
session: An open AsyncSession.
Returns:
A ProviderConfig if an active row exists; None if the table is empty or
no row is marked active.
"""
from db.models import SystemSettings # local import to avoid circular deps from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.is_active.is_(True)) row = await _load_settings_row(session, SystemSettings.is_active.is_(True))
result = await session.execute(stmt) return _config_from_settings_row(row) if row else None
row = result.scalar_one_or_none()
if row is None:
return None
# Decrypt API key if present
api_key = ""
if row.api_key_enc:
master_key = settings.cloud_creds_key.encode("utf-8")
try:
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
except Exception:
logger.warning(
"ai_config.load_provider_config: failed to decrypt api_key_enc",
provider_id=row.provider_id,
)
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
model=row.model_name,
context_chars=row.context_chars,
)
# ── Provider config loader by ID ─────────────────────────────────────────────
async def load_provider_config_by_id(session: AsyncSession, provider_id: str) -> Optional[ProviderConfig]: async def load_provider_config_by_id(session: AsyncSession, provider_id: str) -> Optional[ProviderConfig]:
"""Load an AI provider config from system_settings by provider_id.
Unlike load_provider_config(), this function does NOT require is_active=True.
Used by the admin test-connection endpoint so admins can test inactive rows.
Args:
session: An open AsyncSession.
provider_id: The provider slug to load (e.g. "openai", "lmstudio").
Returns:
A ProviderConfig if a row with the given provider_id exists; None otherwise.
"""
from db.models import SystemSettings # local import to avoid circular deps from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id) row = await _load_settings_row(session, SystemSettings.provider_id == provider_id)
result = await session.execute(stmt) return _config_from_settings_row(row) if row else None
row = result.scalar_one_or_none()
if row is None:
return None
# Decrypt API key if present
api_key = ""
if row.api_key_enc:
master_key = settings.cloud_creds_key.encode("utf-8")
try:
api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc)
except Exception:
logger.warning(
"ai_config.load_provider_config_by_id: failed to decrypt api_key_enc",
provider_id=row.provider_id,
)
return ProviderConfig(
provider_id=row.provider_id,
api_key=api_key,
base_url=row.base_url,
model=row.model_name,
context_chars=row.context_chars,
)
# ── Provider ID validator (D-11 migration) ───────────────────────────────────
def validate_provider_id(v: str) -> str: def validate_provider_id(v: str) -> str:
"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule.""" """Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""
@@ -207,21 +98,8 @@ def validate_provider_id(v: str) -> str:
return v return v
# ── Startup seed ──────────────────────────────────────────────────────────────
async def seed_system_settings_from_env(session: AsyncSession) -> None: async def seed_system_settings_from_env(session: AsyncSession) -> None:
"""Populate system_settings with a default provider row on first boot. """Seed the default provider row from env settings if it does not exist."""
Reads settings.default_ai_provider and settings.default_ai_model from config.
If no row exists for that provider_id, inserts one with is_active=True.
Never overwrites an existing row idempotent across restarts (D-04).
This function is called from the FastAPI lifespan in main.py after the
session factory is available. Caller is responsible for committing.
Args:
session: An open AsyncSession.
"""
from db.models import SystemSettings # local import to avoid circular deps from db.models import SystemSettings # local import to avoid circular deps
provider_id = settings.default_ai_provider provider_id = settings.default_ai_provider
@@ -232,13 +110,10 @@ async def seed_system_settings_from_env(session: AsyncSession) -> None:
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if existing is not None: if existing is not None:
# Row already exists — never overwrite (idempotent)
return return
# Use PROVIDER_DEFAULTS context_chars for the provider, fallback to 8000
context_chars = PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000) context_chars = PROVIDER_DEFAULTS.get(provider_id, {}).get("context_chars", 8000)
# Insert default row with no API key (local providers like Ollama don't need one)
row = SystemSettings( row = SystemSettings(
provider_id=provider_id, provider_id=provider_id,
model_name=model_name, model_name=model_name,
+49 -73
View File
@@ -1,20 +1,4 @@
""" """Authentication, token, TOTP, and account bootstrap services."""
Auth service pure Python, no FastAPI coupling.
Handles:
- Password hashing (Argon2 via pwdlib) and constant-time verification (SEC-06)
- JWT access token creation/decode (PyJWT)
- Refresh token lifecycle with family revocation on reuse (AUTH-07, RFC 9700)
- TOTP provisioning and verification with replay prevention (AUTH-08)
- Backup code generation, storage, and constant-time verification (AUTH-02)
- HaveIBeenPwned k-anonymity check (SEC-03)
- Admin account bootstrap (D-04, D-05, D-06)
Security invariants:
- All token/code comparisons use hmac.compare_digest (constant-time, SEC-06)
- No function raises HTTPException callers (api/) map ValueError to HTTP errors
- refresh token family revocation enqueues send_security_alert_email.delay (AUTH-07)
"""
from __future__ import annotations from __future__ import annotations
import base64 import base64
@@ -32,7 +16,7 @@ import jwt
import pyotp import pyotp
from pwdlib import PasswordHash from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher from pwdlib.hashers.argon2 import Argon2Hasher
from sqlalchemy import select, update from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
@@ -45,8 +29,6 @@ _PASSWORD_DETAIL = (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Password hashing ────────────────────────────────────────────────────────────
# Single shared PasswordHash instance; Argon2 is the only enabled hasher.
_pwd = PasswordHash([Argon2Hasher()]) _pwd = PasswordHash([Argon2Hasher()])
@@ -82,10 +64,8 @@ def validate_password_strength(password: str) -> None:
raise ValueError(_PASSWORD_DETAIL) raise ValueError(_PASSWORD_DETAIL)
# ── JWT helpers ─────────────────────────────────────────────────────────────────
def _compute_fgp(user_agent: str, accept_lang: str) -> str: def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint binding a token to its client context (D-04).""" """Return a short fingerprint binding a token to its client context."""
return hmac.new( return hmac.new(
settings.secret_key.encode(), settings.secret_key.encode(),
(user_agent + "\x00" + accept_lang).encode(), (user_agent + "\x00" + accept_lang).encode(),
@@ -93,6 +73,36 @@ def _compute_fgp(user_agent: str, accept_lang: str) -> str:
).hexdigest()[:16] ).hexdigest()[:16]
def _jwt_private_key() -> str:
return base64.b64decode(settings.jwt_private_key).decode()
def _jwt_public_key() -> str:
return base64.b64decode(settings.jwt_public_key).decode()
def _encode_jwt(payload: dict) -> str:
return jwt.encode(payload, _jwt_private_key(), algorithm="ES256")
def _decode_jwt(
token: str,
expected_type: str,
expired_message: str,
invalid_prefix: str,
) -> dict:
try:
payload = jwt.decode(token, _jwt_public_key(), algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError(expired_message) from exc
except jwt.PyJWTError as exc:
raise ValueError(f"{invalid_prefix}: {exc}") from exc
if payload.get("typ") != expected_type:
raise ValueError(f"Token type mismatch: expected '{expected_type}'")
return payload
def create_access_token( def create_access_token(
user_id: str, user_id: str,
role: str, role: str,
@@ -114,8 +124,7 @@ def create_access_token(
"jti": str(uuid.uuid4()), "jti": str(uuid.uuid4()),
"fgp": _compute_fgp(user_agent, accept_lang), "fgp": _compute_fgp(user_agent, accept_lang),
} }
private_pem = base64.b64decode(settings.jwt_private_key).decode() return _encode_jwt(payload)
return jwt.encode(payload, private_pem, algorithm="ES256")
def decode_access_token(token: str) -> dict: def decode_access_token(token: str) -> dict:
@@ -124,17 +133,7 @@ def decode_access_token(token: str) -> dict:
Verifies: signature, expiry, and typ='access' (T-02-01 prevents password-reset Verifies: signature, expiry, and typ='access' (T-02-01 prevents password-reset
tokens from being used as access tokens). tokens from being used as access tokens).
""" """
try: return _decode_jwt(token, "access", "Token has expired", "Invalid token")
public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError("Token has expired") from exc
except jwt.PyJWTError as exc:
raise ValueError(f"Invalid token: {exc}") from exc
if payload.get("typ") != "access":
raise ValueError("Token type mismatch: expected 'access'")
return payload
def create_password_reset_token(user_id: str) -> str: def create_password_reset_token(user_id: str) -> str:
@@ -149,8 +148,7 @@ def create_password_reset_token(user_id: str) -> str:
"iat": now, "iat": now,
"exp": now + timedelta(seconds=3600), "exp": now + timedelta(seconds=3600),
} }
private_pem = base64.b64decode(settings.jwt_private_key).decode() return _encode_jwt(payload)
return jwt.encode(payload, private_pem, algorithm="ES256")
def decode_password_reset_token(token: str) -> str: def decode_password_reset_token(token: str) -> str:
@@ -158,21 +156,15 @@ def decode_password_reset_token(token: str) -> str:
Returns the user_id string. Returns the user_id string.
""" """
try: payload = _decode_jwt(
public_pem = base64.b64decode(settings.jwt_public_key).decode() token,
payload = jwt.decode(token, public_pem, algorithms=["ES256"]) "password-reset",
except jwt.ExpiredSignatureError as exc: "Reset token has expired",
raise ValueError("Reset token has expired") from exc "Invalid reset token",
except jwt.PyJWTError as exc: )
raise ValueError(f"Invalid reset token: {exc}") from exc
if payload.get("typ") != "password-reset":
raise ValueError("Token type mismatch: expected 'password-reset'")
return payload["sub"] return payload["sub"]
# ── Refresh token lifecycle ─────────────────────────────────────────────────────
async def create_refresh_token( async def create_refresh_token(
session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False
) -> str: ) -> str:
@@ -181,8 +173,8 @@ async def create_refresh_token(
The raw token is returned to the caller and set as an httpOnly cookie. The raw token is returned to the caller and set as an httpOnly cookie.
Only the SHA-256 hash is stored in the database. Only the SHA-256 hash is stored in the database.
remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10) remember_me=False uses the short-session TTL; remember_me=True uses the
remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11) extended-session TTL.
""" """
raw = secrets.token_urlsafe(32) raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest() token_hash = hashlib.sha256(raw.encode()).hexdigest()
@@ -231,9 +223,7 @@ async def rotate_refresh_token(
raise ValueError("Refresh token has expired") raise ValueError("Refresh token has expired")
if row.revoked: if row.revoked:
# T-02-02: reuse of revoked token — family revocation
await revoke_all_refresh_tokens(session, row.user_id) await revoke_all_refresh_tokens(session, row.user_id)
# Enqueue security alert email (deferred import to avoid circular dependency)
from tasks.email_tasks import send_security_alert_email # noqa: PLC0415 from tasks.email_tasks import send_security_alert_email # noqa: PLC0415
send_security_alert_email.delay(str(row.user_id)) send_security_alert_email.delay(str(row.user_id))
raise ValueError("token_family_revoked") raise ValueError("token_family_revoked")
@@ -273,8 +263,6 @@ async def revoke_all_refresh_tokens(
return count return count
# ── TOTP provisioning ───────────────────────────────────────────────────────────
async def provision_totp( async def provision_totp(
session: AsyncSession, user_id: uuid.UUID session: AsyncSession, user_id: uuid.UUID
) -> tuple[str, str]: ) -> tuple[str, str]:
@@ -329,13 +317,10 @@ async def verify_totp(
return True return True
# ── Backup codes ────────────────────────────────────────────────────────────────
def generate_backup_codes(n: int = 10) -> list[str]: def generate_backup_codes(n: int = 10) -> list[str]:
"""Return *n* random 8-character uppercase alphanumeric backup codes.""" """Return *n* random 8-character uppercase alphanumeric backup codes."""
codes = [] codes = []
for _ in range(n): for _ in range(n):
# secrets.token_hex(4) returns 8 hex chars; uppercase for readability
code = secrets.token_hex(4).upper() code = secrets.token_hex(4).upper()
codes.append(code) codes.append(code)
return codes return codes
@@ -349,7 +334,6 @@ async def store_backup_codes(
Each code is stored as an Argon2 hash (never plaintext, T-02-03). Each code is stored as an Argon2 hash (never plaintext, T-02-03).
Existing unused codes are deleted first to prevent accumulation. Existing unused codes are deleted first to prevent accumulation.
""" """
# Delete existing unused codes
result = await session.execute( result = await session.execute(
select(BackupCode).where( select(BackupCode).where(
BackupCode.user_id == user_id, BackupCode.user_id == user_id,
@@ -360,7 +344,6 @@ async def store_backup_codes(
await session.delete(row) await session.delete(row)
await session.flush() await session.flush()
# Insert new hashed codes
for code in codes: for code in codes:
row = BackupCode( row = BackupCode(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -392,21 +375,17 @@ async def verify_backup_code(
matched_row: Optional[BackupCode] = None matched_row: Optional[BackupCode] = None
for row in rows: for row in rows:
# Always call verify_password for ALL rows (constant-time: no early exit)
if verify_password(code, row.code_hash): if verify_password(code, row.code_hash):
matched_row = row # record match but keep iterating matched_row = row # keep iterating for constant-time behavior
if matched_row is None: if matched_row is None:
return False return False
# Mark the matched code as used
matched_row.used_at = datetime.now(timezone.utc) matched_row.used_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
return True return True
# ── HaveIBeenPwned check ────────────────────────────────────────────────────────
async def check_hibp(password: str) -> bool: async def check_hibp(password: str) -> bool:
"""Check if password appears in HaveIBeenPwned using the k-anonymity model. """Check if password appears in HaveIBeenPwned using the k-anonymity model.
@@ -442,19 +421,17 @@ async def check_hibp(password: str) -> bool:
return False return False
# ── Admin bootstrap ─────────────────────────────────────────────────────────────
async def bootstrap_admin(session: AsyncSession) -> None: async def bootstrap_admin(session: AsyncSession) -> None:
"""Idempotent admin account bootstrap (D-04, D-05, D-06). """Idempotent admin account bootstrap.
If the users table is empty AND settings.admin_email and settings.admin_password If the users table is empty AND settings.admin_email and settings.admin_password
are both non-empty, creates an admin User row with a Quota row. are both non-empty, creates an admin User row with a Quota row.
Logs a WARNING if env vars are missing (D-05) but never raises. Logs a warning if env vars are missing but never raises.
""" """
if not settings.admin_email or not settings.admin_password: if not settings.admin_email or not settings.admin_password:
logger.warning( logger.warning(
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set (D-05). " "Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set. "
"Set both env vars to seed the first admin account on startup." "Set both env vars to seed the first admin account on startup."
) )
return return
@@ -462,7 +439,6 @@ async def bootstrap_admin(session: AsyncSession) -> None:
# Check if any users exist # Check if any users exist
result = await session.execute(select(User).limit(1)) result = await session.execute(select(User).limit(1))
if result.scalar_one_or_none() is not None: if result.scalar_one_or_none() is not None:
# Users already exist — idempotent, skip (D-04)
return return
admin_id = uuid.uuid4() admin_id = uuid.uuid4()
@@ -477,7 +453,7 @@ async def bootstrap_admin(session: AsyncSession) -> None:
) )
quota = Quota( quota = Quota(
user_id=admin_id, user_id=admin_id,
limit_bytes=104857600, # 100 MB default (D-06) limit_bytes=104857600,
used_bytes=0, used_bytes=0,
) )
session.add(admin_user) session.add(admin_user)
+28 -100
View File
@@ -1,21 +1,4 @@
""" """Async document and topic storage helpers."""
Async document/topic/settings storage service for DocuVault.
This module replaces the legacy flat-file + filelock implementation with:
- Async SQLAlchemy ORM for document and topic persistence (PostgreSQL)
- MinIO SDK (via asyncio.to_thread) for binary object storage
Public function names are PRESERVED from the old flat-file implementation so
that api/documents.py and api/topics.py can be updated in Plan 05 with minimal
changes (async def + await + session parameter).
Phase 3 D-12: load_settings / save_settings / mask_api_key / settings_masked removed.
All AI config comes from DB (users.ai_provider / users.ai_model set by admin).
D-05: Storage service layer switched to PostgreSQL + MinIO.
D-06: Object key schema: {user_id}/{document_id}/{uuid4()}{ext} human filename in DB only.
D-03: documents.user_id is None (nullable) in Phase 1 no auth system yet.
"""
from __future__ import annotations from __future__ import annotations
import sys import sys
@@ -31,26 +14,17 @@ from db.models import Document, DocumentTopic, Topic
from storage import get_storage_backend from storage import get_storage_backend
# ── Lazy singleton storage backend ────────────────────────────────────────────
_storage = None _storage = None
def _backend(): def _backend():
"""Return the lazily-instantiated StorageBackend singleton. """Return the lazily-instantiated StorageBackend singleton."""
Mirrors the module-level singleton behaviour of the old filelock objects so
the MinIO client is created once per process, not once per request.
"""
global _storage global _storage
_storage = _storage or get_storage_backend() _storage = _storage or get_storage_backend()
return _storage return _storage
# ── Private helpers ────────────────────────────────────────────────────────────
def _doc_to_dict(doc: Document, topic_names: list) -> dict: def _doc_to_dict(doc: Document, topic_names: list) -> dict:
"""Convert a Document ORM row + resolved topic names to the legacy dict shape."""
return { return {
"id": str(doc.id), "id": str(doc.id),
"original_name": doc.filename, "original_name": doc.filename,
@@ -65,8 +39,22 @@ def _doc_to_dict(doc: Document, topic_names: list) -> dict:
} }
def _topic_to_dict(topic: Topic) -> dict:
return {
"id": str(topic.id),
"name": topic.name,
"description": topic.description,
"color": topic.color,
}
def _topic_namespace_filter(name: str, user_id: Optional[uuid.UUID]):
criteria = [sql_func.lower(Topic.name) == name.lower()]
criteria.append(Topic.user_id.is_(None) if user_id is None else Topic.user_id == user_id)
return criteria
async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list: async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
"""Return the list of topic names for a given document UUID."""
q = await session.execute( q = await session.execute(
select(Topic.name) select(Topic.name)
.join(DocumentTopic, DocumentTopic.topic_id == Topic.id) .join(DocumentTopic, DocumentTopic.topic_id == Topic.id)
@@ -75,9 +63,6 @@ async def _load_topic_names(session: AsyncSession, doc_id: uuid.UUID) -> list:
return [row[0] for row in q] return [row[0] for row in q]
# ── Documents ─────────────────────────────────────────────────────────────────
async def save_metadata(session: AsyncSession, meta: dict) -> None: async def save_metadata(session: AsyncSession, meta: dict) -> None:
"""Update a Document row from the legacy metadata dict shape. """Update a Document row from the legacy metadata dict shape.
@@ -120,10 +105,7 @@ async def get_metadata(session: AsyncSession, doc_id: str) -> Optional[dict]:
async def list_metadata( async def list_metadata(
session: AsyncSession, user_id: uuid.UUID, topic: Optional[str] = None session: AsyncSession, user_id: uuid.UUID, topic: Optional[str] = None
) -> list: ) -> list:
"""Return a list of metadata dicts for a specific user, optionally filtered by topic name. """Return metadata dicts for a user's documents, optionally filtered by topic."""
D-16: always filters by user_id a user can only see their own documents.
"""
stmt = select(Document).where(Document.user_id == user_id).order_by(Document.created_at.desc()) stmt = select(Document).where(Document.user_id == user_id).order_by(Document.created_at.desc())
if topic is not None: if topic is not None:
stmt = ( stmt = (
@@ -176,10 +158,6 @@ async def delete_document(
print(f"[storage] WARNING: MinIO delete_object failed for {doc.object_key!r}: {exc}", file=sys.stderr) print(f"[storage] WARNING: MinIO delete_object failed for {doc.object_key!r}: {exc}", file=sys.stderr)
if not skip_quota: if not skip_quota:
# Atomic quota decrement (STORE-06, D-07).
# user_id is always set post-migration (Plan 03-03+) — guard removed.
# Use CASE WHEN instead of GREATEST() for SQLite compatibility
# (PostgreSQL supports both; SQLite lacks the GREATEST scalar function).
await session.execute( await session.execute(
text( text(
"UPDATE quotas " "UPDATE quotas "
@@ -212,12 +190,10 @@ async def update_document_topics(
if doc is None: if doc is None:
return None return None
# Remove all existing associations
await session.execute( await session.execute(
delete(DocumentTopic).where(DocumentTopic.document_id == uid) delete(DocumentTopic).where(DocumentTopic.document_id == uid)
) )
# Re-insert, deduplicating by name
seen: set = set() seen: set = set()
for name in topics: for name in topics:
if name in seen: if name in seen:
@@ -257,15 +233,10 @@ async def remove_topic_from_all_documents(
return result.rowcount return result.rowcount
# ── Topics ────────────────────────────────────────────────────────────────────
async def load_topics(session: AsyncSession) -> list: async def load_topics(session: AsyncSession) -> list:
"""Return all topics ordered by name.""" """Return all topics ordered by name."""
q = await session.execute(select(Topic).order_by(Topic.name)) q = await session.execute(select(Topic).order_by(Topic.name))
return [ return [_topic_to_dict(t) for t in q.scalars()]
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
for t in q.scalars()
]
async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> list: async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> list:
@@ -280,17 +251,11 @@ async def load_topics_for_user(session: AsyncSession, user_id: uuid.UUID) -> lis
or_(Topic.user_id == user_id, Topic.user_id.is_(None)) or_(Topic.user_id == user_id, Topic.user_id.is_(None))
).order_by(Topic.name) ).order_by(Topic.name)
) )
return [ return [_topic_to_dict(t) for t in q.scalars()]
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
for t in q.scalars()
]
async def save_topics(session: AsyncSession, topics: list) -> None: async def save_topics(session: AsyncSession, topics: list) -> None:
"""Idempotent bulk replace — delete all Topic rows then insert the list. """Idempotent bulk replace; kept for compatibility with older callers."""
# legacy: not used by current endpoints; preserved for API compatibility.
"""
await session.execute(delete(Topic)) await session.execute(delete(Topic))
for t in topics: for t in topics:
session.add( session.add(
@@ -313,7 +278,7 @@ async def get_topic(session: AsyncSession, topic_id: str) -> Optional[dict]:
t = await session.get(Topic, uid) t = await session.get(Topic, uid)
if t is None: if t is None:
return None return None
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color} return _topic_to_dict(t)
async def create_topic( async def create_topic(
@@ -323,51 +288,16 @@ async def create_topic(
color: str = "#6366f1", color: str = "#6366f1",
user_id: Optional[uuid.UUID] = None, user_id: Optional[uuid.UUID] = None,
) -> dict: ) -> dict:
"""Create a topic, or return the existing one (case-insensitive, namespace-scoped dedup). """Create a topic, or return an existing case-insensitive namespace match."""
q = await session.execute(select(Topic).where(*_topic_namespace_filter(name, user_id)))
D-08: user_id=None creates a system topic (visible to all users).
D-08: user_id=<uuid> creates a per-user topic (visible only to that user).
Deduplication is scoped by user_id namespace:
- System topics (user_id=None) dedup against other system topics only
- Per-user topics dedup within that user's namespace only
This allows "Finance" to exist as both a system topic and a per-user topic.
SQLite note: Uses a branching approach instead of IS NOT DISTINCT FROM
(SQLite doesn't support that PostgreSQL construct for NULL comparison).
"""
if user_id is None:
q = await session.execute(
select(Topic).where(
sql_func.lower(Topic.name) == name.lower(),
Topic.user_id.is_(None),
)
)
else:
q = await session.execute(
select(Topic).where(
sql_func.lower(Topic.name) == name.lower(),
Topic.user_id == user_id,
)
)
existing = q.scalars().first() existing = q.scalars().first()
if existing is not None: if existing is not None:
return { return _topic_to_dict(existing)
"id": str(existing.id),
"name": existing.name,
"description": existing.description,
"color": existing.color,
}
topic = Topic(name=name, description=description, color=color, user_id=user_id) topic = Topic(name=name, description=description, color=color, user_id=user_id)
session.add(topic) session.add(topic)
await session.commit() await session.commit()
return { return _topic_to_dict(topic)
"id": str(topic.id),
"name": topic.name,
"description": topic.description,
"color": topic.color,
}
async def update_topic( async def update_topic(
@@ -392,7 +322,7 @@ async def update_topic(
if color is not None: if color is not None:
t.color = color t.color = color
await session.commit() await session.commit()
return {"id": str(t.id), "name": t.name, "description": t.description, "color": t.color} return _topic_to_dict(t)
async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]: async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
@@ -408,7 +338,7 @@ async def delete_topic(session: AsyncSession, topic_id: str) -> Optional[str]:
if t is None: if t is None:
return None return None
name = t.name name = t.name
await session.delete(t) # ondelete="CASCADE" removes DocumentTopic rows await session.delete(t)
await session.commit() await session.commit()
return name return name
@@ -437,8 +367,6 @@ async def topic_doc_counts(
return {name: count for name, count in q} return {name: count for name, count in q}
# ── Public surface ─────────────────────────────────────────────────────────────
__all__ = [ __all__ = [
"save_metadata", "save_metadata",
"get_metadata", "get_metadata",
+33
View File
@@ -0,0 +1,33 @@
"""Factory for user-scoped cloud storage backends."""
def build_cloud_backend(provider: str, credentials: dict):
if provider == "google_drive":
from storage.google_drive_backend import GoogleDriveBackend # lazy import
return GoogleDriveBackend(credentials)
if provider == "onedrive":
from storage.onedrive_backend import OneDriveBackend # lazy import
return OneDriveBackend(credentials)
if provider == "nextcloud":
from storage.nextcloud_backend import NextcloudBackend # lazy import
return NextcloudBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
if provider == "webdav":
from storage.webdav_backend import WebDAVBackend # lazy import
return WebDAVBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
raise ValueError(f"Unknown provider: {provider}")
+1 -5
View File
@@ -6,12 +6,8 @@ class CloudConnectionError(Exception):
"""Raised when a cloud provider signals a non-retryable connection problem. """Raised when a cloud provider signals a non-retryable connection problem.
Attributes: Attributes:
reason: "token_expired" access token expired; API layer can refresh and retry. reason: "token_expired" access token expired.
"invalid_grant" refresh token revoked; user must reconnect. "invalid_grant" refresh token revoked; user must reconnect.
The backend never updates the DB. The API layer (_call_cloud_op in cloud.py)
catches this exception, performs the DB state transition, and decides whether
to retry or surface a 503 to the client (B2 design, D-05/D-06).
""" """
def __init__(self, msg: str = "", *, reason: str = "") -> None: def __init__(self, msg: str = "", *, reason: str = "") -> None:
+2 -4
View File
@@ -11,10 +11,8 @@ Design notes:
- D-14: presigned_get_url and generate_presigned_put_url raise - D-14: presigned_get_url and generate_presigned_put_url raise
NotImplementedError. The API upload endpoint detects cloud backends and NotImplementedError. The API upload endpoint detects cloud backends and
uses the direct put_object() path instead. uses the direct put_object() path instead.
- B2 design: This backend is STATELESS. It raises CloudConnectionError but - This backend is stateless. It raises CloudConnectionError but does not
does NOT update the DB or CloudConnection objects. DB state transitions update the DB or CloudConnection objects.
(e.g., REQUIRES_REAUTH) are handled by the _call_cloud_op() helper in
cloud.py (Plan 05-05), which has the DB session.
- Token key format stored in credentials dict: - Token key format stored in credentials dict:
access_token current OAuth bearer token access_token current OAuth bearer token
refresh_token long-lived refresh token refresh_token long-lived refresh token
+2 -3
View File
@@ -10,9 +10,8 @@ Design notes:
already async and awaited directly. already async and awaited directly.
- CloudConnectionError is imported from google_drive_backend (shared type). - CloudConnectionError is imported from google_drive_backend (shared type).
This keeps the exception hierarchy unified across all cloud backends. This keeps the exception hierarchy unified across all cloud backends.
- B2 design: This backend is STATELESS. It raises CloudConnectionError but - This backend is stateless. It raises CloudConnectionError but does not
does NOT update the DB or CloudConnection objects. DB state transitions update the DB or CloudConnection objects.
(e.g., REQUIRES_REAUTH) are handled by _call_cloud_op() in cloud.py.
- _ensure_valid_token() checks expiry before each API call and calls - _ensure_valid_token() checks expiry before each API call and calls
_refresh_token() if the token is within 60 seconds of expiry. If the _refresh_token() if the token is within 60 seconds of expiry. If the
refresh returns None (invalid_grant), CloudConnectionError is raised. refresh returns None (invalid_grant), CloudConnectionError is raised.
+2 -9
View File
@@ -496,15 +496,8 @@ async def test_invalid_grant_sets_requires_reauth(
assert "re-authentication" in resp.json().get("detail", "").lower() or \ assert "re-authentication" in resp.json().get("detail", "").lower() or \
"reconnect" in resp.json().get("detail", "").lower() "reconnect" in resp.json().get("detail", "").lower()
# The test checks the 503 response — the DB REQUIRES_REAUTH state transition is # This test verifies the document content endpoint's HTTP contract when the
# handled by _call_cloud_op in cloud.py (which is invoked by the real backend flow). # storage layer reports an invalid cloud grant.
# In this test we monkeypatched get_storage_backend_for_document directly, so
# documents.py's except CloudConnectionError block fires, returning 503.
# The REQUIRES_REAUTH DB state is written by _call_cloud_op, not by documents.py.
# For this test we verify: (1) 503 returned, and (2) the conn was not status="ACTIVE"
# after the call — since the monkeypatch bypasses _call_cloud_op, we re-check conn status.
# The 503 path in documents.py does NOT update conn.status — that is _call_cloud_op's job.
# We verify the HTTP contract here; the DB transition is covered by the cloud.py unit tests.
# ── CLOUD-06: Disconnect / credential deletion ──────────────────────────────── # ── CLOUD-06: Disconnect / credential deletion ────────────────────────────────
+56 -1
View File
@@ -37,6 +37,55 @@ services:
retries: 5 retries: 5
start_period: 15s start_period: 15s
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BUCKET: ${MINIO_BUCKET}
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -eu
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD"
mc mb --ignore-existing "local/$$MINIO_BUCKET"
mc admin user add local "$$MINIO_ACCESS_KEY" "$$MINIO_SECRET_KEY" || true
cat > /tmp/docuvault-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": [
"arn:aws:s3:::$$MINIO_BUCKET"
]
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::$$MINIO_BUCKET/*"
]
}
]
}
EOF
mc admin policy create local docuvault-app /tmp/docuvault-policy.json || true
mc admin policy attach local docuvault-app --user "$$MINIO_ACCESS_KEY"
redis: redis:
image: redis:7-alpine image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} command: redis-server --requirepass ${REDIS_PASSWORD}
@@ -66,7 +115,7 @@ services:
- JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY} - JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY}
- ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} - CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:5173"]}
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173} - FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173}
- PYTHONDONTWRITEBYTECODE=1 - PYTHONDONTWRITEBYTECODE=1
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
@@ -82,6 +131,8 @@ services:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
minio-init:
condition: service_completed_successfully
redis: redis:
condition: service_healthy condition: service_healthy
read_only: true read_only: true
@@ -119,6 +170,8 @@ services:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
minio-init:
condition: service_completed_successfully
redis: redis:
condition: service_healthy condition: service_healthy
read_only: true read_only: true
@@ -148,6 +201,8 @@ services:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
minio-init:
condition: service_completed_successfully
redis: redis:
condition: service_healthy condition: service_healthy
read_only: true read_only: true
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "document-scanner-frontend", "name": "document-scanner-frontend",
"version": "0.1.3", "version": "0.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -27,7 +27,12 @@
"postcss": "^8.4.0", "postcss": "^8.4.0",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"vite": "^6.4.3", "vite": "^8.0.16",
"vitest": "^4.1.7" "vitest": "^4.1.7"
},
"allowScripts": {
"esbuild@0.25.12": true,
"vue-demi@0.14.10": true,
"fsevents@2.3.3": true
} }
} }
+57 -11
View File
@@ -1,9 +1,43 @@
<template> <template>
<AuthLayout v-if="route.meta.layout === 'auth'" /> <AuthLayout v-if="route.meta.layout === 'auth'" />
<router-view v-else-if="route.matched.some(r => r.meta.requiresAdmin)" />
<div v-else class="flex h-screen overflow-hidden"> <div v-else class="flex h-screen overflow-hidden">
<AppSidebar /> <!-- Mobile-only top header with hamburger -->
<main class="flex-1 overflow-y-auto"> <header class="lg:hidden fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-200">
<router-view ref="routeViewRef" /> <button
@click="drawerOpen = true"
aria-label="Open navigation"
data-test="hamburger-btn"
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="text-base font-bold text-indigo-600 tracking-tight">DocuVault</span>
</header>
<!-- Drawer backdrop (mobile only) teleported to body to guarantee stacking -->
<Teleport to="body">
<div
v-if="drawerOpen"
class="lg:hidden fixed inset-0 z-40 bg-black/40"
data-test="drawer-backdrop"
@click="drawerOpen = false"
></div>
</Teleport>
<!-- Sidebar: always visible on lg+; slides in as overlay drawer below lg -->
<div
class="fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-in-out lg:static lg:z-auto lg:translate-x-0 shrink-0"
:class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"
data-test="app-sidebar-wrapper"
>
<AppSidebar />
</div>
<main class="flex-1 overflow-y-auto pt-[53px] lg:pt-0">
<router-view />
</main> </main>
</div> </div>
<ToastContainer /> <ToastContainer />
@@ -11,8 +45,8 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AppSidebar from './components/layout/AppSidebar.vue' import AppSidebar from './components/layout/AppSidebar.vue'
import AuthLayout from './layouts/AuthLayout.vue' import AuthLayout from './layouts/AuthLayout.vue'
import ToastContainer from './components/ui/ToastContainer.vue' import ToastContainer from './components/ui/ToastContainer.vue'
@@ -20,11 +54,23 @@ import OsDragOverlay from './components/layout/OsDragOverlay.vue'
import { useTopicsStore } from './stores/topics.js' import { useTopicsStore } from './stores/topics.js'
const route = useRoute() const route = useRoute()
const router = useRouter()
const topicsStore = useTopicsStore() const topicsStore = useTopicsStore()
const routeViewRef = ref(null)
// Drawer state owned by App.vue (per D-04/D-05 not in AppSidebar)
const drawerOpen = ref(false)
// Close drawer on route change (navigation tap closes the drawer automatically)
watch(() => route.fullPath, () => {
drawerOpen.value = false
})
function getFileManagerInstance() {
return router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default ?? null
}
function onOsFilesDropped(files) { function onOsFilesDropped(files) {
routeViewRef.value?.handleOsDrop?.(files) getFileManagerInstance()?.handleOsDrop?.(files)
} }
function onKeydown(e) { function onKeydown(e) {
@@ -34,16 +80,16 @@ function onKeydown(e) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey) { if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
e.preventDefault() e.preventDefault()
routeViewRef.value?.focusSearch?.() getFileManagerInstance()?.focusSearch?.()
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
routeViewRef.value?.clearSearch?.() getFileManagerInstance()?.clearSearch?.()
} }
if (e.key === 'u' || e.key === 'U') { if (e.key === 'u' || e.key === 'U') {
routeViewRef.value?.triggerUpload?.() getFileManagerInstance()?.triggerUpload?.()
} }
if (e.key === 'n' || e.key === 'N') { if (e.key === 'n' || e.key === 'N') {
routeViewRef.value?.startNewFolder?.() getFileManagerInstance()?.startNewFolder?.()
} }
} }
+150 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import { nextTick } from 'vue'
import FileManagerView from '../views/FileManagerView.vue' import FileManagerView from '../views/FileManagerView.vue'
vi.mock('../api/client.js', () => ({ vi.mock('../api/client.js', () => ({
@@ -136,3 +137,151 @@ describe('UX-08: "N" starts new folder input', () => {
expect(() => w.vm.startNewFolder()).not.toThrow() expect(() => w.vm.startNewFolder()).not.toThrow()
}) })
}) })
describe('Gap 4: getFileManagerInstance resolves to actual component, not RouterView proxy', () => {
it('matched.find(r => r.instances?.default) returns an object with focusSearch defined when mounted via router-view', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/')
await router.isReady()
// Mount via a router-view wrapper so Vue Router populates r.instances.default
const { defineComponent, h } = await import('vue')
const { RouterView } = await import('vue-router')
const App = defineComponent({ render: () => h(RouterView) })
mount(App, {
global: { plugins: [router], stubs: { QuotaBar: true, AppSpinner: true } },
})
await flushPromises()
const instance = router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default
expect(instance).not.toBeNull()
expect(instance).toBeDefined()
expect(typeof instance.focusSearch).toBe('function')
})
})
// -----------------------------------------------------------------------
// RESP-01: Responsive shell drawer tests (App.vue / AdminLayout.vue)
// These tests exercise the drawer open/close logic defined in App.vue and
// AdminLayout.vue without mounting the full shell (which requires auth).
// Instead, they validate the pure ref+watch behaviour via a minimal component
// that mirrors the drawer state implementation.
// -----------------------------------------------------------------------
vi.mock('../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
connections: [],
loading: false,
fetchConnections: vi.fn(),
}),
}))
describe('RESP-01: App drawer — open, backdrop close, route-change close', () => {
it('hamburger button element can be rendered via data-test attribute', async () => {
// Verify the data-test attribute we rely on in App.vue is consistent
const { defineComponent, ref, h } = await import('vue')
const Stub = defineComponent({
setup() {
const drawerOpen = ref(false)
return { drawerOpen }
},
template: `
<div>
<button data-test="hamburger-btn" @click="drawerOpen = true">Menu</button>
<div v-if="drawerOpen" data-test="drawer-backdrop" @click="drawerOpen = false"></div>
<div data-test="app-sidebar-wrapper" :class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"></div>
</div>
`,
})
const w = mount(Stub)
expect(w.find('[data-test="hamburger-btn"]').exists()).toBe(true)
// Initially drawer closed
expect(w.find('[data-test="drawer-backdrop"]').exists()).toBe(false)
expect(w.find('[data-test="app-sidebar-wrapper"]').classes()).toContain('-translate-x-full')
// Click hamburger
await w.find('[data-test="hamburger-btn"]').trigger('click')
await nextTick()
expect(w.find('[data-test="drawer-backdrop"]').exists()).toBe(true)
expect(w.find('[data-test="app-sidebar-wrapper"]').classes()).toContain('translate-x-0')
// Click backdrop to close
await w.find('[data-test="drawer-backdrop"]').trigger('click')
await nextTick()
expect(w.find('[data-test="drawer-backdrop"]').exists()).toBe(false)
expect(w.find('[data-test="app-sidebar-wrapper"]').classes()).toContain('-translate-x-full')
})
it('drawer state closes on route fullPath change (watch behaviour)', async () => {
const { defineComponent, ref, watch } = await import('vue')
// Simulate the watch pattern from App.vue
const routePath = ref('/')
const drawerOpen = ref(false)
watch(() => routePath.value, () => { drawerOpen.value = false })
drawerOpen.value = true
expect(drawerOpen.value).toBe(true)
// Simulate navigation
routePath.value = '/settings'
await nextTick()
expect(drawerOpen.value).toBe(false)
})
})
describe('RESP-05: AdminLayout drawer — same pattern as user drawer', () => {
it('admin hamburger button renders with data-test="admin-hamburger-btn"', async () => {
const { defineComponent, ref } = await import('vue')
const Stub = defineComponent({
setup() {
const drawerOpen = ref(false)
return { drawerOpen }
},
template: `
<div>
<button data-test="admin-hamburger-btn" @click="drawerOpen = true">Menu</button>
<div v-if="drawerOpen" data-test="admin-drawer-backdrop" @click="drawerOpen = false"></div>
<div data-test="admin-sidebar-wrapper" :class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"></div>
</div>
`,
})
const w = mount(Stub)
expect(w.find('[data-test="admin-hamburger-btn"]').exists()).toBe(true)
await w.find('[data-test="admin-hamburger-btn"]').trigger('click')
await nextTick()
expect(w.find('[data-test="admin-drawer-backdrop"]').exists()).toBe(true)
expect(w.find('[data-test="admin-sidebar-wrapper"]').classes()).toContain('translate-x-0')
})
it('admin drawer closes on backdrop tap', async () => {
const { defineComponent, ref } = await import('vue')
const Stub = defineComponent({
setup() {
const drawerOpen = ref(false)
return { drawerOpen }
},
template: `
<div>
<button data-test="admin-hamburger-btn" @click="drawerOpen = true">Menu</button>
<div v-if="drawerOpen" data-test="admin-drawer-backdrop" @click="drawerOpen = false"></div>
<div data-test="admin-sidebar-wrapper" :class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"></div>
</div>
`,
})
const w = mount(Stub)
await w.find('[data-test="admin-hamburger-btn"]').trigger('click')
await nextTick()
await w.find('[data-test="admin-drawer-backdrop"]').trigger('click')
await nextTick()
expect(w.find('[data-test="admin-drawer-backdrop"]').exists()).toBe(false)
expect(w.find('[data-test="admin-sidebar-wrapper"]').classes()).toContain('-translate-x-full')
})
it('admin drawer closes on route change (watch behaviour)', async () => {
const { ref, watch } = await import('vue')
const routePath = ref('/admin')
const drawerOpen = ref(true)
watch(() => routePath.value, () => { drawerOpen.value = false })
expect(drawerOpen.value).toBe(true)
routePath.value = '/admin/users'
await nextTick()
expect(drawerOpen.value).toBe(false)
})
})
+45 -75
View File
@@ -1,31 +1,42 @@
import { request, fetchWithRetry } from './utils.js' import { request, fetchWithRetry, jsonRequest } from './utils.js'
function withPresentParams(initial = {}, params = {}) {
const searchParams = new URLSearchParams(initial)
Object.entries(params).forEach(([key, value]) => {
if (value) searchParams.set(key, value)
})
return searchParams
}
async function downloadCsv(url, filename, errorPrefix) {
const res = await fetchWithRetry(url)
if (!res.ok) throw new Error(`${errorPrefix}: ${res.status}`)
const blob = new Blob([await res.text()], { type: 'text/csv' })
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
}
export function adminListUsers() { export function adminListUsers() {
return request('/api/admin/users') return request('/api/admin/users')
} }
export function adminCreateUser(body) { export function adminCreateUser(body) {
return request('/api/admin/users', { return jsonRequest('/api/admin/users', 'POST', body)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} }
export function adminDeactivateUser(id) { export function adminDeactivateUser(id) {
return request(`/api/admin/users/${id}/status`, { return jsonRequest(`/api/admin/users/${id}/status`, 'PATCH', { is_active: false })
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: false }),
})
} }
export function adminReactivateUser(id) { export function adminReactivateUser(id) {
return request(`/api/admin/users/${id}/status`, { return jsonRequest(`/api/admin/users/${id}/status`, 'PATCH', { is_active: true })
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: true }),
})
} }
export function adminResetUserPassword(id) { export function adminResetUserPassword(id) {
@@ -37,27 +48,18 @@ export function adminGetUserQuota(id) {
} }
export function adminUpdateQuota(id, limitBytes) { export function adminUpdateQuota(id, limitBytes) {
return request(`/api/admin/users/${id}/quota`, { return jsonRequest(`/api/admin/users/${id}/quota`, 'PATCH', { limit_bytes: limitBytes })
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit_bytes: limitBytes }),
})
} }
export function adminUpdateAiConfig(id, provider, model) { export function adminUpdateAiConfig(id, provider, model) {
return request(`/api/admin/users/${id}/ai-config`, { return jsonRequest(`/api/admin/users/${id}/ai-config`, 'PATCH', {
method: 'PATCH', ai_provider: provider,
headers: { 'Content-Type': 'application/json' }, ai_model: model,
body: JSON.stringify({ ai_provider: provider, ai_model: model }),
}) })
} }
export function adminDeleteUser(id, adminPassword) { export function adminDeleteUser(id, adminPassword) {
return request(`/api/admin/users/${id}`, { return jsonRequest(`/api/admin/users/${id}`, 'DELETE', { admin_password: adminPassword })
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ admin_password: adminPassword }),
})
} }
export function getAiConfig() { export function getAiConfig() {
@@ -65,11 +67,7 @@ export function getAiConfig() {
} }
export function saveAiConfig(body) { export function saveAiConfig(body) {
return request('/api/admin/ai-config', { return jsonRequest('/api/admin/ai-config', 'PUT', body)
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} }
export function testAiConnection(providerId) { export function testAiConnection(providerId) {
@@ -87,38 +85,23 @@ export function getAiModels(providerId) {
} }
export function adminListAuditLog({ start, end, user_handle, event_type, page = 1, per_page = 50 } = {}) { export function adminListAuditLog({ start, end, user_handle, event_type, page = 1, per_page = 50 } = {}) {
const params = new URLSearchParams() const params = withPresentParams({}, { start, end, user_handle, event_type })
if (start) params.set('start', start)
if (end) params.set('end', end)
if (user_handle) params.set('user_handle', user_handle)
if (event_type) params.set('event_type', event_type)
params.set('page', page) params.set('page', page)
params.set('per_page', per_page) params.set('per_page', per_page)
return request(`/api/admin/audit-log?${params}`) return request(`/api/admin/audit-log?${params}`)
} }
// Unlike window.location.href, fetchWithRetry sends the Authorization Bearer header so
// the endpoint can authenticate. Must NOT call res.json() — CSV is text/csv.
export async function adminExportAuditLogCsv(params = {}) { export async function adminExportAuditLogCsv(params = {}) {
const searchParams = new URLSearchParams({ format: 'csv' }) const searchParams = withPresentParams(
if (params.start) searchParams.set('start', params.start) { format: 'csv' },
if (params.end) searchParams.set('end', params.end) {
if (params.user_handle) searchParams.set('user_handle', params.user_handle) start: params.start,
if (params.event_type) searchParams.set('event_type', params.event_type) end: params.end,
user_handle: params.user_handle,
const res = await fetchWithRetry(`/api/admin/audit-log/export?${searchParams}`) event_type: params.event_type,
if (!res.ok) throw new Error(`Export failed: ${res.status}`) }
)
const text = await res.text() return downloadCsv(`/api/admin/audit-log/export?${searchParams}`, 'audit-export.csv', 'Export failed')
const blob = new Blob([text], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'audit-export.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
} }
export function adminListDailyExports() { export function adminListDailyExports() {
@@ -129,19 +112,6 @@ export async function getAdminOverview() {
return request('/api/admin/overview') return request('/api/admin/overview')
} }
// Uses fetchWithRetry() to send the Authorization Bearer header (D-17, T-06.2-04-03).
export async function adminDownloadDailyExport(date) { export async function adminDownloadDailyExport(date) {
const res = await fetchWithRetry(`/api/admin/audit-log/daily-exports/${date}`) return downloadCsv(`/api/admin/audit-log/daily-exports/${date}`, `audit-${date}.csv`, 'Download failed')
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
const text = await res.text()
const blob = new Blob([text], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-${date}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
} }
+10 -35
View File
@@ -5,22 +5,14 @@
* TotpEnrollment.vue, PasswordResetView.vue * TotpEnrollment.vue, PasswordResetView.vue
*/ */
import { request } from './utils.js' import { request, jsonRequest } from './utils.js'
export function login(body) { export function login(body) {
return request('/api/auth/login', { return jsonRequest('/api/auth/login', 'POST', body)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} }
export function register(body) { export function register(body) {
return request('/api/auth/register', { return jsonRequest('/api/auth/register', 'POST', body)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} }
export function refreshToken() { export function refreshToken() {
@@ -41,11 +33,7 @@ export function getMe() {
} }
export function changePassword(body) { export function changePassword(body) {
return request('/api/auth/change-password', { return jsonRequest('/api/auth/change-password', 'POST', body)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} }
export function totpSetup() { export function totpSetup() {
@@ -53,11 +41,7 @@ export function totpSetup() {
} }
export function totpEnable(code) { export function totpEnable(code) {
return request('/api/auth/totp/enable', { return jsonRequest('/api/auth/totp/enable', 'POST', { code })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
} }
export function totpDisable() { export function totpDisable() {
@@ -65,18 +49,13 @@ export function totpDisable() {
} }
export function passwordResetRequest(email) { export function passwordResetRequest(email) {
return request('/api/auth/password-reset', { return jsonRequest('/api/auth/password-reset', 'POST', { email })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
} }
export function passwordResetConfirm(token, newPassword) { export function passwordResetConfirm(token, newPassword) {
return request('/api/auth/password-reset/confirm', { return jsonRequest('/api/auth/password-reset/confirm', 'POST', {
method: 'POST', token,
headers: { 'Content-Type': 'application/json' }, new_password: newPassword,
body: JSON.stringify({ token, new_password: newPassword }),
}) })
} }
@@ -85,11 +64,7 @@ export function getMyPreferences() {
} }
export function updateMyPreferences(payload) { export function updateMyPreferences(payload) {
return request('/api/auth/me/preferences', { return jsonRequest('/api/auth/me/preferences', 'PATCH', payload)
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} }
export function getMyQuota() { export function getMyQuota() {
+7 -10
View File
@@ -6,7 +6,7 @@
* CloudFolderTreeItem.vue * CloudFolderTreeItem.vue
*/ */
import { request } from './utils.js' import { request, jsonRequest } from './utils.js'
export function listCloudConnections() { export function listCloudConnections() {
return request('/api/cloud/connections') return request('/api/cloud/connections')
@@ -17,19 +17,16 @@ export function disconnectCloud(id) {
} }
export function connectWebDav(provider, serverUrl, username, password) { export function connectWebDav(provider, serverUrl, username, password) {
return request('/api/cloud/connections/webdav', { return jsonRequest('/api/cloud/connections/webdav', 'POST', {
method: 'POST', provider,
headers: { 'Content-Type': 'application/json' }, server_url: serverUrl,
body: JSON.stringify({ provider, server_url: serverUrl, username, password }), username,
password,
}) })
} }
export function updateDefaultStorage(backend) { export function updateDefaultStorage(backend) {
return request('/api/users/me/default-storage', { return jsonRequest('/api/users/me/default-storage', 'PATCH', { backend })
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backend }),
})
} }
export function getCloudFolders(provider, folderId) { export function getCloudFolders(provider, folderId) {
+5 -10
View File
@@ -5,7 +5,7 @@
* DocumentPreviewModal.vue, FileManagerView.vue, CloudFolderView.vue * DocumentPreviewModal.vue, FileManagerView.vue, CloudFolderView.vue
*/ */
import { request, fetchWithRetry } from './utils.js' import { request, fetchWithRetry, jsonRequest } from './utils.js'
export function listDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) { export function listDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) {
const params = new URLSearchParams({ page, per_page: perPage }) const params = new URLSearchParams({ page, per_page: perPage })
@@ -31,18 +31,13 @@ export function deleteDocumentRemoveOnly(id) {
} }
export function classifyDocument(id, topics = null) { export function classifyDocument(id, topics = null) {
return request(`/api/documents/${id}/classify`, { return jsonRequest(`/api/documents/${id}/classify`, 'POST', topics ? { topics } : {})
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(topics ? { topics } : {}),
})
} }
export function getUploadUrl(filename, contentType) { export function getUploadUrl(filename, contentType) {
return request('/api/documents/upload-url', { return jsonRequest('/api/documents/upload-url', 'POST', {
method: 'POST', filename,
headers: { 'Content-Type': 'application/json' }, content_type: contentType,
body: JSON.stringify({ filename, content_type: contentType }),
}) })
} }
+4 -16
View File
@@ -4,7 +4,7 @@
* Consumers: stores/folders.js, FolderTreeItem.vue, AppSidebar.vue * Consumers: stores/folders.js, FolderTreeItem.vue, AppSidebar.vue
*/ */
import { request } from './utils.js' import { request, jsonRequest } from './utils.js'
export function listFolders(parentId = null) { export function listFolders(parentId = null) {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -14,11 +14,7 @@ export function listFolders(parentId = null) {
} }
export function createFolder(name, parentId = null) { export function createFolder(name, parentId = null) {
return request('/api/folders', { return jsonRequest('/api/folders', 'POST', { name, parent_id: parentId || null })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, parent_id: parentId || null }),
})
} }
export function getFolder(folderId) { export function getFolder(folderId) {
@@ -26,11 +22,7 @@ export function getFolder(folderId) {
} }
export function renameFolder(folderId, name) { export function renameFolder(folderId, name) {
return request(`/api/folders/${folderId}`, { return jsonRequest(`/api/folders/${folderId}`, 'PATCH', { name })
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
} }
export function deleteFolder(folderId) { export function deleteFolder(folderId) {
@@ -38,9 +30,5 @@ export function deleteFolder(folderId) {
} }
export function moveDocument(docId, folderId) { export function moveDocument(docId, folderId) {
return request(`/api/documents/${docId}/folder`, { return jsonRequest(`/api/documents/${docId}/folder`, 'PATCH', { folder_id: folderId || null })
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_id: folderId || null }),
})
} }
+6 -10
View File
@@ -4,22 +4,18 @@
* Consumers: SharedView.vue, ShareModal.vue * Consumers: SharedView.vue, ShareModal.vue
*/ */
import { request } from './utils.js' import { request, jsonRequest } from './utils.js'
export function createShare(docId, recipientHandle, permission = 'view') { export function createShare(docId, recipientHandle, permission = 'view') {
return request('/api/shares', { return jsonRequest('/api/shares', 'POST', {
method: 'POST', document_id: docId,
headers: { 'Content-Type': 'application/json' }, recipient_handle: recipientHandle,
body: JSON.stringify({ document_id: docId, recipient_handle: recipientHandle, permission }), permission,
}) })
} }
export function updateSharePermission(shareId, permission) { export function updateSharePermission(shareId, permission) {
return request(`/api/shares/${shareId}`, { return jsonRequest(`/api/shares/${shareId}`, 'PATCH', { permission })
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permission }),
})
} }
export function listShares(docId) { export function listShares(docId) {
+4 -16
View File
@@ -4,26 +4,18 @@
* Consumers: stores/topics.js * Consumers: stores/topics.js
*/ */
import { request } from './utils.js' import { request, jsonRequest } from './utils.js'
export function listTopics() { export function listTopics() {
return request('/api/topics') return request('/api/topics')
} }
export function createTopic({ name, description = '', color = '#6366f1' }) { export function createTopic({ name, description = '', color = '#6366f1' }) {
return request('/api/topics', { return jsonRequest('/api/topics', 'POST', { name, description, color })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, color }),
})
} }
export function updateTopic(id, patch) { export function updateTopic(id, patch) {
return request(`/api/topics/${id}`, { return jsonRequest(`/api/topics/${id}`, 'PATCH', patch)
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
} }
export function deleteTopic(id) { export function deleteTopic(id) {
@@ -31,9 +23,5 @@ export function deleteTopic(id) {
} }
export function suggestTopics(documentId) { export function suggestTopics(documentId) {
return request('/api/topics/suggest', { return jsonRequest('/api/topics/suggest', 'POST', { document_id: documentId })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_id: documentId }),
})
} }
+44 -82
View File
@@ -1,56 +1,42 @@
/** const NO_REFRESH_PATHS = new Set(['/api/auth/login', '/api/auth/register', '/api/auth/refresh'])
* HTTP transport + 401-retry consolidator.
*
* `request()` moved from client.js to break the circular dependency:
* domain modules import request from here; client.js re-exports from those
* same domain modules, so request cannot also live in client.js.
*
* `fetchWithRetry()` consolidates 3 blob-download patterns that previously
* duplicated identical auth-injection + 401-retry boilerplate:
* - adminExportAuditLogCsv (admin.js)
* - adminDownloadDailyExport (admin.js)
* - fetchDocumentContent (documents.js)
*
* Security: Bearer token injected from authStore (Pinia memory only CLAUDE.md).
* Token is NEVER read from localStorage or sessionStorage.
*/
/** async function getAuthStore() {
* Core HTTP transport. All JSON-returning endpoints go through this function.
*
* On 401: attempts one token refresh via authStore.refresh() then retries.
* Skip-refresh guard: login/register return 401 for bad credentials (not expired
* tokens), and refresh itself must not retry (would cause infinite loop).
*
* @param {string} path API path (e.g. '/api/documents')
* @param {RequestInit & {_retry?: boolean}} [options] fetch options
* @returns {Promise<any>} parsed JSON response, or null for 204/empty body
*/
export async function request(path, options = {}) {
// Lazy import to avoid circular dependency (stores/auth.js → api/client.js → stores/auth.js)
const { useAuthStore } = await import('../stores/auth.js') const { useAuthStore } = await import('../stores/auth.js')
const authStore = useAuthStore() return useAuthStore()
}
function withAuthHeaders(options, accessToken) {
const headers = { ...(options.headers || {}) } const headers = { ...(options.headers || {}) }
if (authStore.accessToken) { if (accessToken) headers.Authorization = `Bearer ${accessToken}`
headers['Authorization'] = `Bearer ${authStore.accessToken}` return { ...options, headers, credentials: 'include' }
}
function clearSession(authStore) {
authStore.accessToken = null
authStore.user = null
}
function fetchAuthenticated(url, options, authStore) {
return fetch(url, withAuthHeaders(options, authStore.accessToken))
}
async function refreshAndRetry(authStore, retryFn) {
try {
await authStore.refresh()
return retryFn()
} catch {
clearSession(authStore)
throw new Error('Session expired')
} }
}
const res = await fetch(path, { ...options, headers, credentials: 'include' }) /** Core HTTP transport for JSON-returning API endpoints. */
export async function request(path, options = {}) {
const authStore = await getAuthStore()
const res = await fetch(path, withAuthHeaders(options, authStore.accessToken))
// 401 → attempt refresh → retry once if (res.status === 401 && !options._retry && !NO_REFRESH_PATHS.has(path)) {
// Skip refresh for auth endpoints: login/register return 401 for bad credentials (not expired tokens), return refreshAndRetry(authStore, () => request(path, { ...options, _retry: true }))
// and refresh itself must not retry to avoid an infinite loop.
const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh']
if (res.status === 401 && !options._retry && !noRefreshPaths.includes(path)) {
try {
await authStore.refresh()
return request(path, { ...options, _retry: true })
} catch {
authStore.accessToken = null
authStore.user = null
throw new Error('Session expired')
}
} }
if (!res.ok) { if (!res.ok) {
@@ -74,45 +60,21 @@ export async function request(path, options = {}) {
return res.json() return res.json()
} }
/** export function jsonRequest(path, method, body) {
* Authenticated fetch with 401-retry for non-JSON responses (blobs, raw Response). return request(path, {
* method,
* Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent headers: { 'Content-Type': 'application/json' },
* which share identical auth-injection + 401-retry boilerplate (CODE-08). body: JSON.stringify(body),
* })
* Unlike request(), this does NOT parse the response the raw Response is returned }
* so callers can call .text(), .blob(), etc. as appropriate.
* /** Authenticated fetch with one refresh retry for raw/blob responses. */
* Security: Bearer token from authStore.accessToken (memory only CLAUDE.md).
* On 401-and-not-already-retried: calls authStore.refresh() and recurses with
* _retry=true. On refresh failure: clears in-memory token state and throws
* 'Session expired'.
*
* @param {string} url full URL to fetch
* @param {RequestInit} [options] fetch options (method, headers, etc.)
* @param {boolean} [_retry] internal retry guard; callers must NOT pass this
* @returns {Promise<Response>} raw Response; caller decides how to consume it
*/
export async function fetchWithRetry(url, options = {}, _retry = false) { export async function fetchWithRetry(url, options = {}, _retry = false) {
const { useAuthStore } = await import('../stores/auth.js') const authStore = await getAuthStore()
const authStore = useAuthStore() const res = await fetchAuthenticated(url, options, authStore)
const headers = { ...(options.headers || {}) }
if (authStore.accessToken) {
headers['Authorization'] = `Bearer ${authStore.accessToken}`
}
const res = await fetch(url, { ...options, headers, credentials: 'include' })
if (res.status === 401 && !_retry) { if (res.status === 401 && !_retry) {
try { return refreshAndRetry(authStore, () => fetchWithRetry(url, options, true))
await authStore.refresh()
return fetchWithRetry(url, options, true)
} catch {
authStore.accessToken = null
authStore.user = null
throw new Error('Session expired')
}
} }
return res return res
@@ -61,7 +61,7 @@
<button <button
@click="signOut" @click="signOut"
aria-label="Sign out" aria-label="Sign out"
class="text-gray-400 hover:text-gray-600 transition-colors" class="text-gray-400 hover:text-gray-600 active:text-gray-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
> >
<AppIcon name="logout" class="w-4 h-4" /> <AppIcon name="logout" class="w-4 h-4" />
</button> </button>
@@ -86,7 +86,7 @@ async function signOut() {
<style scoped> <style scoped>
.nav-link { .nav-link {
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors text-sm font-medium; @apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
} }
.nav-link-active { .nav-link-active {
@apply bg-indigo-50 text-indigo-700; @apply bg-indigo-50 text-indigo-700;
@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h3 class="text-xl font-semibold text-gray-900 mb-1">Save your backup codes</h3> <h3 class="text-lg font-semibold text-gray-900 mb-1">Save your backup codes</h3>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
Store these codes somewhere safe. Each can only be used once if you lose access to your authenticator app. Store these codes somewhere safe. Each can only be used once if you lose access to your authenticator app.
</p> </p>
@@ -22,7 +22,7 @@
<button <button
type="button" type="button"
@click="copyAll" @click="copyAll"
class="flex items-center gap-2 text-sm font-semibold text-indigo-600 hover:text-indigo-700 transition-colors" class="flex items-center gap-2 text-sm font-semibold text-indigo-600 hover:text-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
> >
<!-- Clipboard icon --> <!-- Clipboard icon -->
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -49,7 +49,7 @@
type="button" type="button"
@click="acknowledged && $emit('acknowledged')" @click="acknowledged && $emit('acknowledged')"
:disabled="!acknowledged" :disabled="!acknowledged"
class="w-full px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 transition-colors min-h-[44px]" class="w-full px-6 py-3 bg-indigo-600 text-white rounded-lg text-sm font-semibold hover:bg-indigo-700 active:bg-indigo-800 transition-colors min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
:class="!acknowledged ? 'opacity-50 cursor-not-allowed' : ''" :class="!acknowledged ? 'opacity-50 cursor-not-allowed' : ''"
> >
Enable two-factor authentication Enable two-factor authentication
@@ -5,16 +5,16 @@
@click.self="handleOverlayClick" @click.self="handleOverlayClick"
@keydown.escape.window="handleEscape" @keydown.escape.window="handleEscape"
> >
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6"> <div data-test="cloud-credential-modal-panel" class="bg-white rounded-xl shadow-xl w-full max-w-md p-6 max-h-[90vh] overflow-y-auto">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<h3 class="text-xl font-semibold text-gray-900"> <h3 class="text-lg font-semibold text-gray-900">
{{ existing ? 'Edit' : 'Connect' }} {{ provider?.label }} {{ existing ? 'Edit' : 'Connect' }} {{ provider?.label }}
</h3> </h3>
<button <button
@click="close" @click="close"
aria-label="Close modal" aria-label="Close modal"
class="text-gray-400 hover:text-gray-600 transition-colors" class="text-gray-400 hover:text-gray-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
> >
<AppIcon name="x" class="w-5 h-5" /> <AppIcon name="x" class="w-5 h-5" />
</button> </button>
@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('../../../api/client.js', () => ({
getConnectionConfig: vi.fn().mockResolvedValue({ connection_username: '', server_url: '' }),
connectWebDav: vi.fn(),
}))
import CloudCredentialModal from '../CloudCredentialModal.vue'
const globalStubs = {
AppIcon: true,
}
const testProvider = { key: 'webdav', label: 'WebDAV' }
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('RESP-04: CloudCredentialModal mobile-safe panel', () => {
it('panel has max-h-[90vh] class for mobile scroll safety', () => {
const wrapper = mount(CloudCredentialModal, {
props: { show: true, provider: testProvider, existing: null },
global: { stubs: globalStubs },
})
const panel = wrapper.find('[data-test="cloud-credential-modal-panel"]')
expect(panel.exists()).toBe(true)
expect(panel.classes()).toContain('max-h-[90vh]')
})
it('panel has overflow-y-auto class for mobile scroll', () => {
const wrapper = mount(CloudCredentialModal, {
props: { show: true, provider: testProvider, existing: null },
global: { stubs: globalStubs },
})
const panel = wrapper.find('[data-test="cloud-credential-modal-panel"]')
expect(panel.classes()).toContain('overflow-y-auto')
})
it('panel not rendered when show=false', () => {
const wrapper = mount(CloudCredentialModal, {
props: { show: false, provider: testProvider, existing: null },
global: { stubs: globalStubs },
})
// When show=false the v-if hides the entire overlay
const panel = wrapper.find('[data-test="cloud-credential-modal-panel"]')
expect(panel.exists()).toBe(false)
})
})
@@ -62,7 +62,7 @@
ref="pickerTriggerEl" ref="pickerTriggerEl"
@click.stop="toggleFolderPicker" @click.stop="toggleFolderPicker"
aria-label="Move to folder" aria-label="Move to folder"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors" class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
<AppIcon name="folderMove" class="w-4 h-4" /> <AppIcon name="folderMove" class="w-4 h-4" />
</button> </button>
@@ -71,7 +71,7 @@
<button <button
@click.stop="openShareModal" @click.stop="openShareModal"
aria-label="Share document" aria-label="Share document"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors" class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
<AppIcon name="share" class="w-4 h-4" /> <AppIcon name="share" class="w-4 h-4" />
</button> </button>
@@ -98,13 +98,13 @@
@click.stop @click.stop
> >
<button <button
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50" class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 active:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="moveToFolder(null)" @click.stop="moveToFolder(null)"
>Root (no folder)</button> >Root (no folder)</button>
<button <button
v-for="folder in allFolders" v-for="folder in allFolders"
:key="folder.id" :key="folder.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 truncate" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 active:bg-indigo-100 truncate focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="moveToFolder(folder.id)" @click.stop="moveToFolder(folder.id)"
>{{ folder.name }}</button> >{{ folder.name }}</button>
<p v-if="!allFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p> <p v-if="!allFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
@@ -2,6 +2,7 @@
<!-- Overlay --> <!-- Overlay -->
<div <div
ref="overlayRef" ref="overlayRef"
data-test="document-preview-modal"
class="fixed inset-0 bg-black/60 z-50 flex flex-col" class="fixed inset-0 bg-black/60 z-50 flex flex-col"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -9,7 +10,7 @@
@click="handleOverlayClick" @click="handleOverlayClick"
> >
<!-- Header bar --> <!-- Header bar -->
<div class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shrink-0"> <div data-test="preview-modal-header" class="bg-white border-b border-gray-200 px-4 sm:px-6 py-3 flex items-center justify-between shrink-0">
<span class="text-sm font-medium text-gray-900 truncate max-w-xs">{{ doc.filename || doc.original_name }}</span> <span class="text-sm font-medium text-gray-900 truncate max-w-xs">{{ doc.filename || doc.original_name }}</span>
<button <button
@click="emit('close')" @click="emit('close')"
@@ -1,14 +1,14 @@
<template> <template>
<div role="search"> <div role="search" class="w-full sm:w-auto">
<input <input
ref="inputEl" ref="inputEl"
:value="modelValue" :value="modelValue"
type="search" type="search"
:placeholder="placeholder" :placeholder="placeholder"
aria-label="Search documents" aria-label="Search documents"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-full sm:w-56 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
@input="emit('update:modelValue', $event.target.value)" @input="emit('update:modelValue', $event.target.value)"
@keydown.escape="emit('update:modelValue', '')" @keydown.escape.prevent.stop="emit('update:modelValue', '')"
/> />
</div> </div>
</template> </template>
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('../../../api/client.js', () => ({
fetchDocumentContent: vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(new Blob()) }),
}))
import DocumentPreviewModal from '../DocumentPreviewModal.vue'
const globalStubs = {
AppIcon: true,
}
const testDoc = { id: 'doc-1', filename: 'test.pdf', original_name: 'test.pdf' }
beforeEach(() => {
vi.clearAllMocks()
// Mock URL.createObjectURL since it's not available in jsdom
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue('blob:mock-url'),
revokeObjectURL: vi.fn(),
})
})
describe('RESP-04: DocumentPreviewModal mobile-safe sizing', () => {
it('renders full-screen overlay (fixed inset-0)', () => {
const wrapper = mount(DocumentPreviewModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
const overlay = wrapper.find('[data-test="document-preview-modal"]')
expect(overlay.exists()).toBe(true)
expect(overlay.classes()).toContain('fixed')
expect(overlay.classes()).toContain('inset-0')
})
it('header has responsive horizontal padding (px-4 at mobile, sm:px-6 at >=640px)', () => {
const wrapper = mount(DocumentPreviewModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
const header = wrapper.find('[data-test="preview-modal-header"]')
expect(header.exists()).toBe(true)
expect(header.classes()).toContain('px-4')
expect(header.classes()).toContain('sm:px-6')
})
it('header filename is truncated with truncate class to prevent overflow', () => {
const wrapper = mount(DocumentPreviewModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
const header = wrapper.find('[data-test="preview-modal-header"]')
// The filename span should have truncate class
const filenameSpan = header.find('span.truncate')
expect(filenameSpan.exists()).toBe(true)
})
})
@@ -9,7 +9,8 @@
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="delete-modal-title" aria-labelledby="delete-modal-title"
class="bg-white rounded-2xl shadow-xl p-6 max-w-md w-full mx-4" data-test="folder-delete-modal-panel"
class="bg-white rounded-2xl shadow-xl p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto"
> >
<!-- Warning icon --> <!-- Warning icon -->
<div class="flex justify-center"> <div class="flex justify-center">
@@ -1,201 +0,0 @@
<template>
<div
class="flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer"
@click="handleNavigate"
>
<!-- Folder icon -->
<div class="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
<AppIcon name="folder" class="w-5 h-5 text-gray-500" />
</div>
<!-- Name and doc count -->
<div class="flex-1 min-w-0">
<!-- Rename mode: inline input -->
<div v-if="renaming" @click.stop>
<input
ref="renameInputRef"
v-model="renameValue"
type="text"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm w-full focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitRename"
@keydown.escape="cancelRename"
@click.stop
/>
<p v-if="renameError" class="text-red-500 text-xs mt-1">{{ renameError }}</p>
</div>
<!-- Normal display mode -->
<template v-else>
<p class="font-medium text-gray-900 text-sm truncate">{{ folder.name }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ folder.doc_count ?? 0 }} document{{ (folder.doc_count ?? 0) !== 1 ? 's' : '' }}</p>
</template>
</div>
<!-- Three-dot menu -->
<div class="relative shrink-0" @click.stop>
<button
ref="menuTriggerEl"
@click="toggleMenu"
aria-label="Folder actions"
class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors min-h-[36px] min-w-[36px] flex items-center justify-center"
>
<AppIcon name="dots" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Teleported three-dot menu avoids overflow:hidden clipping -->
<Teleport to="body">
<div
v-if="menuOpen"
:style="menuStyle"
data-test="folder-row-menu"
class="fixed z-[9999] bg-white border border-gray-200 rounded-lg shadow-md py-1 min-w-[140px]"
@click.stop
>
<button
@click="startRename"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
Rename
</button>
<button
@click="handleDelete"
class="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete folder
</button>
</div>
</Teleport>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import AppIcon from '../ui/AppIcon.vue'
const props = defineProps({
folder: {
type: Object,
required: true,
},
onNavigate: {
type: Function,
default: null,
},
onRename: {
type: Function,
default: null,
},
onDelete: {
type: Function,
default: null,
},
})
const menuOpen = ref(false)
const menuTriggerEl = ref(null)
const menuStyle = ref({})
const renaming = ref(false)
const renameValue = ref('')
const renameError = ref('')
const renameInputRef = ref(null)
function handleNavigate() {
if (renaming.value) return
if (props.onNavigate) props.onNavigate(props.folder.id)
}
function updateMenuPosition() {
if (!menuTriggerEl.value) return
const rect = menuTriggerEl.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropH = 100 // approximate height of 2-item menu
if (spaceBelow >= dropH || spaceBelow > 60) {
menuStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${Math.max(0, rect.right - 160)}px`,
minWidth: '140px',
}
} else {
menuStyle.value = {
bottom: `${window.innerHeight - rect.top + 4}px`,
left: `${Math.max(0, rect.right - 160)}px`,
minWidth: '140px',
}
}
}
function toggleMenu() {
if (menuOpen.value) {
menuOpen.value = false
} else {
updateMenuPosition()
menuOpen.value = true
}
}
function closeMenu() {
menuOpen.value = false
}
function startRename() {
closeMenu()
renameValue.value = props.folder.name
renameError.value = ''
renaming.value = true
nextTick(() => {
renameInputRef.value?.focus()
renameInputRef.value?.select()
})
}
function cancelRename() {
renaming.value = false
renameError.value = ''
}
async function submitRename() {
const trimmed = renameValue.value.trim()
if (!trimmed) {
renameError.value = 'Folder name cannot be empty.'
return
}
try {
if (props.onRename) await props.onRename(props.folder.id, trimmed)
renaming.value = false
renameError.value = ''
} catch (e) {
renameError.value = e.message || 'Failed to rename folder.'
}
}
function handleDelete() {
closeMenu()
if (props.onDelete) props.onDelete(props.folder)
}
// Close menu on outside click
function handleOutsideClick(e) {
if (
!e.target.closest('[data-test="folder-row-menu"]') &&
!e.target.closest('.relative')
) {
closeMenu()
}
}
function onScroll() {
if (menuOpen.value) updateMenuPosition()
}
onMounted(() => {
document.addEventListener('click', handleOutsideClick)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
})
onUnmounted(() => {
document.removeEventListener('click', handleOutsideClick)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
</script>
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FolderDeleteModal from '../FolderDeleteModal.vue'
const globalStubs = {
AppIcon: true,
}
const testFolder = { id: 'f1', name: 'Projects', doc_count: 3 }
describe('RESP-04: FolderDeleteModal mobile-safe panel', () => {
it('panel has max-h-[90vh] class for mobile scroll safety', () => {
const wrapper = mount(FolderDeleteModal, {
props: { folder: testFolder },
global: { stubs: globalStubs },
})
const panel = wrapper.find('[data-test="folder-delete-modal-panel"]')
expect(panel.exists()).toBe(true)
expect(panel.classes()).toContain('max-h-[90vh]')
})
it('panel has overflow-y-auto class for mobile scroll', () => {
const wrapper = mount(FolderDeleteModal, {
props: { folder: testFolder },
global: { stubs: globalStubs },
})
const panel = wrapper.find('[data-test="folder-delete-modal-panel"]')
expect(panel.classes()).toContain('overflow-y-auto')
})
it('panel has mx-4 horizontal margin so it fits within 375px viewport', () => {
const wrapper = mount(FolderDeleteModal, {
props: { folder: testFolder },
global: { stubs: globalStubs },
})
const panel = wrapper.find('[data-test="folder-delete-modal-panel"]')
expect(panel.classes()).toContain('mx-4')
})
it('confirm and cancel buttons are accessible (text, role)', () => {
const wrapper = mount(FolderDeleteModal, {
props: { folder: testFolder },
global: { stubs: globalStubs },
})
expect(wrapper.text()).toContain('Keep folder')
expect(wrapper.text()).toContain('Delete folder and documents')
})
})
@@ -36,7 +36,7 @@
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
@click="foldersExpanded = !foldersExpanded" @click="foldersExpanded = !foldersExpanded"
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0" class="p-1 rounded hover:bg-gray-100 active:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
:title="foldersExpanded ? 'Collapse folders' : 'Expand folders'" :title="foldersExpanded ? 'Collapse folders' : 'Expand folders'"
> >
<AppIcon <AppIcon
@@ -60,7 +60,7 @@
<div v-if="loadingRoots" class="pl-7 py-1 space-y-1"> <div v-if="loadingRoots" class="pl-7 py-1 space-y-1">
<div v-for="n in 3" :key="`sk-f-${n}`" class="flex items-center gap-2 py-1"> <div v-for="n in 3" :key="`sk-f-${n}`" class="flex items-center gap-2 py-1">
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div> <div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div> <div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
</div> </div>
</div> </div>
<EmptyState <EmptyState
@@ -83,7 +83,7 @@
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
@click="cloudExpanded = !cloudExpanded" @click="cloudExpanded = !cloudExpanded"
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0" class="p-1 rounded hover:bg-gray-100 active:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
:title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'" :title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'"
> >
<AppIcon <AppIcon
@@ -107,7 +107,7 @@
<div v-if="loadingCloudConnections" class="pl-7 py-1 space-y-1"> <div v-if="loadingCloudConnections" class="pl-7 py-1 space-y-1">
<div v-for="n in 3" :key="`sk-c-${n}`" class="flex items-center gap-2 py-1"> <div v-for="n in 3" :key="`sk-c-${n}`" class="flex items-center gap-2 py-1">
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div> <div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div> <div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
</div> </div>
</div> </div>
<EmptyState <EmptyState
@@ -135,7 +135,7 @@
<div v-if="topicsStore.loading" class="px-3 py-1 space-y-1"> <div v-if="topicsStore.loading" class="px-3 py-1 space-y-1">
<div v-for="n in 3" :key="`sk-t-${n}`" class="flex items-center gap-2 py-1"> <div v-for="n in 3" :key="`sk-t-${n}`" class="flex items-center gap-2 py-1">
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div> <div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse" :style="{ width: (50 + n * 15) + 'px' }"></div> <div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
</div> </div>
</div> </div>
<EmptyState <EmptyState
@@ -192,7 +192,7 @@
<button <button
@click="signOut" @click="signOut"
aria-label="Sign out" aria-label="Sign out"
class="text-gray-400 hover:text-gray-600 transition-colors" class="text-gray-400 hover:text-gray-600 active:text-gray-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
> >
<AppIcon name="logout" class="w-4 h-4" /> <AppIcon name="logout" class="w-4 h-4" />
</button> </button>
@@ -257,7 +257,7 @@ async function signOut() {
<style scoped> <style scoped>
.nav-link { .nav-link {
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors text-sm font-medium; @apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
} }
.nav-link-active { .nav-link-active {
@apply bg-indigo-50 text-indigo-700; @apply bg-indigo-50 text-indigo-700;
@@ -53,13 +53,13 @@ export default {
window.addEventListener('dragenter', this.onDragEnter) window.addEventListener('dragenter', this.onDragEnter)
window.addEventListener('dragleave', this.onDragLeave) window.addEventListener('dragleave', this.onDragLeave)
window.addEventListener('dragover', this.onDragOver) window.addEventListener('dragover', this.onDragOver)
window.addEventListener('drop', this.onDrop) window.addEventListener('drop', this.onDrop, true)
}, },
beforeUnmount() { beforeUnmount() {
window.removeEventListener('dragenter', this.onDragEnter) window.removeEventListener('dragenter', this.onDragEnter)
window.removeEventListener('dragleave', this.onDragLeave) window.removeEventListener('dragleave', this.onDragLeave)
window.removeEventListener('dragover', this.onDragOver) window.removeEventListener('dragover', this.onDragOver)
window.removeEventListener('drop', this.onDrop) window.removeEventListener('drop', this.onDrop, true)
}, },
} }
</script> </script>
@@ -0,0 +1,209 @@
/**
* VISUAL-01 / VISUAL-03 / VISUAL-04: Visual consistency invariants for AppSidebar
*
* Tests that:
* 1. Skeleton items use Tailwind width classes, not data-driven inline styles (VISUAL-01)
* 2. Nav-link buttons have focus-visible ring classes for keyboard accessibility (VISUAL-04)
* 3. Expand/collapse toggle buttons have focus-visible ring classes (VISUAL-04)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
vi.mock('../../../api/client.js', () => ({
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
const STUBS = {
FolderTreeItem: true,
CloudProviderTreeItem: true,
QuotaBar: true,
RouterLink: true,
EmptyState: true,
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div/>' } },
{ path: '/settings', component: { template: '<div/>' } },
{ path: '/topics', component: { template: '<div/>' } },
{ path: '/shared', component: { template: '<div/>' } },
{ path: '/cloud', component: { template: '<div/>' } },
{ path: '/admin', component: { template: '<div/>' } },
],
})
}
describe('VISUAL-01: Sidebar skeleton items use class-based widths (no inline style)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('skeleton width divs have w-12/w-16/w-20 class, not style attribute', async () => {
vi.doMock('../../../api/client.js', () => ({
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listCloudConnections: vi.fn().mockReturnValue(new Promise(() => {})),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
setActivePinia(createPinia())
const { useCloudConnectionsStore } = await import('../../../stores/cloudConnections.js')
const cloudStore = useCloudConnectionsStore()
cloudStore.loading = true
cloudStore.connections = []
const router = makeRouter()
await router.push('/')
await router.isReady()
const AppSidebar = (await import('../AppSidebar.vue')).default
const wrapper = mount(AppSidebar, {
global: { plugins: [router], stubs: STUBS },
})
cloudStore.loading = true
await wrapper.vm.$nextTick()
// All skeleton width divs should use Tailwind class, not inline style width
const skeletonWidthDivs = wrapper
.findAll('.animate-pulse')
.filter(el => {
const cls = el.classes()
return cls.includes('h-3') // width skeleton lines (not icon squares)
})
expect(skeletonWidthDivs.length).toBeGreaterThan(0)
for (const el of skeletonWidthDivs) {
// Must NOT have an inline style with width
const style = el.attributes('style') || ''
expect(style).not.toMatch(/width\s*:/)
// Must have a Tailwind width class (w-12, w-16, or w-20)
const cls = el.classes().join(' ')
const hasWidthClass = /\bw-\d+\b/.test(cls)
expect(hasWidthClass).toBe(true)
}
wrapper.unmount()
})
it('topics skeleton items use width classes, not inline style', async () => {
vi.doMock('../../../api/client.js', () => ({
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
setActivePinia(createPinia())
const { useTopicsStore } = await import('../../../stores/topics.js')
const topicsStore = useTopicsStore()
topicsStore.loading = true
topicsStore.topics = []
const router = makeRouter()
await router.push('/')
await router.isReady()
const AppSidebar = (await import('../AppSidebar.vue')).default
const wrapper = mount(AppSidebar, {
global: { plugins: [router], stubs: STUBS },
})
topicsStore.loading = true
await wrapper.vm.$nextTick()
const skeletonWidthDivs = wrapper
.findAll('.animate-pulse')
.filter(el => el.classes().includes('h-3'))
expect(skeletonWidthDivs.length).toBeGreaterThan(0)
for (const el of skeletonWidthDivs) {
const style = el.attributes('style') || ''
expect(style).not.toMatch(/width\s*:/)
}
wrapper.unmount()
})
})
describe('VISUAL-04: Sidebar interactive elements have focus-visible ring classes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('expand/collapse folder toggle button has focus-visible:ring-2', async () => {
vi.doMock('../../../api/client.js', () => ({
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/')
await router.isReady()
const AppSidebar = (await import('../AppSidebar.vue')).default
const wrapper = mount(AppSidebar, {
global: { plugins: [router], stubs: STUBS },
})
await flushPromises()
const toggleBtn = wrapper.find('button[title="Expand folders"]')
expect(toggleBtn.exists()).toBe(true)
const cls = toggleBtn.attributes('class') || ''
expect(cls).toContain('focus-visible:ring-2')
expect(cls).toContain('focus-visible:ring-indigo-500')
wrapper.unmount()
})
it('sign-out button has focus-visible:ring-2', async () => {
vi.doMock('../../../api/client.js', () => ({
listFolders: vi.fn().mockResolvedValue({ items: [] }),
getSharedWithMe: vi.fn().mockResolvedValue([]),
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
}))
setActivePinia(createPinia())
const { useAuthStore } = await import('../../../stores/auth.js')
const authStore = useAuthStore()
authStore.user = { email: 'test@example.com', role: 'user' }
const router = makeRouter()
await router.push('/')
await router.isReady()
const AppSidebar = (await import('../AppSidebar.vue')).default
const wrapper = mount(AppSidebar, {
global: { plugins: [router], stubs: STUBS },
})
await flushPromises()
const signOutBtn = wrapper.find('button[aria-label="Sign out"]')
expect(signOutBtn.exists()).toBe(true)
const cls = signOutBtn.attributes('class') || ''
expect(cls).toContain('focus-visible:ring-2')
wrapper.unmount()
})
})
@@ -3,7 +3,7 @@
<!-- 1. Account information --> <!-- 1. Account information -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3> <h3 class="text-sm font-semibold text-gray-800 mb-4">Account information</h3>
<div class="space-y-2 text-sm text-gray-700"> <div class="space-y-2 text-sm text-gray-700">
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div> <div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
<div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div> <div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div>
@@ -23,7 +23,7 @@
<!-- 2. Two-factor authentication --> <!-- 2. Two-factor authentication -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Two-factor authentication</h3> <h3 class="text-sm font-semibold text-gray-800 mb-4">Two-factor authentication</h3>
<!-- TOTP enabled: show status + disable option --> <!-- TOTP enabled: show status + disable option -->
<template v-if="authStore.user?.totp_enabled"> <template v-if="authStore.user?.totp_enabled">
@@ -39,7 +39,7 @@
<button <button
type="button" type="button"
@click="confirmDisable2fa = true" @click="confirmDisable2fa = true"
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors" class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 active:bg-red-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
> >
Disable 2FA Disable 2FA
</button> </button>
@@ -64,7 +64,7 @@
<!-- 3. Change password --> <!-- 3. Change password -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Change password</h3> <h3 class="text-sm font-semibold text-gray-800 mb-4">Change password</h3>
<form @submit.prevent="changePassword" class="space-y-4 max-w-sm"> <form @submit.prevent="changePassword" class="space-y-4 max-w-sm">
<div> <div>
@@ -125,13 +125,13 @@
<!-- 4. Sessions sign out all devices --> <!-- 4. Sessions sign out all devices -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Sessions</h3> <h3 class="text-sm font-semibold text-gray-800 mb-4">Sessions</h3>
<template v-if="!confirmSignOutAll"> <template v-if="!confirmSignOutAll">
<button <button
type="button" type="button"
@click="confirmSignOutAll = true" @click="confirmSignOutAll = true"
class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors" class="text-sm px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 active:bg-red-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
> >
Sign out all devices Sign out all devices
</button> </button>
@@ -1,6 +1,6 @@
<template> <template>
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">AI configuration</h3> <h3 class="text-lg font-semibold text-gray-800 mb-2">AI configuration</h3>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
AI provider and model are managed by your administrator. Contact your admin AI provider and model are managed by your administrator. Contact your admin
to request changes to which AI provider is used for your documents. to request changes to which AI provider is used for your documents.
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-1">Cloud Storage</h3> <h3 class="text-lg font-semibold text-gray-800 mb-1">Cloud Storage</h3>
<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p> <p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>
<!-- Loading state --> <!-- Loading state -->
@@ -52,7 +52,7 @@
<template v-if="!connectionFor(provider.key)"> <template v-if="!connectionFor(provider.key)">
<button <button
@click="handleConnect(provider)" @click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px]" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
Connect {{ provider.label }} Connect {{ provider.label }}
</button> </button>
@@ -63,14 +63,14 @@
<button <button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id" v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)" @click="handleEdit(provider)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors" class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
Edit Edit
</button> </button>
<button <button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id" v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id" @click="confirmRemoveId = connectionFor(provider.key)?.id"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors" class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
Remove {{ provider.label }} Remove {{ provider.label }}
</button> </button>
@@ -90,14 +90,14 @@
<template v-else-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'"> <template v-else-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'">
<button <button
@click="handleConnect(provider)" @click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px]" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
Reconnect {{ provider.label }} Reconnect {{ provider.label }}
</button> </button>
<button <button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id" v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id" @click="confirmRemoveId = connectionFor(provider.key)?.id"
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors" class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
> >
Remove {{ provider.label }} Remove {{ provider.label }}
</button> </button>
@@ -118,14 +118,14 @@
<button <button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id" v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)" @click="handleEdit(provider)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors" class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
Edit Edit
</button> </button>
<button <button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id" v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id" @click="confirmRemoveId = connectionFor(provider.key)?.id"
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 text-red-600 transition-colors" class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
> >
Remove {{ provider.label }} Remove {{ provider.label }}
</button> </button>
@@ -162,7 +162,7 @@
<button <button
v-if="!showDisconnectAll" v-if="!showDisconnectAll"
@click="showDisconnectAll = true" @click="showDisconnectAll = true"
class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors" class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded"
> >
Disconnect all cloud storage Disconnect all cloud storage
</button> </button>
@@ -1,6 +1,6 @@
<template> <template>
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">Document Preferences</h3> <h3 class="text-lg font-semibold text-gray-800 mb-2">Document Preferences</h3>
<p class="text-sm text-gray-600 mb-4">Choose how PDF documents open when you click on them.</p> <p class="text-sm text-gray-600 mb-4">Choose how PDF documents open when you click on them.</p>
<div class="space-y-3"> <div class="space-y-3">
@@ -9,7 +9,8 @@
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="share-modal-title" aria-labelledby="share-modal-title"
class="bg-white rounded-2xl shadow-xl p-6 max-w-md w-full mx-4 relative" data-test="share-modal-panel"
class="bg-white rounded-2xl shadow-xl p-6 max-w-md w-full mx-4 relative max-h-[90vh] overflow-y-auto"
> >
<!-- Close button --> <!-- Close button -->
<button <button
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// Mock stores and API so mount doesn't error out
vi.mock('../../../stores/documents.js', () => ({
useDocumentsStore: () => ({
listShares: vi.fn().mockResolvedValue([]),
shareDocument: vi.fn(),
updateSharePermission: vi.fn(),
revokeShare: vi.fn(),
}),
}))
vi.mock('../../../stores/toast.js', () => ({
useToastStore: () => ({ show: vi.fn() }),
}))
import ShareModal from '../ShareModal.vue'
const globalStubs = {
AppIcon: true,
}
const testDoc = { id: 'doc-1', original_name: 'test.pdf' }
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('VISUAL-02: ShareModal form baseline (@tailwindcss/forms consistent pattern)', () => {
it('text input uses consistent ring-2 focus class pattern', async () => {
const wrapper = mount(ShareModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
await flushPromises()
const input = wrapper.find('input[type="text"]')
expect(input.exists()).toBe(true)
expect(input.classes()).toContain('focus:ring-2')
expect(input.classes()).toContain('focus:outline-none')
})
it('select uses consistent ring-2 focus class pattern', async () => {
const wrapper = mount(ShareModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
await flushPromises()
const select = wrapper.find('select')
expect(select.exists()).toBe(true)
expect(select.classes()).toContain('focus:ring-2')
expect(select.classes()).toContain('focus:outline-none')
})
})
describe('RESP-04: ShareModal mobile-safe panel', () => {
it('panel has max-h-[90vh] class for mobile scroll safety', async () => {
const wrapper = mount(ShareModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
await flushPromises()
const panel = wrapper.find('[data-test="share-modal-panel"]')
expect(panel.exists()).toBe(true)
expect(panel.classes()).toContain('max-h-[90vh]')
})
it('panel has overflow-y-auto class for mobile scroll', async () => {
const wrapper = mount(ShareModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
await flushPromises()
const panel = wrapper.find('[data-test="share-modal-panel"]')
expect(panel.classes()).toContain('overflow-y-auto')
})
it('panel has mx-4 horizontal margin so it fits within 375px viewport', async () => {
const wrapper = mount(ShareModal, {
props: { doc: testDoc },
global: { stubs: globalStubs },
})
await flushPromises()
const panel = wrapper.find('[data-test="share-modal-panel"]')
expect(panel.classes()).toContain('mx-4')
})
})
@@ -1,45 +1,93 @@
<template> <template>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<!-- Sticky toolbar -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100"> <div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="px-6 py-3 flex items-center gap-3 flex-wrap"> <div class="px-4 sm:px-6 py-3 space-y-2">
<BreadcrumbBar <div class="flex items-center gap-3 min-w-0">
:segments="breadcrumb" <div class="min-w-0 flex-1">
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'" <BreadcrumbBar
@navigate="$emit('breadcrumb-navigate', $event)" :segments="breadcrumb"
/> :root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
<div class="ml-auto flex items-center gap-2 shrink-0"> @navigate="$emit('breadcrumb-navigate', $event)"
<SearchBar v-if="showSearch" ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" /> />
</div>
<div class="hidden sm:flex items-center gap-2 shrink-0" data-test="desktop-toolbar">
<SearchBar v-if="showSearch" ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
<SortControls
v-if="showSearch"
:sort="sortField"
:order="sortOrder"
@change="handleSortChange"
/>
<button
v-if="mode === 'local'"
@click="$emit('new-folder')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="plus" class="w-4 h-4" />
New folder
</button>
</div>
<div class="sm:hidden flex items-center gap-1 shrink-0" data-test="mobile-toolbar">
<button
v-if="showSearch"
type="button"
title="Search"
aria-label="Search"
:aria-expanded="mobileSearchOpen"
class="w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 active:bg-gray-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="mobileSearchOpen = !mobileSearchOpen"
>
<AppIcon name="search" class="w-4 h-4" />
</button>
<button
v-if="showSearch"
type="button"
title="Sort"
aria-label="Sort"
:aria-expanded="mobileSortOpen"
class="w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 active:bg-gray-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="mobileSortOpen = !mobileSortOpen"
>
<AppIcon name="chartBar" class="w-4 h-4" />
</button>
<button
v-if="mode === 'local'"
type="button"
title="New folder"
aria-label="New folder"
class="w-9 h-9 rounded-lg border border-indigo-200 text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="$emit('new-folder')"
>
<AppIcon name="plus" class="w-4 h-4" />
</button>
</div>
</div>
<div v-if="showSearch && mobileSearchOpen" class="sm:hidden w-full" data-test="mobile-search-row">
<SearchBar ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
</div>
<div
v-if="showSearch && mobileSortOpen"
class="sm:hidden w-full rounded-lg border border-gray-200 bg-gray-50 px-2 py-2"
data-test="mobile-sort-panel"
>
<SortControls <SortControls
v-if="showSearch"
:sort="sortField" :sort="sortField"
:order="sortOrder" :order="sortOrder"
@change="$emit('sort-change', $event)" @change="handleSortChange"
/> />
<button
v-if="mode === 'local'"
@click="$emit('new-folder')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 rounded-lg transition-colors"
>
<AppIcon name="plus" class="w-4 h-4" />
New folder
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Content area -->
<div class="flex-1 overflow-y-auto flex flex-col"> <div class="flex-1 overflow-y-auto flex flex-col">
<!-- Upload zone --> <div class="px-4 sm:px-6 pt-5 pb-3">
<div class="px-6 pt-5 pb-3">
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" /> <DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
<UploadProgress :items="uploadQueue" /> <UploadProgress :items="uploadQueue" />
</div> </div>
<!-- Column headers 5-column grid, consistent for local and cloud --> <div class="mx-4 sm:mx-6 px-4 py-2 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none" data-test="list-header">
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none">
<span></span> <span></span>
<span>Name</span> <span>Name</span>
<span class="text-right hidden md:block">Size</span> <span class="text-right hidden md:block">Size</span>
@@ -47,18 +95,17 @@
<span></span> <span></span>
</div> </div>
<!-- Inline new-folder row (local only) --> <div v-if="showNewFolderInput" class="mx-4 sm:mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center"> <div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
<AppIcon name="folder" class="w-4 h-4 text-amber-400" /> <AppIcon name="folder" class="w-4 h-4 text-amber-400" />
</div> </div>
<div class="flex items-center gap-2 col-span-4"> <div class="flex items-center gap-2 col-span-2 sm:col-span-3 md:col-span-4 min-w-0 flex-wrap">
<input <input
ref="newFolderInputRef" ref="newFolderInputRef"
v-model="newFolderName" v-model="newFolderName"
type="text" type="text"
placeholder="Folder name" placeholder="Folder name"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" class="border border-gray-300 rounded-lg px-2 py-1 text-sm min-w-0 flex-1 focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitNewFolder" @keydown.enter="submitNewFolder"
@keydown.escape="cancelNewFolder" @keydown.escape="cancelNewFolder"
/> />
@@ -68,14 +115,12 @@
</div> </div>
</div> </div>
<!-- Item list --> <div class="mx-4 sm:mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<div class="mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<!-- Folder rows -->
<div <div
v-for="folder in folders" v-for="folder in folders"
:key="`f-${folder.id}`" :key="`f-${folder.id}`"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors" class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
:class="{ :class="{
'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id, 'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id,
'bg-gray-50': renamingId === folder.id, 'bg-gray-50': renamingId === folder.id,
@@ -89,7 +134,6 @@
<AppIcon name="folder" class="w-4 h-4 text-amber-500" /> <AppIcon name="folder" class="w-4 h-4 text-amber-500" />
</div> </div>
<!-- Name / rename input -->
<div> <div>
<input <input
v-if="renamingId === folder.id" v-if="renamingId === folder.id"
@@ -105,15 +149,14 @@
<span class="text-right text-xs text-gray-400 hidden md:block"></span> <span class="text-right text-xs text-gray-400 hidden md:block"></span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span> <span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
<!-- Folder actions (local only) --> <div class="flex justify-end gap-0.5" data-test="folder-row-actions" @click.stop>
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<template v-if="mode === 'local'"> <template v-if="mode === 'local'">
<button @click.stop="startRename(folder)" title="Rename" <button @click.stop="startRename(folder)" title="Rename"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors"> class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
<AppIcon name="pencil" class="w-3.5 h-3.5" /> <AppIcon name="pencil" class="w-3.5 h-3.5" />
</button> </button>
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder" <button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"> class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
<AppIcon name="trash" class="w-3.5 h-3.5" /> <AppIcon name="trash" class="w-3.5 h-3.5" />
</button> </button>
</template> </template>
@@ -125,7 +168,7 @@
v-for="file in files" v-for="file in files"
:key="`d-${file.id}`" :key="`d-${file.id}`"
:draggable="mode === 'local'" :draggable="mode === 'local'"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none" class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
:class="{ 'opacity-50': draggingFile?.id === file.id }" :class="{ 'opacity-50': draggingFile?.id === file.id }"
@click="draggingFile ? null : $emit('file-open', file)" @click="draggingFile ? null : $emit('file-open', file)"
@dragstart="mode === 'local' ? onFileDragStart(file, $event) : null" @dragstart="mode === 'local' ? onFileDragStart(file, $event) : null"
@@ -135,7 +178,6 @@
<AppIcon name="document" class="w-4 h-4 text-indigo-400" /> <AppIcon name="document" class="w-4 h-4 text-indigo-400" />
</div> </div>
<!-- Name + topics + shared badge -->
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p> <p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p>
@@ -154,27 +196,23 @@
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span> <span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span> <span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
<!-- File actions (local only) --> <div class="flex justify-end gap-0.5" data-test="file-row-actions" @click.stop>
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<template v-if="mode === 'local'"> <template v-if="mode === 'local'">
<!-- Share -->
<button @click.stop="$emit('file-share', file)" title="Share" <button @click.stop="$emit('file-share', file)" title="Share"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors"> class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
<AppIcon name="share" class="w-3.5 h-3.5" /> <AppIcon name="share" class="w-3.5 h-3.5" />
</button> </button>
<!-- Move -->
<div class="relative"> <div class="relative">
<button <button
@click.stop="openFolderPicker(file.id, $event)" @click.stop="openFolderPicker(file.id, $event)"
title="Move to folder" title="Move to folder"
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors" class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
<AppIcon name="folderMove" class="w-3.5 h-3.5" /> <AppIcon name="folderMove" class="w-3.5 h-3.5" />
</button> </button>
</div> </div>
<!-- Delete -->
<button @click.stop="$emit('file-delete', file.id)" title="Delete" <button @click.stop="$emit('file-delete', file.id)" title="Delete"
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"> class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
<AppIcon name="trash" class="w-3.5 h-3.5" /> <AppIcon name="trash" class="w-3.5 h-3.5" />
</button> </button>
</template> </template>
@@ -185,7 +223,7 @@
<div <div
v-for="n in 5" v-for="n in 5"
:key="`sk-${n}`" :key="`sk-${n}`"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100" class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center border-b border-gray-100"
> >
<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div> <div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>
<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div> <div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>
@@ -225,7 +263,6 @@
</div> </div>
</div> </div>
<!-- Teleported folder picker avoids overflow:hidden clipping -->
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="folderPickerFileId" v-if="folderPickerFileId"
@@ -236,7 +273,7 @@
@click.stop @click.stop
> >
<button <button
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50" class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 active:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: null }); folderPickerFileId = null" @click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: null }); folderPickerFileId = null"
> >
Root (no folder) Root (no folder)
@@ -244,7 +281,7 @@
<button <button
v-for="f in rootFolders" v-for="f in rootFolders"
:key="f.id" :key="f.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 truncate" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 active:bg-indigo-100 truncate focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: f.id }); folderPickerFileId = null" @click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: f.id }); folderPickerFileId = null"
>{{ f.name }}</button> >{{ f.name }}</button>
<p v-if="!rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p> <p v-if="!rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
@@ -299,18 +336,16 @@ const emit = defineEmits([
'file-delete', 'file-delete',
]) ])
const showSearch = computed(() => props.mode === 'local' && props.breadcrumb.length > 0) const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
function topicColor(name) { function topicColor(name) {
return props.topicColorFn(name) return props.topicColorFn(name)
} }
// Child component refs
const dropZoneRef = ref(null) const dropZoneRef = ref(null)
const searchBarRef = ref(null) const searchBarRef = ref(null)
const mobileSearchOpen = ref(false)
// New folder inline input const mobileSortOpen = ref(false)
const showNewFolderInput = ref(false) const showNewFolderInput = ref(false)
const newFolderName = ref('') const newFolderName = ref('')
@@ -329,10 +364,24 @@ function cancelNewFolder() {
newFolderError.value = '' newFolderError.value = ''
} }
function handleSortChange(payload) {
emit('sort-change', payload)
mobileSortOpen.value = false
}
async function submitNewFolder() { async function submitNewFolder() {
const name = newFolderName.value.trim() const name = newFolderName.value.trim()
if (!name) { newFolderError.value = 'Folder name cannot be empty.'; return } if (!name) {
emit('folder-create', { name, onError: (msg) => { newFolderError.value = msg }, onSuccess: cancelNewFolder }) newFolderError.value = 'Folder name cannot be empty.'
return
}
emit('folder-create', {
name,
onError: (msg) => {
newFolderError.value = msg
},
onSuccess: cancelNewFolder,
})
} }
defineExpose({ defineExpose({
@@ -342,8 +391,6 @@ defineExpose({
clearSearch: () => emit('search-change', ''), clearSearch: () => emit('search-change', ''),
}) })
// Rename
const renamingId = ref(null) const renamingId = ref(null)
const renameValue = ref('') const renameValue = ref('')
@@ -352,8 +399,6 @@ function startRename(folder) {
renameValue.value = folder.name renameValue.value = folder.name
} }
// Drag and drop (local only)
const draggingFile = ref(null) const draggingFile = ref(null)
const dragOverFolderId = ref(null) const dragOverFolderId = ref(null)
@@ -383,11 +428,8 @@ async function onDropDocOnFolder(folderId) {
draggingFile.value = null draggingFile.value = null
} }
// Move folder picker (Teleport + getBoundingClientRect)
const folderPickerFileId = ref(null) const folderPickerFileId = ref(null)
const pickerStyle = ref({}) const pickerStyle = ref({})
// Map fileId trigger button element, so scroll listener can reposition
const pickerTriggerMap = new Map() const pickerTriggerMap = new Map()
function updatePickerPosition(rect) { function updatePickerPosition(rect) {
@@ -409,7 +451,6 @@ function updatePickerPosition(rect) {
} }
function openFolderPicker(fileId, ev) { function openFolderPicker(fileId, ev) {
// Toggle off if same file
if (folderPickerFileId.value === fileId) { if (folderPickerFileId.value === fileId) {
folderPickerFileId.value = null folderPickerFileId.value = null
return return
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import StorageBrowser from '../StorageBrowser.vue'
const globalStubs = {
BreadcrumbBar: true,
SearchBar: true,
SortControls: true,
DropZone: true,
UploadProgress: true,
TopicBadge: true,
AppIcon: true,
EmptyState: true,
}
function mountBrowser(mode, breadcrumb = []) {
setActivePinia(createPinia())
return mount(StorageBrowser, {
props: {
mode,
breadcrumb,
documents: [],
folders: [],
files: [],
topicColorFn: () => '#000',
loading: false,
},
global: { stubs: globalStubs },
})
}
describe('Gap 2: showSearch visible at root for local and cloud modes', () => {
it("mode='local', breadcrumb=[] → showSearch is true (root-level local)", () => {
const w = mountBrowser('local', [])
expect(w.vm.showSearch).toBe(true)
})
it("mode='local', breadcrumb=[{name:'Folder'}] → showSearch is true (non-root local)", () => {
const w = mountBrowser('local', [{ name: 'Folder' }])
expect(w.vm.showSearch).toBe(true)
})
it("mode='cloud', breadcrumb=[] → showSearch is true (cloud root)", () => {
const w = mountBrowser('cloud', [])
expect(w.vm.showSearch).toBe(true)
})
it("mode='shared' → showSearch is false", () => {
const w = mountBrowser('shared', [])
expect(w.vm.showSearch).toBe(false)
})
})
@@ -46,12 +46,21 @@ describe('UX-02: StorageBrowser shows skeleton rows during loading', () => {
expect(pulseEls.length).toBe(0) expect(pulseEls.length).toBe(0)
}) })
it('skeleton row grid matches grid-cols-[2rem_1fr_6rem_8rem_6rem]', () => { it('skeleton row has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, { const wrapper = mount(StorageBrowser, {
props: { loading: true, folders: [], files: [] }, props: { loading: true, folders: [], files: [] },
global: { stubs: globalStubs }, global: { stubs: globalStubs },
}) })
const skeletonGrids = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]') const skeletonGrids = wrapper.findAll('.grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_7rem\\]')
expect(skeletonGrids.length).toBeGreaterThanOrEqual(1)
})
it('skeleton row has md breakpoint full grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { loading: true, folders: [], files: [] },
global: { stubs: globalStubs },
})
const skeletonGrids = wrapper.findAll('.md\\:grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_6rem_8rem_7rem\\]')
expect(skeletonGrids.length).toBeGreaterThanOrEqual(1) expect(skeletonGrids.length).toBeGreaterThanOrEqual(1)
}) })
}) })
@@ -68,6 +77,180 @@ const SAMPLE_FILE = {
const SAMPLE_FOLDER = { id: 'folder-1', name: 'Archive', doc_count: 0 } const SAMPLE_FOLDER = { id: 'folder-1', name: 'Archive', doc_count: 0 }
describe('RESP-02/RESP-03: StorageBrowser responsive column classes and touch targets', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('list header has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const header = wrapper.find('[data-test="list-header"]')
expect(header.exists()).toBe(true)
expect(header.classes()).toContain('grid-cols-[2rem_minmax(0,1fr)_7rem]')
})
it('list header has md breakpoint full-column class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const header = wrapper.find('[data-test="list-header"]')
expect(header.classes()).toContain('md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem]')
})
it('folder row has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const folderRows = wrapper.findAll('.grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_7rem\\]')
// At least the folder row and header exist
expect(folderRows.length).toBeGreaterThanOrEqual(1)
})
it('file row has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
global: { stubs: globalStubs },
})
const fileRows = wrapper.findAll('.grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_7rem\\]')
expect(fileRows.length).toBeGreaterThanOrEqual(1)
})
it('size column is hidden below md (has hidden md:block classes)', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
global: { stubs: globalStubs },
})
const sizeSpans = wrapper.findAll('.hidden.md\\:block')
expect(sizeSpans.length).toBeGreaterThanOrEqual(1)
})
it('modified column is hidden below sm (has hidden sm:block classes)', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
global: { stubs: globalStubs },
})
const modifiedSpans = wrapper.findAll('.hidden.sm\\:block')
expect(modifiedSpans.length).toBeGreaterThanOrEqual(1)
})
it('file action buttons have min-w-[36px] touch target class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
global: { stubs: globalStubs },
})
const actionBtns = wrapper.findAll('button[title="Delete"], button[title="Share"]')
expect(actionBtns.length).toBeGreaterThanOrEqual(1)
for (const btn of actionBtns) {
expect(btn.classes()).toContain('min-w-[36px]')
expect(btn.classes()).toContain('min-h-[36px]')
}
})
it('folder action buttons have min-w-[36px] touch target class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const renameBtns = wrapper.findAll('button[title="Rename"]')
expect(renameBtns.length).toBeGreaterThanOrEqual(1)
for (const btn of renameBtns) {
expect(btn.classes()).toContain('min-w-[36px]')
expect(btn.classes()).toContain('min-h-[36px]')
}
})
it('file row actions are visible without hover-only opacity classes', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
global: { stubs: globalStubs },
})
const actions = wrapper.find('[data-test="file-row-actions"]')
expect(actions.exists()).toBe(true)
expect(actions.classes()).not.toContain('opacity-0')
expect(actions.classes()).not.toContain('group-hover:opacity-100')
})
it('folder row actions are visible without hover-only opacity classes', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const actions = wrapper.find('[data-test="folder-row-actions"]')
expect(actions.exists()).toBe(true)
expect(actions.classes()).not.toContain('opacity-0')
expect(actions.classes()).not.toContain('group-hover:opacity-100')
})
})
describe('Plan 11-07: StorageBrowser compact mobile toolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders icon-only mobile controls and hides full toolbar below sm', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: globalStubs },
})
const mobileToolbar = wrapper.find('[data-test="mobile-toolbar"]')
const desktopToolbar = wrapper.find('[data-test="desktop-toolbar"]')
expect(mobileToolbar.exists()).toBe(true)
expect(mobileToolbar.classes()).toContain('sm:hidden')
expect(desktopToolbar.exists()).toBe(true)
expect(desktopToolbar.classes()).toContain('hidden')
expect(desktopToolbar.classes()).toContain('sm:flex')
expect(wrapper.find('button[aria-label="Search"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="Sort"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="New folder"]').exists()).toBe(true)
})
it('search icon toggles a full-width mobile search row', async () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: globalStubs },
})
expect(wrapper.find('[data-test="mobile-search-row"]').exists()).toBe(false)
await wrapper.find('button[aria-label="Search"]').trigger('click')
await nextTick()
const row = wrapper.find('[data-test="mobile-search-row"]')
expect(row.exists()).toBe(true)
expect(row.classes()).toContain('w-full')
})
it('sort icon opens mobile sort options and emits sort-change', async () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: { ...globalStubs, SortControls: false } },
})
await wrapper.find('button[aria-label="Sort"]').trigger('click')
await nextTick()
const panel = wrapper.find('[data-test="mobile-sort-panel"]')
expect(panel.exists()).toBe(true)
await panel.findAll('button').find(btn => btn.text().includes('Name')).trigger('click')
expect(wrapper.emitted('sort-change')).toEqual([[{ sort: 'name', order: 'desc' }]])
expect(wrapper.find('[data-test="mobile-sort-panel"]').exists()).toBe(false)
})
it('new-folder icon emits the existing new-folder event', async () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: globalStubs },
})
await wrapper.find('button[aria-label="New folder"]').trigger('click')
expect(wrapper.emitted('new-folder')).toHaveLength(1)
})
})
describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => { describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
@@ -17,7 +17,7 @@
/> />
<button <button
type="submit" type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
:disabled="saving" :disabled="saving"
> >
{{ saving ? 'Adding…' : 'Add' }} {{ saving ? 'Adding…' : 'Add' }}
@@ -50,8 +50,8 @@
placeholder="Description" placeholder="Description"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/> />
<button @click="saveEdit(topic.id)" class="text-xs text-indigo-600 font-medium">Save</button> <button @click="saveEdit(topic.id)" class="text-xs text-indigo-600 font-medium hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">Save</button>
<button @click="editing = null" class="text-xs text-gray-400">Cancel</button> <button @click="editing = null" class="text-xs text-gray-400 hover:text-gray-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">Cancel</button>
</div> </div>
<div v-else class="flex-1 min-w-0"> <div v-else class="flex-1 min-w-0">
@@ -63,8 +63,8 @@
</div> </div>
<div class="flex gap-2 shrink-0"> <div class="flex gap-2 shrink-0">
<button @click="startEdit(topic)" class="text-xs text-gray-500 hover:text-indigo-600">Edit</button> <button @click="startEdit(topic)" class="text-xs text-gray-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">Edit</button>
<button @click="remove(topic)" class="text-xs text-gray-500 hover:text-red-500">Delete</button> <button @click="remove(topic)" class="text-xs text-gray-500 hover:text-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded">Delete</button>
</div> </div>
</div> </div>
+8 -5
View File
@@ -9,7 +9,7 @@
<button <button
v-if="expandable" v-if="expandable"
@click.prevent.stop="toggleExpand" @click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors" class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
:aria-label="expanded ? 'Collapse' : 'Expand'" :aria-label="expanded ? 'Collapse' : 'Expand'"
> >
<AppIcon <AppIcon
@@ -24,7 +24,7 @@
<router-link <router-link
v-if="to" v-if="to"
:to="to" :to="to"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors" class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
:class="isActive :class="isActive
? 'bg-indigo-50 text-indigo-700' ? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'" : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
@@ -35,7 +35,7 @@
<button <button
v-else v-else
@click="$emit('select')" @click="$emit('select')"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:bg-gray-100 hover:text-gray-900" class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
> >
<slot name="icon" /> <slot name="icon" />
<span class="truncate">{{ label }}</span> <span class="truncate">{{ label }}</span>
@@ -46,10 +46,13 @@
<template v-if="expanded"> <template v-if="expanded">
<div <div
v-if="loading" v-if="loading"
class="text-xs text-gray-400 py-1" class="py-1 space-y-1"
:style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }" :style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
> >
Loading <div v-for="n in 3" :key="`sk-t-${n}`" class="flex items-center gap-2 py-1">
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
</div>
</div> </div>
<div <div
v-else-if="loadError" v-else-if="loadError"
@@ -0,0 +1,124 @@
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import TreeItem from '../TreeItem.vue'
vi.mock('../AppIcon.vue', () => ({ default: { template: '<span/>' } }))
function mountItem(overrides = {}) {
return mount(TreeItem, {
props: {
label: 'Test',
loadChildren: vi.fn().mockResolvedValue([]),
expandable: true,
...overrides,
},
global: { stubs: { RouterLink: true } },
})
}
describe('Gap 1: sidebar shimmer rows', () => {
it('renders animate-pulse elements when loading=true (expanded via refresh)', async () => {
// First expand the item (so expanded=true, childrenLoaded=true)
let resolveFn1
const loadChildren = vi.fn()
.mockReturnValueOnce(Promise.resolve([])) // first load resolves immediately
.mockReturnValue(new Promise(resolve => { resolveFn1 = resolve })) // refresh never resolves
const w = mountItem({ loadChildren })
// Expand by awaiting toggleExpand — sets expanded=true after load completes
const setupState = w.vm.$.setupState
await setupState.toggleExpand()
await flushPromises()
// Now call refresh() without awaiting — it calls load() which sets loading=true while expanded stays true
setupState.refresh() // NOT awaited — loading=true while promise is pending
await nextTick() // let Vue update the DOM
const pulseEls = w.findAll('.animate-pulse')
expect(pulseEls.length).toBeGreaterThanOrEqual(2)
// Cleanup
if (resolveFn1) resolveFn1([])
await flushPromises()
})
it('does not render "Loading" text when loading=true (expanded via refresh)', async () => {
let resolveFn1
const loadChildren = vi.fn()
.mockReturnValueOnce(Promise.resolve([]))
.mockReturnValue(new Promise(resolve => { resolveFn1 = resolve }))
const w = mountItem({ loadChildren })
const setupState = w.vm.$.setupState
await setupState.toggleExpand()
await flushPromises()
setupState.refresh()
await nextTick()
expect(w.text()).not.toContain('Loading')
if (resolveFn1) resolveFn1([])
await flushPromises()
})
})
describe('Gap 1 regression: non-loading branches unchanged', () => {
it('shows Empty text when loadChildren resolves to [] and item is expanded', async () => {
const w = mountItem({ loadChildren: vi.fn().mockResolvedValue([]) })
const toggleExpand = w.vm.$.setupState.toggleExpand
await toggleExpand()
await flushPromises()
await nextTick()
expect(w.text()).toContain('Empty')
})
})
describe('Phase 11 validation: TreeItem visual invariants', () => {
it('applies focus-visible rings to the expand control and selectable row button', () => {
const w = mountItem()
const buttons = w.findAll('button')
expect(buttons).toHaveLength(2)
for (const button of buttons) {
const cls = button.attributes('class') || ''
expect(cls).toContain('focus-visible:outline-none')
expect(cls).toContain('focus-visible:ring-2')
expect(cls).toContain('focus-visible:ring-indigo-500')
expect(cls).toContain('focus-visible:ring-offset-1')
}
})
it('applies focus-visible rings to the router-link row', () => {
const w = mountItem({ to: '/folders/archive' })
const link = w.findComponent({ name: 'RouterLink' })
expect(link.exists()).toBe(true)
const cls = link.attributes('class') || ''
expect(cls).toContain('focus-visible:outline-none')
expect(cls).toContain('focus-visible:ring-2')
expect(cls).toContain('focus-visible:ring-indigo-500')
expect(cls).toContain('focus-visible:ring-offset-1')
})
it('uses Tailwind width classes for loading skeleton labels without inline width styles', async () => {
let resolveFn
const loadChildren = vi.fn()
.mockReturnValueOnce(Promise.resolve([]))
.mockReturnValue(new Promise(resolve => { resolveFn = resolve }))
const w = mountItem({ loadChildren })
await w.vm.$.setupState.toggleExpand()
await flushPromises()
w.vm.$.setupState.refresh()
await nextTick()
const skeletonLabels = w.findAll('.h-3.animate-pulse')
expect(skeletonLabels).toHaveLength(3)
expect(skeletonLabels.map(el => el.classes().find(cls => /^w-\d+$/.test(cls)))).toEqual([
'w-12',
'w-16',
'w-20',
])
for (const label of skeletonLabels) {
expect(label.attributes('style') || '').not.toMatch(/width\s*:/)
}
resolveFn([])
await flushPromises()
})
})
@@ -1,9 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import DocumentCard from '../../documents/DocumentCard.vue' import DocumentCard from '../../documents/DocumentCard.vue'
import FolderRow from '../../folders/FolderRow.vue'
// ── Stubs ────────────────────────────────────────────────────────────────────── // ── Stubs ──────────────────────────────────────────────────────────────────────
@@ -24,12 +23,6 @@ const SAMPLE_DOC = {
is_shared: false, is_shared: false,
} }
const SAMPLE_FOLDER = {
id: 'folder-1',
name: 'Archive',
doc_count: 5,
}
describe('UX-13: Teleport + getBoundingClientRect dropdown pattern across components', () => { describe('UX-13: Teleport + getBoundingClientRect dropdown pattern across components', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
@@ -96,59 +89,4 @@ describe('UX-13: Teleport + getBoundingClientRect dropdown pattern across compon
wrapper.unmount() wrapper.unmount()
}) })
it('FolderRow three-dot menu uses Teleport to body', async () => {
const wrapper = mount(FolderRow, {
props: { folder: SAMPLE_FOLDER },
attachTo: document.body,
})
const menuBtn = wrapper.find('[aria-label="Folder actions"]')
expect(menuBtn.exists()).toBe(true)
await menuBtn.trigger('click')
await nextTick()
// The menu should be teleported to body
const menu = document.body.querySelector('[data-test="folder-row-menu"]')
expect(menu).not.toBeNull()
wrapper.unmount()
})
it('FolderRow three-dot menu repositions on window scroll', async () => {
const wrapper = mount(FolderRow, {
props: { folder: SAMPLE_FOLDER },
attachTo: document.body,
})
const menuBtn = wrapper.find('[aria-label="Folder actions"]')
await menuBtn.trigger('click')
await nextTick()
const menu = document.body.querySelector('[data-test="folder-row-menu"]')
expect(menu).not.toBeNull()
const initialTop = menu.style.top || menu.style.bottom
// Change what getBoundingClientRect returns (simulate scroll)
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
top: 200,
bottom: 240,
left: 50,
right: 250,
width: 200,
height: 40,
x: 50,
y: 200,
})
// Dispatch scroll event
window.dispatchEvent(new Event('scroll', { bubbles: true }))
await nextTick()
const updatedTop = menu.style.top || menu.style.bottom
// Position should have updated (top changed from 140+4 to 240+4)
expect(updatedTop).not.toBe(initialTop)
wrapper.unmount()
})
}) })
+48 -2
View File
@@ -1,7 +1,41 @@
<template> <template>
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
<AdminSidebar /> <!-- Mobile-only top header with hamburger (admin) -->
<main class="flex-1 overflow-y-auto"> <header class="lg:hidden fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-200">
<button
@click="drawerOpen = true"
aria-label="Open admin navigation"
data-test="admin-hamburger-btn"
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="text-base font-bold text-indigo-600 tracking-tight">DocuVault</span>
<span class="text-xs font-semibold text-indigo-500">Admin</span>
</header>
<!-- Drawer backdrop (mobile only) teleported to body to guarantee stacking -->
<Teleport to="body">
<div
v-if="drawerOpen"
class="lg:hidden fixed inset-0 z-40 bg-black/40"
data-test="admin-drawer-backdrop"
@click="drawerOpen = false"
></div>
</Teleport>
<!-- Sidebar: always visible on lg+; slides in as overlay drawer below lg -->
<div
class="fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-in-out lg:static lg:z-auto lg:translate-x-0 shrink-0"
:class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"
data-test="admin-sidebar-wrapper"
>
<AdminSidebar />
</div>
<main class="flex-1 overflow-y-auto pt-[53px] lg:pt-0">
<div class="p-8 max-w-5xl mx-auto"> <div class="p-8 max-w-5xl mx-auto">
<router-view /> <router-view />
</div> </div>
@@ -10,5 +44,17 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AdminSidebar from '../components/admin/AdminSidebar.vue' import AdminSidebar from '../components/admin/AdminSidebar.vue'
// Drawer state owned by AdminLayout (per D-04/D-05 not in AdminSidebar)
const drawerOpen = ref(false)
const route = useRoute()
// Close drawer on route change (navigation tap closes the drawer automatically)
watch(() => route.fullPath, () => {
drawerOpen.value = false
})
</script> </script>
@@ -6,7 +6,11 @@ vi.mock('../../stores/auth.js', () => ({
useAuthStore: vi.fn(), useAuthStore: vi.fn(),
})) }))
// Mock all lazily-imported view components so the router can be instantiated // Mock all lazily-imported view components so the router can be instantiated.
// These mocks also exercise the lazy-load code paths: because the router uses
// () => import(...) for these modules, Vitest's vi.mock() intercepts the dynamic
// import and resolves synchronously — verifying that guard logic is not broken
// by the switch from static to lazy imports (PERF-03 / Plan 11-02).
vi.mock('../../views/auth/LoginView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/auth/LoginView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/auth/RegisterView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/auth/RegisterView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/auth/PasswordResetView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/auth/PasswordResetView.vue', () => ({ default: { template: '<div />' } }))
@@ -19,8 +23,10 @@ vi.mock('../../views/admin/AdminAiView.vue', () => ({ default: { template: '<div
vi.mock('../../views/admin/AdminAuditView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/admin/AdminAuditView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/SharedView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/SharedView.vue', () => ({ default: { template: '<div />' } }))
// Heavy view components imported statically — stub them too // FileManagerView is the only statically-imported component (kept synchronous per D-10).
vi.mock('../../views/FileManagerView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/FileManagerView.vue', () => ({ default: { template: '<div />' } }))
// Lazy-loaded route components (now use () => import(...) in router/index.js).
vi.mock('../../views/TopicsView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/TopicsView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/DocumentView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/DocumentView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/SettingsView.vue', () => ({ default: { template: '<div />' } })) vi.mock('../../views/SettingsView.vue', () => ({ default: { template: '<div />' } }))
@@ -35,6 +41,10 @@ beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
// ---------------------------------------------------------------------------
// Auth route meta
// ---------------------------------------------------------------------------
describe('router — auth route meta (SEC-07, AUTH-01)', () => { describe('router — auth route meta (SEC-07, AUTH-01)', () => {
const authPaths = ['/login', '/register', '/password-reset', '/password-reset/confirm'] const authPaths = ['/login', '/register', '/password-reset', '/password-reset/confirm']
@@ -57,6 +67,10 @@ describe('router — auth route meta (SEC-07, AUTH-01)', () => {
) )
}) })
// ---------------------------------------------------------------------------
// Admin guard — non-admin blocked, admin redirected from user routes
// ---------------------------------------------------------------------------
describe('router — admin guard (SEC-07)', () => { describe('router — admin guard (SEC-07)', () => {
it('redirects a non-admin user away from /admin to /', async () => { it('redirects a non-admin user away from /admin to /', async () => {
// Arrange: authenticated but role = 'viewer' // Arrange: authenticated but role = 'viewer'
@@ -97,4 +111,146 @@ describe('router — admin guard (SEC-07)', () => {
// The D-09 guard branch returns { path: '/admin' } // The D-09 guard branch returns { path: '/admin' }
expect(router.currentRoute.value.path).toBe('/admin') expect(router.currentRoute.value.path).toBe('/admin')
}) })
it('non-admin user is blocked from /admin/users (admin child route)', async () => {
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '2', role: 'viewer' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push('/admin/users')
// Guard returns { path: '/' } because to.matched.some(r => r.meta.requiresAdmin) is true
expect(router.currentRoute.value.path).toBe('/')
})
it('non-admin user is blocked from /admin/quotas (admin child route)', async () => {
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '2', role: 'viewer' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push('/admin/quotas')
expect(router.currentRoute.value.path).toBe('/')
})
it('admin is redirected away from /settings to /admin (D-09)', async () => {
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '1', role: 'admin' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push('/settings')
expect(router.currentRoute.value.path).toBe('/admin')
})
it('admin is redirected away from /cloud to /admin (D-09)', async () => {
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '1', role: 'admin' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push('/cloud')
expect(router.currentRoute.value.path).toBe('/admin')
})
})
// ---------------------------------------------------------------------------
// Refresh-before-guard — runs when access token is absent on reload
// ---------------------------------------------------------------------------
describe('router — refresh-before-guard (PERF-03, AUTH-01)', () => {
it('calls authStore.refresh() when access token is absent for a protected route', async () => {
const mockRefresh = vi.fn().mockResolvedValue(undefined)
useAuthStore.mockReturnValue({
accessToken: null, // simulates page reload (token is memory-only)
user: { id: '3', role: 'viewer' },
refresh: mockRefresh,
})
await router.push('/topics')
expect(mockRefresh).toHaveBeenCalledOnce()
})
it('redirects to /login when refresh fails and no token exists', async () => {
useAuthStore.mockReturnValue({
accessToken: null,
user: null,
refresh: vi.fn().mockRejectedValue(new Error('session expired')),
})
await router.push('/settings')
expect(router.currentRoute.value.path).toBe('/login')
})
it('does NOT call refresh() for public routes', async () => {
const mockRefresh = vi.fn()
useAuthStore.mockReturnValue({
accessToken: null,
user: null,
refresh: mockRefresh,
})
await router.push('/login')
expect(mockRefresh).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/login')
})
})
// ---------------------------------------------------------------------------
// Lazy-loaded routes resolve correctly for authenticated regular users
// (verifies that switching from static to () => import() did not break routing)
// ---------------------------------------------------------------------------
describe('router — lazy-loaded routes resolve (PERF-03)', () => {
const regularUserRoutes = [
'/topics',
'/topics/finance',
'/document/some-uuid',
'/settings',
'/cloud',
'/cloud/onedrive/root',
]
it.each(regularUserRoutes)(
'route %s resolves for an authenticated regular user',
async (path) => {
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '4', role: 'viewer' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
// The guard does not redirect regular users away from their own routes.
// Because router.push() resolves after guards run and the component is
// loaded, currentRoute.path should match the navigation target.
expect(router.currentRoute.value.path).toBe(path)
}
)
it('/folders/:folderId reuses synchronous FileManagerView (D-10)', async () => {
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '4', role: 'viewer' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push('/folders/abc-123')
expect(router.currentRoute.value.path).toBe('/folders/abc-123')
})
it('/shared resolves for an authenticated regular user', async () => {
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '4', role: 'viewer' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push('/shared')
expect(router.currentRoute.value.path).toBe('/shared')
})
}) })
+13 -12
View File
@@ -1,19 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js' import { useAuthStore } from '../stores/auth.js'
// FileManagerView is kept synchronous because it is the critical first authenticated
// surface rendered at '/'. Lazy-loading it would delay the initial paint for logged-in
// users with a valid refresh cookie (the most common app entry point).
// All other authenticated routes are lazy-loaded to split them into separate chunks.
// See PERF-03 / Plan 11-02 and D-10 in 11-CONTEXT.md.
import FileManagerView from '../views/FileManagerView.vue' import FileManagerView from '../views/FileManagerView.vue'
import TopicsView from '../views/TopicsView.vue'
import DocumentView from '../views/DocumentView.vue'
import SettingsView from '../views/SettingsView.vue'
import CloudFolderView from '../views/CloudFolderView.vue'
import CloudStorageView from '../views/CloudStorageView.vue'
const routes = [ const routes = [
// File manager is the home — handles both root and folder views // File manager is the home — handles both root and folder views.
// FileManagerView stays synchronous (see comment above).
{ path: '/', component: FileManagerView }, { path: '/', component: FileManagerView },
{ path: '/topics', component: TopicsView }, { path: '/topics', component: () => import('../views/TopicsView.vue') },
{ path: '/topics/:name', component: TopicsView }, { path: '/topics/:name', component: () => import('../views/TopicsView.vue') },
{ path: '/document/:id', component: DocumentView }, { path: '/document/:id', component: () => import('../views/DocumentView.vue') },
{ path: '/settings', component: SettingsView }, { path: '/settings', component: () => import('../views/SettingsView.vue') },
{ {
path: '/login', path: '/login',
@@ -57,13 +58,13 @@ const routes = [
{ {
path: '/cloud', path: '/cloud',
name: 'cloud', name: 'cloud',
component: CloudStorageView, component: () => import('../views/CloudStorageView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: '/cloud/:provider/:folderId(.*)', path: '/cloud/:provider/:folderId(.*)',
name: 'cloud-folder', name: 'cloud-folder',
component: CloudFolderView, component: () => import('../views/CloudFolderView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },

Some files were not shown because too many files have changed in this diff Show More