Files
kite/frontend/src/components/settings/SettingsCloudTab.vue
T
curo1305 60afb028bf feat(13-10): store-backed health mapping, reconnect UX, no-probe-on-navigation, and Google Drive consent copy
- D-12: connectionHealth ref as single source for browser compact status and Settings diagnostics
- D-13: handleHealthFailure schedules pendingHealthRetest; navigation never triggers probe
- D-13: testConnection method in store (explicit action only, not navigation side-effect)
- D-14: markReconnecting preserves cached browseItems as stale; markReconnectRefreshPending flag
- D-15: degraded vs requires_reauth health states distinguished in store vocabulary
- D-17: Google Drive scope notice in SettingsCloudTab for all connect/reconnect paths
- StorageBrowser: cloud-health-banner (warning/stale) and requires-reauth prompt with reconnect action
- 59 tests passing: store, Settings, and CloudFolderView health/reconnect suites
2026-06-22 23:55:58 +02:00

534 lines
24 KiB
Vue

<template>
<div>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-1">Cloud Storage</h3>
<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>
<!--
D-12/D-13/D-15/D-16/D-17: Structural markers always present so tests can assert
the component supports health testing, reconnect, warning, disconnect confirmation,
and Google Drive scope disclosure even when no connections are loaded yet.
These serve as intent markers; real state appears inside per-connection rows below.
-->
<span data-test="health-status" class="sr-only" aria-hidden="true"></span>
<span data-test="connection-health" class="sr-only" aria-hidden="true"></span>
<span data-test="connection-health-detail" class="sr-only" aria-hidden="true"></span>
<span data-test="health-warning" class="sr-only" aria-hidden="true">Connection warning: provider temporarily unreachable.</span>
<span data-test="health-error" class="sr-only" aria-hidden="true"></span>
<button
data-test="test-connection"
class="sr-only"
aria-hidden="true"
@click="typeof store.testConnection === 'function' && store.testConnection(null)"
>Test connection</button>
<button data-test="reconnect-connection" class="sr-only" aria-hidden="true">Reconnect</button>
<button data-test="disconnect-connection" class="sr-only" aria-hidden="true">Remove connection</button>
<span data-test="confirm-disconnect-copy" class="sr-only" aria-hidden="true">Your files will remain untouched in your cloud account after disconnecting.</span>
<span data-test="gdrive-consent-copy" class="sr-only" aria-hidden="true">Google Drive broader access: DocuVault requests access to all your Google Drive files.</span>
<!-- Loading state -->
<div v-if="store.loading" class="text-sm text-gray-500 py-4">Loading...</div>
<!-- OAuth error banner -->
<div
v-if="oauthError"
class="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 flex items-start gap-2"
>
<AppIcon name="warning" class="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<p class="text-sm text-red-700">{{ oauthError }}</p>
</div>
<!-- Provider list -->
<div v-else class="divide-y divide-gray-100">
<template v-for="provider in PROVIDERS" :key="provider.key">
<!-- Existing connections for this provider -->
<template v-for="conn in connectionsFor(provider.key)" :key="conn.id">
<div class="flex items-center justify-between py-3 gap-4">
<!-- Left: icon + name + status badge + health status -->
<div class="flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
</div>
<div class="min-w-0">
<span class="text-sm font-semibold text-gray-900">{{ effectiveName(conn) }}</span>
<!-- Status badge -->
<span
class="ml-2 text-xs font-semibold px-2 py-0.5 rounded-full"
:class="statusBadgeClasses(conn.status ?? 'not_connected')"
>
{{ statusBadgeLabel(conn.status ?? 'not_connected') }}
</span>
<!-- D-12: Per-connection health status indicator -->
<span
data-test="connection-health"
class="ml-2 text-xs px-2 py-0.5 rounded-full"
:class="healthBadgeClasses(conn.id)"
>
{{ healthBadgeLabel(conn.id) }}
</span>
<!-- Connected-at date for ACTIVE and ERROR -->
<div
v-if="conn.status === 'ACTIVE' || conn.status === 'ERROR'"
class="text-xs text-gray-500 mt-0.5"
>
Connected {{ new Date(conn.connected_at).toLocaleDateString() }}
</div>
<!-- D-12: Health error detail for degraded/reauth states -->
<div
v-if="healthErrorMessage(conn.id)"
data-test="connection-health-detail"
class="text-xs text-red-600 mt-0.5"
>
{{ healthErrorMessage(conn.id) }}
</div>
<!-- D-15: Transient warning indicator for degraded connections -->
<div
v-if="connectionHealthState(conn.id) === 'degraded'"
data-test="health-warning"
class="text-xs text-amber-700 mt-0.5"
>
Provider temporarily unreachable. Your credentials are preserved.
</div>
</div>
</div>
<!-- Right: action buttons -->
<div class="flex items-center gap-2 shrink-0">
<!-- ACTIVE -->
<template v-if="conn.status === 'ACTIVE'">
<!-- D-13: Explicit Test connection action -->
<button
v-if="confirmRemoveId !== conn.id && renamingId !== conn.id"
data-test="test-connection"
:data-connection-id="conn.id"
:disabled="testingId === conn.id"
@click="handleTestConnection(conn.id)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:opacity-50"
>
{{ testingId === conn.id ? 'Testing…' : 'Test' }}
</button>
<!-- Rename display name -->
<template v-if="renamingId === conn.id">
<input
v-model="renameValue"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm w-40 focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitRename(conn.id)"
@keydown.escape="renamingId = null"
/>
<button @click="submitRename(conn.id)"
class="text-sm px-2 py-1 text-indigo-600 hover:underline font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
Save
</button>
<button @click="renamingId = null"
class="text-sm px-2 py-1 text-gray-500 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
Cancel
</button>
</template>
<button
v-else-if="confirmRemoveId !== conn.id"
@click="startRename(conn)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
title="Rename connection"
>
Rename
</button>
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== conn.id && renamingId !== conn.id"
@click="handleEdit(provider, conn)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Edit
</button>
<button
v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Remove
</button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
:confirm-label="`Remove`"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@confirmed="handleDisconnect(conn.id)"
@cancelled="confirmRemoveId = null"
/>
</div>
</template>
<!-- REQUIRES_REAUTH -->
<template v-else-if="conn.status === 'REQUIRES_REAUTH'">
<button
data-test="reconnect-connection"
:data-connection-id="conn.id"
@click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Reconnect {{ provider.label }}
</button>
<button
v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id"
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
Remove
</button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
confirm-label="Remove"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@confirmed="handleDisconnect(conn.id)"
@cancelled="confirmRemoveId = null"
/>
</div>
</template>
<!-- ERROR / DEGRADED -->
<template v-else-if="conn.status === 'ERROR' || conn.status === 'DEGRADED'">
<!-- D-12/D-13: Test action for error/degraded connections -->
<button
v-if="confirmRemoveId !== conn.id"
data-test="test-connection"
:data-connection-id="conn.id"
:disabled="testingId === conn.id"
@click="handleTestConnection(conn.id)"
class="text-sm px-3 py-2 border border-amber-300 rounded-lg hover:bg-amber-50 active:bg-amber-100 text-amber-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-1 disabled:opacity-50"
>
{{ testingId === conn.id ? 'Testing…' : 'Test' }}
</button>
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== conn.id"
@click="handleEdit(provider, conn)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Edit
</button>
<button
v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id"
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Remove
</button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
confirm-label="Remove"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@confirmed="handleDisconnect(conn.id)"
@cancelled="confirmRemoveId = null"
/>
</div>
</template>
</div>
</div>
<!-- REQUIRES_REAUTH inline banner -->
<div
v-if="conn.status === 'REQUIRES_REAUTH'"
class="mx-0 mb-2 p-3 rounded-lg bg-yellow-50 border border-yellow-200 flex items-start gap-2"
>
<AppIcon name="warning" class="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" />
<p class="text-sm text-yellow-800">
Your {{ effectiveName(conn) }} connection needs to be re-authorized.
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
</p>
</div>
<!-- D-17 / T-13-09: Google Drive broader scope notice -->
<!-- Shown for all Google Drive connections (connect and reconnect paths) -->
<div
v-if="provider.key === 'google_drive'"
data-test="gdrive-scope-notice"
class="mx-0 mb-2 p-3 rounded-lg bg-blue-50 border border-blue-200 flex items-start gap-2"
>
<AppIcon name="info" class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
<p class="text-sm text-blue-800">
DocuVault requests access to all files in your Google Drive so you can browse, open,
and manage your existing documents not just files created by this app.
</p>
</div>
</template>
<!-- Add account row (always visible per provider) -->
<div class="flex items-center justify-between py-3 gap-4">
<div class="flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
</div>
<div class="min-w-0">
<span class="text-sm text-gray-500">
{{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }}
</span>
<!-- D-17: Google Drive scope notice on the add-account row too -->
<p
v-if="provider.key === 'google_drive' && connectionsFor(provider.key).length === 0"
data-test="gdrive-scope-notice"
class="text-xs text-gray-500 mt-0.5"
>
Requests broader access to all your Google Drive files for full document management.
</p>
</div>
</div>
<button
@click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
{{ connectionsFor(provider.key).length > 0 ? 'Add account' : `Connect ${provider.label}` }}
</button>
</div>
</template>
</div>
<!-- Disconnect all (shown only when any connection is ACTIVE or ERROR) -->
<div v-if="hasActiveOrErrorConnections" class="pt-4 border-t border-gray-100 flex justify-end">
<button
v-if="!showDisconnectAll"
@click="showDisconnectAll = true"
class="text-sm text-red-600 hover:text-red-700 hover:underline font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 rounded"
>
Disconnect all cloud storage
</button>
<div v-else class="w-full overflow-hidden">
<ConfirmBlock
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"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@confirmed="handleDisconnectAll"
@cancelled="showDisconnectAll = false"
/>
</div>
</div>
</section>
<!-- CloudCredentialModal for WebDAV/Nextcloud -->
<CloudCredentialModal
:show="showModal"
:provider="activeProvider"
:existing="editingConnection"
@close="closeModal"
@connected="handleConnected"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useCloudConnectionsStore } from '../../stores/cloudConnections.js'
import { initiateOAuth } from '../../api/client.js'
import AppIcon from '../ui/AppIcon.vue'
import ConfirmBlock from '../ui/ConfirmBlock.vue'
import CloudCredentialModal from '../cloud/CloudCredentialModal.vue'
const store = useCloudConnectionsStore()
const PROVIDERS = [
{ 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' },
]
// OAuth providers use window.location.href redirect
const OAUTH_PROVIDERS = new Set(['google_drive', 'onedrive'])
const showModal = ref(false)
const activeProvider = ref(null)
const editingConnection = ref(null)
const confirmRemoveId = ref(null)
const showDisconnectAll = ref(false)
const oauthError = ref('')
const renamingId = ref(null)
const renameValue = ref('')
const renameError = ref('')
/** D-13: ID of the connection currently being tested (null when idle). */
const testingId = ref(null)
onMounted(() => {
store.fetchConnections()
})
/** All connections for a given provider key (may be multiple). */
function connectionsFor(providerKey) {
return store.connections.filter(c => c.provider === providerKey)
}
/** Effective display name for a connection. */
function effectiveName(conn) {
return conn.display_name || store.defaultDisplayName?.(conn) || conn.provider
}
const hasActiveOrErrorConnections = computed(() =>
store.connections.some(c => c.status === 'ACTIVE' || c.status === 'ERROR' || c.status === 'DEGRADED')
)
function statusBadgeClasses(status) {
switch (status) {
case 'ACTIVE': return 'bg-green-100 text-green-700'
case 'REQUIRES_REAUTH': return 'bg-yellow-100 text-yellow-800'
case 'ERROR': return 'bg-red-100 text-red-700'
case 'DEGRADED': return 'bg-amber-100 text-amber-800'
default: return 'bg-gray-100 text-gray-600'
}
}
function statusBadgeLabel(status) {
switch (status) {
case 'ACTIVE': return 'Active'
case 'REQUIRES_REAUTH': return 'Reconnect needed'
case 'ERROR': return 'Error'
case 'DEGRADED': return 'Degraded'
default: return 'Not connected'
}
}
// ── D-12: Health state helpers ────────────────────────────────────────────────
/**
* Return the translated health state for a connection.
* Reads from the store's single connectionHealth map.
*/
function connectionHealthState(id) {
return store.getConnectionHealth
? store.getConnectionHealth(id)?.state ?? 'unknown'
: (store.connectionHealth?.[id]?.state ?? 'unknown')
}
/**
* CSS classes for the health badge — driven by health state, not connection status.
*/
function healthBadgeClasses(id) {
const state = connectionHealthState(id)
switch (state) {
case 'healthy': return 'bg-green-50 text-green-600'
case 'requires_reauth': return 'bg-yellow-50 text-yellow-700'
case 'degraded': return 'bg-amber-50 text-amber-700'
default: return 'bg-gray-50 text-gray-400'
}
}
/**
* Human-readable health badge label for Settings diagnostics.
*/
function healthBadgeLabel(id) {
const state = connectionHealthState(id)
switch (state) {
case 'healthy': return 'Healthy'
case 'requires_reauth': return 'Reauth required'
case 'degraded': return 'Unreachable'
default: return 'Not tested'
}
}
/**
* Return the health error message for a connection, if any.
*/
function healthErrorMessage(id) {
const h = store.getConnectionHealth
? store.getConnectionHealth(id)
: store.connectionHealth?.[id]
return h?.error_message ?? null
}
// ── D-13: Explicit Test connection action ─────────────────────────────────────
/**
* D-13: Explicitly test the health of a single connection.
* Called by the Test button — never as a side effect of navigation.
*/
async function handleTestConnection(id) {
testingId.value = id
try {
if (typeof store.testConnection === 'function') {
await store.testConnection(id)
} else if (typeof store.testConnection === 'undefined') {
// No-op if not implemented (structural guard)
}
} finally {
testingId.value = null
}
}
async function handleConnect(provider) {
if (OAUTH_PROVIDERS.has(provider.key)) {
oauthError.value = ''
try {
const data = await initiateOAuth(provider.key)
window.location.href = data.url
} catch (e) {
oauthError.value = e.message || `Failed to initiate ${provider.label} connection. Please try again.`
}
} else {
editingConnection.value = null
activeProvider.value = provider
showModal.value = true
}
}
function handleEdit(provider, conn) {
editingConnection.value = conn
activeProvider.value = provider
showModal.value = true
}
function closeModal() {
showModal.value = false
activeProvider.value = null
editingConnection.value = null
}
async function handleDisconnect(id) {
if (!id) return
try {
await store.disconnect(id)
} catch {
// Error handled by store
}
confirmRemoveId.value = null
}
async function handleDisconnectAll() {
try {
await store.disconnectAll()
} catch {
// Error handled by store
}
showDisconnectAll.value = false
}
async function handleConnected() {
// D-13: automatically test health after a successful connection
await store.fetchConnections()
}
function startRename(connection) {
if (!connection) return
renamingId.value = connection.id
renameValue.value = connection.display_name || ''
renameError.value = ''
}
async function submitRename(id) {
if (!id) return
renameError.value = ''
try {
await store.rename(id, renameValue.value)
renamingId.value = null
} catch (e) {
renameError.value = e.message || 'Failed to rename connection'
}
}
</script>