Files
kite/.planning/phases/03-document-migration-multi-user-isolation/03-05-PLAN.md
T
curo1305 fdc32d431d docs(03): create Phase 3 execution plan — document migration & multi-user isolation
5 plans across 5 sequential waves covering: Alembic migration 0003 (null-user
cleanup, NOT NULL constraint, quota reconciliation), presigned MinIO PUT upload
flow with atomic quota enforcement, auth guards on all document/topic endpoints,
flat-file settings retirement + per-user AI classification, and frontend quota bar
with 3-step XHR upload progress.

Verification passed across all 12 dimensions. All 8 phase requirements covered
(STORE-03/04/05/06, SEC-04, DOC-03/04/05).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:36:28 +02:00

31 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-document-migration-multi-user-isolation 05 execute 5
03-04
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
false
STORE-03
STORE-04
STORE-05
truths artifacts key_links
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
path provides contains
frontend/src/stores/documents.js Three-step upload action with uploadProgress reactive map; quota refetch on success/delete getUploadUrl
path provides exports
frontend/src/stores/auth.js Reactive quota state + fetchQuota action
fetchQuota
path provides contains
frontend/src/components/layout/QuotaBar.vue Quota bar widget consuming useAuthStore quota state role="progressbar"
path provides contains
frontend/src/components/layout/AppSidebar.vue QuotaBar embedded between topics nav and footer <QuotaBar
path provides contains
frontend/src/components/upload/UploadProgress.vue Per-row progress bar, percentage label, quota rejection error block role="progressbar"
path provides contains
frontend/src/api/client.js uploadDocument legacy multipart removed; getUploadUrl/confirmUpload/getMyQuota retained from Plan 03-04 getMyQuota
from to via pattern
frontend/src/stores/documents.js MinIO (direct PUT) uploadToMinIO(url, file, onProgress) XHR helper XMLHttpRequest
from to via pattern
frontend/src/stores/documents.js frontend/src/stores/auth.js authStore.fetchQuota() invoked after upload and delete fetchQuota
from to via pattern
frontend/src/components/layout/QuotaBar.vue frontend/src/stores/auth.js useAuthStore() reads quota.used_bytes + quota.limit_bytes; onMounted triggers fetchQuota() useAuthStore
from to via pattern
frontend/src/components/upload/UploadProgress.vue documents store uploadProgress map items prop carries item.progress (0-100) and item.quotaError ({used_bytes, limit_bytes, rejected_bytes}) item.progress|item.quotaError
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.

<execution_context> @/Users/nik/.claude/get-shit-done/workflows/execute-plan.md @/Users/nik/.claude/get-shit-done/templates/summary.md </execution_context>

@.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

<threat_model>

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
</threat_model>
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 `<div class="flex-1 min-w-0">` wrapper, after the status text lines (`<p v-if="item.error" ...>` etc.), add the progress bar:
   ```vue
   <!-- Progress bar (visible while uploading / processing) -->
   <div
     v-if="!item.done && !item.error && !item.quotaError && item.progress !== undefined"
     class="w-full h-2 bg-gray-200 rounded-full mt-1 overflow-hidden"
   >
     <div
       role="progressbar"
       :aria-valuenow="item.progress"
       aria-valuemin="0"
       aria-valuemax="100"
       :aria-label="`Upload progress for ${item.name}`"
       class="h-full rounded-full transition-all duration-300 bg-indigo-500"
       :style="{ width: `${item.progress}%` }"
     ></div>
   </div>
   <p
     v-if="!item.done && !item.error && !item.quotaError && item.progress !== undefined"
     class="text-sm text-gray-400 mt-1 text-right"
   >{{ item.progress }}%</p>
   ```
