Files
mail_manager/src/components/AddAccountDialog.vue
Sebastian Krupinski cceaf809d9
All checks were successful
Build Test / test (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Successful in 27s
PHP Unit Tests / test (pull_request) Successful in 1m8s
refactor: unify streaming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-06 22:53:08 -05:00

569 lines
17 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
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'
// ==================== 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 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 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)
// Step 5: Test & Save
const accountLabel = ref<string>('')
const accountEnabled = ref(true)
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 !!configuredLocation.value
}
if (currentStep.value === MANUAL_STEPS.AUTH) {
return authValid.value
}
} else {
if (currentStep.value === DISCOVERY_STEPS.AUTH) {
return authValid.value
}
}
return false
})
// 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 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
// 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
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
const service = discoveredServices.value.find(s => s.provider === identifier)
configuredLocation.value = service?.location || null
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
selectedProviderId.value = identifier
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier
currentStep.value = MANUAL_STEPS.CONFIG // Go to manual config
}
function goBackToIdentity() {
currentStep.value = DISCOVERY_STEPS.ENTRY
isManualMode.value = false
discoveredServices.value = []
discoveryStatus.value = {}
}
async function testConnection() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) {
return {
success: false,
message: 'Missing configuration'
}
}
const testResult = await servicesStore.test(
selectedProviderId.value,
null,
configuredLocation.value,
configuredIdentity.value
)
return testResult
}
async function saveAccount() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) return
saving.value = true
try {
const accountData = {
label: accountLabel.value || discoverAddress.value,
email: discoverAddress.value,
enabled: accountEnabled.value,
location: configuredLocation.value,
identity: configuredIdentity.value
}
await servicesStore.create(
selectedProviderId.value,
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
selectedProviderId.value = undefined
selectedProviderLabel.value = ''
configuredLocation.value = null
configuredIdentity.value = null
authValid.value = false
accountLabel.value = ''
accountEnabled.value = true
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">
<DiscoveryEntryStep
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 -->
<DiscoveryStatusStep
v-if="!isManualMode"
:address="discoverAddress"
:status="discoveryStatus"
@select="handleProviderSelect"
@advanced="handleProviderAdvanced"
@manual="handleManualMode"
@back="goBackToIdentity"
/>
<!-- Manual path - provider picker -->
<ProviderSelectionStep
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 -->
<ProviderConfigStep
v-if="isManualMode && selectedProviderId"
:provider-id="selectedProviderId"
:discovered-location="configuredLocation || undefined"
v-model="configuredLocation"
@valid="() => { /* Can proceed to next step */ }"
/>
<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"
/>
</v-card>
</template>
<!-- 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"
/>
<!-- 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"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
/>
</v-card>
</template>
<!-- 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"
:on-test="testConnection"
@update:label="(val) => accountLabel = val"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
/>
</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>