Files
mail_manager/src/components/AddAccountDialog.vue
Sebastian Krupinski 3362afb7ec
All checks were successful
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
chore: bunch of improvements
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-23 22:00:50 -04:00

612 lines
18 KiB
Vue

<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 { 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
const DISCOVERY_STEPS = {
ENTRY: 1,
DISCOVERY: 2,
AUTH: 3,
TEST: 4
} as const
// Manual flow: Entry → Provider Select → Config → Auth → Test
const MANUAL_STEPS = {
ENTRY: 1,
PROVIDER_SELECT: 2,
CONFIG: 3,
AUTH: 4,
TEST: 5
} as const
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const integrationStore = useIntegrationStore()
const servicesStore = useServicesStore()
const providersStore = useProvidersStore()
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const currentStep = ref<number>(DISCOVERY_STEPS.ENTRY)
const saving = ref(false)
const isManualMode = ref(false)
// Step 1: Entry
const discoverAddress = ref<string>('')
const discoverSecret = ref<string | null>(null)
const discoverHostname = ref<string | null>(null)
// Step 2: Discovery Status / Provider Selection
const selectedProvider = ref<ProviderObject | null>(null)
const selectedService = ref<ServiceObject | null>(null)
// Step 5: Test & Save
const testAndSaveValid = ref(false)
// Local discovery state (not stored in global store)
const discoveredServices = ref<ServiceObject[]>([])
const discoveryStatus = ref<Record<string, ProviderDiscoveryStatus>>({})
// Load providers when dialog opens
watch(dialogOpen, async (isOpen) => {
if (isOpen && !providersStore.has) {
await providersStore.list()
}
})
// Stepper configuration
const stepperItems = computed(() => {
if (isManualMode.value) {
// Manual: Entry → Selection → Config → Auth → Test
return [
{ title: 'Email', value: MANUAL_STEPS.ENTRY },
{ title: 'Provider', value: MANUAL_STEPS.PROVIDER_SELECT },
{ title: 'Protocol', value: MANUAL_STEPS.CONFIG },
{ title: 'Authentication', value: MANUAL_STEPS.AUTH },
{ title: 'Test & Save', value: MANUAL_STEPS.TEST }
]
}
// Discovery: Entry → Discovery → Auth → Test
if (currentStep.value >= DISCOVERY_STEPS.AUTH) {
return [
{ title: 'Email', value: DISCOVERY_STEPS.ENTRY },
{ title: 'Discovery', value: DISCOVERY_STEPS.DISCOVERY },
{ title: 'Authentication', value: DISCOVERY_STEPS.AUTH },
{ title: 'Test & Save', value: DISCOVERY_STEPS.TEST }
]
}
return [
{ title: 'Email', value: DISCOVERY_STEPS.ENTRY },
{ title: 'Discovery', value: DISCOVERY_STEPS.DISCOVERY }
]
})
const canSave = computed(() => {
return testAndSaveValid.value
})
// Navigation button visibility
const showNextButton = computed(() => {
if (isManualMode.value) {
// Manual: Show Next on Config (3) and Auth (4)
return currentStep.value === MANUAL_STEPS.CONFIG || currentStep.value === MANUAL_STEPS.AUTH
} else {
// Discovery: Show Next on Auth (3)
return currentStep.value === DISCOVERY_STEPS.AUTH
}
})
const showSaveButton = computed(() => {
if (isManualMode.value) {
return currentStep.value === MANUAL_STEPS.TEST
} else {
return currentStep.value === DISCOVERY_STEPS.TEST
}
})
const canProceedToNext = computed(() => {
if (isManualMode.value) {
if (currentStep.value === MANUAL_STEPS.CONFIG) {
return !!selectedService.value?.location
}
if (currentStep.value === MANUAL_STEPS.AUTH) {
return !!selectedService.value?.identity
}
} else {
if (currentStep.value === DISCOVERY_STEPS.AUTH) {
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) {
currentStep.value--
}
}
function handleNextStep() {
if (isManualMode.value) {
if (currentStep.value < MANUAL_STEPS.TEST) {
currentStep.value++
}
} else {
if (currentStep.value < DISCOVERY_STEPS.TEST) {
currentStep.value++
}
}
}
async function handleDiscover() {
// Move to discovery status screen
currentStep.value = DISCOVERY_STEPS.DISCOVERY
// Extract provider IDs
const providerIds = Object.values(providersStore.providers).map(p => p.identifier)
if (providerIds.length === 0) {
console.error('No providers available')
return
}
// Initialize status for all providers
discoveryStatus.value = providerIds.reduce((acc, identifier) => {
acc[identifier] = {
provider: identifier,
status: 'pending'
}
return acc
}, {} as Record<string, ProviderDiscoveryStatus>)
discoveredServices.value = []
// Start discovery for each provider in parallel
const promises = providerIds.map(async (identifier) => {
// Mark as discovering
discoveryStatus.value[identifier].status = 'discovering'
try {
let discoveredService: any = undefined
await servicesStore.discover(
discoverAddress.value,
discoverSecret.value || undefined,
discoverHostname.value || undefined,
identifier,
(service) => { discoveredService = service }
)
// Success - check if we got results for this provider
if (discoveredService && discoveredService.location) {
discoveryStatus.value[identifier] = {
provider: identifier,
status: 'success',
location: discoveredService.location,
metadata: extractLocationMetadata(discoveredService.location)
}
discoveredServices.value.push(discoveredService)
} else {
// No configuration found for this provider
discoveryStatus.value[identifier].status = 'failed'
discoveryStatus.value[identifier].error = 'Not configured'
}
} catch (error: any) {
// Failed - update status with error
discoveryStatus.value[identifier] = {
provider: identifier,
status: 'failed',
error: error.message || 'Discovery failed'
}
}
})
// Wait for all discoveries to complete
await Promise.allSettled(promises)
}
/**
* Extract display metadata from location for UI
*/
function extractLocationMetadata(location: ServiceLocation) {
switch (location.type) {
case 'URI':
return {
host: location.host,
port: location.port,
protocol: location.scheme
}
case 'SOCKET_SOLE':
return {
host: location.host,
port: location.port,
protocol: location.encryption
}
default:
return {}
}
}
async function handleProviderSelect(identifier: string) {
// User clicked "Select" on discovered provider - skip config, go to auth
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
}
function handleProviderAdvanced(identifier: string) {
// User clicked "Advanced" - show manual config with pre-filled values
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
currentStep.value = MANUAL_STEPS.CONFIG // Go to config step
}
function handleManualMode() {
// User clicked "Manual Configuration" - show provider picker
isManualMode.value = true
discoveredServices.value = []
discoveryStatus.value = {}
currentStep.value = MANUAL_STEPS.PROVIDER_SELECT // Go to provider selection
}
function handleProviderManualSelect(identifier: string) {
// User selected a provider in manual mode
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
}
function goBackToIdentity() {
currentStep.value = DISCOVERY_STEPS.ENTRY
isManualMode.value = false
discoveredServices.value = []
discoveryStatus.value = {}
selectedProvider.value = null
selectedService.value = null
testAndSaveValid.value = false
}
async function testConnection() {
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'
}
}
const testResult = await servicesStore.test(
selectedProvider.value.identifier,
null,
serviceData.location,
serviceData.identity
)
return testResult
}
async function saveAccount() {
if (!selectedProvider.value || !selectedService.value) return
const serviceData = selectedService.value.toJson()
if (!serviceData.location || !serviceData.identity) return
saving.value = true
try {
const accountData = {
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(
selectedProvider.value.identifier,
accountData
)
emit('saved')
close()
} catch (error) {
console.error('Failed to save account:', error)
// TODO: Show error message to user
} finally {
saving.value = false
}
}
function close() {
dialogOpen.value = false
// Reset state after animation
setTimeout(resetForm, 300)
}
function resetForm() {
currentStep.value = DISCOVERY_STEPS.ENTRY
isManualMode.value = false
discoverAddress.value = ''
discoverSecret.value = null
discoverHostname.value = null
selectedProvider.value = null
selectedService.value = null
testAndSaveValid.value = false
discoveredServices.value = []
discoveryStatus.value = {}
}
</script>
<template>
<v-dialog
v-model="dialogOpen"
max-width="900"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex justify-space-between align-center pa-6">
<span class="text-h5">Add Mail Account</span>
<v-btn
icon="mdi-close"
variant="text"
@click="close"
/>
</v-card-title>
<v-divider />
<v-card-text class="pa-0">
<v-stepper
v-model="currentStep"
:items="stepperItems"
alt-labels
flat
hide-actions
>
<!-- Step 1: Discovery Entry -->
<template #item.1>
<v-card flat class="pa-6">
<DiscoveryEntryPanel
v-model:address="discoverAddress"
v-model:secret="discoverSecret"
v-model:hostname="discoverHostname"
@discover="handleDiscover"
@manual="handleManualMode"
/>
</v-card>
</template>
<!-- Step 2: Discovery Status OR Provider Selection -->
<template #item.2>
<v-card flat class="pa-6">
<!-- Discovery path -->
<DiscoveryStatusPanel
v-if="!isManualMode"
:address="discoverAddress"
:status="discoveryStatus"
@select="handleProviderSelect"
@advanced="handleProviderAdvanced"
@manual="handleManualMode"
@back="goBackToIdentity"
/>
<!-- Manual path - provider picker -->
<ProviderSelectionPanel
v-else
@select="handleProviderManualSelect"
@back="goBackToIdentity"
/>
</v-card>
</template>
<!-- Step 3: Config (manual) OR Auth (discovery) -->
<template #item.3>
<v-card flat class="pa-6">
<!-- Manual path: Protocol Configuration -->
<ProviderProtocolPanel
v-if="isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
<ProviderAuthPanel
v-else-if="!isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
</v-card>
</template>
<!-- Step 4: Auth (manual) OR Test (discovery) -->
<template #item.4>
<v-card flat class="pa-6">
<ProviderAuthPanel
v-if="isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
@update:service="handleServiceUpdate"
/>
<!-- Discovery path: Test & Save -->
<TestAndSavePanel
v-else-if="!isManualMode && selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
:on-test="testConnection"
@update:service="handleServiceUpdate"
@tested="handleServiceTested"
/>
</v-card>
</template>
<!-- Step 5: Test & Save (manual only) -->
<template #item.5>
<v-card flat class="pa-6">
<TestAndSavePanel
v-if="selectedProvider && selectedService"
:provider="selectedProvider"
:service="selectedService"
:on-test="testConnection"
@update:service="handleServiceUpdate"
@tested="handleServiceTested"
/>
</v-card>
</template>
</v-stepper>
</v-card-text>
<v-divider />
<v-card-actions class="pa-6">
<!-- Previous Button -->
<v-btn
v-if="currentStep > 1"
variant="text"
prepend-icon="mdi-arrow-left"
@click="handlePreviousStep"
>
Previous
</v-btn>
<v-spacer />
<v-btn
variant="text"
@click="close"
>
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"
color="primary"
:loading="saving"
:disabled="!canSave"
@click="saveAccount"
>
<v-icon start>mdi-content-save</v-icon>
Save Account
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>