docs(05): create UAT gap closure plans 09-11
Three new plans address all 6 diagnosed gaps from 05-UAT.md:
- 05-09: cloud document open (fetch+Blob URL), re-analyze (cloud-aware
Celery task), and edit (PATCH /api/documents/{id})
- 05-10: OAuth initiate JSON response fix, Nextcloud custom endpoint
edit round-trip, Edit button on ERROR rows, confirmation text overflow
- 05-11: admin hard-delete with admin-password confirmation (backend
UserDeleteConfirm model + frontend inline panel)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -219,7 +219,7 @@ Before any phase is marked complete, all three gates must pass:
|
|||||||
4. A user can disconnect a cloud backend; credentials are permanently deleted from the DB and a subsequent attempt to use that backend returns an appropriate error — no orphaned data remains
|
4. A user can disconnect a cloud backend; credentials are permanently deleted from the DB and a subsequent attempt to use that backend returns an appropriate error — no orphaned data remains
|
||||||
5. An admin API response for a user's cloud connections returns only `provider, display_name, connected_at, status` — the `credentials_enc` column is never present in any serialized response
|
5. An admin API response for a user's cloud connections returns only `provider, display_name, connected_at, status` — the `credentials_enc` column is never present in any serialized response
|
||||||
|
|
||||||
**Plans**: 8 plans
|
**Plans**: 11 plans (8 original + 3 UAT gap closure)
|
||||||
|
|
||||||
**Wave 1** — Test scaffold + dependencies
|
**Wave 1** — Test scaffold + dependencies
|
||||||
|
|
||||||
@@ -250,11 +250,18 @@ Before any phase is marked complete, all three gates must pass:
|
|||||||
|
|
||||||
- [x] 05-08-PLAN.md — AppSidebar cloud section + CloudProviderTreeItem + CloudFolderTreeItem + human checkpoint
|
- [x] 05-08-PLAN.md — AppSidebar cloud section + CloudProviderTreeItem + CloudFolderTreeItem + human checkpoint
|
||||||
|
|
||||||
|
**Wave 8** — UAT gap closure (parallel, all independent)
|
||||||
|
|
||||||
|
- [ ] 05-09-PLAN.md — Cloud document open/re-analyze/edit: authenticated fetch+Blob URL, cloud-aware Celery task, PATCH /api/documents/{id}
|
||||||
|
- [ ] 05-10-PLAN.md — OAuth initiate fix (JSON response), Nextcloud custom endpoint edit round-trip, Edit button on ERROR rows, confirmation text overflow
|
||||||
|
- [ ] 05-11-PLAN.md — Admin hard-delete with password confirmation: UserDeleteConfirm backend model + inline frontend panel
|
||||||
|
|
||||||
**Phase gates (must pass before Phase 5 is complete):**
|
**Phase gates (must pass before Phase 5 is complete):**
|
||||||
|
|
||||||
- [x] `pytest -v` — zero failures; SSRF prevention on WebDAV/Nextcloud user-supplied URLs; credential encryption/decryption round-trip; admin response never exposes `credentials_enc`; OAuth invalid_grant handling
|
- [x] `pytest -v` — zero failures; SSRF prevention on WebDAV/Nextcloud user-supplied URLs; credential encryption/decryption round-trip; admin response never exposes `credentials_enc`; OAuth invalid_grant handling
|
||||||
- [x] Security agent: SSRF allowlist verification; credential key derivation correctness; connection status never leaks raw credential values
|
- [x] Security agent: SSRF allowlist verification; credential key derivation correctness; connection status never leaks raw credential values
|
||||||
- [x] Bandit + pip audit + npm audit all clean
|
- [x] Bandit + pip audit + npm audit all clean
|
||||||
|
- [ ] UAT gaps 05-09, 05-10, 05-11 resolved and re-tested
|
||||||
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
@@ -268,4 +275,4 @@ Before any phase is marked complete, all three gates must pass:
|
|||||||
| 2. Users & Authentication | 5/5 | Complete | 2026-05-22 |
|
| 2. Users & Authentication | 5/5 | Complete | 2026-05-22 |
|
||||||
| 3. Document Migration & Multi-User Isolation | 5/5 | Complete | 2026-05-25 |
|
| 3. Document Migration & Multi-User Isolation | 5/5 | Complete | 2026-05-25 |
|
||||||
| 4. Folders, Sharing, Quotas & Document UX | 9/9 | Complete | 2026-05-28 |
|
| 4. Folders, Sharing, Quotas & Document UX | 9/9 | Complete | 2026-05-28 |
|
||||||
| 5. Cloud Storage Backends | 8/8 | Complete | 2026-05-29 |
|
| 5. Cloud Storage Backends | 8/11 | UAT gap closure in progress | — |
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
---
|
||||||
|
phase: 05-cloud-storage-backends
|
||||||
|
plan: 09
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- backend/api/documents.py
|
||||||
|
- backend/tasks/document_tasks.py
|
||||||
|
- frontend/src/api/client.js
|
||||||
|
- frontend/src/components/documents/DocumentPreviewModal.vue
|
||||||
|
- frontend/src/views/DocumentView.vue
|
||||||
|
- backend/tests/test_cloud.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CLOUD-03, CLOUD-05, CLOUD-07]
|
||||||
|
gap_closure: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Opening a cloud document proxies bytes through the backend and renders without 401"
|
||||||
|
- "Re-analyzing a cloud document retrieves file bytes from the cloud backend, not MinIO"
|
||||||
|
- "PATCH /api/documents/{id} accepts filename and folder_id and persists the change"
|
||||||
|
- "Frontend fetch-with-blob-URL approach works in DocumentPreviewModal and DocumentView"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/api/documents.py"
|
||||||
|
provides: "PATCH /{doc_id} endpoint accepting {filename, folder_id}"
|
||||||
|
- path: "backend/tasks/document_tasks.py"
|
||||||
|
provides: "Cloud-aware re-analyze: routes to correct backend by doc.storage_backend"
|
||||||
|
- path: "frontend/src/components/documents/DocumentPreviewModal.vue"
|
||||||
|
provides: "Authenticated fetch + Blob URL for document preview"
|
||||||
|
- path: "frontend/src/views/DocumentView.vue"
|
||||||
|
provides: "Authenticated fetch + Blob URL for document open"
|
||||||
|
key_links:
|
||||||
|
- from: "frontend/src/components/documents/DocumentPreviewModal.vue"
|
||||||
|
to: "/api/documents/{id}/content"
|
||||||
|
via: "fetch with Authorization header → Blob URL"
|
||||||
|
- from: "backend/tasks/document_tasks.py"
|
||||||
|
to: "get_storage_backend_for_document"
|
||||||
|
via: "doc.storage_backend != 'minio' branch"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Fix three independent root causes that prevent cloud documents from being opened, re-analyzed, or edited.
|
||||||
|
|
||||||
|
Purpose: Cloud-stored documents are inaccessible after upload because (1) the preview uses an unauthenticated iframe src, (2) the Celery re-analyze task hardcodes MinIO, and (3) no PATCH endpoint exists for document metadata.
|
||||||
|
|
||||||
|
Output: All three flows work end-to-end for cloud documents. Three new/updated tests pass.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-cloud-storage-backends/05-UAT.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types the executor needs. Extracted from existing codebase. -->
|
||||||
|
|
||||||
|
From backend/api/documents.py:
|
||||||
|
- router = APIRouter(prefix="/api/documents")
|
||||||
|
- get_regular_user dep: rejects admins (403), requires active user
|
||||||
|
- Document ORM fields: id, user_id, filename, folder_id, storage_backend, object_key
|
||||||
|
- Ownership check pattern: `if doc.user_id != current_user.id: raise HTTPException(404)`
|
||||||
|
- Existing PATCH for folder move lives in backend/api/folders.py with separate router
|
||||||
|
|
||||||
|
From backend/tasks/document_tasks.py:
|
||||||
|
- `_run(document_id)` calls `get_storage_backend()` unconditionally (returns MinIO backend)
|
||||||
|
- `doc.storage_backend` column holds "minio" | "google_drive" | "onedrive" | "nextcloud" | "webdav"
|
||||||
|
- Session is opened fresh per task: `async with AsyncSessionLocal() as session:`
|
||||||
|
- User is fetched: `user = await session.get(User, doc.user_id)`
|
||||||
|
- `get_storage_backend_for_document(doc, user, session)` already exists in storage/__init__.py
|
||||||
|
|
||||||
|
From backend/storage/__init__.py (inferred from documents.py usage):
|
||||||
|
- `get_storage_backend_for_document(doc, user, session)` → returns correct backend instance
|
||||||
|
|
||||||
|
From frontend/src/api/client.js:
|
||||||
|
- `request(path, options)` injects Authorization: Bearer header automatically
|
||||||
|
- Returns parsed JSON; for binary responses a different approach is needed
|
||||||
|
- `getDocumentContentUrl(docId)` currently returns a plain URL string (no auth)
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add PATCH /api/documents/{doc_id} endpoint + cloud-aware Celery re-analyze</name>
|
||||||
|
<files>backend/api/documents.py, backend/tasks/document_tasks.py, backend/tests/test_cloud.py</files>
|
||||||
|
<behavior>
|
||||||
|
- PATCH /api/documents/{doc_id} with body {filename?: str, folder_id?: uuid | null} returns 200 with updated document dict; non-owner gets 404; admin gets 403 (get_regular_user).
|
||||||
|
- PATCH with only filename updates filename, folder_id unchanged.
|
||||||
|
- PATCH with folder_id=null moves document to root (no folder).
|
||||||
|
- Re-analyze task: when doc.storage_backend != "minio", calls get_storage_backend_for_document(doc, user, session) instead of get_storage_backend(); if user is None returns {"status": "missing_user"}; on CloudConnectionError returns {"status": "extract_failed", "error": "cloud backend error"}.
|
||||||
|
- Re-analyze task: MinIO path (storage_backend == "minio") unchanged.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
In backend/api/documents.py, add a DocumentPatch Pydantic model with optional fields `filename: Optional[str] = None` and `folder_id: Optional[uuid.UUID] = None` — use a sentinel default of `_UNSET = object()` or `Optional[uuid.UUID]` with a custom flag; the cleanest approach for nullable-vs-absent folder_id is to use `model_fields_set` to distinguish "not provided" from "set to null". Accept the body and update only the fields present in `model_fields_set`. Validate that at least one field is provided (422 if body is empty). Apply ownership guard (doc.user_id != current_user.id → 404). Return the updated document dict using the same shape as GET /api/documents/{id} (call `storage.get_metadata(session, doc_id)` after commit).
|
||||||
|
|
||||||
|
In backend/tasks/document_tasks.py, in `_run()`, replace the unconditional `backend = get_storage_backend()` block with:
|
||||||
|
- If `doc.storage_backend` is None or `doc.storage_backend == "minio"`: use `get_storage_backend()` (existing MinIO path).
|
||||||
|
- Else: use `get_storage_backend_for_document(doc, user, session)`. If user is None (doc.user_id is None), return `{"document_id": document_id, "status": "missing_user"}`. Import `get_storage_backend_for_document` at the top of `_run()` using a deferred import (same pattern as other imports in this file: `from storage import get_storage_backend_for_document`). Catch `CloudConnectionError` from the get_object call and return `{"document_id": document_id, "status": "extract_failed", "error": "cloud backend error"}` (do NOT include the raw provider error message).
|
||||||
|
|
||||||
|
In backend/tests/test_cloud.py, add three tests:
|
||||||
|
1. `test_patch_document_filename` — create a document, PATCH with {filename: "renamed.pdf"}, assert 200 and updated filename.
|
||||||
|
2. `test_patch_document_wrong_owner` — create two users, try to PATCH user A's document as user B, assert 404.
|
||||||
|
3. `test_reanalyze_cloud_document_routes_to_cloud_backend` — mock `get_storage_backend_for_document` to return an AsyncMock backend, set doc.storage_backend="nextcloud", call `_run(doc_id)` directly, assert the mock backend's `get_object` was called and MinIO backend's `get_object` was NOT called.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_cloud.py::test_patch_document_filename tests/test_cloud.py::test_patch_document_wrong_owner tests/test_cloud.py::test_reanalyze_cloud_document_routes_to_cloud_backend -v</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Three tests pass. PATCH /api/documents/{id} returns 200 for valid owner, 404 for wrong owner. Re-analyze task uses cloud backend for cloud documents.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Authenticated document preview — fetch-with-Blob-URL in frontend</name>
|
||||||
|
<files>frontend/src/api/client.js, frontend/src/components/documents/DocumentPreviewModal.vue, frontend/src/views/DocumentView.vue</files>
|
||||||
|
<action>
|
||||||
|
In frontend/src/api/client.js, add a new exported function `fetchDocumentContent(docId)` that calls `fetch(/api/documents/${docId}/content, { headers: { Authorization: Bearer ${authStore.accessToken} }, credentials: "include" })`. On 401, attempt one refresh via `authStore.refresh()` and retry (same pattern as `request()`). Return the raw `Response` object (not parsed JSON) so callers can call `.blob()` on it. Do NOT use the existing `request()` helper because it calls `res.json()` unconditionally.
|
||||||
|
|
||||||
|
In frontend/src/components/documents/DocumentPreviewModal.vue:
|
||||||
|
- Remove the computed `proxyUrl` that returns a raw URL string.
|
||||||
|
- Add reactive state: `blobUrl = ref(null)`, `loadError = ref(null)`, `loading = ref(true)`.
|
||||||
|
- On `onMounted` (and watch `props.doc.id` for changes), call `fetchDocumentContent(props.doc.id)` from the API client, then `response.blob()`, then `URL.createObjectURL(blob)` and assign to `blobUrl`.
|
||||||
|
- In the template, change `iframe :src="proxyUrl"` to `iframe :src="blobUrl"` with a `v-if="blobUrl"`.
|
||||||
|
- Show a loading spinner while `loading` is true and `blobUrl` is null.
|
||||||
|
- Show an error message if `loadError` is set.
|
||||||
|
- On `onUnmounted`, call `URL.revokeObjectURL(blobUrl.value)` to release the object URL.
|
||||||
|
|
||||||
|
In frontend/src/views/DocumentView.vue:
|
||||||
|
- Locate any `window.open()` call that opens `/api/documents/{id}/content` directly.
|
||||||
|
- Replace it with: call `fetchDocumentContent(docId)`, get the blob, create an object URL, then `window.open(objectUrl)`. After a short delay (e.g. `setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)`), revoke the URL.
|
||||||
|
- Import `fetchDocumentContent` from the API client.
|
||||||
|
|
||||||
|
Note: the `request()` helper in client.js already handles 401 → refresh → retry. The new `fetchDocumentContent` must replicate only the auth injection + single retry, not the JSON parsing. Keep it simple: use the `useAuthStore` lazy import pattern already in `request()`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Frontend build passes with zero errors. DocumentPreviewModal and DocumentView use fetchDocumentContent. No unauthenticated src= URLs remain for the /content endpoint.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| frontend→/api/documents/{id}/content | Previously browser-navigated; now fetch() with Bearer — closes the unauthenticated access gap |
|
||||||
|
| PATCH /api/documents/{id} body | User-supplied filename and folder_id — validated via Pydantic; ownership enforced |
|
||||||
|
| Celery task → cloud backend | Task now instantiates cloud backend inside worker process using DB-resident credentials |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-09-01 | Spoofing | PATCH /documents/{id} | mitigate | get_regular_user enforced; admin → 403; wrong owner → 404 |
|
||||||
|
| T-05-09-02 | Information Disclosure | PATCH response | mitigate | storage.get_metadata() whitelist used for response — no credentials_enc or password_hash |
|
||||||
|
| T-05-09-03 | Tampering | Celery task cloud credentials | mitigate | get_storage_backend_for_document loads credentials from DB inside task's own session — no credentials in broker message |
|
||||||
|
| T-05-09-04 | Information Disclosure | fetchDocumentContent Blob URL | accept | Blob URL is same-origin, revoked on unmount — no persistent exposure |
|
||||||
|
| T-05-09-SC | Tampering | npm/pip installs | mitigate | No new packages installed in this plan |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `pytest backend/tests/test_cloud.py -v` — all three new tests pass, no regressions
|
||||||
|
- `npm run build` — zero errors
|
||||||
|
- Manual: open a cloud document in the app — preview loads without 401
|
||||||
|
- Manual: re-analyze a cloud document — task completes without NoSuchKey error
|
||||||
|
- Manual: rename a cloud document — PATCH returns 200 with updated filename
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- PATCH /api/documents/{id} is callable with {filename} or {folder_id} and returns the updated document
|
||||||
|
- Re-analyze Celery task calls the cloud backend for cloud documents (not MinIO)
|
||||||
|
- DocumentPreviewModal uses fetch + Blob URL, no unauthenticated iframe src
|
||||||
|
- DocumentView uses fetch + Blob URL, no window.open with raw /content URL
|
||||||
|
- All three new pytest tests pass; full suite has zero new failures
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/05-cloud-storage-backends/05-09-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
---
|
||||||
|
phase: 05-cloud-storage-backends
|
||||||
|
plan: 10
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- backend/api/cloud.py
|
||||||
|
- frontend/src/components/settings/SettingsCloudTab.vue
|
||||||
|
- frontend/src/components/cloud/CloudCredentialModal.vue
|
||||||
|
- frontend/src/components/ui/ConfirmBlock.vue
|
||||||
|
- backend/tests/test_cloud.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CLOUD-01, CLOUD-04]
|
||||||
|
gap_closure: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Clicking Connect on Google Drive or OneDrive initiates OAuth without a 401"
|
||||||
|
- "Nextcloud custom endpoint is preserved when re-editing an existing connection"
|
||||||
|
- "Edit button appears on ERROR-state Nextcloud/WebDAV rows"
|
||||||
|
- "Disconnect confirmation text renders fully within its container without overflow"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/api/cloud.py"
|
||||||
|
provides: "oauth_initiate returns JSON {url} (200) instead of RedirectResponse (302)"
|
||||||
|
- path: "frontend/src/components/settings/SettingsCloudTab.vue"
|
||||||
|
provides: "handleConnect uses fetch() + Bearer header; Edit button in ERROR template block"
|
||||||
|
- path: "frontend/src/components/cloud/CloudCredentialModal.vue"
|
||||||
|
provides: "watch handler detects custom endpoint on edit and repopulates showAdvanced + customEndpoint"
|
||||||
|
- path: "frontend/src/components/ui/ConfirmBlock.vue"
|
||||||
|
provides: "break-words on message paragraph"
|
||||||
|
key_links:
|
||||||
|
- from: "frontend/src/components/settings/SettingsCloudTab.vue"
|
||||||
|
to: "/api/cloud/oauth/initiate/{provider}"
|
||||||
|
via: "fetch() with Authorization header → data.url → window.location.href"
|
||||||
|
- from: "backend/api/cloud.py"
|
||||||
|
to: "oauth_initiate handler"
|
||||||
|
via: "returns JSONResponse({url: authorization_url}) status 200"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Fix four cloud settings UI gaps: OAuth initiate 401, Nextcloud custom endpoint lost on edit, missing Edit button on ERROR rows, and confirmation text overflow.
|
||||||
|
|
||||||
|
Purpose: Users cannot connect OAuth providers (Google Drive/OneDrive) due to a bare browser navigation that carries no auth header. Nextcloud connections with custom endpoints lose their configuration on re-edit. ERROR-state connections cannot be edited without removing first.
|
||||||
|
|
||||||
|
Output: OAuth flow initiates correctly, Nextcloud edit round-trips custom endpoint, ERROR rows have Edit button, confirmation text wraps.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-cloud-storage-backends/05-UAT.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key contracts the executor needs. -->
|
||||||
|
|
||||||
|
From backend/api/cloud.py (current oauth_initiate):
|
||||||
|
- Route: GET /api/cloud/oauth/initiate/{provider}
|
||||||
|
- Currently returns: RedirectResponse(url=authorization_url, status_code=302)
|
||||||
|
- Requires: Depends(get_regular_user)
|
||||||
|
- State token stored in Redis; OAuth URL constructed from google/onedrive SDKs
|
||||||
|
- Change: return JSONResponse({"url": authorization_url}) with status 200
|
||||||
|
|
||||||
|
From frontend/src/components/settings/SettingsCloudTab.vue (current handleConnect):
|
||||||
|
- OAUTH_PROVIDERS = new Set(['google_drive', 'onedrive'])
|
||||||
|
- Currently: window.location.href = `/api/cloud/oauth/initiate/${provider.key}`
|
||||||
|
- Change: fetch with Authorization header, then window.location.href = data.url
|
||||||
|
|
||||||
|
From frontend/src/api/client.js:
|
||||||
|
- The `request()` function is JSON-only (calls res.json()). For OAuth initiate, we want JSON back (the {url} object), so `request()` can be used directly.
|
||||||
|
- Export: `initiateOAuth(provider)` → calls request(`/api/cloud/oauth/initiate/${provider}`)
|
||||||
|
|
||||||
|
From frontend/src/components/cloud/CloudCredentialModal.vue (watch handler, lines 191-209):
|
||||||
|
- Nextcloud edit: extracts only hostname with regex match[1], clears customEndpoint
|
||||||
|
- Bug: if stored server_url does NOT match /remote.php/dav/files/{username}/ pattern, the custom endpoint is silently lost
|
||||||
|
- Fix: compare resolvedServerUrl (auto-constructed) to existing.server_url; if they differ, populate customEndpoint with the stored URL and set showAdvanced=true
|
||||||
|
|
||||||
|
From frontend/src/components/settings/SettingsCloudTab.vue (ERROR template, lines 88-96):
|
||||||
|
- Only has Remove button
|
||||||
|
- Add Edit button identical to the one in the ACTIVE block (same v-if guard: !OAUTH_PROVIDERS.has(provider.key))
|
||||||
|
|
||||||
|
From frontend/src/components/ui/ConfirmBlock.vue:
|
||||||
|
- The message <p> has class "text-sm text-gray-700" — add "break-words" to it
|
||||||
|
- The wrapper div in SettingsCloudTab.vue (lines 100-113) needs "w-full overflow-hidden" on the outer div
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Fix OAuth initiate — return JSON URL instead of 302 redirect</name>
|
||||||
|
<files>backend/api/cloud.py, backend/tests/test_cloud.py</files>
|
||||||
|
<behavior>
|
||||||
|
- GET /api/cloud/oauth/initiate/google_drive with valid Bearer token returns 200 JSON {url: "https://accounts.google.com/..."}.
|
||||||
|
- GET /api/cloud/oauth/initiate/onedrive with valid Bearer token returns 200 JSON {url: "https://login.microsoftonline.com/..."}.
|
||||||
|
- GET /api/cloud/oauth/initiate/invalid_provider returns 400 (unchanged).
|
||||||
|
- GET /api/cloud/oauth/initiate/google_drive with no auth returns 401 or 403 (unchanged behavior — get_regular_user enforces this).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
In backend/api/cloud.py, change the `oauth_initiate` handler:
|
||||||
|
- Remove `response_class=RedirectResponse` from the `@router.get` decorator.
|
||||||
|
- Replace the two `return RedirectResponse(url=authorization_url, status_code=302)` statements (one for google_drive, one for onedrive) with `from fastapi.responses import JSONResponse` (already imported via fastapi) and return `JSONResponse({"url": authorization_url})`.
|
||||||
|
- The state token generation, Redis storage, and authorization URL construction are unchanged.
|
||||||
|
- Update the return type annotation if present.
|
||||||
|
|
||||||
|
In backend/tests/test_cloud.py, add test `test_oauth_initiate_returns_json_url`:
|
||||||
|
- Creates a regular user + token.
|
||||||
|
- Mocks Redis setex (the app state redis client).
|
||||||
|
- For google_drive provider: mocks `google_auth_oauthlib.flow.Flow.from_client_config` to return a mock whose `authorization_url` returns ("https://accounts.google.com/test", "state123").
|
||||||
|
- Calls GET /api/cloud/oauth/initiate/google_drive with Bearer header.
|
||||||
|
- Asserts response.status_code == 200.
|
||||||
|
- Asserts response.json()["url"].startswith("https://accounts.google.com/").
|
||||||
|
- Also adds `test_oauth_initiate_requires_auth`: calls without token, asserts 401 or 403.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_cloud.py::test_oauth_initiate_returns_json_url tests/test_cloud.py::test_oauth_initiate_requires_auth -v</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Both tests pass. oauth_initiate returns 200 JSON {url}. No RedirectResponse in handler.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Frontend OAuth fetch, Nextcloud edit fix, Edit on ERROR, text overflow</name>
|
||||||
|
<files>frontend/src/components/settings/SettingsCloudTab.vue, frontend/src/components/cloud/CloudCredentialModal.vue, frontend/src/components/ui/ConfirmBlock.vue, frontend/src/api/client.js</files>
|
||||||
|
<action>
|
||||||
|
### 1. client.js — add initiateOAuth helper
|
||||||
|
Export `initiateOAuth(provider)` that calls `request(`/api/cloud/oauth/initiate/${provider}`)`. This uses the existing `request()` helper which injects the Bearer header and handles 401 → refresh → retry.
|
||||||
|
|
||||||
|
### 2. SettingsCloudTab.vue — fix handleConnect for OAuth providers
|
||||||
|
Replace the current `window.location.href = /api/cloud/oauth/initiate/${provider.key}` line in `handleConnect` with:
|
||||||
|
```
|
||||||
|
const data = await initiateOAuth(provider.key)
|
||||||
|
window.location.href = data.url
|
||||||
|
```
|
||||||
|
Import `initiateOAuth` from `../../api/client.js`. Wrap in try/catch; on error show a toast or set a reactive `oauthError` ref displayed above the provider list.
|
||||||
|
|
||||||
|
### 3. SettingsCloudTab.vue — add Edit button to ERROR template block
|
||||||
|
In the `<!-- ERROR -->` template block (currently lines 88-96), add an Edit button before the Remove button, mirroring the ACTIVE block exactly:
|
||||||
|
```html
|
||||||
|
<button
|
||||||
|
v-if="!OAUTH_PROVIDERS.has(provider.key)"
|
||||||
|
@click="handleEdit(provider)"
|
||||||
|
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. SettingsCloudTab.vue — fix confirmation wrapper overflow
|
||||||
|
Add `w-full overflow-hidden` to the outer `<div>` that wraps the `<ConfirmBlock>` component (the div at the current line starting `v-if="confirmRemoveId === connectionFor(provider.key)?.id"`). This div needs to break out of the flex row — it is already rendered outside the `flex items-center` row as a sibling block, so adding `w-full` and `overflow-hidden` is sufficient.
|
||||||
|
|
||||||
|
### 5. CloudCredentialModal.vue — fix Nextcloud edit custom endpoint pre-population
|
||||||
|
In the watch handler on `props.show`, after the existing logic that sets `serverBase.value` by extracting the hostname:
|
||||||
|
- Compute what the auto-constructed URL would be using the extracted hostname and `existing.connection_username`:
|
||||||
|
`const autoUrl = match ? \`${match[1]}/remote.php/dav/files/${encodeURIComponent(existing.connection_username ?? '')}/\` : ''`
|
||||||
|
- If `existing.server_url !== autoUrl` (the stored URL doesn't match the auto-constructed pattern) AND `existing.server_url` has a path beyond just the hostname, then:
|
||||||
|
- Set `showAdvanced.value = true`
|
||||||
|
- Set `customEndpoint.value = existing.server_url`
|
||||||
|
- Otherwise leave `showAdvanced.value = false` and `customEndpoint.value = ''` (existing behavior).
|
||||||
|
|
||||||
|
### 6. ConfirmBlock.vue — add break-words to message paragraph
|
||||||
|
Change `<p class="text-sm text-gray-700">` to `<p class="text-sm text-gray-700 break-words">`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Frontend build passes with zero errors. All four UI changes are applied: OAuth uses fetch, ERROR rows have Edit button, Nextcloud watch handler preserves custom endpoint, ConfirmBlock message has break-words.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| frontend→/api/cloud/oauth/initiate | Now goes through fetch() with Bearer header instead of bare browser navigation |
|
||||||
|
| OAuth URL returned to frontend | URL is generated by the backend OAuth library and stored state in Redis — frontend only receives the URL string |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-10-01 | Spoofing | oauth_initiate auth | mitigate | get_regular_user still enforced — only authenticated users receive the OAuth URL |
|
||||||
|
| T-05-10-02 | Information Disclosure | OAuth URL in JSON response | accept | URL is a standard OAuth authorization URL with CSRF state token — no credentials in the URL |
|
||||||
|
| T-05-10-03 | Tampering | OAuth state token | mitigate | State token generated server-side (secrets.token_urlsafe(32)), stored in Redis with TTL 1800, single-use (deleted in callback) — unchanged from original design |
|
||||||
|
| T-05-10-04 | Spoofing | Nextcloud custom endpoint re-edit | accept | Pre-populated values come from encrypted DB credentials decrypted server-side and returned as server_url field — not user-alterable before display |
|
||||||
|
| T-05-10-SC | Tampering | npm/pip installs | mitigate | No new packages installed in this plan |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `pytest backend/tests/test_cloud.py::test_oauth_initiate_returns_json_url backend/tests/test_cloud.py::test_oauth_initiate_requires_auth -v`
|
||||||
|
- `npm run build` — zero errors
|
||||||
|
- Manual: click Connect on Google Drive — browser navigates to accounts.google.com (not localhost 401)
|
||||||
|
- Manual: edit Nextcloud connection with custom endpoint — Advanced section opens with endpoint pre-filled
|
||||||
|
- Manual: connection in ERROR state — Edit button is visible
|
||||||
|
- Manual: disconnect confirmation text wraps within its container
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- oauth_initiate returns 200 JSON {url} (not 302 redirect)
|
||||||
|
- Two new pytest tests pass for OAuth initiate
|
||||||
|
- handleConnect in SettingsCloudTab uses fetch() + initiateOAuth(); no window.location.href to /api path
|
||||||
|
- ERROR status template block has Edit button for non-OAuth providers
|
||||||
|
- CloudCredentialModal watch handler repopulates customEndpoint and showAdvanced when stored URL differs from auto-constructed pattern
|
||||||
|
- ConfirmBlock message paragraph has break-words class
|
||||||
|
- Full frontend build and pytest suite have zero new failures
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/05-cloud-storage-backends/05-10-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
---
|
||||||
|
phase: 05-cloud-storage-backends
|
||||||
|
plan: 11
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- backend/api/admin.py
|
||||||
|
- frontend/src/api/client.js
|
||||||
|
- frontend/src/components/admin/AdminUsersTab.vue
|
||||||
|
- backend/tests/test_admin.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: [ADMIN-02, SEC-09]
|
||||||
|
gap_closure: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Admin can permanently delete a non-admin user after entering their own admin password"
|
||||||
|
- "Backend verifies the admin password before executing the delete"
|
||||||
|
- "Delete purges cloud connections + MinIO objects + all DB rows (existing SEC-09 code runs)"
|
||||||
|
- "Frontend presents an inline confirmation panel with admin password field before calling DELETE"
|
||||||
|
- "Incorrect admin password returns 403 without deleting the user"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/api/admin.py"
|
||||||
|
provides: "UserDeleteConfirm Pydantic model; delete_user handler reads admin_password from body and verifies it"
|
||||||
|
- path: "frontend/src/api/client.js"
|
||||||
|
provides: "adminDeleteUser(id, adminPassword) calling DELETE /api/admin/users/{id}"
|
||||||
|
- path: "frontend/src/components/admin/AdminUsersTab.vue"
|
||||||
|
provides: "Inline delete confirmation panel with admin password field, mirroring confirmDeactivate pattern"
|
||||||
|
key_links:
|
||||||
|
- from: "frontend/src/components/admin/AdminUsersTab.vue"
|
||||||
|
to: "DELETE /api/admin/users/{id}"
|
||||||
|
via: "adminDeleteUser(id, adminPassword)"
|
||||||
|
- from: "backend/api/admin.py delete_user"
|
||||||
|
to: "services.auth.verify_password"
|
||||||
|
via: "verify_password(body.admin_password, admin.password_hash)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add admin hard-delete with password confirmation: a backend body model that verifies the admin's own password before permanent deletion, and a frontend inline confirmation panel with password field.
|
||||||
|
|
||||||
|
Purpose: The backend delete endpoint exists and correctly purges all user data, but it accepts no authentication proof for the destructive action. There is also no frontend UI to trigger it.
|
||||||
|
|
||||||
|
Output: Admin can initiate deletion from the Users tab, enter their password in an inline panel, and the backend verifies the password before deleting. Incorrect password is rejected with 403.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-cloud-storage-backends/05-UAT.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key contracts the executor needs. -->
|
||||||
|
|
||||||
|
From backend/api/admin.py:
|
||||||
|
- Existing delete_user handler: DELETE /api/admin/users/{user_id}, status 204, no request body
|
||||||
|
- Already purges cloud connections + MinIO objects, writes audit log (SEC-09 — do NOT change this logic)
|
||||||
|
- Uses Depends(get_current_admin) → resolves to User ORM instance as `_admin`
|
||||||
|
- verify_password not currently imported; services.auth exports it: `from services.auth import verify_password`
|
||||||
|
- The handler must add: parse body as UserDeleteConfirm, call verify_password(body.admin_password, _admin.password_hash), raise 403 on failure
|
||||||
|
|
||||||
|
From services/auth.py (existing pattern from admin.py imports):
|
||||||
|
- `hash_password(plain: str) -> str`
|
||||||
|
- `verify_password(plain: str, hashed: str) -> bool` — uses pwdlib Argon2
|
||||||
|
|
||||||
|
From frontend/src/components/admin/AdminUsersTab.vue (confirmDeactivate pattern to mirror):
|
||||||
|
- `confirmDeactivate = ref(null)` tracks which user ID is awaiting confirmation
|
||||||
|
- `startDeactivate(id)` sets confirmDeactivate = id
|
||||||
|
- Inline panel in <td> renders when `confirmDeactivate === user.id`
|
||||||
|
- Panel has confirm + cancel buttons
|
||||||
|
- Model to follow: add parallel state `confirmDelete = ref(null)`, `deletePassword = ref('')`, `deleteError = ref(null)`
|
||||||
|
|
||||||
|
From frontend/src/api/client.js:
|
||||||
|
- All admin functions follow: request(`/api/admin/users/${id}/...`, { method, headers, body })
|
||||||
|
- DELETE with body: `request(\`/api/admin/users/${id}\`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ admin_password: adminPassword }) })`
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Backend — UserDeleteConfirm model + password verification in delete_user</name>
|
||||||
|
<files>backend/api/admin.py, backend/tests/test_admin.py</files>
|
||||||
|
<behavior>
|
||||||
|
- DELETE /api/admin/users/{id} with correct admin_password in body returns 204 and user is deleted.
|
||||||
|
- DELETE /api/admin/users/{id} with wrong admin_password returns 403 {"detail": "Invalid admin password"} and user is NOT deleted.
|
||||||
|
- DELETE /api/admin/users/{id} with no body returns 422 (Pydantic validation).
|
||||||
|
- Cannot delete admin accounts (existing guard: 400 "Cannot delete admin accounts") — unchanged.
|
||||||
|
- Cannot delete non-existent user (existing guard: 404) — unchanged.
|
||||||
|
- Audit log entry written for successful delete (existing code) — unchanged.
|
||||||
|
- Cloud credentials purged before DB delete (existing SEC-09 code) — unchanged.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
In backend/api/admin.py:
|
||||||
|
1. Add `UserDeleteConfirm` Pydantic model in the Request models section:
|
||||||
|
```python
|
||||||
|
class UserDeleteConfirm(BaseModel):
|
||||||
|
admin_password: str
|
||||||
|
```
|
||||||
|
2. Add `from services.auth import verify_password` to the existing imports from services.auth (currently imports `hash_password, revoke_all_refresh_tokens`).
|
||||||
|
3. Modify the `delete_user` handler signature to accept the body:
|
||||||
|
- Change `async def delete_user(user_id, request, session, _admin)` to also accept `body: UserDeleteConfirm`.
|
||||||
|
- FastAPI will parse the JSON body automatically.
|
||||||
|
4. Add password verification BEFORE any deletion logic (fail fast):
|
||||||
|
```python
|
||||||
|
if not verify_password(body.admin_password, _admin.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid admin password",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
5. All existing deletion logic (cloud purge, MinIO purge, audit log, session.delete) is unchanged.
|
||||||
|
|
||||||
|
In backend/tests/test_admin.py, add three tests:
|
||||||
|
1. `test_delete_user_correct_password` — create admin + regular user, call DELETE with correct admin password, assert 204, assert user no longer in GET /admin/users.
|
||||||
|
2. `test_delete_user_wrong_password` — same setup, call DELETE with wrong password, assert 403, assert user still in GET /admin/users (not deleted).
|
||||||
|
3. `test_delete_user_no_body` — call DELETE with no body (or empty body), assert 422.
|
||||||
|
|
||||||
|
Use the existing `_create_user_and_token(session, role="admin")` pattern from test_cloud.py (or the conftest admin_user fixture if available).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_admin.py::test_delete_user_correct_password tests/test_admin.py::test_delete_user_wrong_password tests/test_admin.py::test_delete_user_no_body -v</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Three tests pass. Delete with correct password returns 204. Delete with wrong password returns 403 and user survives. Delete with no body returns 422.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Frontend — adminDeleteUser API function + inline delete confirmation panel</name>
|
||||||
|
<files>frontend/src/api/client.js, frontend/src/components/admin/AdminUsersTab.vue</files>
|
||||||
|
<action>
|
||||||
|
### 1. client.js — add adminDeleteUser
|
||||||
|
Export `adminDeleteUser(id, adminPassword)`:
|
||||||
|
```javascript
|
||||||
|
export function adminDeleteUser(id, adminPassword) {
|
||||||
|
return request(`/api/admin/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ admin_password: adminPassword }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. AdminUsersTab.vue — add delete confirmation state
|
||||||
|
In `<script setup>`, add alongside the existing `confirmDeactivate` state:
|
||||||
|
- `const confirmDelete = ref(null)` — holds the user ID awaiting delete confirmation
|
||||||
|
- `const deletePassword = ref('')` — the admin password input
|
||||||
|
- `const deleteError = ref(null)` — error message for wrong password
|
||||||
|
|
||||||
|
Add functions:
|
||||||
|
- `startDelete(id)`: sets `confirmDelete.value = id`, clears `deletePassword.value` and `deleteError.value`, and sets `confirmDeactivate.value = null` (cannot have both panels open at once).
|
||||||
|
- `cancelDelete()`: sets `confirmDelete.value = null`, clears password + error.
|
||||||
|
- `confirmDoDelete(id)`: sets `pendingAction[id] = true`, calls `await api.adminDeleteUser(id, deletePassword.value)`, on success removes the user from `users.value` and calls `cancelDelete()`. On error, sets `deleteError.value = e.message`. Always clears `pendingAction[id]` in finally.
|
||||||
|
|
||||||
|
### 3. AdminUsersTab.vue — template: Delete button in action column
|
||||||
|
In the `<template v-else-if="user.is_active">` block (the normal active user actions), add a Delete button after the Deactivate button:
|
||||||
|
```html
|
||||||
|
<span class="text-gray-300">·</span>
|
||||||
|
<button
|
||||||
|
@click="startDelete(user.id)"
|
||||||
|
class="text-red-800 hover:text-red-900 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
For deactivated users (the `<template v-else>` block), also add a Delete button after Reactivate, using the same markup.
|
||||||
|
|
||||||
|
### 4. AdminUsersTab.vue — template: inline delete confirmation panel
|
||||||
|
Replace the existing `<div v-if="confirmDeactivate === user.id">` inline panel pattern: add a second conditional panel for delete below it (as a sibling `<div>` within the `<td>`):
|
||||||
|
```html
|
||||||
|
<div v-if="confirmDelete === user.id" class="space-y-2">
|
||||||
|
<p class="text-xs text-red-700 font-semibold">
|
||||||
|
Permanently delete <span class="font-bold">{{ user.email }}</span>?
|
||||||
|
This will erase all their documents, cloud connections, and quota data. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-700 mb-1 font-semibold">Your admin password to confirm</label>
|
||||||
|
<input
|
||||||
|
v-model="deletePassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="Admin password"
|
||||||
|
class="block w-full rounded-lg px-2 py-1.5 text-xs border border-red-300 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
|
||||||
|
@keydown.enter.prevent="confirmDoDelete(user.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="deleteError" class="text-xs text-red-600">{{ deleteError }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="confirmDoDelete(user.id)"
|
||||||
|
:disabled="pendingAction[user.id] || !deletePassword"
|
||||||
|
class="text-red-700 hover:text-red-800 text-sm font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span v-if="pendingAction[user.id]" class="flex items-center gap-1">
|
||||||
|
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
|
||||||
|
Deleting…
|
||||||
|
</span>
|
||||||
|
<span v-else>Delete permanently</span>
|
||||||
|
</button>
|
||||||
|
<span class="text-gray-300">·</span>
|
||||||
|
<button @click="cancelDelete" class="text-gray-500 hover:text-gray-700 text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
The delete panel and deactivate panel are mutually exclusive: `startDelete` clears `confirmDeactivate`, and `startDeactivate` should also clear `confirmDelete` (add `confirmDelete.value = null` to `startDeactivate`).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Frontend build passes with zero errors. AdminUsersTab has Delete button and inline confirmation panel with password field. adminDeleteUser exported from client.js.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| DELETE /api/admin/users/{id} body | Admin password sent in JSON body — verified server-side with Argon2 before any deletion |
|
||||||
|
| admin password in transit | Sent over HTTPS (production); never stored, logged, or returned in any response |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-11-01 | Elevation of Privilege | DELETE /admin/users/{id} | mitigate | Requires get_current_admin (admin role) AND correct admin password verification via pwdlib Argon2 — two-factor confirmation |
|
||||||
|
| T-05-11-02 | Information Disclosure | Wrong password error message | mitigate | 403 "Invalid admin password" — does not confirm whether user exists (user existence already checked first but 403 is returned regardless to prevent oracle) |
|
||||||
|
| T-05-11-03 | Tampering | admin_password in request body | mitigate | Pydantic UserDeleteConfirm validates presence; verify_password uses constant-time Argon2 comparison (pwdlib) |
|
||||||
|
| T-05-11-04 | Repudiation | User deletion audit trail | mitigate | write_audit_log("admin.user_deleted") written before session.delete — existing code preserved unchanged |
|
||||||
|
| T-05-11-05 | Denial of Service | Repeated wrong-password delete attempts | accept | Admin endpoints already rate-limited at application level; admin accounts are trusted actors |
|
||||||
|
| T-05-11-SC | Tampering | npm/pip installs | mitigate | No new packages installed in this plan |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `pytest backend/tests/test_admin.py::test_delete_user_correct_password backend/tests/test_admin.py::test_delete_user_wrong_password backend/tests/test_admin.py::test_delete_user_no_body -v`
|
||||||
|
- `npm run build` — zero errors
|
||||||
|
- Full pytest suite: `pytest -v` — zero new failures
|
||||||
|
- Manual: open Admin panel → Users tab, confirm Delete button visible per user row
|
||||||
|
- Manual: click Delete, enter correct admin password → user removed from list
|
||||||
|
- Manual: click Delete, enter wrong password → error message shown, user not removed
|
||||||
|
- Security: verify admin_password not present in any audit log entry
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- UserDeleteConfirm model added to admin.py
|
||||||
|
- delete_user verifies admin password via verify_password before proceeding
|
||||||
|
- Wrong password returns 403 without deleting user
|
||||||
|
- adminDeleteUser(id, adminPassword) exported from client.js
|
||||||
|
- AdminUsersTab has Delete button on active and deactivated rows
|
||||||
|
- Inline password confirmation panel appears on Delete click, mutually exclusive with deactivate panel
|
||||||
|
- Three new backend tests pass; full test suite has zero new failures; frontend build clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/05-cloud-storage-backends/05-11-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user