2. Replace the existing `<p v-else class="text-xs text-gray-400 mt-0.5">Uploading…</p>` line with a status-string mapper. New `<p v-else ...>` 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
   <div
     v-if="item.quotaError"
     role="alert"
     class="mt-1 p-3 rounded-lg bg-red-50 border border-red-200"
   >
     <p class="text-sm font-semibold text-red-700">Not enough storage</p>
     <p class="text-sm text-red-600 mt-1">
       This file ({{ (item.quotaError.rejected_bytes / 1048576).toFixed(1) }} MB) would exceed your quota.
     </p>
     <p class="text-sm text-red-600">
       You're using {{ (item.quotaError.used_bytes / 1048576).toFixed(1) }} MB of {{ (item.quotaError.limit_bytes / 1048576).toFixed(1) }} MB.
     </p>
     <router-link to="/settings" class="text-sm text-red-600 underline hover:text-red-700 font-semibold">
       Manage storage →
     </router-link>
   </div>
   ```
   Add `import { RouterLink } from 'vue-router'` to `<script setup>` if needed (alternatively use plain `<a href="/settings">` to avoid the import).
4. No changes to `defineProps({ items: ... })` — `progress`, `status`, `quotaError` are properties on each `item` object.
cd frontend && grep -c "uploadToMinIO" src/stores/documents.js && grep -c "XMLHttpRequest" src/stores/documents.js && grep -c "fetchQuota" src/stores/auth.js && grep -c "role=\"progressbar\"" src/components/upload/UploadProgress.vue && grep -c "Not enough storage" src/components/upload/UploadProgress.vue && ! grep -q "uploadDocument" src/api/client.js && node -e "const fs = require('fs'); const f = fs.readFileSync('src/stores/documents.js', 'utf8'); if (!f.includes('getUploadUrl')) process.exit(1); if (!f.includes('confirmUpload')) process.exit(1); if (!f.includes('fetchQuota')) process.exit(1); console.log('store wiring OK')" `stores/documents.js` exposes the 3-step upload action and calls `authStore.fetchQuota()` on success + delete. `stores/auth.js` exposes a `quota` ref and `fetchQuota` action. `UploadProgress.vue` renders an aria-progressbar row and the quota rejection error block on `item.quotaError`. `api/client.js` no longer exports `uploadDocument`. `request()` attaches `.status` and `.payload` to thrown errors. Task 2: Create QuotaBar.vue and embed in AppSidebar between topics nav and footer frontend/src/components/layout/QuotaBar.vue, frontend/src/components/layout/AppSidebar.vue - frontend/src/components/layout/AppSidebar.vue — current footer block (px-3 py-4 border-t border-gray-100 section) - frontend/src/components/auth/PasswordStrengthBar.vue — visual style analog for a Tailwind progress bar - frontend/src/stores/auth.js — fetchQuota + quota state added by Task 1 - .planning/phases/03-document-migration-multi-user-isolation/03-UI-SPEC.md — Quota Usage Bar — Sidebar Contract (Placement, Structure, Loading and Error States, Data Source, Accessibility) - .planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md — QuotaBar.vue analog (PasswordStrengthBar visual style only) - QuotaBar.vue is a self-contained component: onMounted calls `authStore.fetchQuota()`; computed properties `pct`, `barColor`, `labelColor`, `label` derive from `authStore.quota.used_bytes` and `authStore.quota.limit_bytes` per UI-SPEC color logic - Bar fill includes `role="progressbar"`, `aria-valuenow`, `aria-valuemin=0`, `aria-valuemax=100`, `aria-label="Storage usage: {used} MB of {limit} MB"` - Loading state: when limit_bytes === 0 AND fetch has never completed, render skeleton `bg-gray-100 animate-pulse h-2 rounded-full` with `aria-label="Loading storage usage"` and `aria-busy="true"`. After first successful fetchQuota, even used_bytes=0 renders the real bar (UI-SPEC: "do not hide the widget when used_bytes=0") - Error state: if fetchQuota threw, the component hides itself via v-if (no DOM) - Label format: "12.3 MB of 100.0 MB" (1 decimal place) - QuotaBar mounted inside AppSidebar.vue, placed between the topics nav `` and the settings/admin/footer `
` block - AppSidebar.vue script-setup imports QuotaBar Create `frontend/src/components/layout/QuotaBar.vue` as a new file: ```vue
Storage {{ label }}
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth.js'

const authStore = useAuthStore()
const ready = ref(false)
const loadFailed = ref(false)

const pct = computed(() => {
  const { used_bytes, limit_bytes } = authStore.quota
  if (!limit_bytes || limit_bytes <= 0) return 0
  return Math.min(100, (used_bytes / limit_bytes) * 100)
})

const barColor = computed(() => {
  if (pct.value >= 95) return 'bg-red-500'
  if (pct.value >= 80) return 'bg-amber-500'
  return 'bg-indigo-500'
})

const labelColor = computed(() => {
  if (pct.value >= 95) return 'text-red-600'
  if (pct.value >= 80) return 'text-amber-600'
  return 'text-gray-500'
})

const label = computed(() => {
  const used = (authStore.quota.used_bytes / 1048576).toFixed(1)
  const limit = (authStore.quota.limit_bytes / 1048576).toFixed(1)
  return `${used} MB of ${limit} MB`
})

