feat(12-06): connection-ID native lifecycle — always INSERT, PUT credentials, connectionsFor multi-account

- Replace _upsert_cloud_connection with _insert_cloud_connection (always inserts new UUID row)
- Add PUT /connections/{id}/credentials endpoint (owner-scoped, SSRF + health-check, password-preserve)
- SettingsCloudTab: connectionsFor() renders all same-provider connections with Add account row
- CloudCredentialModal.submit: calls updateWebDavCredentials on edit, connectWebDav on create
- utils.js: FastAPI validation arrays normalised to concise field messages (Rule 2)
- Backend tests: same-provider independence + IDOR negative test for credential update
This commit is contained in:
curo1305
2026-06-21 22:29:59 +02:00
parent 97c30c3a15
commit 731b65ecdd
7 changed files with 405 additions and 192 deletions
@@ -19,165 +19,174 @@
<!-- Provider list -->
<div v-else class="divide-y divide-gray-100">
<template v-for="provider in PROVIDERS" :key="provider.key">
<!-- Provider row -->
<!-- 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 -->
<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>
<!-- 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>
</div>
</div>
<!-- Right: action buttons -->
<div class="flex items-center gap-2 shrink-0">
<!-- ACTIVE -->
<template v-if="conn.status === 'ACTIVE'">
<!-- 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"
@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">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your cloud documents will remain 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
@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"
@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">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`"
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 -->
<template v-else-if="conn.status === 'ERROR'">
<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"
@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">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`"
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>
</template>
<!-- Add account row (always visible per provider) -->
<div class="flex items-center justify-between py-3 gap-4">
<!-- Left: icon + name + status badge -->
<div class="flex items-center gap-3 min-w-0">
<!-- Provider icon -->
<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">{{ provider.label }}</span>
<!-- Status badge -->
<span
class="ml-2 text-xs font-semibold px-2 py-0.5 rounded-full"
:class="statusBadgeClasses(connectionFor(provider.key)?.status ?? 'not_connected')"
>
{{ statusBadgeLabel(connectionFor(provider.key)?.status ?? 'not_connected') }}
</span>
<!-- Connected-at date for ACTIVE and ERROR -->
<div
v-if="connectionFor(provider.key)?.status === 'ACTIVE' || connectionFor(provider.key)?.status === 'ERROR'"
class="text-xs text-gray-500 mt-0.5"
>
Connected {{ new Date(connectionFor(provider.key).connected_at).toLocaleDateString() }}
</div>
</div>
<span class="text-sm text-gray-500">
{{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }}
</span>
</div>
<!-- Right: action buttons -->
<div class="flex items-center gap-2 shrink-0">
<!-- not_connected -->
<template v-if="!connectionFor(provider.key)">
<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 min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Connect {{ provider.label }}
</button>
</template>
<!-- ACTIVE -->
<template v-else-if="connectionFor(provider.key)?.status === 'ACTIVE'">
<!-- Rename display name -->
<template v-if="renamingId === connectionFor(provider.key)?.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(connectionFor(provider.key)?.id)"
@keydown.escape="renamingId = null"
/>
<button @click="submitRename(connectionFor(provider.key)?.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 !== connectionFor(provider.key)?.id"
@click="startRename(connectionFor(provider.key))"
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 !== connectionFor(provider.key)?.id && renamingId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)"
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 !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.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 {{ provider.label }}
</button>
<div v-if="confirmRemoveId === connectionFor(provider.key)?.id" class="w-full overflow-hidden">
<ConfirmBlock
:message="`This will permanently remove your ${provider.label} credentials from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
:confirm-label="`Remove ${provider.label}`"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@confirmed="handleDisconnect(connectionFor(provider.key)?.id)"
@cancelled="confirmRemoveId = null"
/>
</div>
</template>
<!-- REQUIRES_REAUTH -->
<template v-else-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'">
<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 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 !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.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 {{ provider.label }}
</button>
<div v-if="confirmRemoveId === connectionFor(provider.key)?.id" class="w-full overflow-hidden">
<ConfirmBlock
:message="`This will permanently remove your ${provider.label} credentials from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
:confirm-label="`Remove ${provider.label}`"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@confirmed="handleDisconnect(connectionFor(provider.key)?.id)"
@cancelled="confirmRemoveId = null"
/>
</div>
</template>
<!-- ERROR -->
<template v-else-if="connectionFor(provider.key)?.status === 'ERROR'">
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)"
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 !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.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 {{ provider.label }}
</button>
<div v-if="confirmRemoveId === connectionFor(provider.key)?.id" class="w-full overflow-hidden">
<ConfirmBlock
:message="`This will permanently remove your ${provider.label} credentials from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
:confirm-label="`Remove ${provider.label}`"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@confirmed="handleDisconnect(connectionFor(provider.key)?.id)"
@cancelled="confirmRemoveId = null"
/>
</div>
</template>
</div>
</div>
<!-- REQUIRES_REAUTH inline banner -->
<div
v-if="connectionFor(provider.key)?.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 {{ provider.label }} connection needs to be re-authorized.
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
</p>
<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>
@@ -249,8 +258,14 @@ onMounted(() => {
store.fetchConnections()
})
function connectionFor(providerKey) {
return store.connections.find(c => c.provider === providerKey) ?? null
/** 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(() =>
@@ -291,8 +306,8 @@ async function handleConnect(provider) {
}
}
function handleEdit(provider) {
editingConnection.value = connectionFor(provider.key)
function handleEdit(provider, conn) {
editingConnection.value = conn
activeProvider.value = provider
showModal.value = true
}