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:
curo1305
2026-05-30 10:39:47 +02:00
parent 7691477c6d
commit f006c00d49
4 changed files with 679 additions and 2 deletions
@@ -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>