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
This commit is contained in:
curo1305
2026-06-22 23:55:58 +02:00
parent a77b7324aa
commit 60afb028bf
5 changed files with 473 additions and 14 deletions
@@ -4,6 +4,28 @@
<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>
@@ -22,7 +44,7 @@
<!-- 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 -->
<!-- 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" />
@@ -36,6 +58,14 @@
>
{{ 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'"
@@ -43,6 +73,22 @@
>
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>
@@ -50,6 +96,17 @@
<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
@@ -84,14 +141,16 @@
</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">
<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 cloud documents will remain in your ${provider.label} account.`"
: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"
@@ -104,6 +163,8 @@
<!-- 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"
>
@@ -111,14 +172,16 @@
</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">
<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.`"
: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"
@@ -128,8 +191,19 @@
</div>
</template>
<!-- ERROR -->
<template v-else-if="conn.status === 'ERROR'">
<!-- 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)"
@@ -139,14 +213,16 @@
</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">
<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.`"
: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"
@@ -169,6 +245,20 @@
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) -->
@@ -177,9 +267,19 @@
<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>
<span class="text-sm text-gray-500">
{{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }}
</span>
<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)"
@@ -253,6 +353,8 @@ 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()
@@ -269,7 +371,7 @@ function effectiveName(conn) {
}
const hasActiveOrErrorConnections = computed(() =>
store.connections.some(c => c.status === 'ACTIVE' || c.status === 'ERROR')
store.connections.some(c => c.status === 'ACTIVE' || c.status === 'ERROR' || c.status === 'DEGRADED')
)
function statusBadgeClasses(status) {
@@ -277,6 +379,7 @@ function statusBadgeClasses(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'
}
}
@@ -286,10 +389,78 @@ function statusBadgeLabel(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 = ''
@@ -338,6 +509,7 @@ async function handleDisconnectAll() {
}
async function handleConnected() {
// D-13: automatically test health after a successful connection
await store.fetchConnections()
}