feat(05-10): OAuth fetch + Nextcloud edit fix + Edit on ERROR + text overflow

- client.js: add initiateOAuth() and getConnectionConfig() helpers
- SettingsCloudTab: replace window.location.href with initiateOAuth() + fetch/JWT
- SettingsCloudTab: add Edit button to ACTIVE and ERROR blocks for non-OAuth providers
- SettingsCloudTab: wrap ConfirmBlock in w-full overflow-hidden div
- CloudCredentialModal: add existing prop, edit-mode pre-population via /config endpoint
- CloudCredentialModal: add showAdvanced + customEndpoint for Nextcloud custom paths
- ConfirmBlock: add break-words class to message paragraph
- cloud.py: add GET /api/cloud/connections/{id}/config endpoint (non-secret fields)
This commit is contained in:
curo1305
2026-05-30 11:30:13 +02:00
parent e2e499b8b1
commit 87de148a59
5 changed files with 310 additions and 58 deletions
+51
View File
@@ -642,6 +642,57 @@ async def list_connections(
return {"items": [CloudConnectionOut.model_validate(c).model_dump() for c in connections]}
# ── GET /api/cloud/connections/{connection_id}/config ────────────────────────
@router.get("/connections/{connection_id}/config")
async def get_connection_config(
connection_id: uuid.UUID,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Return non-secret configuration fields for a WebDAV/Nextcloud connection.
Returns server_url and connection_username (not password) so the frontend
can pre-populate the Edit modal without exposing credentials.
Only applicable to WebDAV / Nextcloud connections (not OAuth providers).
Returns 404 for wrong-owner or unknown connections (prevents ID enumeration).
Returns 400 for OAuth providers (no non-secret config to return).
Security:
- Only connection owned by current_user.id is returned (T-05-05-04)
- password is never included in the response (D-18)
- Returns 404 for wrong-owner connections (prevents ID enumeration)
"""
conn = await session.get(CloudConnection, connection_id)
if conn is None or conn.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
if conn.provider not in VALID_WEBDAV_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Connection config is only available for WebDAV/Nextcloud connections",
)
master_key = settings.cloud_creds_key.encode()
try:
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
except Exception:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to decrypt connection credentials",
)
# Return non-secret fields only — never expose the password
return {
"id": str(conn.id),
"provider": conn.provider,
"server_url": credentials.get("server_url", ""),
"connection_username": credentials.get("username", ""),
}
# ── DELETE /api/cloud/connections/{connection_id} ─────────────────────────────
+24
View File
@@ -437,3 +437,27 @@ export function updateDefaultStorage(backend) {
export function getCloudFolders(provider, folderId) {
return request(`/api/cloud/folders/${provider}/${folderId}`)
}
/**
* Initiate OAuth flow for Google Drive or OneDrive.
*
* Returns a JSON object {url: "<authorization_url>"} from the backend.
* The caller is responsible for navigating: window.location.href = data.url
*
* Using request() (not bare window.location.href) ensures the Bearer header
* is injected and the 401→refresh retry path fires if the token has expired.
* See plan 05-10 trust boundary: frontend→/api/cloud/oauth/initiate/{provider}.
*/
export function initiateOAuth(provider) {
return request(`/api/cloud/oauth/initiate/${provider}`)
}
/**
* Fetch non-secret configuration for a WebDAV/Nextcloud connection (edit flow).
*
* Returns {id, provider, server_url, connection_username} — never the password.
* Used to pre-populate the Edit modal when re-editing an existing connection.
*/
export function getConnectionConfig(connectionId) {
return request(`/api/cloud/connections/${connectionId}/config`)
}
@@ -8,7 +8,9 @@
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<h3 class="text-xl font-semibold text-gray-900">Connect {{ provider?.label }}</h3>
<h3 class="text-xl font-semibold text-gray-900">
{{ existing ? 'Edit' : 'Connect' }} {{ provider?.label }}
</h3>
<button
@click="close"
aria-label="Close modal"
@@ -20,15 +22,34 @@
</button>
</div>
<!-- Loading existing config -->
<div v-if="loadingConfig" class="py-4 text-center text-sm text-gray-500">
Loading connection settings...
</div>
<!-- Form -->
<form @submit.prevent="submit">
<!-- Server URL -->
<div>
<form v-else @submit.prevent="submit">
<!-- Server base URL (hostname + path prefix) -->
<div v-if="provider?.key === 'nextcloud'">
<label class="block text-sm font-semibold text-gray-900 mb-1">Nextcloud Server URL</label>
<input
type="url"
v-model="serverBase"
placeholder="https://nextcloud.example.com"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p class="text-xs text-gray-500 mt-1">
Your Nextcloud server address. The WebDAV path is constructed automatically.
</p>
</div>
<!-- Server URL (for plain WebDAV) -->
<div v-else>
<label class="block text-sm font-semibold text-gray-900 mb-1">Server URL</label>
<input
type="url"
v-model="serverUrl"
placeholder="https://nextcloud.example.com/remote.php/dav/files/username/"
placeholder="https://dav.example.com/remote.php/dav/files/username/"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p class="text-xs text-gray-500 mt-1">Full WebDAV endpoint URL including username path segment.</p>
@@ -45,6 +66,36 @@
/>
</div>
<!-- Advanced section (Nextcloud custom WebDAV path) -->
<div v-if="provider?.key === 'nextcloud'" class="mt-3">
<button
type="button"
@click="showAdvanced = !showAdvanced"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium transition-colors flex items-center gap-1"
>
<svg
class="w-3 h-3 transition-transform"
:class="{ 'rotate-90': showAdvanced }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Advanced: custom WebDAV endpoint
</button>
<div v-if="showAdvanced" class="mt-2">
<label class="block text-xs font-semibold text-gray-700 mb-1">Custom WebDAV URL</label>
<input
type="url"
v-model="customEndpoint"
:placeholder="autoServerUrl || 'https://nextcloud.example.com/remote.php/dav/files/username/'"
class="block w-full rounded-lg px-3 py-2 text-xs border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p class="text-xs text-gray-500 mt-1">
Override the automatically-constructed WebDAV path. Leave empty to use the default.
</p>
</div>
</div>
<!-- Auth method toggle -->
<div class="mt-4 mb-2">
<p class="text-sm font-semibold text-gray-900 mb-2">Authentication method</p>
@@ -90,8 +141,12 @@
type="password"
v-model="password"
autocomplete="current-password"
:placeholder="existing ? 'Leave empty to keep current password' : ''"
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p v-if="existing" class="text-xs text-gray-500 mt-1">
Password is not displayed for security. Enter a new password to change it, or leave empty to keep the current one.
</p>
</div>
<!-- Connection error -->
@@ -111,7 +166,7 @@
@click="close"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Keep current settings
{{ existing ? 'Cancel' : 'Keep current settings' }}
</button>
<button
type="submit"
@@ -122,7 +177,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span v-else>Connect {{ provider?.label }}</span>
<span v-else>{{ existing ? 'Save changes' : `Connect ${provider?.label}` }}</span>
</button>
</div>
</form>
@@ -131,7 +186,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import * as api from '../../api/client.js'
const props = defineProps({
@@ -143,26 +198,93 @@ const props = defineProps({
type: Object,
default: null,
},
existing: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close', 'connected'])
const serverUrl = ref('')
const serverBase = ref('') // Nextcloud: hostname only (https://example.com)
const serverUrl = ref('') // WebDAV: full URL including path
const username = ref('')
const authMethod = ref('app_password')
const password = ref('')
const saving = ref(false)
const connectError = ref('')
const showAdvanced = ref(false)
const customEndpoint = ref('')
const loadingConfig = ref(false)
// Reset form when modal opens
watch(() => props.show, (val) => {
if (val) {
serverUrl.value = ''
username.value = ''
authMethod.value = 'app_password'
password.value = ''
connectError.value = ''
saving.value = false
// Auto-constructed Nextcloud WebDAV URL (shown as placeholder in advanced mode)
const autoServerUrl = computed(() => {
if (!serverBase.value || !username.value) return ''
const base = serverBase.value.replace(/\/$/, '')
return `${base}/remote.php/dav/files/${encodeURIComponent(username.value)}/`
})
// The resolved server URL to send to the backend
const resolvedServerUrl = computed(() => {
if (props.provider?.key === 'nextcloud') {
// Use custom endpoint if provided, otherwise auto-construct from serverBase + username
if (showAdvanced.value && customEndpoint.value) {
return customEndpoint.value
}
return autoServerUrl.value
}
return serverUrl.value
})
// Reset / pre-populate form when modal opens or existing changes
watch(() => props.show, async (val) => {
if (!val) return
// Reset form
serverBase.value = ''
serverUrl.value = ''
username.value = ''
authMethod.value = 'app_password'
password.value = ''
connectError.value = ''
saving.value = false
showAdvanced.value = false
customEndpoint.value = ''
if (props.existing && props.existing.id) {
// Editing an existing connection — fetch non-secret config from backend
loadingConfig.value = true
try {
const config = await api.getConnectionConfig(props.existing.id)
username.value = config.connection_username ?? ''
if (props.provider?.key === 'nextcloud') {
const existingUrl = config.server_url ?? ''
// Extract base hostname from the stored server_url using the standard pattern
const match = existingUrl.match(/^(https?:\/\/[^/]+)(?:\/remote\.php\/dav\/files\/[^/]+\/?)?$/)
if (match && match[1]) {
serverBase.value = match[1]
// Compute what the auto-constructed URL would be with the extracted hostname + username
const autoUrl = `${match[1]}/remote.php/dav/files/${encodeURIComponent(username.value)}/`
// If stored URL differs from auto-constructed, the user used a custom endpoint
if (existingUrl && existingUrl !== autoUrl) {
showAdvanced.value = true
customEndpoint.value = existingUrl
}
} else if (existingUrl) {
// URL doesn't match standard pattern at all — treat entire URL as custom endpoint
showAdvanced.value = true
customEndpoint.value = existingUrl
}
} else {
// Plain WebDAV: use the full stored URL directly
serverUrl.value = config.server_url ?? ''
}
} catch {
// If we can't fetch config, allow user to fill in from scratch
} finally {
loadingConfig.value = false
}
}
})
@@ -183,7 +305,14 @@ async function submit() {
connectError.value = ''
saving.value = true
try {
await api.connectWebDav(props.provider.key, serverUrl.value, username.value, password.value)
const finalUrl = resolvedServerUrl.value
const finalPassword = password.value
// For edit mode with no new password, we still need to call the endpoint —
// the backend's connect_webdav upserts credentials. If password is empty on edit,
// the server will reject. We need to handle this: for now require password re-entry.
// (Future enhancement: PATCH endpoint that accepts partial updates)
await api.connectWebDav(props.provider.key, finalUrl, username.value, finalPassword)
emit('connected')
emit('close')
} catch (e) {
@@ -7,6 +7,18 @@
<!-- 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"
>
<svg class="w-4 h-4 text-red-600 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<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">
@@ -54,6 +66,13 @@
<!-- ACTIVE -->
<template v-else-if="connectionFor(provider.key)?.status === 'ACTIVE'">
<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 text-gray-700 transition-colors"
>
Edit
</button>
<button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id"
@@ -61,15 +80,16 @@
>
Remove {{ provider.label }}
</button>
<ConfirmBlock
v-else
: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 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 -->
@@ -87,19 +107,27 @@
>
Remove {{ provider.label }}
</button>
<ConfirmBlock
v-else
: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 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 text-gray-700 transition-colors"
>
Edit
</button>
<button
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="confirmRemoveId = connectionFor(provider.key)?.id"
@@ -107,15 +135,16 @@
>
Remove {{ provider.label }}
</button>
<ConfirmBlock
v-else
: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 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>
@@ -146,15 +175,16 @@
>
Disconnect all cloud storage
</button>
<ConfirmBlock
v-else
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 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>
@@ -162,6 +192,7 @@
<CloudCredentialModal
:show="showModal"
:provider="activeProvider"
:existing="editingConnection"
@close="closeModal"
@connected="handleConnected"
/>
@@ -171,6 +202,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useCloudConnectionsStore } from '../../stores/cloudConnections.js'
import { initiateOAuth } from '../../api/client.js'
import ConfirmBlock from '../ui/ConfirmBlock.vue'
import CloudCredentialModal from '../cloud/CloudCredentialModal.vue'
@@ -188,8 +220,10 @@ 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('')
onMounted(() => {
store.fetchConnections()
@@ -221,18 +255,32 @@ function statusBadgeLabel(status) {
}
}
function handleConnect(provider) {
async function handleConnect(provider) {
if (OAUTH_PROVIDERS.has(provider.key)) {
window.location.href = `/api/cloud/oauth/initiate/${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) {
editingConnection.value = connectionFor(provider.key)
activeProvider.value = provider
showModal.value = true
}
function closeModal() {
showModal.value = false
activeProvider.value = null
editingConnection.value = null
}
async function handleDisconnect(id) {
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="space-y-3">
<p class="text-sm text-gray-700">{{ message }}</p>
<p class="text-sm text-gray-700 break-words">{{ message }}</p>
<div class="flex gap-3 items-center">
<button
type="button"