- 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
534 lines
24 KiB
Vue
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>
|