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:
@@ -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 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 `<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 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):**
|
||||
|
||||
```
|
||||
<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.
|
||||
Reference in New Issue
Block a user