Files
kite/frontend/src/stores/cloudConnections.js
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

341 lines
11 KiB
JavaScript

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as api from '../api/client.js'
/**
* Session-storage key for last visited folder per connection.
* Only folder navigation state is stored — never tokens or credentials.
*/
function folderSessionKey(connectionId) {
return `docuvault:cloud:folder:${connectionId}`
}
/**
* Valid health state values used by both browser compact status and Settings diagnostics.
* Translates server vocabulary to UI-actionable states (D-12).
*
* healthy — provider reachable and credentials valid
* requires_reauth — credentials expired or revoked; reconnect required (D-14/D-15)
* degraded — transient outage; credentials preserved, retry/reconnect allowed (D-15)
* unknown — not yet tested or health state unavailable
*/
const VALID_HEALTH_STATES = new Set(['healthy', 'requires_reauth', 'degraded', 'unknown'])
export function saveLastFolder(connectionId, folderId) {
try {
if (folderId && folderId !== 'root') {
sessionStorage.setItem(folderSessionKey(connectionId), folderId)
} else {
sessionStorage.removeItem(folderSessionKey(connectionId))
}
} catch {
// sessionStorage unavailable — ignore
}
}
export function loadLastFolder(connectionId) {
try {
return sessionStorage.getItem(folderSessionKey(connectionId)) || null
} catch {
return null
}
}
export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
const connections = ref([])
const loading = ref(false)
const error = ref(null)
// Active browse state (populated by CloudFolderView)
const selectedConnectionId = ref(null)
const browseItems = ref([])
const browseLoading = ref(false)
const browseError = ref(null)
/** Capabilities for the currently selected connection */
const capabilities = ref(null)
/** 'refreshing' | 'fresh' | 'stale' | null */
const folderFreshness = ref(null)
const lastRefreshedAt = ref(null)
/** 'cached' | 'cloud_only' | null */
const byteAvailability = ref(null)
/**
* D-12: Connection health state map — single source for both browser compact status
* and Settings full diagnostics.
*
* Key: connection UUID
* Value: { state: 'healthy'|'requires_reauth'|'degraded'|'unknown', error_code, error_message, checked_at }
*
* Neither the browser nor Settings translate server states independently —
* they read from this map only.
*/
const connectionHealth = ref({})
/**
* D-13 / D-14: Set of connection IDs that need a health retest.
* CloudFolderView reads this and performs the test after navigation settles.
* Cleared after the test is performed.
*/
const pendingHealthRetest = ref(new Set())
/**
* D-14: Flag set during reconnect so CloudFolderView refreshes the current folder
* after a successful reconnect without clearing cached browse items first.
*/
const reconnectRefreshPending = ref(false)
const selectedConnection = computed(() =>
connections.value.find(c => c.id === selectedConnectionId.value) ?? 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)
if (selectedConnectionId.value === id) {
selectedConnectionId.value = null
}
} 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 = []
}
/**
* Rename a connection's display_name via the API.
* Only updates the single returned connection in state.
*/
async function rename(id, displayName) {
const trimmed = displayName?.trim()
if (!trimmed) throw new Error('Display name cannot be empty')
const updated = await api.renameCloudConnection(id, trimmed)
const idx = connections.value.findIndex(c => c.id === id)
if (idx !== -1) {
connections.value[idx] = { ...connections.value[idx], ...updated }
}
return updated
}
/**
* Default display_name for a connection.
* If another connection shares the same provider, append a 4-char UUID suffix.
*/
function defaultDisplayName(connection) {
const providerLabel = {
google_drive: 'Google Drive',
onedrive: 'OneDrive',
nextcloud: 'Nextcloud',
webdav: 'WebDAV',
}[connection.provider] ?? connection.provider
const siblings = connections.value.filter(
c => c.provider === connection.provider && c.id !== connection.id
)
if (siblings.length > 0) {
return `${providerLabel} (${connection.id.slice(0, 4)})`
}
return providerLabel
}
function selectConnection(id) {
selectedConnectionId.value = id
browseItems.value = []
browseError.value = null
capabilities.value = null
folderFreshness.value = null
lastRefreshedAt.value = null
byteAvailability.value = null
}
function setBrowseState({ items, caps, freshness, refreshedAt, byteAvail, err }) {
if (err !== undefined) browseError.value = err
if (items !== undefined) browseItems.value = items
if (caps !== undefined) capabilities.value = caps
if (freshness !== undefined) folderFreshness.value = freshness
if (refreshedAt !== undefined) lastRefreshedAt.value = refreshedAt
if (byteAvail !== undefined) byteAvailability.value = byteAvail
}
/**
* D-12: Set the health state for a specific connection.
* Translates server health payload to the UI-actionable vocabulary.
* Both browser compact status and Settings diagnostics consume this.
*
* @param {string} id — connection UUID
* @param {{ state: string, error_code?: string, error_message?: string, checked_at?: string }} healthData
*/
function setConnectionHealth(id, healthData) {
const state = VALID_HEALTH_STATES.has(healthData?.state) ? healthData.state : 'unknown'
connectionHealth.value = {
...connectionHealth.value,
[id]: {
state,
error_code: healthData?.error_code ?? null,
error_message: healthData?.error_message ?? null,
checked_at: healthData?.checked_at ?? null,
},
}
}
/**
* D-13: Mark a connection as requiring a health retest after a credential failure.
* This is the complement of the no-probe-on-navigation rule:
* probes happen only after failures (here), not on ordinary navigation.
*
* @param {string} id — connection UUID
* @param {{ state: string, error_code?: string }} failureData
*/
function handleHealthFailure(id, failureData) {
// Record the failure state in the health map
setConnectionHealth(id, failureData)
// Schedule a retest (D-13: auto-test after credential failure)
pendingHealthRetest.value = new Set([...pendingHealthRetest.value, id])
}
/**
* D-14: Mark that a reconnect just completed and the current folder should refresh.
* Does NOT clear browse items — they stay visible as stale while the refresh runs.
*
* @param {string} id — connection UUID
*/
function markReconnectRefreshPending(id) {
reconnectRefreshPending.value = true
// Keep browse items visible but downgrade freshness to stale (D-14)
if (folderFreshness.value !== null) {
folderFreshness.value = 'stale'
}
}
/**
* D-14: Enter reconnecting state — preserve cached items as stale.
* Does NOT call selectConnection (which clears items).
*
* @param {string} id — connection UUID
*/
function markReconnecting(id) {
// D-14: cached metadata remains visible; downgrade freshness only
if (folderFreshness.value !== null) {
folderFreshness.value = 'stale'
}
// Set health to unknown while reconnect is in flight
connectionHealth.value = {
...connectionHealth.value,
[id]: { state: 'unknown', error_code: null, error_message: null, checked_at: null },
}
}
/**
* D-13: Explicitly test the health of a connection.
* Only called after connect/reconnect/failure — never as a navigation side effect.
*
* @param {string} id — connection UUID
* @returns {Promise<{ state: string }>}
*/
async function testConnection(id) {
try {
const result = await api.testCloudConnection(id)
const state = result?.state ?? 'unknown'
setConnectionHealth(id, { state, ...result })
// Clear pending retest flag for this connection
const next = new Set(pendingHealthRetest.value)
next.delete(id)
pendingHealthRetest.value = next
return result
} catch (e) {
const failState = { state: 'degraded', error_code: 'probe_error', error_message: e?.message ?? 'Health check failed' }
setConnectionHealth(id, failState)
return failState
}
}
/**
* D-13: Store-level reconnect handler.
* Marks reconnecting state (cached items preserved), then fetches updated connections.
*
* @param {string} id — connection UUID
*/
async function reconnect(id) {
markReconnecting(id)
// Re-fetch connection list to pick up updated status
await fetchConnections()
markReconnectRefreshPending(id)
}
/**
* D-12 / D-13: Connections with null or absent health are candidates for an initial test.
* The store exposes this as a computed so the view can decide when to run tests.
*/
const connectionsNeedingHealthCheck = computed(() =>
connections.value.filter(c => {
const h = connectionHealth.value[c.id]
return !h || h.state === undefined || h.state === 'unknown'
})
)
/**
* D-12: Retrieve the health state for a specific connection.
* Returns { state: 'unknown', ... } if no health data is available.
*
* @param {string} id — connection UUID
* @returns {{ state: string, error_code: string|null, error_message: string|null, checked_at: string|null }}
*/
function getConnectionHealth(id) {
return connectionHealth.value[id] ?? { state: 'unknown', error_code: null, error_message: null, checked_at: null }
}
return {
connections,
loading,
error,
selectedConnectionId,
selectedConnection,
browseItems,
browseLoading,
browseError,
capabilities,
folderFreshness,
lastRefreshedAt,
byteAvailability,
// D-12: health state map — single source for browser and Settings
connectionHealth,
// D-13: pending health retests (after credential failures)
pendingHealthRetest,
// D-14: reconnect refresh flag
reconnectRefreshPending,
// D-12: connections needing initial health check
connectionsNeedingHealthCheck,
fetchConnections,
disconnect,
disconnectAll,
rename,
defaultDisplayName,
selectConnection,
setBrowseState,
// D-12/D-13: health management
setConnectionHealth,
getConnectionHealth,
handleHealthFailure,
markReconnectRefreshPending,
markReconnecting,
testConnection,
reconnect,
}
})