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>
This commit is contained in:
curo1305
2026-05-23 13:36:28 +02:00
parent 1ba578c7f6
commit fdc32d431d
10 changed files with 3940 additions and 15 deletions
@@ -0,0 +1,521 @@
---
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>