---
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: "<QuotaBar"
    - path: "frontend/src/components/upload/UploadProgress.vue"
      provides: "Per-row progress bar, percentage label, quota rejection error block"
      contains: "role=\"progressbar\""
    - path: "frontend/src/api/client.js"
      provides: "uploadDocument legacy multipart removed; getUploadUrl/confirmUpload/getMyQuota retained from Plan 03-04"
      contains: "getMyQuota"
  key_links:
    - from: "frontend/src/stores/documents.js"
      to: "MinIO (direct PUT)"
      via: "uploadToMinIO(url, file, onProgress) XHR helper"
      pattern: "XMLHttpRequest"
    - from: "frontend/src/stores/documents.js"
      to: "frontend/src/stores/auth.js"
      via: "authStore.fetchQuota() invoked after upload and delete"
      pattern: "fetchQuota"
    - from: "frontend/src/components/layout/QuotaBar.vue"
      to: "frontend/src/stores/auth.js"
      via: "useAuthStore() reads quota.used_bytes + quota.limit_bytes; onMounted triggers fetchQuota()"
      pattern: "useAuthStore"
    - from: "frontend/src/components/upload/UploadProgress.vue"
      to: "documents store uploadProgress map"
      via: "items prop carries item.progress (0-100) and item.quotaError ({used_bytes, limit_bytes, rejected_bytes})"
      pattern: "item.progress|item.quotaError"
---

<objective>
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.
</objective>

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

<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

<interfaces>
<!-- Contracts the executor needs without re-reading the codebase. -->

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
</interfaces>
</context>

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

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Refactor documents store to 3-step upload + quota state in auth store + extend UploadProgress.vue with progress bar and quota error block</name>
  <files>frontend/src/stores/documents.js, frontend/src/stores/auth.js, frontend/src/components/upload/UploadProgress.vue, frontend/src/api/client.js</files>
  <read_first>
    - 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
  </read_first>
  <behavior>
    - 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
  </behavior>
  <action>
    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.
  </action>
  <verify>
    <automated>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')"</automated>
  </verify>
  <done>
    `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.
  </done>
</task>

<task type="auto">
  <name>Task 2: Create QuotaBar.vue and embed in AppSidebar between topics nav and footer</name>
  <files>frontend/src/components/layout/QuotaBar.vue, frontend/src/components/layout/AppSidebar.vue</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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 `<nav>` and the settings/admin/footer `<div class="px-3 py-4 border-t border-gray-100">` block
    - AppSidebar.vue script-setup imports QuotaBar
  </behavior>
  <action>
    Create `frontend/src/components/layout/QuotaBar.vue` as a new file:
    ```vue
    <template>
      <div v-if="!loadFailed" class="px-4 py-3 border-t border-gray-100">
        <div class="flex items-center justify-between mb-1">
          <span class="text-sm font-semibold text-gray-500">Storage</span>
          <span v-if="ready" class="text-sm" :class="labelColor">{{ label }}</span>
        </div>
        <div
          v-if="ready"
          class="w-full h-2 bg-gray-200 rounded-full overflow-hidden"
        >
          <div
            role="progressbar"
            :aria-valuenow="pct"
            aria-valuemin="0"
            aria-valuemax="100"
            :aria-label="`Storage usage: ${label}`"
            class="h-2 rounded-full transition-all duration-500"
            :class="barColor"
            :style="{ width: `${pct}%` }"
          ></div>
        </div>
        <div
          v-else
          class="w-full h-2 bg-gray-100 rounded-full animate-pulse"
          aria-label="Loading storage usage"
          aria-busy="true"
        ></div>
      </div>
    </template>

    <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.
  </action>
  <verify>
    <automated>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" src/components/layout/AppSidebar.vue && grep -c "QuotaBar from './QuotaBar.vue'" src/components/layout/AppSidebar.vue</automated>
  </verify>
  <done>
    `QuotaBar.vue` file exists with `role="progressbar"` and `useAuthStore` reference. `AppSidebar.vue` imports and renders `<QuotaBar />`. Color classes match UI-SPEC: `bg-indigo-500` (<80%), `bg-amber-500` (80-95%), `bg-red-500` (≥95%).
  </done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Human-verify Phase 3 end-to-end upload + quota UX in browser</name>
  <what-built>
    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.
  </what-built>
  <how-to-verify>
    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.
  </how-to-verify>
  <resume-signal>Type "approved" if all 6 scenarios pass; otherwise describe which step failed and the observed behavior</resume-signal>
</task>

</tasks>

<verification>
- 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
</verification>

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

<output>
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.
</output>
