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>
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 |
|
|
false |
|
|
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(orHTTP {status}) - For structured 413 detail (dict shape), response.json().detail is an object — current
msg = (await res.json()).detail || msgwill 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> |
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 `<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>