--- phase: 03-document-migration-multi-user-isolation plan: 05 type: execute wave: 5 depends_on: - 03-04 files_modified: - frontend/src/stores/documents.js - frontend/src/stores/auth.js - frontend/src/components/upload/UploadProgress.vue - frontend/src/components/layout/QuotaBar.vue - frontend/src/components/layout/AppSidebar.vue - frontend/src/api/client.js autonomous: false requirements: - STORE-03 - STORE-04 - STORE-05 must_haves: truths: - "Selecting a file in the existing DropZone triggers a 3-step upload: POST /api/documents/upload-url → XHR PUT to MinIO with progress events → POST /api/documents/{id}/confirm — the user sees a smooth progress bar advancing 0% → 100%" - "An upload that would exceed the user's quota receives an HTTP 413 from /confirm; the upload row displays the inline quota rejection error block with 'Not enough storage', file size, and 'Manage storage →' link per UI-SPEC" - "The sidebar shows a quota bar between the topics nav and the footer; bar fills with usage; turns amber at >=80%, red at >=95%; refreshes after every successful upload + every document delete" - "auth store exposes a quota: {used_bytes, limit_bytes} reactive object updated by the documents store after upload/delete" - "Legacy uploadDocument(file) (multipart POST) is removed from api/client.js since no consumer remains" artifacts: - path: "frontend/src/stores/documents.js" provides: "Three-step upload action with uploadProgress reactive map; quota refetch on success/delete" contains: "getUploadUrl" - path: "frontend/src/stores/auth.js" provides: "Reactive quota state + fetchQuota action" exports: - "fetchQuota" - path: "frontend/src/components/layout/QuotaBar.vue" provides: "Quota bar widget consuming useAuthStore quota state" contains: "role=\"progressbar\"" - path: "frontend/src/components/layout/AppSidebar.vue" provides: "QuotaBar embedded between topics nav and footer" contains: " Wire the frontend to the Phase 3 backend API per UI-SPEC. Replace the multipart `uploadDocument` action with a 3-step flow (upload-url → XHR PUT → confirm) and surface progress via `XMLHttpRequest` upload events. Add the sidebar quota bar (STORE-04) and the inline quota rejection error block (STORE-05) per the UI-SPEC contract. Store quota state in `useAuthStore` per UI-SPEC. Purpose: Phase 3 SC1-SC5 cannot be observed end-to-end without the frontend cutover; the auto-classify regression test in Plan 03-01 stops at the API but the user-visible experience needs the new flow. The plan includes one `checkpoint:human-verify` to confirm the visual progress bar and quota bar match the UI-SPEC. Output: 6 frontend files modified; 1 net-new component (QuotaBar.vue). After this plan, Phase 3 is feature-complete. @/Users/nik/.claude/get-shit-done/workflows/execute-plan.md @/Users/nik/.claude/get-shit-done/templates/summary.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-document-migration-multi-user-isolation/03-CONTEXT.md @.planning/phases/03-document-migration-multi-user-isolation/03-UI-SPEC.md @.planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md @.planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md @.planning/phases/03-document-migration-multi-user-isolation/03-04-SUMMARY.md @CLAUDE.md @frontend/src/stores/documents.js @frontend/src/stores/auth.js @frontend/src/api/client.js @frontend/src/components/upload/UploadProgress.vue @frontend/src/components/layout/AppSidebar.vue @frontend/src/components/auth/PasswordStrengthBar.vue From frontend/src/api/client.js (post Plan 03-04): getUploadUrl(filename, contentType) -> Promise<{upload_url: string, document_id: string}> confirmUpload(documentId) -> Promise<{id: string, size_bytes: number, used_bytes: number, status: "uploaded"}> getMyQuota() -> Promise<{used_bytes: number, limit_bytes: number}> listDocuments({topic?, page?, perPage?}) -> Promise<{items, total, page, per_page}> deleteDocument(id) -> Promise<{success: boolean}> getDocument(id), classifyDocument(id, topics) From frontend/src/stores/auth.js (current): state: accessToken (ref null), user (ref null), loading, error actions: register, login, refresh, logout, logoutAll This plan adds: quota (ref({used_bytes: 0, limit_bytes: 0})), fetchQuota async action API contract for /confirm (Plan 03-02): Success: 200 + {id, size_bytes, used_bytes, status: "uploaded"} Quota exceeded: 413 + {detail: {used_bytes, limit_bytes, rejected_bytes}} Upload not found: 422 + {detail: "Upload not found — presigned URL may have expired"} API client `request()` error handling (current): - On HTTP !ok, throws Error(msg) where msg comes from response body `.detail` (or `HTTP {status}`) - For structured 413 detail (dict shape), response.json().detail is an object — current `msg = (await res.json()).detail || msg` will coerce object to "[object Object]". This plan must extend the request helper or catch 413 specifically in the upload store action. UI-SPEC Upload Progress steps: Awaiting URL: 0% / "Preparing upload…" URL received: 5% XHR progress: 5% → 90% (linear loaded/total) Confirm in flight: 92% / "Processing…" Confirm 200: 100% / "Done — classifying…" UI-SPEC Quota Bar: pct < 80% → bg-indigo-500 fill, text-gray-500 label 80 ≤ pct < 95% → bg-amber-500 fill, text-amber-600 label pct ≥ 95% → bg-red-500 fill, text-red-600 label Track: bg-gray-200, h-2 rounded-full Label format: "{used} MB of {limit} MB" (1 decimal place) role="progressbar" on fill, aria-valuenow, aria-valuemin=0, aria-valuemax=100 ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser ↔ MinIO presigned PUT | XHR sends file bytes directly to MinIO over a time-limited presigned URL; no Authorization header (URL is self-authenticating) | | browser → /api/documents/* | Authenticated requests carry Bearer JWT via existing request() helper | | browser quota display | Quota values come only from authoritative server response — never computed from local file size alone (UI-SPEC Copywriting Security) | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-22 | Information Disclosure | XHR PUT to MinIO with Authorization header attached | mitigate | uploadToMinIO helper uses bare XMLHttpRequest with NO setRequestHeader("Authorization", ...) — presigned URL is self-authenticating; CLAUDE.md "MinIO presigned URL flow" | | T-03-23 | Spoofing | Client-side quota display showing values from local file.size only | mitigate | Quota rejection error block populates `used_bytes / limit_bytes / rejected_bytes` from the 413 response body — never from `file.size` calculations | | T-03-24 | Denial of Service | Multiple concurrent uploads exhaust browser memory | accept | XHR-based uploads stream bytes natively (no buffering); v1 accepts user-driven concurrency | | T-03-25 | Tampering | Upload state corruption from race conditions in uploadProgress map | mitigate | Use file name + Date.now() composite key in uploadProgress map to avoid collisions when the same filename is uploaded twice in quick succession | | T-03-26 | Repudiation | Upload quota refetch silently fails | mitigate | authStore.fetchQuota wraps the API call in try/catch and resets to last-known state on error; QuotaBar hides itself on fetch error per UI-SPEC "Loading and Error States" | | T-03-SC | Tampering | npm installs | mitigate | No new npm dependencies — uses native XMLHttpRequest | Task 1: Refactor documents store to 3-step upload + quota state in auth store + extend UploadProgress.vue with progress bar and quota error block frontend/src/stores/documents.js, frontend/src/stores/auth.js, frontend/src/components/upload/UploadProgress.vue, frontend/src/api/client.js - frontend/src/stores/documents.js — current upload action (single multipart POST) - frontend/src/stores/auth.js — current state shape; pattern for adding new ref + action - frontend/src/api/client.js — `request()` helper and existing exports (uploadDocument, getUploadUrl, confirmUpload, getMyQuota added in Plan 03-04) - frontend/src/components/upload/UploadProgress.vue — current row template; item shape (name, done, error, topics) - .planning/phases/03-document-migration-multi-user-isolation/03-UI-SPEC.md — Upload Flow Interaction Contract, Quota Bar Color Logic, Error States (Quota Rejection Error Block), Accessibility, Copywriting Contract - .planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md — Finding 9 (XHR upload progress helper) - .planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md — XHR upload helper, Pinia Store Action Pattern - frontend/src/stores/documents.js: replace single `upload(file)` with 3-step async action; track `uploadProgress` (ref({})) keyed by unique row key (filename + timestamp); on success call `authStore.fetchQuota()`; on 413 capture `quotaError` (object {used_bytes, limit_bytes, rejected_bytes}) on the corresponding upload row and re-throw a tagged error so DropZone/parent can display it - On `remove(id)` success, also call `authStore.fetchQuota()` - frontend/src/stores/auth.js: add `quota` ref({used_bytes: 0, limit_bytes: 0}) and `fetchQuota()` async action that calls `api.getMyQuota()` and updates `quota.value`; expose in the returned store object. fetchQuota wraps in try/catch — on failure leaves last-known values intact (UI-SPEC "Hide widget entirely on fetch error" → handled in QuotaBar via a separate quotaLoadFailed ref) - frontend/src/api/client.js: extend `request()` to surface structured 413 detail. Detect `res.status === 413` and `typeof body.detail === "object"` then throw an Error with a `.payload` property carrying the structured detail; rest of code path unchanged. Remove `uploadDocument(file, autoClassify)` (legacy multipart) — no consumer remains after this plan - frontend/src/components/upload/UploadProgress.vue: extend `item` shape to include `progress` (0-100) and `quotaError` ({used_bytes, limit_bytes, rejected_bytes}); render a progress bar (h-2 bg-gray-200 → fill bg-indigo-500/green-500/red-400 per UI-SPEC) when item.progress is set; when item.quotaError is set, render the inline error block per UI-SPEC "Quota Rejection Error Block" (Not enough storage / This file (X MB) would exceed your quota / You're using Y MB of Z MB / Manage storage →) - All Phase 3 backend tests already cover the API contract — no new test files in this plan; manual verification via the human checkpoint in Task 2 Modify `frontend/src/api/client.js`: 1. Remove the `uploadDocument(file, autoClassify = true)` function entirely (legacy multipart POST; no longer called). 2. Replace the existing `request()` body with extended error handling for structured 413 detail. Specifically, the `if (!res.ok)` block becomes: ```js if (!res.ok) { let msg = `HTTP ${res.status}` let payload = null try { const body = await res.json() if (typeof body.detail === 'object' && body.detail !== null) { payload = body.detail msg = body.detail.message || `HTTP ${res.status}` } else { msg = body.detail || msg } } catch {} const err = new Error(msg) err.status = res.status if (payload) err.payload = payload throw err } ``` Modify `frontend/src/stores/auth.js`: 1. Add import-side `import * as api from '../api/client.js'` (already present). 2. Add inside the setup function (alongside accessToken, user, loading, error): ```js const quota = ref({ used_bytes: 0, limit_bytes: 0 }) async function fetchQuota() { try { const data = await api.getMyQuota() quota.value = { used_bytes: data.used_bytes, limit_bytes: data.limit_bytes } } catch { // Silently ignore — QuotaBar hides itself on fetch error (UI-SPEC) } } ``` 3. Add `quota` and `fetchQuota` to the returned store object. Rewrite `frontend/src/stores/documents.js` `upload` action. Add at module top (alongside existing imports) `import { useAuthStore } from './auth.js'`. Replace the existing `upload` function with: ```js function uploadToMinIO(url, file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) }) xhr.addEventListener('load', () => { if (xhr.status < 400) resolve() else reject(new Error(`PUT failed: ${xhr.status}`)) }) xhr.addEventListener('error', () => reject(new Error('Network error during upload'))) xhr.open('PUT', url) xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') // NOTE: no Authorization header — presigned URL is self-authenticating (T-03-22) xhr.send(file) }) } const uploadProgress = ref({}) // { [rowKey]: 0-100 } async function upload(file, autoClassify = true) { const authStore = useAuthStore() const rowKey = `${file.name}__${Date.now()}` uploadProgress.value[rowKey] = 0 try { // Step 1: get presigned PUT URL (UI-SPEC 0% → 5%) const { upload_url, document_id } = await api.getUploadUrl(file.name, file.type || 'application/octet-stream') uploadProgress.value[rowKey] = 5 // Step 2: XHR PUT to MinIO (UI-SPEC 5% → 90%) await uploadToMinIO(upload_url, file, (pct) => { // Map XHR progress 0-100 into the 5-90 visual range uploadProgress.value[rowKey] = 5 + Math.round(pct * 0.85) }) uploadProgress.value[rowKey] = 92 // Step 3: confirm (UI-SPEC 92% → 100%) const doc = await api.confirmUpload(document_id) uploadProgress.value[rowKey] = 100 documents.value.unshift({ id: doc.id, original_name: file.name, filename: file.name, mime_type: file.type, size_bytes: doc.size_bytes, topics: [], created_at: new Date().toISOString(), classified_at: null, }) total.value++ // Refresh quota (STORE-04) await authStore.fetchQuota() return { rowKey, doc } } catch (e) { error.value = e.message // Tag the row with structured quota error from 413 if (e.status === 413 && e.payload) { throw Object.assign(e, { rowKey }) } throw Object.assign(e, { rowKey }) } finally { // Keep the rowKey progress entry visible until parent (DropZone) clears it // (do NOT delete uploadProgress[rowKey] here — UploadProgress component still reads it) } } ``` Also modify `remove(id)`: after the existing `documents.value = documents.value.filter(...)` block, add `const authStore = useAuthStore(); await authStore.fetchQuota()`. Update the store return object to expose `uploadProgress`. Modify `frontend/src/components/upload/UploadProgress.vue`: 1. Inside the `
` wrapper, after the status text lines (`

` etc.), add the progress bar: ```vue

{{ item.progress }}%

``` 2. Replace the existing `

Uploading…

` line with a status-string mapper. New `

` reads `item.status` (a string set by parent — defaults to "Uploading…"). The UI-SPEC step strings are "Preparing upload…", "Uploading…", "Processing…", "Done — classifying…". Use `text-sm text-gray-400 mt-0.5`. 3. Add the inline quota rejection error block at the bottom of the row's flex-1 wrapper (after the status text): ```vue

``` Add `import { RouterLink } from 'vue-router'` to ` ``` Modify `frontend/src/components/layout/AppSidebar.vue`: 1. In `