--- phase: 05-cloud-storage-backends plan: 07 type: execute wave: 6 depends_on: - "05-06" files_modified: - frontend/src/stores/cloudConnections.js - frontend/src/stores/__tests__/cloudConnections.test.js - frontend/src/api/client.js - frontend/src/views/SettingsView.vue - frontend/src/components/settings/SettingsPreferencesTab.vue - frontend/src/components/settings/SettingsAiTab.vue - frontend/src/components/settings/SettingsCloudTab.vue - frontend/src/components/cloud/CloudCredentialModal.vue autonomous: true requirements: - CLOUD-01 - CLOUD-03 - CLOUD-04 - CLOUD-05 - CLOUD-06 must_haves: truths: - "SettingsView has a 3-tab layout (Preferences, AI Configuration, Cloud Storage)" - "Cloud Storage tab shows all 4 providers with status badges (ACTIVE, REQUIRES_REAUTH, ERROR, not_connected)" - "Connect Google Drive / OneDrive triggers a redirect to the OAuth initiation endpoint" - "Connect Nextcloud / WebDAV opens CloudCredentialModal with server URL, username, and auth method toggle" - "Remove {provider} button disconnects the connection via DELETE /api/cloud/connections/{id}" - "OAuth redirect success/error handled in onMounted via ?cloud_connected= and ?cloud_error= query params" - "Success toast auto-dismisses in 5 seconds; error banner persists until dismissed" - "cloudConnectionsStore: connections, loading, error state; fetchConnections, disconnect, disconnectAll actions" - "Vitest unit tests for cloudConnections store (4 tests) and SettingsCloudTab mount test (2 tests) — per CLAUDE.md testing protocol (W4)" artifacts: - path: "frontend/src/stores/cloudConnections.js" provides: "Pinia store for cloud connections state" contains: "useCloudConnectionsStore" - path: "frontend/src/stores/__tests__/cloudConnections.test.js" provides: "Vitest unit tests for cloudConnections store (W4)" contains: "fetchConnections" - path: "frontend/src/api/client.js" provides: "Cloud API client functions" contains: "listCloudConnections" - path: "frontend/src/views/SettingsView.vue" provides: "3-tab settings view with OAuth callback handling" contains: "activeTab" - path: "frontend/src/components/settings/SettingsCloudTab.vue" provides: "Cloud provider card list with status badges and action buttons" contains: "CloudCredentialModal" - path: "frontend/src/components/cloud/CloudCredentialModal.vue" provides: "WebDAV/Nextcloud credential input modal" contains: "authMethod" key_links: - from: "frontend/src/components/settings/SettingsCloudTab.vue" to: "frontend/src/stores/cloudConnections.js" via: "useCloudConnectionsStore()" pattern: "useCloudConnectionsStore" - from: "frontend/src/views/SettingsView.vue" to: "frontend/src/stores/cloudConnections.js" via: "fetchConnections on tab switch to cloud" pattern: "fetchConnections" --- Build the frontend cloud storage UI: Pinia store, API client functions, SettingsView 3-tab conversion, SettingsCloudTab provider cards, and CloudCredentialModal. Purpose: Complete the user-facing cloud storage management experience — connect, view status, and disconnect providers from SettingsView. Output: cloudConnections.js store, API client additions, SettingsView tab conversion, 3 settings tab components, CloudCredentialModal. @/Users/nik/.claude/get-shit-done/workflows/execute-plan.md @/Users/nik/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/05-cloud-storage-backends/05-CONTEXT.md @.planning/phases/05-cloud-storage-backends/05-UI-SPEC.md @.planning/phases/05-cloud-storage-backends/05-06-SUMMARY.md From frontend/src/api/client.js: async function request(path, options = {}) — handles auth headers, 401 retry All API functions call request(path, options) and return the JSON payload defineStore('folders', () => { const folders = ref([]) const loading = ref(false) const error = ref(null) async function fetchFolders() { loading.value=true; ... } return { folders, loading, error, fetchFolders, ... } }) Current SettingsView: flat layout with AI config section + Document Preferences section (pdf_open_mode radios) After conversion: 3-tab layout; "preferences" tab has pdf_open_mode; "ai" tab has AI config text; "cloud" tab is new Tab strip: copy AdminView pattern verbatim (px-4 py-2 text-sm font-semibold border-b-2) Provider rows: divide-y divide-gray-100 inside bg-white border border-gray-200 rounded-xl p-6 Status badge: bg-green-100 text-green-700 (ACTIVE), bg-yellow-100 text-yellow-800 (REQUIRES_REAUTH), bg-red-100 text-red-700 (ERROR), bg-gray-100 text-gray-600 (not_connected) Action buttons per status: per UI-SPEC table OAuth success toast: fixed top-4 right-4 z-50; auto-dismiss 5000ms Error banner: mb-6 inline inside cloud tab content; persistent Overlay: fixed inset-0 bg-gray-900 bg-opacity-40 z-40 flex items-center justify-center p-4 Panel: bg-white rounded-xl shadow-xl w-full max-w-md p-6 Fields: Server URL, Username, auth method radio (app_password default), Password/App password Cancel label: "Keep current settings" Save button label: "Connect {providerLabel}" Task 1: Create cloudConnections Pinia store, API client additions, and Vitest tests frontend/src/stores/cloudConnections.js, frontend/src/stores/__tests__/cloudConnections.test.js, frontend/src/api/client.js - frontend/src/stores/folders.js — Pinia store structure (defineStore composition API pattern) - frontend/src/api/client.js — existing API function patterns, request() helper - frontend/src/stores/auth.js — how stores handle loading/error state - .planning/phases/05-cloud-storage-backends/05-UI-SPEC.md — cloudConnections store state contract - frontend/src/stores/cloudConnections.js exports useCloudConnectionsStore with: connections (ref []), loading (ref false), error (ref null); fetchConnections(), disconnect(id), disconnectAll() actions - fetchConnections calls GET /api/cloud/connections; sets connections from response.items - disconnect(id) calls DELETE /api/cloud/connections/{id}; removes connection from connections array - disconnectAll() calls disconnect(id) for each connection serially; clears connections on completion - frontend/src/api/client.js gains: listCloudConnections(), disconnectCloud(id), connectWebDav(provider, serverUrl, username, password), updateDefaultStorage(backend) - API functions follow existing pattern: return request(...) or request(...).then(r => r) - GET /api/cloud/oauth/initiate/{provider} is a redirect — frontend navigates via window.location.href (not a fetch call); no API client function needed for OAuth initiation Create frontend/src/stores/cloudConnections.js following the folders.js defineStore composition pattern: import { defineStore } from 'pinia' import { ref } from 'vue' import * as api from '../api/client.js' export const useCloudConnectionsStore = defineStore('cloudConnections', () => { const connections = ref([]) const loading = ref(false) const error = ref(null) async function fetchConnections() { loading.value = true; error.value = null try { const data = await api.listCloudConnections() connections.value = data.items ?? [] } catch (e) { error.value = e.message || 'Failed to load cloud connections' } finally { loading.value = false } } async function disconnect(id) { try { await api.disconnectCloud(id) connections.value = connections.value.filter(c => c.id !== id) } catch (e) { throw e } } async function disconnectAll() { const ids = connections.value.map(c => c.id) for (const id of ids) await disconnect(id) connections.value = [] } return { connections, loading, error, fetchConnections, disconnect, disconnectAll } }) Append to frontend/src/api/client.js (add after the existing adminListAuditLog function): // ── Cloud Storage ───────────────────────────────────────────────────────── export function listCloudConnections() { return request('/api/cloud/connections') } export function disconnectCloud(id) { return request(`/api/cloud/connections/${id}`, { method: 'DELETE' }) } export function connectWebDav(provider, serverUrl, username, password) { return request('/api/cloud/connections/webdav', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, server_url: serverUrl, username, password }), }) } export function updateDefaultStorage(backend) { return request('/api/users/me/default-storage', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backend }), }) } Create frontend/src/stores/__tests__/cloudConnections.test.js (W4 — Vitest unit tests per CLAUDE.md): Tests must mock api/client.js functions (no real HTTP calls). import { setActivePinia, createPinia } from 'pinia' import { describe, it, expect, vi, beforeEach } from 'vitest' import { useCloudConnectionsStore } from '../cloudConnections.js' import * as api from '../../api/client.js' beforeEach(() => { setActivePinia(createPinia()) }) describe('useCloudConnectionsStore', () => { it('fetchConnections sets connections from API response', async () => { vi.spyOn(api, 'listCloudConnections').mockResolvedValue({ items: [{id:'1',provider:'google_drive',status:'ACTIVE'}] }) const store = useCloudConnectionsStore() await store.fetchConnections() expect(store.connections).toHaveLength(1) expect(store.connections[0].provider).toBe('google_drive') expect(store.loading).toBe(false) }) it('fetchConnections sets error on API failure', async () => { vi.spyOn(api, 'listCloudConnections').mockRejectedValue(new Error('Network error')) const store = useCloudConnectionsStore() await store.fetchConnections() expect(store.error).toBeTruthy() expect(store.connections).toHaveLength(0) }) it('disconnect removes connection from state after API call', async () => { vi.spyOn(api, 'disconnectCloud').mockResolvedValue(undefined) const store = useCloudConnectionsStore() store.connections = [{ id: 'conn-1', provider: 'google_drive', status: 'ACTIVE' }] await store.disconnect('conn-1') expect(store.connections).toHaveLength(0) expect(api.disconnectCloud).toHaveBeenCalledWith('conn-1') }) it('disconnectAll clears all connections', async () => { vi.spyOn(api, 'disconnectCloud').mockResolvedValue(undefined) const store = useCloudConnectionsStore() store.connections = [ { id: 'a', provider: 'google_drive', status: 'ACTIVE' }, { id: 'b', provider: 'onedrive', status: 'ACTIVE' }, ] await store.disconnectAll() expect(store.connections).toHaveLength(0) }) }) cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e " const fs = require('fs'); const store = fs.readFileSync('src/stores/cloudConnections.js', 'utf8'); ['useCloudConnectionsStore','fetchConnections','disconnect','disconnectAll','connections','loading','error'].forEach(name => { if (!store.includes(name)) throw new Error('Missing: ' + name); console.log('OK: ' + name); }); const api = fs.readFileSync('src/api/client.js', 'utf8'); ['listCloudConnections','disconnectCloud','connectWebDav','updateDefaultStorage'].forEach(name => { if (!api.includes(name)) throw new Error('Missing from api/client.js: ' + name); console.log('OK api: ' + name); }); if (!fs.existsSync('src/stores/__tests__/cloudConnections.test.js')) throw new Error('Missing Vitest file'); console.log('OK: Vitest test file exists'); " && npm run test -- src/stores/__tests__/cloudConnections.test.js 2>&1 | tail -10 - frontend/src/stores/cloudConnections.js exists with useCloudConnectionsStore - Store exports: connections (ref), loading (ref), error (ref), fetchConnections, disconnect, disconnectAll - frontend/src/api/client.js contains listCloudConnections, disconnectCloud, connectWebDav, updateDefaultStorage - frontend/src/stores/__tests__/cloudConnections.test.js exists with 4 Vitest tests (W4 — CLAUDE.md requirement) - All 4 Vitest tests pass: fetchConnections, fetchConnections error path, disconnect, disconnectAll - No modifications to existing API functions (folders, auth, etc.) cloudConnections.js store created; 4 new API functions appended to client.js; 4 Vitest unit tests passing; existing API functions untouched Task 2: Convert SettingsView to 3-tab layout and create all settings + cloud components frontend/src/views/SettingsView.vue, frontend/src/components/settings/SettingsPreferencesTab.vue, frontend/src/components/settings/SettingsAiTab.vue, frontend/src/components/settings/SettingsCloudTab.vue, frontend/src/components/settings/__tests__/SettingsCloudTab.test.js, frontend/src/components/cloud/CloudCredentialModal.vue - frontend/src/views/SettingsView.vue — current content (preferences + AI sections) to preserve - frontend/src/views/AdminView.vue — tab strip pattern to copy verbatim - .planning/phases/05-cloud-storage-backends/05-UI-SPEC.md — all Surface 1-4 specs (tab structure, provider rows, status badges, action buttons, modal) - frontend/src/stores/cloudConnections.js — useCloudConnectionsStore (Task 1) - frontend/src/api/client.js — connectWebDav function - frontend/src/components/ui/ — check for ConfirmBlock component for disconnect confirmation SettingsView.vue: - Converts to 3-tab layout: tabs = [{id:'preferences', label:'Preferences'}, {id:'ai', label:'AI Configuration'}, {id:'cloud', label:'Cloud Storage'}] - activeTab defaults to 'preferences' - onMounted: reads window.location.search for ?cloud_connected={provider} and ?cloud_error={message}; if found: sets activeTab='cloud'; clears query params via router.replace({path:'/settings'}) - oauthSuccessProvider ref: null; auto-clears after 5000ms via setTimeout - oauthError ref: null; dismissed via X button - Renders SettingsPreferencesTab, SettingsAiTab, SettingsCloudTab as tab content - OAuth success toast: fixed top-4 right-4 z-50 (per UI-SPEC Surface 3 exact markup) - Error banner: inline above section card when oauthError is set (per UI-SPEC Surface 3 exact markup) SettingsPreferencesTab.vue: - Extracted from current SettingsView: the pdf_open_mode radio section - Maintains existing pdfOpenMode ref, watch, onMounted behavior - Template: bg-white border border-gray-200 rounded-xl p-6 wrapper; same radios, save feedback text SettingsAiTab.vue: - Extracted from current SettingsView: the "AI configuration" section - Template: bg-white border border-gray-200 rounded-xl p-6; same copy ("AI provider and model are managed by your administrator.") SettingsCloudTab.vue: - Imports useCloudConnectionsStore; calls fetchConnections() in onMounted - PROVIDERS constant: [{key:'google_drive', label:'Google Drive', iconColor:'text-blue-500'}, {key:'onedrive', label:'OneDrive', iconColor:'text-sky-500'}, {key:'nextcloud', label:'Nextcloud', iconColor:'text-orange-500'}, {key:'webdav', label:'WebDAV server', iconColor:'text-gray-500'}] - For each provider: renders row with icon + provider name + StatusBadge + action buttons - Status badge: inline pill span with classes per UI-SPEC status badge table - Action buttons per status: exact labels from UI-SPEC Copywriting Contract - "Connect {provider}" for OAuth providers: window.location.href = `/api/cloud/oauth/initiate/${provider.key}` - "Connect {provider}" for WebDAV/Nextcloud: opens CloudCredentialModal with showModal=true, activePro=provider - "Remove {provider}": calls store.disconnect(connection.id) with inline ConfirmBlock confirm pattern - "Reconnect {provider}": same as "Connect {provider}" - REQUIRES_REAUTH inline banner: per UI-SPEC Surface 2 exact markup (bg-yellow-50 border border-yellow-200) - "Disconnect all cloud storage" link at bottom: only when any connection ACTIVE or ERROR - Disconnect all ConfirmBlock: message, confirm label "Disconnect all", cancel "Keep all connected" CloudCredentialModal.vue: - Props: show (Boolean), provider (Object: {key, label}) - Emits: close, connected - Fields: serverUrl, username, authMethod (ref 'app_password'), password - On submit: calls api.connectWebDav(provider.key, serverUrl, username, password); emits 'connected'; closes - On error: shows connectError message (per UI-SPEC Surface 4) - Cancel label: "Keep current settings" - Save label: "Connect {provider.label}" - Escape key and overlay click close the modal (unless saving=true) - All Tailwind classes and layout per UI-SPEC Surface 4 exact specifications Create directories: frontend/src/components/settings/ and frontend/src/components/cloud/ if they don't exist. 1. Create frontend/src/components/settings/SettingsPreferencesTab.vue: Extract the pdf_open_mode section from current SettingsView.vue. Keep the `