docs(05): add UAT, UI-SPEC, deferred items, debug notes; refine plans 09-11

Plan refinements: Vitest tests added to 09/10 must-haves, explicit
mock_flow two-tuple pattern in 10, test_admin_api.py fixture usage in 11.
New artifacts: UAT checklist, UI-SPEC, deferred-items, debug investigation
for cloud-doc-operations-fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-30 11:57:54 +02:00
parent 34f012b4e8
commit 67edc19a36
7 changed files with 1115 additions and 23 deletions
@@ -0,0 +1,67 @@
---
status: investigating
trigger: "Documents stored on cloud backend cannot be opened, re-analyzed, or edited"
created: 2026-05-30T00:00:00Z
updated: 2026-05-30T00:00:00Z
symptoms_prefilled: true
goal: find_root_cause_only
---
## Current Focus
hypothesis: "CONFIRMED — three independent root causes across open, re-analyze, and edit flows"
test: "Full read of documents.py, document_tasks.py, DocumentPreviewModal.vue, client.js"
expecting: "Three separate bugs identified with specific mechanisms"
next_action: "return root cause findings"
## Symptoms
expected: "Opening, re-analyzing, and editing a document stored on a cloud backend should work correctly via the backend proxy"
actual: "User cannot open, re-analyze, or edit any file stored on a cloud backend"
errors: "None specifically reported, but likely HTTP errors or missing endpoints"
reproduction: "Test 13 in Phase 5 UAT — after uploading a document to a connected Nextcloud/WebDAV backend, all document operations (open, re-analyze, edit) fail"
started: "Discovered during UAT of Phase 5 (cloud storage backends)"
## Eliminated
- hypothesis: "GET /api/documents/{id}/content endpoint missing cloud branch"
evidence: "The endpoint calls get_storage_backend_for_document() which correctly dispatches to NextcloudBackend/WebDAVBackend based on doc.storage_backend — the backend proxy path is implemented"
timestamp: 2026-05-30T00:00:00Z
## Evidence
- timestamp: 2026-05-30T00:00:00Z
checked: "DocumentPreviewModal.vue — how it opens documents"
found: "Uses raw iframe :src pointing to /api/documents/{id}/content — this is a browser navigation, NOT a fetch() call, so the Authorization: Bearer header is never sent"
implication: "The backend /content endpoint uses get_regular_user dep which requires a JWT Bearer token. An iframe or window.open() GET has no Authorization header → 401 Unauthorized → document cannot be opened"
- timestamp: 2026-05-30T00:00:00Z
checked: "backend/tasks/document_tasks.py _run() function — the re-analyze (extract_and_classify) Celery task"
found: "Line 64: backend = get_storage_backend() — this always returns MinIOBackend regardless of doc.storage_backend. For cloud documents, get_storage_backend_for_document() must be called but the Celery task has no User or Session context to look up CloudConnection credentials"
implication: "Re-analysis of a cloud-stored document fails: the task calls MinIO get_object() with a WebDAV path (e.g. 'docuvault/user-id/doc-id.pdf') which does not exist in MinIO → MinIO retrieval error → extract_and_classify returns status='extract_failed'"
- timestamp: 2026-05-30T00:00:00Z
checked: "backend/api/documents.py — full route list via @router decorator scan"
found: "Only these routes exist: POST /upload-url, POST /upload, POST /{id}/confirm, GET /, GET /{id}, DELETE /{id}, POST /{id}/classify, GET /{id}/content. There is NO PATCH or PUT endpoint for editing document metadata (filename, folder, etc.) on cloud documents."
implication: "The 'edit' failure may refer to the classify endpoint (re-analyze) or to a missing document-rename/metadata-update endpoint. The classify endpoint itself works correctly for cloud docs (it uses cached extracted_text, not re-fetching from storage), but re-extraction does not."
- timestamp: 2026-05-30T00:00:00Z
checked: "DocumentView.vue — how openPdf() works and how it uses the content URL"
found: "openPdf() either calls window.open(api.getDocumentContentUrl(doc.value.id), '_blank') or shows DocumentPreviewModal. Both result in unauthenticated browser requests with no Bearer token."
implication: "Both open paths (new tab and in-app preview) hit the /content endpoint without auth → 401 for all documents, not just cloud ones. However cloud documents additionally require credentials decryption, so they would fail even if the auth issue were solved."
- timestamp: 2026-05-30T00:00:00Z
checked: "client.js getDocumentContentUrl — returns a plain URL string, never does a credentialed fetch"
found: "Function returns '/api/documents/{id}/content' as a plain string for use in iframe src or window.open(). No fetch() with Authorization header."
implication: "The content endpoint is auth-protected (get_regular_user dep) but the frontend uses unauthenticated browser navigation to reach it — the 401 response is the actual error the user sees for any document, but for cloud documents there is an additional issue in the Celery worker"
## Resolution
root_cause: |
Three independent root causes:
1. OPEN (401 auth): The /api/documents/{id}/content endpoint requires a JWT Bearer token (get_regular_user dep), but DocumentPreviewModal and DocumentView both access it via iframe src or window.open() — browser navigations that send no Authorization header. All documents fail to open, but cloud documents are additionally impacted.
2. RE-ANALYZE (wrong backend): The extract_and_classify Celery task hardcodes get_storage_backend() (always MinIO) at line 64 of document_tasks.py. For cloud-stored documents it should call get_storage_backend_for_document(), but the Celery task has no User ORM instance and no CloudConnection lookup mechanism. The task reads doc.storage_backend but does nothing with it — it always fetches from MinIO, which 404s on a WebDAV path.
3. EDIT (endpoint missing): There is no PATCH endpoint for updating document metadata (filename/title). The user's "edit" likely refers to the re-analyze/re-extract operation or to metadata editing, neither of which works for cloud docs.
fix:
verification:
files_changed: []
@@ -11,6 +11,7 @@ files_modified:
- frontend/src/components/documents/DocumentPreviewModal.vue
- frontend/src/views/DocumentView.vue
- backend/tests/test_cloud.py
- frontend/src/api/__tests__/client.test.js
autonomous: true
requirements: [CLOUD-03, CLOUD-05, CLOUD-07]
gap_closure: true
@@ -21,6 +22,7 @@ must_haves:
- "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"
- "fetchDocumentContent injects the Bearer token and returns a Blob URL (verified by Vitest)"
artifacts:
- path: "backend/api/documents.py"
provides: "PATCH /{doc_id} endpoint accepting {filename, folder_id}"
@@ -30,6 +32,8 @@ must_haves:
provides: "Authenticated fetch + Blob URL for document preview"
- path: "frontend/src/views/DocumentView.vue"
provides: "Authenticated fetch + Blob URL for document open"
- path: "frontend/src/api/__tests__/client.test.js"
provides: "Vitest unit test for fetchDocumentContent"
key_links:
- from: "frontend/src/components/documents/DocumentPreviewModal.vue"
to: "/api/documents/{id}/content"
@@ -44,7 +48,7 @@ Fix three independent root causes that prevent cloud documents from being opened
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.
Output: All three flows work end-to-end for cloud documents. Three new/updated backend tests plus one Vitest unit test pass.
</objective>
<execution_context>
@@ -68,6 +72,7 @@ From backend/api/documents.py:
- 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
- Try-import fallback pattern (lines 51-54): wraps optional imports in try/except ImportError and sets module to None; callers guard with `if module is not None:`
From backend/tasks/document_tasks.py:
- `_run(document_id)` calls `get_storage_backend()` unconditionally (returns MinIO backend)
@@ -79,6 +84,10 @@ From backend/tasks/document_tasks.py:
From backend/storage/__init__.py (inferred from documents.py usage):
- `get_storage_backend_for_document(doc, user, session)` → returns correct backend instance
From backend/storage/google_drive_backend.py:
- Exports `CloudConnectionError` — the canonical exception class for all cloud backend failures
- Used by the cloud backend when it cannot connect to or retrieve from the remote provider
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
@@ -102,7 +111,18 @@ From frontend/src/api/client.js:
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).
- 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: `from storage import get_storage_backend_for_document`.
Import `CloudConnectionError` inside the cloud-backend branch using the try-import fallback pattern already used in `backend/api/documents.py` (lines 51-54) to avoid hard import errors if the module is absent:
```python
try:
from storage.google_drive_backend import CloudConnectionError
except ImportError:
CloudConnectionError = Exception
```
Place this try-import block at the top of the cloud-backend branch (before the `get_storage_backend_for_document` call), not at module top-level. 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.
@@ -116,8 +136,8 @@ From frontend/src/api/client.js:
</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>
<name>Task 2: Authenticated document preview — fetch-with-Blob-URL in frontend + Vitest test</name>
<files>frontend/src/api/client.js, frontend/src/components/documents/DocumentPreviewModal.vue, frontend/src/views/DocumentView.vue, frontend/src/api/__tests__/client.test.js</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.
@@ -136,11 +156,22 @@ From frontend/src/api/client.js:
- 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()`.
In frontend/src/api/__tests__/client.test.js (create the file if it does not exist), add a Vitest unit test for `fetchDocumentContent`:
- Mock `fetch` globally using `vi.stubGlobal('fetch', vi.fn())`.
- Mock the auth store so `authStore.accessToken` returns a known token string (e.g. "test-token-abc").
- Configure the mock fetch to return a Response-like object with `status: 200` and a `.blob()` method that resolves to a `new Blob(['%PDF-1.4'], { type: 'application/pdf' })`.
- Mock `URL.createObjectURL` to return a fixed string `"blob:http://localhost/fake-uuid"`.
- Call `fetchDocumentContent("doc-123")` and await the result.
- Assert: the first argument to fetch was `/api/documents/doc-123/content`.
- Assert: the `Authorization` header in the fetch call was `"Bearer test-token-abc"`.
- Assert: the returned Response is the mock response (not a Blob URL — callers are responsible for calling `.blob()` and `URL.createObjectURL`).
- Restore all stubs in `afterEach` using `vi.restoreAllMocks()`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run test -- --reporter=verbose --run src/api/__tests__/client.test.js 2>&1 | tail -20 && 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>
<done>Vitest test for fetchDocumentContent passes. Frontend build passes with zero errors. DocumentPreviewModal and DocumentView use fetchDocumentContent. No unauthenticated src= URLs remain for the /content endpoint.</done>
</task>
</tasks>
@@ -168,6 +199,7 @@ From frontend/src/api/client.js:
<verification>
After both tasks complete:
- `pytest backend/tests/test_cloud.py -v` — all three new tests pass, no regressions
- `npm run test -- --run src/api/__tests__/client.test.js` — Vitest test for fetchDocumentContent passes
- `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
@@ -177,9 +209,11 @@ After both tasks complete:
<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)
- CloudConnectionError imported via try-import fallback pattern; raw provider error not exposed in response
- 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
- Vitest test for fetchDocumentContent asserts Bearer header injection and correct fetch URL
- All three new pytest tests pass; Vitest test passes; full suite has zero new failures
</success_criteria>
<output>
@@ -10,6 +10,7 @@ files_modified:
- frontend/src/components/cloud/CloudCredentialModal.vue
- frontend/src/components/ui/ConfirmBlock.vue
- backend/tests/test_cloud.py
- frontend/src/components/settings/__tests__/SettingsCloudTab.test.js
autonomous: true
requirements: [CLOUD-01, CLOUD-04]
gap_closure: true
@@ -20,6 +21,7 @@ must_haves:
- "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"
- "handleConnect on OAuth providers calls initiateOAuth and navigates to the returned URL (verified by Vitest)"
artifacts:
- path: "backend/api/cloud.py"
provides: "oauth_initiate returns JSON {url} (200) instead of RedirectResponse (302)"
@@ -29,6 +31,8 @@ must_haves:
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"
- path: "frontend/src/components/settings/__tests__/SettingsCloudTab.test.js"
provides: "Vitest test asserting handleConnect calls initiateOAuth and sets window.location.href"
key_links:
- from: "frontend/src/components/settings/SettingsCloudTab.vue"
to: "/api/cloud/oauth/initiate/{provider}"
@@ -112,7 +116,7 @@ From frontend/src/components/ui/ConfirmBlock.vue:
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").
- For google_drive provider: mocks `google_auth_oauthlib.flow.Flow.from_client_config` to return a mock flow object. Set the mock explicitly: `mock_flow.authorization_url.return_value = ("https://accounts.google.com/test", "state123")` — do not use a generic "returns a URL" — this exact two-tuple assignment is required because `flow.authorization_url(...)` returns `(url, state)` and the handler unpacks both values.
- 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/").
@@ -125,8 +129,8 @@ From frontend/src/components/ui/ConfirmBlock.vue:
</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>
<name>Task 2: Frontend OAuth fetch, Nextcloud edit fix, Edit on ERROR, text overflow + Vitest test</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, frontend/src/components/settings/__tests__/SettingsCloudTab.test.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.
@@ -165,11 +169,22 @@ From frontend/src/components/ui/ConfirmBlock.vue:
### 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">`.
### 7. SettingsCloudTab.test.js — add Vitest test for handleConnect OAuth flow
The file `frontend/src/components/settings/__tests__/SettingsCloudTab.test.js` already exists. Add a new test asserting the corrected OAuth connect flow:
- Mock `initiateOAuth` from `../../../../api/client.js` using `vi.mock`: `vi.mock('../../../../api/client.js', () => ({ initiateOAuth: vi.fn() }))`.
- In the test, set `initiateOAuth.mockResolvedValue({ url: 'https://accounts.google.com/o/oauth2/auth?state=xyz' })`.
- Assign `window.location = { href: '' }` (use `Object.defineProperty` or `vi.stubGlobal` to make `window.location.href` writable in the test environment).
- Mount `SettingsCloudTab` with a stubbed auth store that has a valid accessToken.
- Find a Connect button for a provider with `key === 'google_drive'` and trigger a click event simulating `handleConnect({ key: 'google_drive' })`.
- Assert: `initiateOAuth` was called with `'google_drive'`.
- Assert: `window.location.href` was set to `'https://accounts.google.com/o/oauth2/auth?state=xyz'` (the exact URL returned by the mock — confirming the component navigates to `data.url` and not directly to `/api/cloud/...`).
- Use `afterEach(() => vi.restoreAllMocks())`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run test -- --reporter=verbose --run src/components/settings/__tests__/SettingsCloudTab.test.js 2>&1 | tail -20 && 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>
<done>Vitest test for handleConnect OAuth flow passes. 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>
@@ -196,6 +211,7 @@ From frontend/src/components/ui/ConfirmBlock.vue:
<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 test -- --run src/components/settings/__tests__/SettingsCloudTab.test.js` — Vitest test for handleConnect OAuth flow passes
- `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
@@ -205,8 +221,9 @@ After both tasks complete:
<success_criteria>
- oauth_initiate returns 200 JSON {url} (not 302 redirect)
- Two new pytest tests pass for OAuth initiate
- Two new pytest tests pass for OAuth initiate; mock uses explicit `mock_flow.authorization_url.return_value = ("https://accounts.google.com/test", "state123")` two-tuple
- handleConnect in SettingsCloudTab uses fetch() + initiateOAuth(); no window.location.href to /api path
- Vitest test asserts initiateOAuth called with provider key and window.location.href set to returned URL
- 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
@@ -8,7 +8,7 @@ files_modified:
- backend/api/admin.py
- frontend/src/api/client.js
- frontend/src/components/admin/AdminUsersTab.vue
- backend/tests/test_admin.py
- backend/tests/test_admin_api.py
autonomous: true
requirements: [ADMIN-02, SEC-09]
gap_closure: true
@@ -70,6 +70,11 @@ 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 backend/tests/test_admin_api.py:
- `admin_client` fixture at line 71 returns `(client, admin, session)` tuple
- Admin user plaintext password: "AdminPass1!Secret"
- Use this fixture for all three new tests — do NOT recreate admin users manually
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
@@ -86,7 +91,7 @@ From frontend/src/api/client.js:
<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>
<files>backend/api/admin.py, backend/tests/test_admin_api.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.
@@ -117,15 +122,13 @@ From frontend/src/api/client.js:
```
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).
In backend/tests/test_admin_api.py, add three tests using the existing `admin_client` fixture (line 71, returns `(client, admin, session)`, admin password is "AdminPass1!Secret"):
1. `test_delete_user_correct_password` — use admin_client fixture, create a regular user, call DELETE with `{"admin_password": "AdminPass1!Secret"}`, assert 204, assert user no longer in GET /admin/users.
2. `test_delete_user_wrong_password` — same setup, call DELETE with `{"admin_password": "WrongPass!"}`, 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.
</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>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_admin_api.py::test_delete_user_correct_password tests/test_admin_api.py::test_delete_user_wrong_password tests/test_admin_api.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>
@@ -241,7 +244,7 @@ From frontend/src/api/client.js:
<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`
- `pytest backend/tests/test_admin_api.py::test_delete_user_correct_password backend/tests/test_admin_api.py::test_delete_user_wrong_password backend/tests/test_admin_api.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
@@ -0,0 +1,196 @@
---
status: diagnosed
phase: 05-cloud-storage-backends
source:
- 05-01-SUMMARY.md
- 05-02-SUMMARY.md
- 05-03-SUMMARY.md
- 05-04-SUMMARY.md
- 05-05-SUMMARY.md
- 05-06-SUMMARY.md
- 05-07-SUMMARY.md
- 05-08-SUMMARY.md
started: 2026-05-29T00:00:00Z
updated: 2026-05-30T00:00:00Z
---
## Current Test
<!-- OVERWRITE each test - shows where we are -->
## Current Test
[testing complete]
## Tests
### 1. Settings Cloud Storage Tab — 3-tab layout
expected: Open the app and navigate to Settings. The page shows three tabs: "Preferences", "AI Configuration", and "Cloud Storage". Clicking the "Cloud Storage" tab switches to the cloud view without a page reload.
result: pass
### 2. All 4 providers visible in Cloud Storage tab
expected: In the Cloud Storage tab, four provider rows are shown — Google Drive, OneDrive, Nextcloud, and WebDAV server — each with a "Not connected" status badge and a "Connect" button (when no connections exist).
result: pass
### 3. WebDAV / Nextcloud credential modal opens
expected: Clicking "Connect" on either the Nextcloud or WebDAV server row opens a modal overlay. The modal contains: Server URL field, Username field, Auth Method radio buttons ("App password" and "Account password"), and a Password field. Pressing Escape or clicking outside the modal closes it without saving.
result: pass
### 4. Cloud Storage sidebar section — collapsible
expected: The left sidebar shows a "Cloud Storage" collapsible section positioned between the Folders section and the Topics section. Clicking the section header collapses and expands it.
result: pass
### 5. Cloud Storage sidebar empty state
expected: When no cloud connections are active, the Cloud Storage sidebar section shows "No cloud storage connected" text and a link or reference to Settings where the user can connect a provider.
result: pass
### 6. OAuth initiate — Google Drive redirect
expected: In Settings → Cloud Storage tab, clicking "Connect" on the Google Drive row redirects the browser to Google's OAuth consent screen (accounts.google.com). Note: requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to be configured in .env.
result: issue
reported: "Clicking Connect redirects browser to http://localhost:5173/api/cloud/oauth/initiate/google_drive and returns {\"detail\":\"Not authenticated\"}"
severity: major
### 7. OAuth initiate — OneDrive redirect
expected: In Settings → Cloud Storage tab, clicking "Connect" on the OneDrive row redirects the browser to Microsoft's login page (login.microsoftonline.com). Note: requires ONEDRIVE_CLIENT_ID and ONEDRIVE_CLIENT_SECRET in .env.
result: skipped
reason: No server-side OAuth credentials configured; same bug as test 6 expected
### 8. OAuth callback — success toast and tab routing
expected: After completing an OAuth flow and being redirected back, the Settings page opens with the Cloud Storage tab already active. A success banner/toast appears ("Google Drive connected" or similar) and auto-dismisses after ~5 seconds. The provider row now shows "Active" status.
result: skipped
reason: Depends on OAuth initiation (tests 6-7) which require credentials not yet configured
### 9. Disconnect provider — inline confirmation
expected: On a provider row with an active connection, clicking "Remove" (or "Disconnect") shows an inline confirmation UI (ConfirmBlock) within the same row rather than a modal. Confirming removes the connection and the row returns to "Not connected" status with the "Connect" button.
result: issue
reported: "I can remove my test nextcloud connection. But the text asking me if I really want to remove the nextcloud connection does not render correctly — text overflows off screen."
severity: minor
### 10. REQUIRES_REAUTH banner
expected: If a provider connection is in "Requires re-authentication" state (expired or revoked token), the provider row shows a yellow warning banner with a "Reconnect" button. Other providers are unaffected.
result: skipped
reason: Only applies to OAuth providers (Google Drive, OneDrive); WebDAV/Nextcloud does not set REQUIRES_REAUTH on auth failure. Cannot test OAuth flow without client credentials configured.
### 11. Active connection sidebar tree — expand and lazy-load folders
expected: When a cloud connection is active, its provider appears as a tree node in the sidebar Cloud Storage section. Clicking the expand arrow for the first time shows a "Loading…" state, then populates with the root-level folders from the cloud provider. Folders with sub-folders can be expanded recursively.
result: pass
### 12. Upload document to cloud backend
expected: Using the document upload flow with a target of a connected cloud backend (e.g. Google Drive), the upload completes successfully. The document appears in the document list with a storage indicator showing the cloud provider (not MinIO). The content can be viewed.
result: pass
### 13. Cloud document content proxy
expected: Opening a document stored on a cloud backend loads and displays its content correctly (the file is streamed through the backend proxy). No error or missing content.
result: issue
reported: "I neither can open nor re-analyze nor edit any file stored on a cloud backend."
severity: major
### 14. Admin user deletion cleans up cloud connections
expected: (Admin only) When an admin deletes a user account that has cloud connections, the deletion completes successfully (200 response). After deletion, no CloudConnection rows remain for that user in the database. The audit log contains a "cloud.credentials_purged" entry.
result: issue
reported: "I only can deactivate a user. I want to have a (admin-password protected) option to delete a user completely."
severity: major
## Summary
total: 14
passed: 7
issues: 6
skipped: 3
blocked: 0
pending: 0
## Gaps
- truth: "Admin panel must provide a hard-delete option (admin-password protected) to permanently remove a user and all associated data including cloud connections"
status: failed
reason: "User reported: I only can deactivate a user. I want to have a (admin-password protected) option to delete a user completely."
severity: major
test: 14
root_cause: "Backend DELETE /api/admin/users/{id} exists and correctly purges cloud connections + emits cloud.credentials_purged audit log. The gap is entirely in the frontend: adminDeleteUser() is absent from client.js, no Delete button exists in AdminUsersTab.vue, and the backend endpoint currently takes no body so cannot verify admin password before executing the delete."
artifacts:
- path: "frontend/src/api/client.js"
issue: "Missing adminDeleteUser(id, adminPassword) function"
- path: "frontend/src/components/admin/AdminUsersTab.vue"
issue: "No Delete button or admin-password confirmation flow"
- path: "backend/api/admin.py"
issue: "DELETE endpoint takes no body; needs UserDeleteConfirm model to verify admin password before proceeding"
missing:
- "adminDeleteUser(id, adminPassword) in client.js calling DELETE /api/admin/users/{id}"
- "UserDeleteConfirm Pydantic model + password verification in delete_user handler"
- "Inline delete confirmation panel in AdminUsersTab.vue (mirroring confirmDeactivate pattern) with admin password field"
- truth: "Opening, re-analyzing, and editing a document stored on a cloud backend should work correctly via the backend proxy"
status: failed
reason: "User reported: I neither can open nor re-analyze nor edit any file stored on a cloud backend."
severity: major
test: 13
root_cause: "Three independent root causes: (1) Open — DocumentPreviewModal uses unauthenticated iframe :src and DocumentView uses window.open() to /content endpoint that requires Bearer auth; browser navigation never sends Authorization header → 401. (2) Re-analyze — document_tasks.py calls get_storage_backend() unconditionally returning MinIO; for cloud docs the MinIO key does not exist → NoSuchKey/extract_failed. (3) Edit/rename — no PATCH /api/documents/{id} endpoint exists at all."
artifacts:
- path: "frontend/src/components/documents/DocumentPreviewModal.vue"
issue: "Uses unauthenticated iframe :src for auth-required /content endpoint"
- path: "frontend/src/views/DocumentView.vue"
issue: "Uses window.open() for auth-required /content URL"
- path: "frontend/src/api/client.js"
issue: "getDocumentContentUrl() returns raw URL; no authenticated fetch"
- path: "backend/tasks/document_tasks.py"
issue: "Hardcodes get_storage_backend() (MinIO) instead of routing to cloud backend based on doc.storage_backend"
- path: "backend/api/documents.py"
issue: "No PATCH /{doc_id} endpoint for document metadata editing"
missing:
- "Authenticated content fetch: either signed query-string token on /content endpoint, or frontend fetches bytes with Bearer header and creates Blob URL"
- "Cloud-aware re-analyze: detect doc.storage_backend != 'minio' and load CloudConnection in Celery task to fetch file bytes"
- "PATCH /api/documents/{doc_id} endpoint accepting {filename, folder_id}"
- truth: "Nextcloud credential modal should accept just the server URL and auto-construct the WebDAV endpoint; full path should be hidden under an expandable Advanced option"
status: failed
reason: "User reported: modal requires the full WebDAV path causing connection failure. Fix: auto-construct https://{server}/remote.php/dav/files/{username}/ for Nextcloud; add Advanced override for non-standard installs."
severity: major
test: 9
root_cause: "Modal already auto-constructs the WebDAV URL from server+username and hides the full path behind an Advanced collapsible — this part was already built. The actual bug is in the edit pre-population watch: it extracts only the hostname from any stored server_url, so if the stored URL was a custom endpoint it is silently discarded and the Advanced field is never re-populated, losing the custom path on re-edit."
artifacts:
- path: "frontend/src/components/cloud/CloudCredentialModal.vue"
issue: "watch handler (lines 195-208) always extracts only hostname match[1] and resets customEndpoint to ''; custom endpoint stored values are never restored on edit"
missing:
- "Detect on edit whether stored server_url matches the auto-constructed pattern; if not, set showAdvanced=true and populate customEndpoint with the full stored URL"
- truth: "User should be able to edit credentials of an existing connection without disconnecting first"
status: failed
reason: "User reported: no Edit button exists on connected provider rows; user must disconnect and re-enter all credentials to change any setting."
severity: major
test: 9
root_cause: "Edit button exists for ACTIVE status Nextcloud/WebDAV rows but is absent from the ERROR status template block. A connection in error state forces the user to remove and re-enter credentials instead of editing in-place."
artifacts:
- path: "frontend/src/components/settings/SettingsCloudTab.vue"
issue: "ERROR status template block (lines 89-96) contains only a Remove button; no Edit button, unlike the ACTIVE block"
missing:
- "Add Edit button to the ERROR status template block mirroring the ACTIVE block"
- truth: "Clicking Connect on Google Drive/OneDrive should redirect the browser to the provider's OAuth consent screen"
status: failed
reason: "User reported: window.location.href navigates to /api/cloud/oauth/initiate/{provider} without a JWT auth header; backend returns 401 Not authenticated. Fix: call /initiate via fetch() with Authorization header, receive OAuth URL in response, then redirect browser to that URL."
severity: major
test: 6
root_cause: "handleConnect() in SettingsCloudTab.vue uses window.location.href = '/api/cloud/oauth/initiate/{provider}' — bare browser navigation sends no Authorization header. The endpoint uses Depends(get_regular_user) which requires Bearer token → returns 401. Fix: change oauth_initiate to return JSON {url: ...} (status 200) instead of 302 redirect; frontend calls it via fetch() with Bearer header then sets window.location.href to the returned URL."
artifacts:
- path: "frontend/src/components/settings/SettingsCloudTab.vue"
issue: "handleConnect uses window.location.href instead of authenticated fetch"
- path: "backend/api/cloud.py"
issue: "oauth_initiate returns RedirectResponse(302); needs to return JSON {url} so fetch() can consume it"
missing:
- "Replace window.location.href with fetch() + Authorization header in handleConnect"
- "Change oauth_initiate to return JSONResponse({url: authorization_url}) instead of RedirectResponse"
- truth: "Disconnect confirmation text should render fully within the provider row without overflowing off screen"
status: failed
reason: "User reported: the text asking 'Do you really want to remove…' overflows off screen."
severity: minor
test: 9
root_cause: "Confirmation wrapper div lacks w-full and overflow-hidden; sits inside a flex row that allows children to grow beyond viewport. ConfirmBlock's <p> has no break-words constraint."
artifacts:
- path: "frontend/src/components/settings/SettingsCloudTab.vue"
issue: "Confirmation wrapper div (line ~102) missing w-full overflow-hidden; may also need to be rendered outside the flex items-center row as a full-width block below it"
- path: "frontend/src/components/ui/ConfirmBlock.vue"
issue: "<p> message element missing break-words / overflow-wrap constraint"
missing:
- "Add w-full overflow-hidden to confirmation wrapper in SettingsCloudTab.vue"
- "Add break-words to message <p> in ConfirmBlock.vue"
@@ -0,0 +1,760 @@
---
phase: 5
slug: cloud-storage-backends
status: approved
shadcn_initialized: false
preset: none
created: 2026-05-28
---
# Phase 5 — UI Design Contract
> Visual and interaction contract for Phase 5: Cloud Storage Backends.
> Generated by gsd-ui-researcher, verified by gsd-ui-checker.
> Design system is vanilla Tailwind CSS (no shadcn) — matches Phases 14.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none |
| Preset | not applicable |
| Component library | none |
| Icon library | inline SVG, stroke-based, w-4 h-4 for nav, w-5 h-5 for status icons |
| Font | system-ui (Tailwind default) |
No shadcn gate applies — this is a Vue 3 / Tailwind project without shadcn.
---
## Spacing Scale
All values match the existing 8-point scale already established in Phases 14:
| Token | Value | Tailwind | Usage |
|-------|-------|----------|-------|
| xs | 4px | `gap-1`, `p-1` | Icon gaps, tight inline pairs |
| sm | 8px | `gap-2`, `p-2` | Badge padding, button icon gap |
| md | 16px | `gap-4`, `p-4` | Default element spacing |
| lg | 24px | `p-6` | Section card inner padding |
| xl | 32px | `p-8` | Page padding (SettingsView wrapper) |
| 2xl | 48px | `mb-12` | Major section breaks (not used in this phase) |
| 3xl | 64px | — | Page-level spacing (not used in this phase) |
Exceptions:
- Provider card row inner padding: `px-4 py-3` (12px top/bottom, 16px sides) — matches existing admin table row density
- Modal inner padding: `p-6` (lg)
- Touch target minimum: `min-h-[44px]` on all primary action buttons in modal (existing ConfirmBlock contract)
- Badge/pill label padding: `px-2 py-0.5` (status badges) and `px-1.5 py-0.5` ("Recommended" tag) — optical micro-sizing for pill text, carry-forward from Phases 14 badge pattern
- Sidebar section header icon gap: `gap-0.5` (2px) between chevron and nav link — optical icon alignment, matches existing Folders section pattern
---
## Typography
All roles match the locked type scale from Phases 14. Exactly 4 distinct font size tokens are in use.
| Role | Tailwind Classes | Usage in Phase 5 |
|------|-----------------|------------------|
| Page heading | `text-2xl font-semibold text-gray-900` | "Settings" page title (unchanged) |
| Section heading | `text-xl font-semibold text-gray-800` | Tab content section headers (e.g. "Connected providers"); also: modal header `<h3>` in CloudCredentialModal (same size role) |
| Subsection heading | `text-sm font-semibold text-gray-900` | Provider name inside card, modal section labels |
| Body | `text-sm text-gray-700 leading-relaxed` | Description text, modal helper text, provider display names |
| Secondary body | `text-sm text-gray-600` | Status description, connected-at date |
| Meta / label | `text-xs text-gray-500` | "Connected on …" date stamps, badge text on status pills |
Font size token summary (4 tokens, no others permitted):
- `text-2xl` — Page heading
- `text-xl` — Section heading (also: modal header)
- `text-sm` — Body / Subsection heading
- `text-xs` — Meta / Label
Weights in use: 400 (regular — body, secondary, meta) and 600 (semibold — headings, button labels, subsection labels). No additional weights introduced.
Body line height: `leading-relaxed` (1.625) — applied to the `text-sm text-gray-700` role. Heading line height: `leading-tight` (1.25) — applied to `text-2xl` and `text-xl` heading roles.
---
## Color
Matches 60/30/10 contract locked in Phases 14:
| Role | Tailwind Value | Usage |
|------|---------------|-------|
| Dominant (60%) | `bg-gray-50` | Page background |
| Secondary (30%) | `bg-white` + `border-gray-200` | Section cards, modal background, provider rows |
| Accent (10%) | `indigo-600` / `indigo-700` | Primary buttons (Connect {provider}, Save, Reconnect {provider}), active tab underline, focus rings |
| Destructive | `red-600` / `red-700` | Remove/Disconnect button, disconnect-all action, error banner background tint |
Accent (`indigo-600`) is reserved for:
- "Connect {provider}" button on unconnected providers
- "Reconnect {provider}" button on REQUIRES_REAUTH providers
- "Save" button in WebDAV/Nextcloud credential modal
- Active tab indicator underline in SettingsView tab strip
- Focus rings (`focus:ring-indigo-500`) on all inputs in the credential modal
Additional semantic colors introduced by Phase 5:
| Role | Tailwind Classes | Usage |
|------|-----------------|-------|
| Success (ACTIVE status) | `bg-green-100 text-green-700` | ACTIVE badge |
| Warning (REQUIRES_REAUTH) | `bg-yellow-100 text-yellow-800` | REQUIRES_REAUTH badge, warning banner background |
| Error (ERROR status) | `bg-red-100 text-red-700` | ERROR badge |
| Neutral (not connected) | `bg-gray-100 text-gray-600` | "Not connected" badge |
| Warning banner | `bg-yellow-50 border border-yellow-200 text-yellow-800` | REQUIRES_REAUTH inline banner within provider row |
| Error banner | `bg-red-50 border border-red-200 text-red-700` | OAuth error banner (persistent, query param triggered) |
---
## Focal Point
**Primary focal anchor for the Cloud Storage tab view:** The provider list rows — specifically the status badges and action buttons on the right side of each row. This is where the user's eye is directed: badges communicate current state at a glance, action buttons drive the next step.
Visual hierarchy to support this: provider name (`font-semibold`) draws attention first, status badge follows as inline confirmation, action button column (`shrink-0`, right-aligned) provides a predictable vertical target for scanning. The "Disconnect all cloud storage" link at the bottom is intentionally low-contrast (plain `text-red-600` text link) to keep it out of the primary scan path.
---
## New UI Surfaces
### Surface 1: SettingsView Tab Conversion
**Change:** Convert SettingsView from flat stacked sections to a 3-tab layout matching AdminView's pattern exactly.
**Tab structure:**
```
tabs = [
{ id: 'preferences', label: 'Preferences' },
{ id: 'ai', label: 'AI Configuration' },
{ id: 'cloud', label: 'Cloud Storage' },
]
```
**Tab strip (copy AdminView verbatim):**
```
<div class="flex border-b border-gray-200 mb-6">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors"
:class="activeTab === tab.id
? 'text-indigo-600 border-indigo-600'
: 'text-gray-500 hover:text-gray-700 border-transparent'"
>
{{ tab.label }}
</button>
</div>
```
**Tab content mapping:**
- `preferences` tab: existing "Document Preferences" section (pdf_open_mode radios) — extracted into `SettingsPreferencesTab.vue`
- `ai` tab: existing "AI configuration" section (admin-managed notice) — extracted into `SettingsAiTab.vue`
- `cloud` tab: new cloud storage management — `SettingsCloudTab.vue`
**Active tab on mount:** `preferences` (first tab, user's existing default context)
**Active tab override on OAuth redirect:** If `?cloud_connected=` or `?cloud_error=` query param is present in `onMounted`, set `activeTab = 'cloud'` before clearing the query params.
**SettingsView wrapper:** Retain `p-8 max-w-3xl mx-auto`. The heading ("Settings") and description stay above the tab strip.
---
### Surface 2: SettingsCloudTab — Provider Cards
**Component:** `frontend/src/components/settings/SettingsCloudTab.vue`
**Layout:** A single section card (`bg-white border border-gray-200 rounded-xl p-6`) containing:
1. Section heading: `<h3 class="text-xl font-semibold text-gray-800 mb-1">Cloud Storage</h3>`
2. Description: `<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>`
3. Provider list: one row per provider, stacked with `divide-y divide-gray-100`
4. "Disconnect all" action (shown only when at least one provider is ACTIVE or ERROR)
**Provider row structure** (one row per provider, always shown — all 4 providers always visible):
```
<div class="flex items-center justify-between py-3 gap-4">
<!-- Left: icon + name + status badge -->
<div class="flex items-center gap-3 min-w-0">
<!-- Provider icon: w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center -->
<span class="text-sm font-semibold text-gray-900">{{ provider.label }}</span>
<StatusBadge :status="connection?.status ?? 'not_connected'" />
</div>
<!-- Right: action button(s) -->
<div class="flex items-center gap-2 shrink-0">
<!-- See button specs per status below -->
</div>
</div>
```
**Provider labels and icons:**
| Provider key | Display label | Icon description |
|-------------|--------------|-----------------|
| `google_drive` | Google Drive | Cloud icon with "G" text or inline SVG cloud, `text-blue-500` |
| `onedrive` | OneDrive | Cloud icon, `text-sky-500` |
| `nextcloud` | Nextcloud | Cloud icon, `text-orange-500` |
| `webdav` | WebDAV server | Server/database icon, `text-gray-500` |
Provider icons are inline SVG, `w-5 h-5`, stroke-based consistent with the project's existing icon vocabulary. Use the standard cloud path from the project's SVG set. Color is applied via `class` on the SVG element.
**Status badges** (pill component, `StatusBadge.vue` or inline):
| Status | Classes | Label |
|--------|---------|-------|
| `ACTIVE` | `bg-green-100 text-green-700 text-xs font-semibold px-2 py-0.5 rounded-full` | Active |
| `REQUIRES_REAUTH` | `bg-yellow-100 text-yellow-800 text-xs font-semibold px-2 py-0.5 rounded-full` | Reconnect needed |
| `ERROR` | `bg-red-100 text-red-700 text-xs font-semibold px-2 py-0.5 rounded-full` | Error |
| `not_connected` | `bg-gray-100 text-gray-600 text-xs font-semibold px-2 py-0.5 rounded-full` | Not connected |
**Action button label pattern:** All action buttons include the provider's display label as the noun object. The `{provider}` placeholder resolves to the display label from the provider labels table above (e.g., "Google Drive", "OneDrive", "Nextcloud", "WebDAV server").
**Action buttons per status:**
| Status | Button label | Classes |
|--------|--------------|---------|
| `not_connected` | "Connect {provider}" | `bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors` |
| `ACTIVE` | "Remove {provider}" | `text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors` |
| `REQUIRES_REAUTH` | "Reconnect {provider}" (primary) + "Remove {provider}" (secondary) | Reconnect: `bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors`; Remove: `text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors` |
| `ERROR` | "Remove {provider}" | `text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 text-red-600 transition-colors` |
Example rendered labels: "Connect Google Drive", "Reconnect OneDrive", "Remove Nextcloud", "Remove WebDAV server".
**Loading state:** When a Connect or Reconnect button is clicked and the API call is in-flight, the button shows a spinner icon (`animate-spin`) replacing the label, and is `disabled opacity-50 cursor-not-allowed`. Width is held fixed to prevent layout shift (use `min-w-[160px]` on the button — wide enough for the longest provider name combination).
**Connected-at date:** For ACTIVE and ERROR connections, show below the badge: `<span class="text-xs text-gray-500">Connected {date}</span>`. Use locale date format: `new Date(connection.connected_at).toLocaleDateString()`. Not shown for `not_connected` or `REQUIRES_REAUTH`.
**REQUIRES_REAUTH inline banner:** Below the row (not inside it), conditionally rendered per provider:
```
<div
v-if="connection?.status === 'REQUIRES_REAUTH'"
class="mx-0 mb-2 p-3 rounded-lg bg-yellow-50 border border-yellow-200 flex items-start gap-2"
>
<svg class="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" ...warning-triangle-icon... />
<p class="text-sm text-yellow-800">
Your {{ provider.label }} connection needs to be re-authorized.
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
</p>
</div>
```
**"Disconnect all" action:** Rendered at the bottom of the section card, below the provider list. Only visible when at least one connection exists with status `ACTIVE` or `ERROR`.
```
<div class="pt-4 border-t border-gray-100 flex justify-end">
<button
@click="showDisconnectAll = true"
class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors"
>
Disconnect all cloud storage
</button>
</div>
```
Clicking opens a confirmation block using the existing `ConfirmBlock` component pattern:
```
Message: "This will permanently delete all cloud storage credentials. Your documents will remain in DocuVault, but cloud documents may become inaccessible."
Confirm label: "Disconnect all"
Cancel label: "Keep all connected"
confirmClass: "bg-red-600 hover:bg-red-700 text-white"
```
**Empty state:** No explicit empty state needed — the tab always shows all 4 providers (3 OAuth + 1 WebDAV), each with "Not connected" status. The section heading description serves as the orientation copy.
---
### Surface 3: OAuth Success / Error Toast
**Trigger:** `onMounted` in SettingsView reads `window.location.search` for `?cloud_connected={provider}` or `?cloud_error={message}`. After reading, replace the URL using `router.replace({ path: '/settings' })` to clean the query params.
**Success toast:**
```
<div
v-if="oauthSuccessProvider"
class="fixed top-4 right-4 z-50 flex items-center gap-3 bg-white border border-green-200 rounded-xl shadow-lg px-5 py-4 max-w-sm"
>
<svg class="w-5 h-5 text-green-500 shrink-0" ...checkmark-circle-icon... />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900">{{ providerLabel }} connected</p>
<p class="text-xs text-gray-500 mt-0.5">Your files are now available in the sidebar.</p>
</div>
<button
@click="oauthSuccessProvider = null"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
>
<svg class="w-4 h-4" ...x-icon... />
</button>
</div>
```
- Auto-dismiss after 5000 ms (`setTimeout(() => oauthSuccessProvider = null, 5000)`)
- Position: `fixed top-4 right-4 z-50` — top-right corner, above all content
- Shadow: `shadow-lg`
**Error banner** (persistent — requires manual dismissal):
```
<div
v-if="oauthError"
class="mb-6 flex items-start gap-3 bg-red-50 border border-red-200 rounded-xl px-5 py-4"
>
<svg class="w-5 h-5 text-red-500 shrink-0 mt-0.5" ...exclamation-circle-icon... />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-red-700">Connection failed</p>
<p class="text-sm text-red-600 mt-0.5">{{ oauthError }}</p>
<p class="text-xs text-red-500 mt-1">Try connecting again. If the problem persists, check that the app has the correct permissions in your provider's account settings.</p>
</div>
<button
@click="oauthError = null"
aria-label="Dismiss error"
class="text-red-400 hover:text-red-600 shrink-0"
>
<svg class="w-4 h-4" ...x-icon... />
</button>
</div>
```
- Position: inline, rendered above the section card inside the "Cloud Storage" tab content
- Persistent until manually dismissed or page navigation
- The `?cloud_error=` value is URL-decoded and displayed as the error message body
**Provider label mapping** (for toast message):
| Query param value | Display label |
|------------------|--------------|
| `google_drive` | Google Drive |
| `onedrive` | OneDrive |
| `nextcloud` | Nextcloud |
| `webdav` | WebDAV server |
---
### Surface 4: WebDAV / Nextcloud Credential Modal
**Trigger:** Clicking "Connect {provider}" on the Nextcloud or WebDAV provider row.
**Component:** `CloudCredentialModal.vue` — a centered modal overlay.
**Overlay:** `fixed inset-0 bg-gray-900 bg-opacity-40 z-40 flex items-center justify-center p-4`
**Modal panel:** `bg-white rounded-xl shadow-xl w-full max-w-md p-6`
**Header:**
```
<div class="flex items-center justify-between mb-5">
<h3 class="text-xl font-semibold text-gray-900">Connect {{ providerLabel }}</h3>
<button
@click="close"
aria-label="Close modal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-5 h-5" ...x-icon... />
</button>
</div>
```
Note: The modal `<h3>` uses `text-xl font-semibold` — the Section heading role from the typography scale.
**Form fields:**
1. **Server URL** (Nextcloud and WebDAV only — not shown for OAuth providers):
```
<label class="block text-sm font-semibold text-gray-900 mb-1">Server URL</label>
<input
type="url"
v-model="serverUrl"
placeholder="https://nextcloud.example.com/remote.php/dav/files/username/"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p class="text-xs text-gray-500 mt-1">Full WebDAV endpoint URL including username path segment.</p>
```
2. **Username:**
```
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">Username</label>
<input
type="text"
v-model="username"
autocomplete="username"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
```
3. **Auth method toggle** (radio group, displayed between Username and Password fields):
```
<div class="mt-4 mb-2">
<p class="text-sm font-semibold text-gray-900 mb-2">Authentication method</p>
<div class="space-y-2">
<label class="flex items-start gap-3 cursor-pointer">
<input type="radio" value="app_password" v-model="authMethod"
class="mt-0.5 text-indigo-600 focus:ring-indigo-500" />
<div>
<span class="text-sm font-semibold text-gray-900">App password</span>
<span class="ml-2 bg-green-100 text-green-700 text-xs font-semibold px-1.5 py-0.5 rounded">Recommended</span>
<p class="text-xs text-gray-500 mt-0.5">
Can be revoked individually without changing your main account password.
</p>
</div>
</label>
<label class="flex items-start gap-3 cursor-pointer">
<input type="radio" value="account_password" v-model="authMethod"
class="mt-0.5 text-indigo-600 focus:ring-indigo-500" />
<div>
<span class="text-sm font-semibold text-gray-900">Account password</span>
<p class="text-xs text-gray-500 mt-0.5">
Simpler to set up, but revoking access requires changing your entire account password.
</p>
</div>
</label>
</div>
</div>
```
Default selected: `app_password`.
4. **Password / App password field:**
```
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">
{{ authMethod === 'app_password' ? 'App password' : 'Password' }}
</label>
<input
type="password"
v-model="password"
autocomplete="current-password"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
```
**Validation error display** (inline, shown below offending field):
```
<p class="text-xs text-red-600 mt-1">{{ fieldError }}</p>
```
**Connection test error** (shown above buttons after failed test):
```
<div
v-if="connectError"
class="mt-4 p-3 rounded-lg bg-red-50 border border-red-200"
>
<p class="text-sm font-semibold text-red-700">Connection failed</p>
<p class="text-sm text-red-600 mt-0.5">{{ connectError }}</p>
<p class="text-xs text-red-500 mt-1">Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients.</p>
</div>
```
**Footer buttons:**
```
<div class="flex justify-end gap-3 mt-6">
<button
type="button"
@click="close"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Keep current settings
</button>
<button
type="button"
@click="submit"
:disabled="saving"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg disabled:opacity-50 transition-colors min-h-[44px] min-w-[80px]"
>
<svg v-if="saving" class="w-4 h-4 animate-spin mx-auto" ...spinner... />
<span v-else>Connect {{ providerLabel }}</span>
</button>
</div>
```
**Dismiss behavior:** Clicking the X button, "Keep current settings", or pressing Escape closes the modal. Clicking the overlay background also closes. When `saving` is true, all close actions are disabled (prevent accidental dismissal during the API call).
---
### Surface 5: Cloud Provider Nodes in Sidebar
**Placement:** A new "Cloud Storage" section added to `AppSidebar.vue`, positioned immediately after the Folders section (after the `</div>` closing the Folders collapsible block), before the Topics section.
**Section header and collapsible pattern** (mirrors Folders section exactly):
```html
<div class="mt-3">
<div class="flex items-center gap-0.5">
<!-- Expand/collapse chevron -->
<button
@click="cloudExpanded = !cloudExpanded"
class="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors shrink-0"
:title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="cloudExpanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- "Cloud Storage" label — navigates to /settings?tab=cloud -->
<a
href="/settings"
class="nav-link flex-1 min-w-0"
>
<svg class="w-4 h-4 mr-2 shrink-0 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
Cloud Storage
</a>
</div>
<!-- Collapsible content: one node per active connection -->
<template v-if="cloudExpanded">
<div v-if="loadingCloudConnections" class="pl-7 py-1 text-xs text-gray-400">Loading…</div>
<div v-else-if="activeCloudConnections.length === 0" class="pl-7 py-1 text-xs text-gray-400">
No cloud storage connected
</div>
<CloudProviderTreeItem
v-for="connection in activeCloudConnections"
:key="connection.id"
:connection="connection"
:depth="1"
/>
</template>
</div>
```
**`activeCloudConnections`:** Only connections with status `ACTIVE` are shown as tree nodes. `REQUIRES_REAUTH` and `ERROR` connections are not shown in the sidebar (user must go to Settings to resolve them).
**`CloudProviderTreeItem.vue`** — new component, mirrors `FolderTreeItem.vue` structure:
```html
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow -->
<button
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse ' + connection.display_name : 'Expand ' + connection.display_name"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Provider name (click navigates to cloud folder root) -->
<button
@click="navigateToRoot"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
>
<!-- Provider cloud icon (w-4 h-4, provider color) -->
<svg class="w-4 h-4 shrink-0" :class="providerIconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<span class="truncate">{{ connection.display_name }}</span>
</button>
</div>
<!-- Children: first-level cloud folders (lazy loaded) -->
<template v-if="expanded">
<div v-if="loading" class="pl-12 py-1 text-xs text-gray-400">Loading…</div>
<div v-else-if="children.length === 0" class="pl-12 py-1 text-xs text-gray-400">Empty</div>
<CloudFolderTreeItem
v-for="folder in children"
:key="folder.id"
:folder="folder"
:provider="connection.provider"
:depth="depth + 1"
/>
</template>
</div>
</template>
```
**Provider icon colors:**
| Provider | `providerIconColor` class |
|----------|--------------------------|
| `google_drive` | `text-blue-500` |
| `onedrive` | `text-sky-500` |
| `nextcloud` | `text-orange-500` |
| `webdav` | `text-gray-500` |
**Loading behavior for cloud folder expansion:**
- On first expand: show `text-xs text-gray-400` "Loading…" at `pl-12` (depth 1 * 12 = 12px left + icon space)
- On success: render `CloudFolderTreeItem` nodes
- On error: show `text-xs text-red-500` "Failed to load — tap to retry" at `pl-12`, clicking retries the fetch
- 60-second TTL cache handled server-side; frontend always calls the API on expand if `childrenLoaded === false`
**`CloudFolderTreeItem.vue`:** A simplified version of `FolderTreeItem.vue` for cloud folder nodes. Uses a plain folder icon (`text-gray-400`). Navigates to `/cloud/{provider}/{folder_id}` on click. Indentation via `depth * 12` matching the existing pattern. Lazy-loads nested children via the same expand/toggle mechanism.
**Depth / indentation:** Cloud tree nodes use the same `depth * 12` px left-padding formula as `FolderTreeItem`. Provider root is at depth 1 (same level as local root folders). Cloud sub-folders start at depth 2.
---
## Copywriting Contract
### Primary CTA labels
All action buttons that target a specific provider use the `{verb} {provider}` pattern. The `{provider}` token resolves to the display label from the provider labels table.
| Action | Button label pattern | Example |
|--------|---------------------|---------|
| Connect OAuth provider | "Connect {provider}" | "Connect Google Drive" |
| Connect WebDAV / Nextcloud (modal submit) | "Connect {provider}" | "Connect Nextcloud" |
| Reconnect (REQUIRES_REAUTH) | "Reconnect {provider}" | "Reconnect OneDrive" |
| Remove single active/error provider | "Remove {provider}" | "Remove Google Drive" |
| Remove single from REQUIRES_REAUTH row (secondary) | "Remove {provider}" | "Remove Nextcloud" |
| Disconnect all providers (trigger link) | "Disconnect all cloud storage" | (no provider token — applies to all) |
| Disconnect all (ConfirmBlock confirm label) | "Disconnect all" | |
| Cancel WebDAV/Nextcloud modal | "Keep current settings" | |
| Dismiss disconnect-single ConfirmBlock | "Keep connected" | |
| Dismiss disconnect-all ConfirmBlock | "Keep all connected" | |
### Status badge labels
| Status | Badge label |
|--------|------------|
| `ACTIVE` | Active |
| `REQUIRES_REAUTH` | Reconnect needed |
| `ERROR` | Error |
| `not_connected` | Not connected |
### Empty states
| Location | Copy |
|----------|------|
| Cloud tab, no active connections | (no explicit empty state — all 4 providers always shown with "Not connected" badge) |
| Sidebar cloud section, no active connections | "No cloud storage connected" |
| Cloud folder tree node, empty provider root | "Empty" |
| Cloud folder tree node, load error | "Failed to load — tap to retry" |
### Error messages
| Error scenario | Copy |
|---------------|------|
| WebDAV connection test failed (modal inline) | "Connection failed" (heading) + server error message (body) + "Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients." |
| OAuth callback error (`?cloud_error=`) | "Connection failed" (heading) + decoded error value (body) + "Try connecting again. If the problem persists, check that the app has the correct permissions in your provider's account settings." |
| Disconnect failed (API error) | Toast or inline: "Failed to disconnect. Please try again." |
| Cloud folder load failed (sidebar tree) | "Failed to load — tap to retry" |
### REQUIRES_REAUTH inline banner
"Your {provider label} connection needs to be re-authorized. Click **Reconnect {provider label}** to restore access."
### Disconnect single confirmation
Uses existing `ConfirmBlock` component:
- Message: "This will permanently remove your {provider label} credentials from DocuVault. Your cloud documents will remain in your {provider label} account."
- Confirm label: "Remove {provider label}" (e.g. "Remove Google Drive")
- Cancel label: "Keep connected"
- `confirmClass`: `"bg-red-600 hover:bg-red-700 text-white"`
### Disconnect-all confirmation
Uses existing `ConfirmBlock` component:
- Message: "This will permanently delete all cloud storage credentials. Your documents will remain in DocuVault, but cloud documents may become inaccessible."
- Confirm label: "Disconnect all"
- Cancel label: "Keep all connected"
- `confirmClass`: `"bg-red-600 hover:bg-red-700 text-white"`
### OAuth success toast
"{provider label} connected" (heading) + "Your files are now available in the sidebar." (body)
---
## Component Inventory
New components introduced in Phase 5:
| Component path | Purpose | Extends |
|---------------|---------|---------|
| `frontend/src/components/settings/SettingsCloudTab.vue` | Cloud Storage tab content | New |
| `frontend/src/components/settings/SettingsPreferencesTab.vue` | Extracted Preferences tab (pdf_open_mode) | Extracted from SettingsView |
| `frontend/src/components/settings/SettingsAiTab.vue` | Extracted AI config tab | Extracted from SettingsView |
| `frontend/src/components/cloud/CloudCredentialModal.vue` | WebDAV / Nextcloud credential input modal | New |
| `frontend/src/components/cloud/CloudProviderTreeItem.vue` | Provider root node in sidebar tree | Mirrors FolderTreeItem |
| `frontend/src/components/cloud/CloudFolderTreeItem.vue` | Cloud sub-folder node in sidebar tree | Mirrors FolderTreeItem |
Modified components:
| Component path | Change |
|---------------|--------|
| `frontend/src/views/SettingsView.vue` | Convert to 3-tab layout; add OAuth param handling in `onMounted`; add success toast + error banner state |
| `frontend/src/components/layout/AppSidebar.vue` | Add "Cloud Storage" collapsible section below Folders |
New Pinia store:
| Store | State |
|-------|-------|
| `frontend/src/stores/cloudConnections.js` | `connections: []`, `loading: bool`, `error: string\|null`. Actions: `fetchConnections()`, `disconnect(id)`, `disconnectAll()` |
---
## Registry Safety
No shadcn registry. No third-party component registries. All UI is custom Tailwind + inline SVG matching the existing project pattern.
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none | not applicable |
| third-party | none | not applicable |
---
## Interaction States Summary
| Component | States |
|-----------|--------|
| Connect {provider} button | default, hover, loading (spinner + disabled), disabled (already connected) |
| Remove {provider} button | default, hover, loading (spinner + disabled) |
| Reconnect {provider} button | default, hover, loading (spinner + disabled) |
| WebDAV modal "Connect {provider}" button | default, hover, loading (spinner + disabled) |
| WebDAV modal inputs | default, focus (indigo ring), error (red border + error text below) |
| Status badge | static — no hover state |
| Success toast | visible (auto-dismiss 5s), dismiss on "Dismiss notification" X button |
| Error banner | visible (persistent), dismiss on "Dismiss error" X button |
| REQUIRES_REAUTH banner | visible when status === REQUIRES_REAUTH, disappears after reconnect |
| Cloud tree provider node | default, hover (bg-gray-100), active/selected (bg-indigo-50 text-indigo-700) |
| Cloud tree expand arrow | default (text-gray-400), hover (text-gray-600), expanded (rotate-90) |
| Cloud folder tree loading | "Loading…" text (text-xs text-gray-400) |
| Cloud folder tree error | "Failed to load — tap to retry" (text-xs text-red-500, cursor-pointer) |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
**Approval:** approved 2026-05-28
@@ -0,0 +1,15 @@
# Phase 5 — Deferred Items
## Pre-existing issues discovered during Plan 05-01 execution (out of scope)
### test_extractor.py::test_extract_docx — ModuleNotFoundError: No module named 'docx'
- **Discovered during:** Task 3 (full pytest run)
- **Root cause:** `python-docx` package is not installed in the local Python environment.
The backend runs in Docker where all requirements.txt packages are installed, but the
local test runner uses the system Python 3.9.6 which does not have a virtualenv
with all requirements installed.
- **Not caused by:** Plan 05-01 changes (requirements.txt, config.py, conftest.py, test_cloud.py)
- **Resolution path:** Install python-docx in the local test environment:
`pip3 install python-docx` or run tests inside Docker with all deps available.
- **Impact:** 1 pre-existing failure in test_extractor.py; does not affect Phase 5 work.