diff --git a/.planning/debug/cloud-doc-operations-fail.md b/.planning/debug/cloud-doc-operations-fail.md new file mode 100644 index 0000000..81f44c3 --- /dev/null +++ b/.planning/debug/cloud-doc-operations-fail.md @@ -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: [] diff --git a/.planning/phases/05-cloud-storage-backends/05-09-PLAN.md b/.planning/phases/05-cloud-storage-backends/05-09-PLAN.md index 4921487..31eb8b6 100644 --- a/.planning/phases/05-cloud-storage-backends/05-09-PLAN.md +++ b/.planning/phases/05-cloud-storage-backends/05-09-PLAN.md @@ -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. @@ -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 2: Authenticated document preview — fetch-with-Blob-URL in frontend - frontend/src/api/client.js, frontend/src/components/documents/DocumentPreviewModal.vue, frontend/src/views/DocumentView.vue + Task 2: Authenticated document preview — fetch-with-Blob-URL in frontend + Vitest test + frontend/src/api/client.js, frontend/src/components/documents/DocumentPreviewModal.vue, frontend/src/views/DocumentView.vue, frontend/src/api/__tests__/client.test.js 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()`. - cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5 + 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 - Frontend build passes with zero errors. DocumentPreviewModal and DocumentView use fetchDocumentContent. No unauthenticated src= URLs remain for the /content endpoint. + 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. @@ -168,6 +199,7 @@ From frontend/src/api/client.js: 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: - 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 diff --git a/.planning/phases/05-cloud-storage-backends/05-10-PLAN.md b/.planning/phases/05-cloud-storage-backends/05-10-PLAN.md index 330d424..edd4585 100644 --- a/.planning/phases/05-cloud-storage-backends/05-10-PLAN.md +++ b/.planning/phases/05-cloud-storage-backends/05-10-PLAN.md @@ -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 2: Frontend OAuth fetch, Nextcloud edit fix, Edit on ERROR, text overflow - frontend/src/components/settings/SettingsCloudTab.vue, frontend/src/components/cloud/CloudCredentialModal.vue, frontend/src/components/ui/ConfirmBlock.vue, frontend/src/api/client.js + Task 2: Frontend OAuth fetch, Nextcloud edit fix, Edit on ERROR, text overflow + Vitest test + 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 ### 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 `

` to `

`. + + ### 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())`. - cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5 + 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 - 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. + 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. @@ -196,6 +211,7 @@ From frontend/src/components/ui/ConfirmBlock.vue: 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: - 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 diff --git a/.planning/phases/05-cloud-storage-backends/05-11-PLAN.md b/.planning/phases/05-cloud-storage-backends/05-11-PLAN.md index c62e282..09f0534 100644 --- a/.planning/phases/05-cloud-storage-backends/05-11-PLAN.md +++ b/.planning/phases/05-cloud-storage-backends/05-11-PLAN.md @@ -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 1: Backend — UserDeleteConfirm model + password verification in delete_user - backend/api/admin.py, backend/tests/test_admin.py + backend/api/admin.py, backend/tests/test_admin_api.py - 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. - 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 + 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 Three tests pass. Delete with correct password returns 204. Delete with wrong password returns 403 and user survives. Delete with no body returns 422. @@ -241,7 +244,7 @@ From frontend/src/api/client.js: 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 diff --git a/.planning/phases/05-cloud-storage-backends/05-UAT.md b/.planning/phases/05-cloud-storage-backends/05-UAT.md new file mode 100644 index 0000000..eae308d --- /dev/null +++ b/.planning/phases/05-cloud-storage-backends/05-UAT.md @@ -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 + + +## 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

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: "

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

in ConfirmBlock.vue" diff --git a/.planning/phases/05-cloud-storage-backends/05-UI-SPEC.md b/.planning/phases/05-cloud-storage-backends/05-UI-SPEC.md new file mode 100644 index 0000000..c34d809 --- /dev/null +++ b/.planning/phases/05-cloud-storage-backends/05-UI-SPEC.md @@ -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 1–4. + +--- + +## 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 1–4: + +| 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 1–4 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 1–4. 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 `

` 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 1–4: + +| 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):** + +``` +
+ +
+``` + +**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: `

Cloud Storage

` +2. Description: `

Connect a cloud storage provider to use as a document destination.

` +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): + +``` +
+ +
+ + {{ provider.label }} + +
+ +
+ +
+
+``` + +**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: `Connected {date}`. 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: + +``` +
+ +

+ Your {{ provider.label }} connection needs to be re-authorized. + Click Reconnect {{ provider.label }} to restore access. +

+
+``` + +**"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`. + +``` +
+ +
+``` + +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:** + +``` +
+ +
+

{{ providerLabel }} connected

+

Your files are now available in the sidebar.

+
+ +
+``` + +- 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): + +``` +
+ +
+

Connection failed

+

{{ oauthError }}

+

Try connecting again. If the problem persists, check that the app has the correct permissions in your provider's account settings.

+
+ +
+``` + +- 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:** + +``` +
+

Connect {{ providerLabel }}

+ +
+``` + +Note: The modal `

` 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): + +``` + + +

Full WebDAV endpoint URL including username path segment.

+``` + +2. **Username:** + +``` + + +``` + +3. **Auth method toggle** (radio group, displayed between Username and Password fields): + +``` +
+

Authentication method

+
+ + +
+
+``` + +Default selected: `app_password`. + +4. **Password / App password field:** + +``` + + +``` + +**Validation error display** (inline, shown below offending field): + +``` +

{{ fieldError }}

+``` + +**Connection test error** (shown above buttons after failed test): + +``` +
+

Connection failed

+

{{ connectError }}

+

Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients.

+
+``` + +**Footer buttons:** + +``` +
+ + +
+``` + +**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 `` closing the Folders collapsible block), before the Topics section. + +**Section header and collapsible pattern** (mirrors Folders section exactly): + +```html +
+
+ + + + + + + + + Cloud Storage + +
+ + + +
+``` + +**`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 + +``` + +**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 diff --git a/.planning/phases/05-cloud-storage-backends/deferred-items.md b/.planning/phases/05-cloud-storage-backends/deferred-items.md new file mode 100644 index 0000000..993bda7 --- /dev/null +++ b/.planning/phases/05-cloud-storage-backends/deferred-items.md @@ -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.