chore: bunch of improvements
JS Unit Tests / test (pull_request) Successful in 33s
Build Test / test (pull_request) Successful in 36s
PHP Unit Tests / test (pull_request) Successful in 1m12s

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-04-23 22:00:50 -04:00
parent b617234b40
commit 3362afb7ec
28 changed files with 1717 additions and 1297 deletions
+147 -104
View File
@@ -1,15 +1,16 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useProvidersStore } from '@MailManager/stores/providersStore'
import type { ProviderDiscoveryStatus, ServiceLocation, ServiceIdentity } from '@MailManager/types'
import type { ServiceObject } from '@MailManager/models/service'
import DiscoveryStatusStep from '@MailManager/components/steps/DiscoveryStatusStep.vue'
import ProviderSelectionStep from '@MailManager/components/steps/ProviderSelectionStep.vue'
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue'
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue'
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue'
import DiscoveryEntryStep from '@MailManager/components/steps/DiscoveryEntryStep.vue'
import { ServiceObject, type ProviderObject } from '@MailManager/models'
import type { ProviderDiscoveryStatus, ServiceInterface, ServiceLocation } from '@MailManager/types'
import DiscoveryEntryPanel from '@MailManager/components/steps/DiscoveryEntryPanel.vue'
import DiscoveryStatusPanel from '@MailManager/components/steps/DiscoveryStatusPanel.vue'
import ProviderSelectionPanel from '@MailManager/components/steps/ProviderSelectionPanel.vue'
import ProviderProtocolPanel from '@MailManager/components/steps/ProviderProtocolPanel.vue'
import ProviderAuthPanel from '@MailManager/components/steps/ProviderAuthPanel.vue'
import TestAndSavePanel from '@MailManager/components/steps/TestAndSavePanel.vue'
// ==================== Step Constants ====================
// Discovery flow: Entry → Discovery → Auth → Test
@@ -38,6 +39,7 @@ const emit = defineEmits<{
'saved': []
}>()
const integrationStore = useIntegrationStore()
const servicesStore = useServicesStore()
const providersStore = useProvidersStore()
@@ -56,19 +58,10 @@ const discoverSecret = ref<string | null>(null)
const discoverHostname = ref<string | null>(null)
// Step 2: Discovery Status / Provider Selection
const selectedProviderId = ref<string | undefined>(undefined)
const selectedProviderLabel = ref<string>('')
// Step 3: Config (manual only) OR Auth (both paths)
const configuredLocation = ref<ServiceLocation | null>(null)
// Step 4: Auth (both paths)
const configuredIdentity = ref<ServiceIdentity | null>(null)
const authValid = ref(false)
const selectedProvider = ref<ProviderObject | null>(null)
const selectedService = ref<ServiceObject | null>(null)
// Step 5: Test & Save
const accountLabel = ref<string>('')
const accountEnabled = ref(true)
const testAndSaveValid = ref(false)
// Local discovery state (not stored in global store)
@@ -137,19 +130,61 @@ const showSaveButton = computed(() => {
const canProceedToNext = computed(() => {
if (isManualMode.value) {
if (currentStep.value === MANUAL_STEPS.CONFIG) {
return !!configuredLocation.value
return !!selectedService.value?.location
}
if (currentStep.value === MANUAL_STEPS.AUTH) {
return authValid.value
return !!selectedService.value?.identity
}
} else {
if (currentStep.value === DISCOVERY_STEPS.AUTH) {
return authValid.value
return !!selectedService.value?.identity
}
}
return false
})
function createServiceObject(
providerId: string,
data: Partial<ServiceInterface> = {}
): ServiceObject {
const model: ServiceInterface = {
'@type': 'mail:service',
version: 1,
provider: providerId,
identifier: null,
label: data.label ?? null,
enabled: data.enabled ?? true,
primaryAddress: data.primaryAddress ?? (discoverAddress.value || null),
secondaryAddresses: data.secondaryAddresses ?? null,
location: data.location ?? null,
identity: data.identity ?? null,
capabilities: data.capabilities ?? {},
auxiliary: data.auxiliary ?? {}
}
const factoryItem = integrationStore.getItemById('mail_service_factory', providerId) as any
const factory = factoryItem?.factory
return factory ? factory(model) : new ServiceObject().fromJson(model)
}
function setSelectedProviderAndService(providerId: string, service: ServiceObject) {
selectedProvider.value = providersStore.provider(providerId)
selectedService.value = service
testAndSaveValid.value = false
}
function handleServiceUpdate(service: ServiceObject) {
selectedService.value = service
}
function handleServiceTested(success: boolean) {
testAndSaveValid.value = success
}
watch(selectedService, () => {
testAndSaveValid.value = false
}, { deep: true })
// Navigation methods
function handlePreviousStep() {
if (currentStep.value > 1) {
@@ -259,12 +294,18 @@ function extractLocationMetadata(location: ServiceLocation) {
async function handleProviderSelect(identifier: string) {
// User clicked "Select" on discovered provider - skip config, go to auth
const service = discoveredServices.value.find(s => s.provider === identifier)
if (!service || !service.location) return
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
configuredLocation.value = service.location
const discovered = discoveredServices.value.find(s => s.provider === identifier)
if (!discovered || !discovered.location) return
const discoveredJson = discovered.toJson()
const service = createServiceObject(identifier, {
...discoveredJson,
label: discoveredJson.label || discoverAddress.value,
enabled: discoveredJson.enabled ?? true,
primaryAddress: discoveredJson.primaryAddress || discoverAddress.value,
location: discoveredJson.location
})
setSelectedProviderAndService(identifier, service)
// Discovery path: Entry → Discovery → Auth → Test
currentStep.value = DISCOVERY_STEPS.AUTH // Go to auth step
@@ -272,11 +313,17 @@ async function handleProviderSelect(identifier: string) {
function handleProviderAdvanced(identifier: string) {
// User clicked "Advanced" - show manual config with pre-filled values
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
const service = discoveredServices.value.find(s => s.provider === identifier)
configuredLocation.value = service?.location || null
const discovered = discoveredServices.value.find(s => s.provider === identifier)
const discoveredJson = discovered?.toJson()
const service = createServiceObject(identifier, {
...discoveredJson,
label: discoveredJson?.label || discoverAddress.value,
enabled: discoveredJson?.enabled ?? true,
primaryAddress: discoveredJson?.primaryAddress || discoverAddress.value,
location: discoveredJson?.location ?? null
})
setSelectedProviderAndService(identifier, service)
isManualMode.value = true
// Manual path: Entry → Discovery → Config → Auth → Test
@@ -293,8 +340,15 @@ function handleManualMode() {
function handleProviderManualSelect(identifier: string) {
// User selected a provider in manual mode
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
const service = createServiceObject(identifier, {
label: discoverAddress.value,
enabled: true,
primaryAddress: discoverAddress.value,
location: null,
identity: null
})
setSelectedProviderAndService(identifier, service)
currentStep.value = MANUAL_STEPS.CONFIG // Go to manual config
}
@@ -303,10 +357,21 @@ function goBackToIdentity() {
isManualMode.value = false
discoveredServices.value = []
discoveryStatus.value = {}
selectedProvider.value = null
selectedService.value = null
testAndSaveValid.value = false
}
async function testConnection() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) {
if (!selectedProvider.value || !selectedService.value) {
return {
success: false,
message: 'Missing configuration'
}
}
const serviceData = selectedService.value.toJson()
if (!serviceData.location || !serviceData.identity) {
return {
success: false,
message: 'Missing configuration'
@@ -314,31 +379,35 @@ async function testConnection() {
}
const testResult = await servicesStore.test(
selectedProviderId.value,
selectedProvider.value.identifier,
null,
configuredLocation.value,
configuredIdentity.value
serviceData.location,
serviceData.identity
)
return testResult
}
async function saveAccount() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) return
if (!selectedProvider.value || !selectedService.value) return
const serviceData = selectedService.value.toJson()
if (!serviceData.location || !serviceData.identity) return
saving.value = true
try {
const accountData = {
label: accountLabel.value || discoverAddress.value,
email: discoverAddress.value,
enabled: accountEnabled.value,
location: configuredLocation.value,
identity: configuredIdentity.value
label: serviceData.label || discoverAddress.value,
primaryAddress: serviceData.primaryAddress || discoverAddress.value,
enabled: serviceData.enabled,
location: serviceData.location,
identity: serviceData.identity,
auxiliary: serviceData.auxiliary
}
await servicesStore.create(
selectedProviderId.value,
selectedProvider.value.identifier,
accountData
)
@@ -364,13 +433,8 @@ function resetForm() {
discoverAddress.value = ''
discoverSecret.value = null
discoverHostname.value = null
selectedProviderId.value = undefined
selectedProviderLabel.value = ''
configuredLocation.value = null
configuredIdentity.value = null
authValid.value = false
accountLabel.value = ''
accountEnabled.value = true
selectedProvider.value = null
selectedService.value = null
testAndSaveValid.value = false
discoveredServices.value = []
discoveryStatus.value = {}
@@ -407,7 +471,7 @@ function resetForm() {
<!-- Step 1: Discovery Entry -->
<template #item.1>
<v-card flat class="pa-6">
<DiscoveryEntryStep
<DiscoveryEntryPanel
v-model:address="discoverAddress"
v-model:secret="discoverSecret"
v-model:hostname="discoverHostname"
@@ -421,7 +485,7 @@ function resetForm() {
<template #item.2>
<v-card flat class="pa-6">
<!-- Discovery path -->
<DiscoveryStatusStep
<DiscoveryStatusPanel
v-if="!isManualMode"
:address="discoverAddress"
:status="discoveryStatus"
@@ -432,7 +496,7 @@ function resetForm() {
/>
<!-- Manual path - provider picker -->
<ProviderSelectionStep
<ProviderSelectionPanel
v-else
@select="handleProviderManualSelect"
@back="goBackToIdentity"
@@ -444,24 +508,18 @@ function resetForm() {
<template #item.3>
<v-card flat class="pa-6">
<!-- Manual path: Protocol Configuration -->
<ProviderConfigStep
v-if="isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:discovered-location="configuredLocation || undefined"
v-model="configuredLocation"
@valid="() => { /* Can proceed to next step */ }"
<ProviderProtocolPanel
v-if="isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
<ProviderAuthStep
v-else-if="!isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:provider-label="selectedProviderLabel"
:email-address="discoverAddress"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="discoverAddress"
:prefilled-secret="discoverSecret || undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
<ProviderAuthPanel
v-else-if="!isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
</v-card>
</template>
@@ -469,31 +527,21 @@ function resetForm() {
<!-- Step 4: Auth (manual) OR Test (discovery) -->
<template #item.4>
<v-card flat class="pa-6">
<ProviderAuthStep
v-if="isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:provider-label="selectedProviderLabel"
:email-address="discoverAddress"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="discoverAddress"
:prefilled-secret="discoverSecret || undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
<ProviderAuthPanel
v-if="isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
<!-- Discovery path: Test & Save -->
<TestAndSaveStep
v-else-if="!isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:provider-label="selectedProviderLabel"
:email-address="discoverAddress"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="discoverAddress"
<TestAndSavePanel
v-else-if="!isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
@update:service="handleServiceUpdate"
@tested="handleServiceTested"
/>
</v-card>
</template>
@@ -501,18 +549,13 @@ function resetForm() {
<!-- Step 5: Test & Save (manual only) -->
<template #item.5>
<v-card flat class="pa-6">
<TestAndSaveStep
v-if="selectedProviderId"
:provider-id="selectedProviderId"
:provider-label="selectedProviderLabel"
:email-address="discoverAddress"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="discoverAddress"
<TestAndSavePanel
v-if="selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
@update:service="handleServiceUpdate"
@tested="handleServiceTested"
/>
</v-card>
</template>
+194 -234
View File
@@ -2,17 +2,12 @@
import { ref, computed, watch } from 'vue'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useProvidersStore } from '@MailManager/stores/providersStore'
import type { ServiceLocation, ServiceIdentity } from '@MailManager/types'
import type { ServiceObject } from '@MailManager/models/service'
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue'
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue'
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue'
import type { ProviderObject, ServiceObject } from '@MailManager/models'
import ProviderProtocolPanel from '@MailManager/components/steps/ProviderProtocolPanel.vue'
import ProviderAuthPanel from '@MailManager/components/steps/ProviderAuthPanel.vue'
import TestAndSavePanel from '@MailManager/components/steps/TestAndSavePanel.vue'
const EDIT_STEPS = {
CONFIG: 1,
AUTH: 2,
TEST: 3
} as const
type EditTab = 'general' | 'protocol' | 'auth'
const props = defineProps<{
modelValue: boolean
@@ -33,146 +28,82 @@ const dialogOpen = computed({
set: (val) => emit('update:modelValue', val)
})
const currentStep = ref<number>(EDIT_STEPS.CONFIG)
const currentTab = ref<EditTab>('general')
const saving = ref(false)
const loading = ref(false)
const loadError = ref<string | null>(null)
// Service data
const service = ref<ServiceObject | null>(null)
const providerLabel = ref<string>('')
// Editable fields
const accountLabel = ref<string>('')
const accountEnabled = ref(true)
const configuredLocation = ref<ServiceLocation | null>(null)
const configuredIdentity = ref<ServiceIdentity | null>(null)
const localProvider = ref<ProviderObject | null>(null)
const localService = ref<ServiceObject | null>(null)
const mutated = ref(false)
// Validation states
const configValid = ref(false)
const authValid = ref(false)
const testAndSaveValid = ref(false)
// Load service data when dialog opens
watch(dialogOpen, async (isOpen) => {
if (isOpen) {
await loadService()
const tabItems = [
{
title: 'General',
icon: 'mdi-view-dashboard-outline',
value: 'general' as const
},
{
title: 'Protocol',
icon: 'mdi-tune-vertical',
value: 'protocol' as const
},
{
title: 'Authentication',
icon: 'mdi-shield-key-outline',
value: 'auth' as const
}
})
async function loadService() {
loading.value = true
try {
// Load providers if not already loaded
if (!providersStore.has) {
await providersStore.list()
}
// Fetch the service
service.value = await servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
// Set initial values
accountLabel.value = service.value.label || ''
accountEnabled.value = service.value.enabled
configuredLocation.value = service.value.location
configuredIdentity.value = service.value.identity
// Get provider label
const provider = providersStore.provider(props.serviceProvider)
providerLabel.value = provider?.label || props.serviceProvider
// Mark config as valid if location exists
configValid.value = !!configuredLocation.value
authValid.value = !!configuredIdentity.value
} catch (error) {
console.error('Failed to load service:', error)
} finally {
loading.value = false
}
}
// Stepper configuration
const stepperItems = [
{ title: 'Protocol', value: EDIT_STEPS.CONFIG },
{ title: 'Authentication', value: EDIT_STEPS.AUTH },
{ title: 'Test & Save', value: EDIT_STEPS.TEST }
]
const canSave = computed(() => {
return testAndSaveValid.value
})
// Navigation button visibility
const showPreviousButton = computed(() => currentStep.value > EDIT_STEPS.CONFIG)
const showNextButton = computed(() => currentStep.value < EDIT_STEPS.TEST)
const showSaveButton = computed(() => currentStep.value === EDIT_STEPS.TEST)
const showSaveButton = computed(() => currentTab.value === 'general')
const accountReady = computed(() => localProvider.value !== null && localService.value !== null)
const canProceedToNext = computed(() => {
if (currentStep.value === EDIT_STEPS.CONFIG) {
return configValid.value && !!configuredLocation.value
}
if (currentStep.value === EDIT_STEPS.AUTH) {
return authValid.value
}
return false
})
// Navigation methods
function handlePreviousStep() {
if (currentStep.value > EDIT_STEPS.CONFIG) {
currentStep.value--
}
}
function handleNextStep() {
if (currentStep.value < EDIT_STEPS.TEST) {
currentStep.value++
}
}
async function testConnection() {
if (!service.value || !configuredLocation.value || !configuredIdentity.value) {
return {
success: false,
message: 'Missing configuration'
// Load service data when the dialog is open and the target account is available.
watch(
() => [props.modelValue, props.serviceProvider, props.serviceIdentifier] as const,
async ([isOpen, serviceProvider, serviceIdentifier]) => {
if (!isOpen || !serviceProvider || !serviceIdentifier) {
return
}
await load()
},
{ immediate: true }
)
async function load() {
loading.value = true
loadError.value = null
localProvider.value = null
localService.value = null
if (!props.serviceProvider || !props.serviceIdentifier) {
console.error('[Mail Manager][Edit Account Dialog] - Cannot open dialog missing service or provider identifier')
loadError.value = 'missing service or provider identifier'
loading.value = false
return
}
const testResult = await servicesStore.test(
service.value.provider,
service.value.identifier,
configuredLocation.value,
configuredIdentity.value
)
return testResult
}
async function saveAccount() {
if (!service.value || !configuredLocation.value || !configuredIdentity.value) return
saving.value = true
try {
const accountData = {
label: accountLabel.value || service.value.label,
enabled: accountEnabled.value,
location: configuredLocation.value,
identity: configuredIdentity.value
}
const [provider, service] = await Promise.all([
providersStore.provider(props.serviceProvider) ?? providersStore.fetch(props.serviceProvider),
servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
])
await servicesStore.update(
service.value.provider,
service.value.identifier as string | number,
accountData
)
emit('saved')
close()
localProvider.value = provider.clone()
localService.value = service.clone()
} catch (error) {
console.error('Failed to save account:', error)
// TODO: Show error message to user
console.error('[Mail Manager][Edit Account Dialog] - Failed to load service:', error)
loadError.value = 'Failed to load service details'
} finally {
saving.value = false
loading.value = false
}
}
@@ -183,21 +114,89 @@ function close() {
}
function resetForm() {
currentStep.value = EDIT_STEPS.CONFIG
service.value = null
accountLabel.value = ''
accountEnabled.value = true
configuredLocation.value = null
configuredIdentity.value = null
configValid.value = false
authValid.value = false
currentTab.value = 'general'
localService.value = null
localProvider.value = null
loadError.value = null
testAndSaveValid.value = false
}
// Watch for location changes
watch(configuredLocation, (newLocation) => {
configValid.value = !!newLocation
})
function isTabDisabled(tab: EditTab) {
if (tab === 'auth') {
return !localService.value?.location
}
return false
}
function handleUpdate(mutatedService: ServiceObject) {
localService.value = mutatedService
mutated.value = true
}
async function testConnection() {
try {
let testResult = null
if (mutated.value) {
testResult = await servicesStore.test(
localService.value.provider,
null,
localService.value.location,
localService.value.identity
)
} else {
testResult = await servicesStore.test(
localService.value.provider,
localService.value.identifier
)
}
testAndSaveValid.value = testResult.success
return testResult
} catch (error) {
console.error('[Mail Manager][Edit Account Dialog] - Test connection failed:', error)
return {
success: false,
message: 'Test failed due to an unexpected error'
}
}
}
async function saveAccount() {
// No changes made, just close the dialog
if (!mutated.value) {
close()
return
}
if (!localService.value?.location || !localService.value?.identity) return
saving.value = true
try {
const accountData = {
label: accountLabel.value || localService.value.label,
enabled: accountEnabled.value,
location: localService.value.location,
identity: localService.value.identity
}
await servicesStore.update(
localService.value.provider,
localService.value.identifier as string | number,
accountData
)
emit('saved')
close()
} catch (error) {
console.error('[Mail Manager][Edit Account Dialog] - Failed to save service:', error)
// TODO: Show error message to user
} finally {
saving.value = false
}
}
</script>
<template>
@@ -219,105 +218,77 @@ watch(configuredLocation, (newLocation) => {
<v-divider />
<v-card-text v-if="loading" class="text-center py-8">
<v-card-text v-if="loading || (!loadError && !accountReady)" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">Loading account...</p>
</v-card-text>
<v-card-text v-else-if="loadError" class="pa-6">
<v-alert type="error" variant="tonal">
{{ loadError }}
</v-alert>
</v-card-text>
<v-card-text v-else class="pa-0">
<!-- Account Info Header -->
<div v-if="service" class="pa-6 bg-surface-variant">
<div class="d-flex align-center gap-3">
<v-avatar color="primary">
<v-icon>mdi-email</v-icon>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ service.label || 'Unnamed Account' }}
</div>
<div class="text-caption text-medium-emphasis">
{{ service.primaryAddress || service.identifier }}
</div>
<div class="text-caption text-medium-emphasis">
Provider: {{ providerLabel }}
</div>
</div>
</div>
</div>
<v-tabs
v-model="currentTab"
bg-color="transparent"
grow
class="px-4 pt-2"
>
<v-tab
v-for="item in tabItems"
:key="item.value"
:value="item.value"
:disabled="isTabDisabled(item.value)"
>
<v-icon start>{{ item.icon }}</v-icon>
{{ item.title }}
</v-tab>
</v-tabs>
<v-divider />
<v-stepper
v-model="currentStep"
:items="stepperItems"
alt-labels
flat
hide-actions
>
<!-- Step 1: Protocol Configuration -->
<template #item.1>
<v-window v-model="currentTab">
<v-window-item value="general">
<v-card flat class="pa-6">
<ProviderConfigStep
v-if="service"
:provider-id="service.provider"
:discovered-location="configuredLocation || undefined"
v-model="configuredLocation"
@valid="(valid) => configValid = valid"
/>
</v-card>
</template>
<!-- Step 2: Authentication -->
<template #item.2>
<v-card flat class="pa-6">
<ProviderAuthStep
v-if="service"
:provider-id="service.provider"
:provider-label="providerLabel"
:email-address="service.primaryAddress || ''"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="service.primaryAddress || ''"
:prefilled-secret="undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
/>
</v-card>
</template>
<!-- Step 3: Test & Save -->
<template #item.3>
<v-card flat class="pa-6">
<TestAndSaveStep
v-if="service"
:provider-id="service.provider"
:provider-label="providerLabel"
:email-address="service.primaryAddress || ''"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="accountLabel"
<TestAndSavePanel
v-if="localProvider && localService"
:provider="localProvider"
:service="localService"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
@update:service="handleUpdate"
/>
</v-card>
</template>
</v-stepper>
</v-window-item>
<v-window-item value="protocol">
<v-card flat class="pa-6">
<ProviderProtocolPanel
v-if="localProvider && localService"
:provider="localProvider"
:service="localService"
@update:service="handleUpdate"
/>
</v-card>
</v-window-item>
<v-window-item value="auth">
<v-card flat class="pa-6">
<ProviderAuthPanel
v-if="localProvider && localService"
:provider="localProvider"
:service="localService"
@update:service="handleUpdate"
/>
</v-card>
</v-window-item>
</v-window>
</v-card-text>
<v-divider />
<v-card-actions class="pa-6">
<!-- Previous Button -->
<v-btn
v-if="showPreviousButton"
variant="text"
prepend-icon="mdi-arrow-left"
@click="handlePreviousStep"
>
Previous
</v-btn>
<v-spacer />
<v-btn
@@ -326,18 +297,7 @@ watch(configuredLocation, (newLocation) => {
>
Cancel
</v-btn>
<!-- Next Button -->
<v-btn
v-if="showNextButton"
color="primary"
append-icon="mdi-arrow-right"
:disabled="!canProceedToNext"
@click="handleNextStep"
>
Next
</v-btn>
<!-- Save Button -->
<v-btn
v-if="showSaveButton"
@@ -36,6 +36,14 @@ const rules = {
required: (v: string) => !!v || 'Required',
email: (v: string) => /.+@.+\..+/.test(v) || 'Invalid email address'
}
function handleDiscoverOnEnter() {
if (!localAddress.value || rules.email(localAddress.value) !== true) {
return
}
emit('discover')
}
</script>
<template>
@@ -55,6 +63,7 @@ const rules = {
autocomplete="off"
:rules="[rules.required, rules.email]"
class="mb-4"
@keydown.enter.prevent="handleDiscoverOnEnter"
/>
<!-- Advanced Options -->
@@ -143,6 +152,8 @@ const rules = {
<v-btn
variant="text"
block
class="manual-action-btn"
prepend-icon="mdi-tune"
@click="$emit('manual')"
>
Manual Configuration
@@ -155,4 +166,8 @@ const rules = {
.gap-3 {
gap: 12px;
}
.manual-action-btn {
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ProviderDiscoveryStatus } from '@MailManager/types/service'
import type { ProviderDiscoveryStatus } from '@MailManager/types'
const props = defineProps<{
address: string
@@ -16,7 +16,7 @@ const emit = defineEmits<{
const sortedStatus = computed(() => {
const statusArray = Object.values(props.status)
const order = { success: 0, discovering: 1, pending: 2, failed: 3 }
const order: Record<string, number> = { success: 0, discovering: 1, pending: 2, failed: 3 }
return statusArray.sort((a, b) => order[a.status] - order[b.status])
})
@@ -191,6 +191,22 @@ function getProviderLabel(providerId: string): string {
</div>
</div>
</v-alert>
<div
v-if="!isDiscovering && successCount === 0"
class="mt-6"
>
<v-btn
size="large"
variant="text"
block
class="manual-action-btn"
prepend-icon="mdi-tune"
@click="$emit('manual')"
>
Manual Configuration
</v-btn>
</div>
</div>
</template>
@@ -217,4 +233,8 @@ function getProviderLabel(providerId: string): string {
.gap-3 {
gap: 12px;
}
.manual-action-btn {
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import type { Component } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceObject } from '@MailManager/models/service'
import type { ProviderObject } from '@MailManager/models/provider'
const props = defineProps<{
provider: ProviderObject
service: ServiceObject
}>()
const emit = defineEmits<{
'update:service': [value: ServiceObject]
}>()
// Local state
const integrationStore = useIntegrationStore()
const panelCache = new Map<string, Component>()
const panelLoading = ref(false)
const panelActive = shallowRef<Component | null>(null)
const localProvider = ref<ProviderObject>(props.provider)
const localService = ref<ServiceObject>(props.service)
// Local watchers
watch(
() => props.provider,
async (provider) => {
localProvider.value = provider
await loadProviderPanel()
}
)
watch(
() => props.service,
(service) => {
localService.value = service
}
)
watch(
() => [localProvider.value?.identifier, localService.value?.provider] as const,
async () => {
await loadProviderPanel()
},
{ immediate: true }
)
// Load provider panel
async function loadProviderPanel() {
const providerIdentifier = localProvider.value?.identifier || localService.value?.provider
if (!providerIdentifier) {
panelActive.value = null
panelLoading.value = false
return
}
// retrieve panel from cache if available
if (panelCache.has(providerIdentifier)) {
panelActive.value = panelCache.get(providerIdentifier) || null
panelLoading.value = false
return
}
panelLoading.value = true
// retrieve panel from integration store
const panel = integrationStore.getItems('mail_account_auth_panels').find((panel: any) => {
return panel.id === providerIdentifier || panel.id.endsWith(`.${providerIdentifier}`)
})
if (!panel?.component) {
console.warn(`No config panel found for provider ID: ${providerIdentifier}`)
panelActive.value = null
panelLoading.value = false
return
}
try {
const module = await panel.component()
const component = module.default || module
panelCache.set(providerIdentifier, component)
panelActive.value = component
} catch (error) {
console.error(`Failed to load panel for ${providerIdentifier}:`, error)
panelActive.value = null
} finally {
panelLoading.value = false
}
}
function handleUpdate(service: ServiceObject) {
localService.value = service
emit('update:service', localService.value)
}
</script>
<template>
<div class="provider-auth-panel">
<h3 class="text-h6 mb-2">Authentication</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure authentication for {{ localProvider?.label || 'this provider' }}.
</p>
<div v-if="panelLoading" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">
Loading authentication panel...
</p>
</div>
<v-alert v-else-if="!panelActive" type="error" variant="tonal">
<v-icon start>mdi-alert-circle</v-icon>
No authentication method available for this provider.
</v-alert>
<component
v-else
:is="panelActive"
:service="localService"
@update:service="handleUpdate"
/>
</div>
</template>
-163
View File
@@ -1,163 +0,0 @@
<template>
<div class="provider-auth-step">
<h3 class="text-h6 mb-2">Authentication</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure authentication for {{ providerLabel }}
</p>
<!-- Loading State -->
<div v-if="loadingPanel" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">
Loading authentication panel...
</p>
</div>
<!-- Dynamic Provider Auth Panel -->
<component
v-else-if="currentAuthPanel"
:is="currentAuthPanel"
:email-address="emailAddress"
:discovered-location="discoveredLocation"
:prefilled-identity="prefilledIdentity"
:prefilled-secret="prefilledSecret"
v-model="localIdentity"
@update:model-value="handleIdentityUpdate"
@valid="handleValidChange"
@error="handleAuthError"
/>
<!-- No Panel Available -->
<v-alert v-else type="error" variant="tonal">
<v-icon start>mdi-alert-circle</v-icon>
No authentication method available for this provider.
Please contact support.
</v-alert>
<!-- Error Display -->
<v-alert
v-if="authError"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="authError = ''"
>
<v-icon start>mdi-alert</v-icon>
{{ authError }}
</v-alert>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceIdentity, ServiceLocation } from '@MailManager/types/service'
const props = defineProps<{
providerId: string
providerLabel: string
emailAddress: string
discoveredLocation?: ServiceLocation
prefilledIdentity?: string
prefilledSecret?: string
modelValue?: ServiceIdentity | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: ServiceIdentity]
'valid': [value: boolean]
}>()
const integrationStore = useIntegrationStore()
const loadedPanels = new Map<string, any>()
const currentAuthPanel = ref<any>(null)
const loadingPanel = ref(false)
const localIdentity = ref<ServiceIdentity | undefined>(props.modelValue)
const authError = ref('')
// The full integration ID (e.g., "jmap")
const effectiveIntegrationId = computed(() => {
return props.providerId
})
// Load provider auth panel dynamically
async function loadAuthPanel(integrationId: string) {
if (loadedPanels.has(integrationId)) {
currentAuthPanel.value = loadedPanels.get(integrationId)
return
}
loadingPanel.value = true
// Try to find panel - integration IDs are prefixed with module handle
// so we need to search for panels that match the provider ID
const panels = integrationStore.getItems('mail_account_auth_panels')
const panelConfig = panels.find((panel: any) => {
// Check if the ID ends with the provider ID (e.g., "provider_jmapc.jmap" contains "jmap")
return panel.id === integrationId || panel.id.endsWith(`.${integrationId}`)
})
if (!panelConfig?.component) {
console.error(`No auth panel found for provider ID: ${integrationId}`)
console.error(`Available panels:`, panels.map((p: any) => p.id))
currentAuthPanel.value = null
loadingPanel.value = false
return
}
try {
const module = await panelConfig.component()
const component = module.default || module
loadedPanels.set(integrationId, component)
currentAuthPanel.value = component
} catch (error) {
console.error(`Failed to load auth panel for ${integrationId}:`, error)
currentAuthPanel.value = null
authError.value = `Failed to load authentication panel: ${error}`
} finally {
loadingPanel.value = false
}
}
// Load panel when provider changes
watch(
effectiveIntegrationId,
(newIntegrationId, oldIntegrationId) => {
if (newIntegrationId && newIntegrationId !== oldIntegrationId) {
loadAuthPanel(newIntegrationId)
}
},
{ immediate: true }
)
function handleIdentityUpdate(identity: ServiceIdentity) {
localIdentity.value = identity
emit('update:modelValue', identity)
}
function handleValidChange(valid: boolean) {
emit('valid', valid)
}
function handleAuthError(error: string) {
authError.value = error
}
// Watch for prop changes
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
localIdentity.value = newValue
}
}
)
</script>
<style scoped>
.provider-auth-step {
max-width: 800px;
}
</style>
-124
View File
@@ -1,124 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceLocation } from '@MailManager/types/service'
const props = defineProps<{
providerId: string
discoveredLocation?: ServiceLocation
modelValue?: ServiceLocation | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: ServiceLocation]
'valid': [value: boolean]
}>()
const integrationStore = useIntegrationStore()
const loadedPanels = new Map<string, any>()
const currentProviderPanel = ref<any>(null)
const loadingPanel = ref(false)
const localLocation = ref<ServiceLocation | undefined>(props.modelValue || props.discoveredLocation)
// The full integration ID (e.g., "provider_jmapc.jmap")
const effectiveIntegrationId = computed(() => {
return props.providerId
})
// Load provider panel dynamically using the integration ID
async function loadProviderPanel(integrationId: string) {
if (loadedPanels.has(integrationId)) {
currentProviderPanel.value = loadedPanels.get(integrationId)
return
}
loadingPanel.value = true
// Try to find panel - integration IDs are prefixed with module handle
// so we need to search for panels that match the provider ID
const panels = integrationStore.getItems('mail_account_config_panels')
const panelConfig = panels.find((panel: any) => {
// Check if the ID ends with the provider ID (e.g., "provider_jmapc.jmap" contains "jmap")
return panel.id === integrationId || panel.id.endsWith(`.${integrationId}`)
})
if (!panelConfig?.component) {
console.warn(`No config panel found for provider ID: ${integrationId}`)
console.warn(`Available panels:`, panels.map((p: any) => p.id))
currentProviderPanel.value = null
loadingPanel.value = false
return
}
try {
const module = await panelConfig.component()
const component = module.default || module
loadedPanels.set(integrationId, component)
currentProviderPanel.value = component
} catch (error) {
console.error(`Failed to load panel for ${integrationId}:`, error)
currentProviderPanel.value = null
} finally {
loadingPanel.value = false
}
}
watch(effectiveIntegrationId, (newIntegrationId, oldIntegrationId) => {
if (newIntegrationId && newIntegrationId !== oldIntegrationId) {
loadProviderPanel(newIntegrationId)
}
}, { immediate: true })
function handleLocationUpdate(location: ServiceLocation) {
localLocation.value = location
emit('update:modelValue', location)
// Emit valid when location is provided
emit('valid', !!location)
}
// Watch for prop changes
watch(() => props.modelValue, (newValue) => {
if (newValue) {
localLocation.value = newValue
}
})
watch(() => props.discoveredLocation, (newValue) => {
if (newValue && !props.modelValue) {
localLocation.value = newValue
emit('update:modelValue', newValue)
emit('valid', true)
}
})
</script>
<template>
<div class="provider-config-step">
<h3 class="text-h6 mb-2">Protocol Configuration</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure the connection settings for your mail service
</p>
<!-- Dynamic Provider Panel -->
<component
v-if="currentProviderPanel"
:is="currentProviderPanel"
v-model="localLocation"
:discovered-location="discoveredLocation"
@update:model-value="handleLocationUpdate"
/>
<!-- Loading state for panel -->
<div v-else-if="loadingPanel" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">Loading configuration panel...</p>
</div>
<!-- No panel available -->
<v-alert v-else type="info" variant="tonal">
<v-icon start>mdi-information</v-icon>
No configuration panel available for this provider
</v-alert>
</div>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import type { Component } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceObject } from '@MailManager/models/service'
import type { ProviderObject } from '@MailManager/models/provider'
const props = defineProps<{
provider: ProviderObject
service: ServiceObject
}>()
const emit = defineEmits<{
'update:service': [value: ServiceObject]
}>()
// Local state
const integrationStore = useIntegrationStore()
const panelCache = new Map<string, Component>()
const panelLoading = ref(false)
const panelActive = shallowRef<Component | null>(null)
const localProvider = ref<ProviderObject>(props.provider)
const localService = ref<ServiceObject>(props.service)
// Local watchers
watch(
() => props.provider,
async (provider) => {
localProvider.value = provider
await loadProviderPanel()
}
)
watch(
() => props.service,
(service) => {
localService.value = service
}
)
watch(
() => [localProvider.value?.identifier, localService.value?.provider] as const,
async () => {
await loadProviderPanel()
},
{ immediate: true }
)
// Load provider panel
async function loadProviderPanel() {
const providerIdentifier = localProvider.value?.identifier || localService.value?.provider
if (!providerIdentifier) {
panelActive.value = null
panelLoading.value = false
return
}
// retrieve panel from cache if available
if (panelCache.has(providerIdentifier)) {
panelActive.value = panelCache.get(providerIdentifier) || null
panelLoading.value = false
return
}
panelLoading.value = true
// retrieve panel from integration store
const panel = integrationStore.getItems('mail_account_protocol_panels').find((panel: any) => {
return panel.id === providerIdentifier || panel.id.endsWith(`.${providerIdentifier}`)
})
if (!panel?.component) {
console.warn(`No config panel found for provider ID: ${providerIdentifier}`)
panelActive.value = null
panelLoading.value = false
return
}
try {
const module = await panel.component()
const component = module.default || module
panelCache.set(providerIdentifier, component)
panelActive.value = component
} catch (error) {
console.error(`Failed to load panel for ${providerIdentifier}:`, error)
panelActive.value = null
} finally {
panelLoading.value = false
}
}
function handleUpdate(service: ServiceObject) {
localService.value = service
emit('update:service', localService.value)
}
</script>
<template>
<div class="provider-protocol-panel">
<h3 class="text-h6 mb-2">Protocol Configuration</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure authentication for {{ localProvider?.label || 'this provider' }}.
</p>
<div v-if="panelLoading" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">
Loading configuration panel...
</p>
</div>
<v-alert v-else-if="!panelActive" type="info" variant="tonal">
<v-icon start>mdi-information</v-icon>
No configuration panel available for this provider
</v-alert>
<component
v-else
:is="panelActive"
:service="localService"
@update:service="handleUpdate"
/>
</div>
</template>
@@ -1,3 +1,121 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ProviderObject, ServiceObject } from '@MailManager/models'
const props = defineProps<{
provider: ProviderObject
service: ServiceObject
onTest?: () => Promise<{ success: boolean; message: string; details?: any }>
}>()
const emit = defineEmits<{
'update:service': [value: ServiceObject]
'tested': [success: boolean]
}>()
// Local state
const localProvider = ref<ProviderObject>(props.provider)
const localService = ref<ServiceObject>(props.service)
const testing = ref(false)
const testResult = ref<any>(null)
// Computed
const testSuccess = computed(() => testResult.value?.success === true)
const serviceLocation = computed(() => localService.value.location?.toJson() ?? null)
const serviceIdentity = computed(() => localService.value.identity?.toJson() ?? null)
// Helper functions
function getAuthIcon(type?: string): string {
switch (type) {
case 'NA': return 'mdi-lock-open-variant'
case 'BA': return 'mdi-account-key'
case 'TA': return 'mdi-key'
case 'OA': return 'mdi-shield-account'
case 'CC': return 'mdi-certificate'
default: return 'mdi-help-circle'
}
}
function getAuthLabel(type?: string): string {
switch (type) {
case 'NA': return 'No Authentication'
case 'BA': return 'Username & Password'
case 'TA': return 'API Token'
case 'OA': return 'OAuth 2.0'
case 'CC': return 'Client Certificate'
default: return 'Unknown'
}
}
function formatCapabilities(capabilities: any): string {
if (!capabilities || typeof capabilities !== 'object') return 'N/A'
const caps = Object.entries(capabilities)
.filter(([_, value]) => value === true)
.map(([key]) => key)
.slice(0, 5)
const total = caps.length
const display = caps.slice(0, 3).join(', ')
if (total > 3) {
return `${display}, +${total - 3} more`
}
return display
}
async function handleTest() {
if (!localService.value.location || !localService.value.identity) {
testResult.value = {
success: false,
message: 'Missing configuration'
}
return
}
testing.value = true
testResult.value = null
try {
if (!props.onTest) {
throw new Error('No connection test callback provided')
}
const result = await props.onTest()
testResult.value = result
emit('tested', result.success)
} catch (error: any) {
testResult.value = {
success: false,
message: error.message || 'Connection test failed'
}
emit('tested', false)
} finally {
testing.value = false
}
}
// Watch for changes and emit
watch(localService, () => {
emit('update:service', localService.value)
}, { deep: true })
watch(
() => props.provider,
(provider) => {
localProvider.value = provider
}
)
watch(
() => props.service,
(service) => {
localService.value = service
testResult.value = null
}
)
</script>
<template>
<div class="test-and-save-step">
<h3 class="text-h6 mb-2">Test & Save</h3>
@@ -16,7 +134,7 @@
<v-icon>mdi-label</v-icon>
</template>
<v-list-item-title>Account Name</v-list-item-title>
<v-list-item-subtitle>{{ accountLabel }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localService.label || localService.primaryAddress || 'New Account' }}</v-list-item-subtitle>
</v-list-item>
<!-- Email Address -->
@@ -25,7 +143,7 @@
<v-icon>mdi-email</v-icon>
</template>
<v-list-item-title>Email Address</v-list-item-title>
<v-list-item-subtitle>{{ emailAddress }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localService.primaryAddress }}</v-list-item-subtitle>
</v-list-item>
<!-- Provider -->
@@ -34,48 +152,48 @@
<v-icon>mdi-cloud</v-icon>
</template>
<v-list-item-title>Provider</v-list-item-title>
<v-list-item-subtitle>{{ providerLabel }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ localProvider.label }}</v-list-item-subtitle>
</v-list-item>
<!-- Location Details -->
<template v-if="location">
<template v-if="serviceLocation">
<v-divider class="my-2" />
<v-list-item v-if="location.type === 'URI'">
<v-list-item v-if="serviceLocation.type === 'URI'">
<template #prepend>
<v-icon>mdi-web</v-icon>
</template>
<v-list-item-title>Service URL</v-list-item-title>
<v-list-item-subtitle>
{{ location.scheme }}://{{ location.host }}:{{ location.port }}{{ location.path || '' }}
{{ serviceLocation.scheme }}://{{ serviceLocation.host }}:{{ serviceLocation.port }}{{ serviceLocation.path || '' }}
</v-list-item-subtitle>
</v-list-item>
<template v-if="location.type === 'SOCKET_SOLE'">
<template v-if="serviceLocation.type === 'SOCKET_SOLE'">
<v-list-item>
<template #prepend>
<v-icon>mdi-server</v-icon>
</template>
<v-list-item-title>Server</v-list-item-title>
<v-list-item-subtitle>{{ location.host }}:{{ location.port }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ serviceLocation.host }}:{{ serviceLocation.port }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-shield-lock</v-icon>
</template>
<v-list-item-title>Security</v-list-item-title>
<v-list-item-subtitle>{{ location.encryption.toUpperCase() }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ serviceLocation.encryption.toUpperCase() }}</v-list-item-subtitle>
</v-list-item>
</template>
<template v-if="location.type === 'SOCKET_SPLIT'">
<template v-if="serviceLocation.type === 'SOCKET_SPLIT'">
<v-list-item>
<template #prepend>
<v-icon>mdi-inbox-arrow-down</v-icon>
</template>
<v-list-item-title>Incoming Mail</v-list-item-title>
<v-list-item-subtitle>
{{ location.inbound.protocol.toUpperCase() }} - {{ location.inbound.host }}:{{ location.inbound.port }} ({{ location.inbound.encryption.toUpperCase() }})
{{ serviceLocation.inboundHost }}:{{ serviceLocation.inboundPort }} ({{ serviceLocation.inboundEncryption.toUpperCase() }})
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
@@ -84,7 +202,7 @@
</template>
<v-list-item-title>Outgoing Mail</v-list-item-title>
<v-list-item-subtitle>
{{ location.outbound.protocol.toUpperCase() }} - {{ location.outbound.host }}:{{ location.outbound.port }} ({{ location.outbound.encryption.toUpperCase() }})
{{ serviceLocation.outboundHost }}:{{ serviceLocation.outboundPort }} ({{ serviceLocation.outboundEncryption.toUpperCase() }})
</v-list-item-subtitle>
</v-list-item>
</template>
@@ -94,10 +212,10 @@
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon>{{ getAuthIcon(identity?.type) }}</v-icon>
<v-icon>{{ getAuthIcon(serviceIdentity?.type) }}</v-icon>
</template>
<v-list-item-title>Authentication</v-list-item-title>
<v-list-item-subtitle>{{ getAuthLabel(identity?.type) }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ getAuthLabel(serviceIdentity?.type) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
@@ -105,7 +223,7 @@
<!-- Account Label Input -->
<v-text-field
v-model="localAccountLabel"
v-model="localService.label"
label="Account Name"
variant="outlined"
hint="A friendly name for this account (e.g., Work Email)"
@@ -116,7 +234,7 @@
<!-- Enable Account Toggle -->
<v-switch
v-model="accountEnabled"
v-model="localService.enabled"
label="Enable this account"
color="primary"
class="mb-4"
@@ -176,120 +294,4 @@
Please test the connection before saving
</v-alert>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ServiceIdentity, ServiceLocation } from '@MailManager/types/service'
const props = defineProps<{
providerId: string
providerLabel: string
emailAddress: string
location: ServiceLocation | null
identity: ServiceIdentity | null
prefilledLabel?: string
onTest: () => Promise<any>
}>()
const emit = defineEmits<{
'update:label': [value: string]
'update:enabled': [value: boolean]
'tested': [success: boolean]
'valid': [value: boolean]
}>()
// Local state
const localAccountLabel = ref(props.prefilledLabel || props.emailAddress || '')
const accountEnabled = ref(true)
const testing = ref(false)
const testResult = ref<any>(null)
// Computed
const accountLabel = computed(() => localAccountLabel.value || props.emailAddress || 'New Account')
const testSuccess = computed(() => testResult.value?.success === true)
const isValid = computed(() => {
return testSuccess.value && !!localAccountLabel.value
})
// Helper functions
function getAuthIcon(type?: string): string {
switch (type) {
case 'NA': return 'mdi-lock-open-variant'
case 'BA': return 'mdi-account-key'
case 'TA': return 'mdi-key'
case 'OA': return 'mdi-shield-account'
case 'CC': return 'mdi-certificate'
default: return 'mdi-help-circle'
}
}
function getAuthLabel(type?: string): string {
switch (type) {
case 'NA': return 'No Authentication'
case 'BA': return 'Username & Password'
case 'TA': return 'API Token'
case 'OA': return 'OAuth 2.0'
case 'CC': return 'Client Certificate'
default: return 'Unknown'
}
}
function formatCapabilities(capabilities: any): string {
if (!capabilities || typeof capabilities !== 'object') return 'N/A'
const caps = Object.entries(capabilities)
.filter(([_, value]) => value === true)
.map(([key]) => key)
.slice(0, 5)
const total = caps.length
const display = caps.slice(0, 3).join(', ')
if (total > 3) {
return `${display}, +${total - 3} more`
}
return display
}
async function handleTest() {
if (!props.location || !props.identity) {
testResult.value = {
success: false,
message: 'Missing configuration'
}
return
}
testing.value = true
testResult.value = null
try {
const result = await props.onTest()
testResult.value = result
emit('tested', result.success)
} catch (error: any) {
testResult.value = {
success: false,
message: error.message || 'Connection test failed'
}
emit('tested', false)
} finally {
testing.value = false
}
}
// Watch for changes and emit
watch(localAccountLabel, (value) => {
emit('update:label', value)
})
watch(accountEnabled, (value) => {
emit('update:enabled', value)
})
watch(isValid, (value) => {
emit('valid', value)
}, { immediate: true })
</script>
</template>