onMounted(async () => {
  try {
    await authStore.fetchQuota()
    ready.value = true
  } catch {
    loadFailed.value = true
  }
})
</script>
```

Modify `frontend/src/components/layout/AppSidebar.vue`:
1. In `<script setup>` imports, add `import QuotaBar from './QuotaBar.vue'`.
2. In the template, insert `<QuotaBar />` between the closing `</nav>` (end of topics section) and the `<div class="px-3 py-4 border-t border-gray-100">` block (settings/admin/footer). Per UI-SPEC the QuotaBar component renders its own `border-t border-gray-100` so no additional separator needed.
cd frontend && test -f src/components/layout/QuotaBar.vue && echo "QuotaBar exists" && grep -c "role=\"progressbar\"" src/components/layout/QuotaBar.vue && grep -c "useAuthStore" src/components/layout/QuotaBar.vue && grep -c " `QuotaBar.vue` file exists with `role="progressbar"` and `useAuthStore` reference. `AppSidebar.vue` imports and renders ``. Color classes match UI-SPEC: `bg-indigo-500` (<80%), `bg-amber-500` (80-95%), `bg-red-500` (≥95%). Task 3: Human-verify Phase 3 end-to-end upload + quota UX in browser Phase 3 frontend is complete: 3-step presigned upload with XHR progress bar, sidebar quota bar with color thresholds, inline 413 error block with "Manage storage →" link, SettingsView admin-managed placeholder. Prerequisites: `docker compose up` is running with healthy postgres/minio/redis/backend/celery-worker/celery-beat. Frontend dev server running on http://localhost:5173. A logged-in non-admin user with a quota row (use admin panel to create a fresh user if needed).
1. **Upload happy path**: Sign in as a non-admin user. Open the home page (DropZone). Drag a small PDF or text file (~1 MB) onto the drop zone. Observe the UploadProgress row:
   - Status text transitions: "Preparing upload…" → "Uploading…" → "Processing…" → "Done — classifying…"
   - Progress bar fills smoothly from 0% → 5% → ~90% (XHR phase) → 92% (confirm) → 100% (done)
   - Color is indigo-500 during upload, green-500 on completion
   - Aria attributes are present on the bar (inspect element: `role="progressbar"`, `aria-valuenow`)
   Expected: file appears in document list within ~3 seconds (Celery classification finishes shortly after; topic chips may appear with a small lag)

2. **Sidebar quota bar**: After upload, the sidebar quota bar (between topics nav and Settings link) updates to reflect new usage. Verify:
   - Label format "X.X MB of 100.0 MB" with 1 decimal place
   - Bar fill color is indigo-500 (since pct < 80%)
   - On admin-side: temporarily set the user's quota to a low limit (e.g. via `/api/admin/users/{id}/quota` PATCH to limit_bytes=12MB) so a fresh upload pushes past 80%. Reload sidebar — bar fill switches to amber-500 and label color to amber-600 at >=80%, red-500 + red-600 at >=95%

3. **Quota rejection (413)**: Upload another file that would exceed quota. The UploadProgress row replaces the progress bar with the red error block:
   - Heading "Not enough storage" in red-700
   - Body line 1: "This file (X.X MB) would exceed your quota."
   - Body line 2: "You're using Y.Y MB of Z.Z MB."
   - Link "Manage storage →" navigates to /settings
   - Sidebar quota bar does not show the failed upload (no quota increment)

4. **Document delete + quota decrement**: Delete a document from the list. The sidebar quota bar usage decreases. The bar color reverts (e.g. from amber to indigo) if usage drops below 80%

5. **/settings placeholder**: Click the Settings link in the sidebar. The view shows the placeholder card with heading "Settings" and the admin-managed message — no form, no console errors

6. **Admin 403**: Sign out and sign in as the admin user. Attempt to navigate to /home (document list). The list either redirects/blocks via 403 banner or returns "Failed to load documents" — confirm document content is not visible to admin. (Frontend may not have a beautiful 403 UI yet — this is Phase 4 polish; verify the network response is 403 in DevTools)

Browser DevTools: confirm no console errors during upload; the XHR PUT request to MinIO targets `localhost:9000` (not `minio:9000`) and returns 200; the POST /api/documents/{id}/confirm carries a Bearer token (Authorization header in request) but the MinIO PUT does NOT carry an Authorization header (presigned URL is self-authenticating)

All steps above must pass before marking this checkpoint complete.
Type "approved" if all 6 scenarios pass; otherwise describe which step failed and the observed behavior - All Phase 3 stub tests now pass: `cd backend && pytest tests/test_quota.py tests/test_documents.py tests/test_topics.py tests/test_classifier.py tests/test_settings.py tests/test_alembic.py -x -q` - Full backend suite green: `cd backend && pytest -v` - Frontend builds without errors: `cd frontend && npm run build` exits 0 - No legacy uploadDocument reference: `cd frontend && grep -rn 'uploadDocument' src/` returns no hits - Human checkpoint Task 3 approved

<success_criteria>

  • Documents store implements 3-step upload with XHR progress
  • Auth store exposes quota state + fetchQuota action
  • UploadProgress.vue renders the progress bar and inline quota error block per UI-SPEC
  • QuotaBar.vue renders the sidebar quota bar with correct color thresholds
  • AppSidebar.vue embeds QuotaBar between topics nav and footer
  • Browser PUT requests succeed against localhost:9000 (not minio:9000)
  • /settings renders the placeholder card without errors
  • Admin role receives 403 on document endpoints (manual verification) </success_criteria>
Create `.planning/phases/03-document-migration-multi-user-isolation/03-05-SUMMARY.md` when done — list the 6 frontend files modified, note any UI-SPEC deviations (e.g., if router-link vs anchor tag), and surface any backend issues discovered during the human checkpoint that need follow-up.