- 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
326 lines
12 KiB
Vue
326 lines
12 KiB
Vue
<template>
|
|
<div
|
|
v-if="show"
|
|
class="fixed inset-0 bg-gray-900 bg-opacity-40 z-40 flex items-center justify-center p-4"
|
|
@click.self="handleOverlayClick"
|
|
@keydown.escape.window="handleEscape"
|
|
>
|
|
<div data-test="cloud-credential-modal-panel" class="bg-white rounded-xl shadow-xl w-full max-w-md p-6 max-h-[90vh] overflow-y-auto">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-5">
|
|
<h3 class="text-lg font-semibold text-gray-900">
|
|
{{ existing ? 'Edit' : 'Connect' }} {{ provider?.label }}
|
|
</h3>
|
|
<button
|
|
@click="close"
|
|
aria-label="Close modal"
|
|
class="text-gray-400 hover:text-gray-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
|
|
>
|
|
<AppIcon name="x" class="w-5 h-5" />
|
|
</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 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://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>
|
|
</div>
|
|
|
|
<!-- Username -->
|
|
<div>
|
|
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">Username</label>
|
|
<input
|
|
type="text"
|
|
v-model="username"
|
|
autocomplete="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"
|
|
/>
|
|
</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"
|
|
>
|
|
<AppIcon
|
|
name="chevronRight"
|
|
class="w-3 h-3 transition-transform"
|
|
:class="{ 'rotate-90': showAdvanced }"
|
|
/>
|
|
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>
|
|
<div class="space-y-2">
|
|
<label class="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
value="app_password"
|
|
v-model="authMethod"
|
|
class="mt-0.5 text-indigo-600 focus:ring-indigo-500"
|
|
/>
|
|
<div>
|
|
<span class="text-sm font-semibold text-gray-900">App password</span>
|
|
<span class="ml-2 bg-green-100 text-green-700 text-xs font-semibold px-1.5 py-0.5 rounded">Recommended</span>
|
|
<p class="text-xs text-gray-500 mt-0.5">
|
|
Can be revoked individually without changing your main account password.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
<label class="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
value="account_password"
|
|
v-model="authMethod"
|
|
class="mt-0.5 text-indigo-600 focus:ring-indigo-500"
|
|
/>
|
|
<div>
|
|
<span class="text-sm font-semibold text-gray-900">Account password</span>
|
|
<p class="text-xs text-gray-500 mt-0.5">
|
|
Simpler to set up, but revoking access requires changing your entire account password.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password field -->
|
|
<div>
|
|
<label class="block text-sm font-semibold text-gray-900 mb-1 mt-4">
|
|
{{ authMethod === 'app_password' ? 'App password' : 'Password' }}
|
|
</label>
|
|
<input
|
|
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 -->
|
|
<div
|
|
v-if="connectError"
|
|
class="mt-4 p-3 rounded-lg bg-red-50 border border-red-200"
|
|
>
|
|
<p class="text-sm font-semibold text-red-700">Connection failed</p>
|
|
<p class="text-sm text-red-600 mt-0.5">{{ connectError }}</p>
|
|
<p class="text-xs text-red-500 mt-1">Check that the server URL is correct, the credentials are valid, and the server allows WebDAV access from external clients.</p>
|
|
</div>
|
|
|
|
<!-- Footer buttons -->
|
|
<div class="flex justify-end gap-3 mt-6">
|
|
<button
|
|
type="button"
|
|
@click="close"
|
|
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
{{ existing ? 'Cancel' : 'Keep current settings' }}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
:disabled="saving"
|
|
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg disabled:opacity-50 transition-colors min-h-[44px] min-w-[80px]"
|
|
>
|
|
<svg v-if="saving" class="w-4 h-4 animate-spin mx-auto" fill="none" viewBox="0 0 24 24">
|
|
<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>{{ existing ? 'Save changes' : `Connect ${provider?.label}` }}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
import * as api from '../../api/client.js'
|
|
import AppIcon from '../ui/AppIcon.vue'
|
|
|
|
const props = defineProps({
|
|
show: {
|
|
type: Boolean,
|
|
required: true,
|
|
},
|
|
provider: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
existing: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits(['close', 'connected'])
|
|
|
|
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)
|
|
|
|
// 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
|
|
}
|
|
}
|
|
})
|
|
|
|
function close() {
|
|
if (saving.value) return
|
|
emit('close')
|
|
}
|
|
|
|
function handleOverlayClick() {
|
|
close()
|
|
}
|
|
|
|
function handleEscape() {
|
|
close()
|
|
}
|
|
|
|
async function submit() {
|
|
connectError.value = ''
|
|
saving.value = true
|
|
try {
|
|
if (props.existing && props.existing.id) {
|
|
// Edit existing connection — call the connection-ID update endpoint.
|
|
// Password is optional: omitting it preserves the current secret.
|
|
await api.updateWebDavCredentials(props.existing.id, {
|
|
serverUrl: resolvedServerUrl.value || undefined,
|
|
username: username.value || undefined,
|
|
password: password.value || undefined,
|
|
})
|
|
} else {
|
|
// New connection — always creates a fresh UUID row on the backend.
|
|
await api.connectWebDav(props.provider.key, resolvedServerUrl.value, username.value, password.value)
|
|
}
|
|
emit('connected')
|
|
emit('close')
|
|
} catch (e) {
|
|
connectError.value = e.message || 'Connection failed. Please check your credentials.'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|