Initial commit
This commit is contained in:
506
src/components/AccountConfigurationPanel.vue
Normal file
506
src/components/AccountConfigurationPanel.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div class="jmap-config-panel">
|
||||
<h3 class="text-h6 mb-4">Connection</h3>
|
||||
<p class="text-body-2 mb-6">Enter your server and account information then press connect.</p>
|
||||
|
||||
<!-- Account Description -->
|
||||
<v-text-field
|
||||
v-model="accountLabel"
|
||||
label="Account Description"
|
||||
hint="Description for this Account"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-card-account-details"
|
||||
class="mb-4"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<!-- Authentication Type Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 mb-2 d-block">Authentication Type</label>
|
||||
<v-btn-toggle
|
||||
v-model="authType"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
mandatory
|
||||
divided
|
||||
class="mb-4"
|
||||
>
|
||||
<v-btn value="BA">Basic</v-btn>
|
||||
<v-btn value="OA">OAuth</v-btn>
|
||||
<v-btn value="JB">Json Basic</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<!-- Basic Auth / Json Basic Auth Fields -->
|
||||
<template v-if="authType === 'BA' || authType === 'JB'">
|
||||
<v-text-field
|
||||
v-model="bauthId"
|
||||
label="Account ID"
|
||||
hint="Authentication ID for your Account"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-account"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="bauthSecret"
|
||||
type="password"
|
||||
label="Account Secret"
|
||||
hint="Authentication secret for your Account"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- OAuth Fields -->
|
||||
<template v-if="authType === 'OA'">
|
||||
<v-text-field
|
||||
v-model="oauthId"
|
||||
label="Account ID"
|
||||
hint="Authentication ID for your Account"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-account"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="oauthToken"
|
||||
type="password"
|
||||
label="Account Token"
|
||||
hint="OAuth access token for your Account"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-key"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Manual Configuration Toggle -->
|
||||
<v-switch
|
||||
v-model="configureManually"
|
||||
label="Configure server manually"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Manual Configuration Fields -->
|
||||
<template v-if="!configureManually">
|
||||
<v-text-field
|
||||
v-model="sessionUrl"
|
||||
label="JMAP Session URL"
|
||||
hint="e.g., https://jmap.example.com/.well-known/jmap"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-link"
|
||||
class="mb-4"
|
||||
:rules="[rules.required, rules.url]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="configureManually">
|
||||
<v-text-field
|
||||
v-model="serviceHost"
|
||||
label="Service Address"
|
||||
hint="Domain or IP Address"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-server"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 mb-2 d-block">Service Protocol</label>
|
||||
<v-btn-toggle
|
||||
v-model="serviceProtocol"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
mandatory
|
||||
divided
|
||||
>
|
||||
<v-btn value="http">http</v-btn>
|
||||
<v-btn value="https">https</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<v-switch
|
||||
v-model="verifyPeer"
|
||||
label="Secure Transport Verification (SSL Certificate Verification)"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
hint="Should always be ON, unless connecting to a service over a secure internal network"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="servicePort"
|
||||
label="Service Port"
|
||||
hint="Leave empty for default. http (80) https (443)"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-numeric"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="servicePath"
|
||||
label="Service Path"
|
||||
hint="Leave empty for default path (/.well-known/jmap)"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-folder"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<v-expansion-panels class="mt-4">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon start>mdi-cog</v-icon>
|
||||
Advanced Settings
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-select
|
||||
v-model="capabilities"
|
||||
:items="jmapCapabilities"
|
||||
label="Enabled Capabilities"
|
||||
multiple
|
||||
chips
|
||||
variant="outlined"
|
||||
hint="Select which JMAP capabilities to enable"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model.number="timeout"
|
||||
type="number"
|
||||
label="Timeout (seconds)"
|
||||
variant="outlined"
|
||||
class="mt-4"
|
||||
hint="Connection timeout in seconds"
|
||||
persistent-hint
|
||||
:min="5"
|
||||
:max="300"
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="verifyHost"
|
||||
label="Verify SSL Hostname"
|
||||
color="primary"
|
||||
hint="Verify the certificate matches the hostname"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<!-- Info Alert -->
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-4"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
</template>
|
||||
<div class="text-caption">
|
||||
JMAP is a modern protocol for mail access. Most JMAP servers use
|
||||
<code>/.well-known/jmap</code> for autodiscovery.
|
||||
</div>
|
||||
</v-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { ServiceLocationUri } from '@KTXM/MailManager/types/service'
|
||||
|
||||
// Types
|
||||
interface ServiceConfiguration {
|
||||
label?: string
|
||||
auth?: 'BA' | 'OA' | 'JB'
|
||||
bauth_id?: string
|
||||
bauth_secret?: string
|
||||
oauth_id?: string
|
||||
oauth_access_token?: string
|
||||
location_host?: string
|
||||
location_protocol?: string
|
||||
location_security?: boolean
|
||||
location_port?: string
|
||||
location_path?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: ServiceLocationUri
|
||||
discoveredLocation?: ServiceLocationUri
|
||||
service?: ServiceConfiguration
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ServiceLocationUri]
|
||||
'update:service': [value: ServiceConfiguration]
|
||||
}>()
|
||||
|
||||
// Initialize from discovered location or create new
|
||||
function buildSessionUrl(location?: ServiceLocationUri): string {
|
||||
if (!location || location.type !== 'URI') return ''
|
||||
|
||||
const protocol = location.scheme || 'https'
|
||||
const host = location.host || ''
|
||||
const port = location.port || (protocol === 'https' ? 443 : 80)
|
||||
const path = location.path || '/.well-known/jmap'
|
||||
|
||||
// Don't include port if it's the default for the protocol
|
||||
const portStr = (protocol === 'https' && port === 443) || (protocol === 'http' && port === 80)
|
||||
? ''
|
||||
: `:${port}`
|
||||
|
||||
return `${protocol}://${host}${portStr}${path}`
|
||||
}
|
||||
|
||||
function parseSessionUrl(url: string): ServiceLocationUri {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return {
|
||||
type: 'URI',
|
||||
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname || '/.well-known/jmap',
|
||||
verifyPeer: verifyPeer.value,
|
||||
verifyHost: verifyHost.value
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
type: 'URI',
|
||||
scheme: 'https',
|
||||
host: '',
|
||||
port: 443,
|
||||
path: '/.well-known/jmap',
|
||||
verifyPeer: verifyPeer.value,
|
||||
verifyHost: verifyHost.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Account configuration fields
|
||||
const accountLabel = ref(props.service?.label || 'New Connection')
|
||||
const authType = ref<'BA' | 'OA' | 'JB'>(props.service?.auth || 'BA')
|
||||
const bauthId = ref(props.service?.bauth_id || '')
|
||||
const bauthSecret = ref(props.service?.bauth_secret || '')
|
||||
const oauthId = ref(props.service?.oauth_id || '')
|
||||
const oauthToken = ref(props.service?.oauth_access_token || '')
|
||||
|
||||
// Manual configuration toggle and fields
|
||||
const configureManually = ref(false)
|
||||
const serviceHost = ref(props.service?.location_host || '')
|
||||
const serviceProtocol = ref(props.service?.location_protocol || 'https')
|
||||
const servicePort = ref(props.service?.location_port || '')
|
||||
const servicePath = ref(props.service?.location_path || '')
|
||||
|
||||
// Local state - protocol settings only
|
||||
const sessionUrl = ref(buildSessionUrl(props.modelValue || props.discoveredLocation))
|
||||
const capabilities = ref<string[]>(['urn:ietf:params:jmap:mail'])
|
||||
const timeout = ref(30)
|
||||
const verifyPeer = ref(
|
||||
props.service?.location_security ?? props.modelValue?.verifyPeer ?? props.discoveredLocation?.verifyPeer ?? true
|
||||
)
|
||||
const verifyHost = ref(
|
||||
props.modelValue?.verifyHost ?? props.discoveredLocation?.verifyHost ?? true
|
||||
)
|
||||
|
||||
// Validation rules
|
||||
const rules = {
|
||||
required: (value: any) => !!value || 'This field is required',
|
||||
url: (value: string) => {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return 'Please enter a valid URL'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build location from current state
|
||||
const currentLocation = computed((): ServiceLocationUri | null => {
|
||||
if (configureManually.value) {
|
||||
// Build from manual fields
|
||||
if (!serviceHost.value) return null
|
||||
|
||||
const port = servicePort.value
|
||||
? parseInt(servicePort.value)
|
||||
: (serviceProtocol.value === 'https' ? 443 : 80)
|
||||
|
||||
return {
|
||||
type: 'URI',
|
||||
scheme: serviceProtocol.value as 'http' | 'https',
|
||||
host: serviceHost.value,
|
||||
port,
|
||||
path: servicePath.value || '/.well-known/jmap',
|
||||
verifyPeer: verifyPeer.value,
|
||||
verifyHost: verifyHost.value
|
||||
}
|
||||
} else {
|
||||
// Build from session URL
|
||||
if (!sessionUrl.value) return null
|
||||
return parseSessionUrl(sessionUrl.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Build service configuration from current state
|
||||
const currentService = computed((): ServiceConfiguration => {
|
||||
return {
|
||||
label: accountLabel.value,
|
||||
auth: authType.value,
|
||||
bauth_id: bauthId.value,
|
||||
bauth_secret: bauthSecret.value,
|
||||
oauth_id: oauthId.value,
|
||||
oauth_access_token: oauthToken.value,
|
||||
location_host: serviceHost.value,
|
||||
location_protocol: serviceProtocol.value,
|
||||
location_security: verifyPeer.value,
|
||||
location_port: servicePort.value,
|
||||
location_path: servicePath.value
|
||||
}
|
||||
})
|
||||
|
||||
// Emit location whenever it changes
|
||||
watch(
|
||||
[sessionUrl, serviceHost, serviceProtocol, servicePort, servicePath, verifyPeer, verifyHost, configureManually],
|
||||
() => {
|
||||
if (currentLocation.value) {
|
||||
emit('update:modelValue', currentLocation.value)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Emit service configuration whenever it changes
|
||||
watch(
|
||||
[accountLabel, authType, bauthId, bauthSecret, oauthId, oauthToken, serviceHost, serviceProtocol, servicePort, servicePath, verifyPeer],
|
||||
() => {
|
||||
emit('update:service', currentService.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Update local state when props change
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
sessionUrl.value = buildSessionUrl(newValue)
|
||||
verifyPeer.value = newValue.verifyPeer ?? true
|
||||
verifyHost.value = newValue.verifyHost ?? true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.discoveredLocation,
|
||||
(newValue) => {
|
||||
if (newValue && !props.modelValue) {
|
||||
sessionUrl.value = buildSessionUrl(newValue)
|
||||
verifyPeer.value = newValue.verifyPeer ?? true
|
||||
verifyHost.value = newValue.verifyHost ?? true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.service,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
accountLabel.value = newValue.label || 'New Connection'
|
||||
authType.value = newValue.auth || 'BA'
|
||||
bauthId.value = newValue.bauth_id || ''
|
||||
bauthSecret.value = newValue.bauth_secret || ''
|
||||
oauthId.value = newValue.oauth_id || ''
|
||||
oauthToken.value = newValue.oauth_access_token || ''
|
||||
serviceHost.value = newValue.location_host || ''
|
||||
serviceProtocol.value = newValue.location_protocol || 'https'
|
||||
servicePort.value = newValue.location_port || ''
|
||||
servicePath.value = newValue.location_path || ''
|
||||
verifyPeer.value = newValue.location_security ?? true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const jmapCapabilities = [
|
||||
{ title: 'Mail', value: 'urn:ietf:params:jmap:mail' },
|
||||
{ title: 'Contacts', value: 'urn:ietf:params:jmap:contacts' },
|
||||
{ title: 'Calendars', value: 'urn:ietf:params:jmap:calendars' },
|
||||
{ title: 'Tasks', value: 'urn:ietf:params:jmap:tasks' },
|
||||
{ title: 'Notes', value: 'urn:ietf:params:jmap:notes' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jmap-config-panel {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.text-h6 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 2rem;
|
||||
letter-spacing: 0.0125em;
|
||||
}
|
||||
|
||||
.text-subtitle-2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.375rem;
|
||||
letter-spacing: 0.00714em;
|
||||
}
|
||||
|
||||
.text-body-2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: 0.0178571429em;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
}
|
||||
</style>
|
||||
290
src/components/JmapAuthPanel.vue
Normal file
290
src/components/JmapAuthPanel.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="jmap-auth-panel">
|
||||
<h3 class="text-h6 mb-4">Authentication</h3>
|
||||
<p class="text-body-2 mb-6">Choose your authentication method and enter your credentials.</p>
|
||||
|
||||
<v-alert type="info" variant="tonal" class="mb-4">
|
||||
<template #prepend>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
</template>
|
||||
<div class="text-caption">
|
||||
JMAP supports multiple authentication methods. Choose the one your server uses.
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- Authentication Type Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 mb-2 d-block">Authentication Method</label>
|
||||
<v-btn-toggle
|
||||
v-model="authType"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
mandatory
|
||||
divided
|
||||
class="mb-4"
|
||||
>
|
||||
<v-btn value="BA">
|
||||
<v-icon start>mdi-account-key</v-icon>
|
||||
Basic Auth
|
||||
</v-btn>
|
||||
<v-btn value="TA">
|
||||
<v-icon start>mdi-key</v-icon>
|
||||
Bearer Token
|
||||
</v-btn>
|
||||
<v-btn value="OA">
|
||||
<v-icon start>mdi-shield-account</v-icon>
|
||||
OAuth 2.0
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<!-- Basic Authentication -->
|
||||
<template v-if="authType === 'BA'">
|
||||
<v-text-field
|
||||
v-model="basicIdentity"
|
||||
label="Username / Email"
|
||||
hint="Your account username or email address"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-account"
|
||||
class="mb-4"
|
||||
autocomplete="username"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="basicSecret"
|
||||
type="password"
|
||||
label="Password"
|
||||
hint="Your account password"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
class="mb-4"
|
||||
autocomplete="current-password"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Bearer Token Authentication -->
|
||||
<template v-else-if="authType === 'TA'">
|
||||
<v-textarea
|
||||
v-model="bearerToken"
|
||||
label="Bearer Token"
|
||||
hint="Enter your API token or bearer token"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-key"
|
||||
class="mb-4"
|
||||
rows="3"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- OAuth 2.0 -->
|
||||
<template v-else-if="authType === 'OA'">
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
<template #prepend>
|
||||
<v-icon>mdi-alert</v-icon>
|
||||
</template>
|
||||
<div class="text-caption">
|
||||
OAuth 2.0 implementation is pending. This will launch a browser window
|
||||
for secure authentication with your JMAP provider.
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
v-if="!oauthSuccess"
|
||||
color="primary"
|
||||
size="large"
|
||||
block
|
||||
@click="initiateOAuth"
|
||||
:loading="oauthLoading"
|
||||
:disabled="true"
|
||||
>
|
||||
<v-icon start>mdi-login</v-icon>
|
||||
Authorize with OAuth 2.0
|
||||
</v-btn>
|
||||
|
||||
<div v-else class="text-center py-4">
|
||||
<v-icon color="success" size="64">mdi-check-circle</v-icon>
|
||||
<p class="text-subtitle-1 mt-2">OAuth Authorized Successfully</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ServiceIdentity } from '@KTXM/MailManager/types/service'
|
||||
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
|
||||
|
||||
const props = defineProps<ProviderAuthPanelProps>()
|
||||
const emit = defineEmits<ProviderAuthPanelEmits>()
|
||||
|
||||
// Auth method selection
|
||||
const authType = ref<'BA' | 'TA' | 'OA'>('BA')
|
||||
|
||||
// Basic auth state
|
||||
const basicIdentity = ref(props.prefilledIdentity || props.emailAddress || '')
|
||||
const basicSecret = ref(props.prefilledSecret || '')
|
||||
|
||||
// Token auth state
|
||||
const bearerToken = ref('')
|
||||
|
||||
// OAuth state
|
||||
const oauthLoading = ref(false)
|
||||
const oauthSuccess = ref(false)
|
||||
const oauthAccessToken = ref('')
|
||||
const oauthRefreshToken = ref('')
|
||||
|
||||
// Validation rules
|
||||
const rules = {
|
||||
required: (value: any) => !!value || 'This field is required'
|
||||
}
|
||||
|
||||
// Validation
|
||||
const isValid = computed(() => {
|
||||
switch (authType.value) {
|
||||
case 'BA':
|
||||
return !!basicIdentity.value && !!basicSecret.value
|
||||
case 'TA':
|
||||
return !!bearerToken.value
|
||||
case 'OA':
|
||||
return oauthSuccess.value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Build ServiceIdentity object
|
||||
const currentIdentity = computed((): ServiceIdentity | null => {
|
||||
if (!isValid.value) return null
|
||||
|
||||
switch (authType.value) {
|
||||
case 'BA':
|
||||
return {
|
||||
type: 'BA',
|
||||
identity: basicIdentity.value,
|
||||
secret: basicSecret.value
|
||||
}
|
||||
case 'TA':
|
||||
return {
|
||||
type: 'TA',
|
||||
token: bearerToken.value
|
||||
}
|
||||
case 'OA':
|
||||
return {
|
||||
type: 'OA',
|
||||
accessToken: oauthAccessToken.value,
|
||||
refreshToken: oauthRefreshToken.value,
|
||||
accessScope: ['mail'],
|
||||
accessExpiry: Date.now() + 3600000
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Watch and emit changes
|
||||
watch(
|
||||
currentIdentity,
|
||||
(identity) => {
|
||||
if (identity) {
|
||||
emit('update:modelValue', identity)
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
isValid,
|
||||
(valid) => {
|
||||
emit('valid', valid)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Update local state if modelValue changes externally
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
authType.value = newValue.type as 'BA' | 'TA' | 'OA'
|
||||
|
||||
switch (newValue.type) {
|
||||
case 'BA':
|
||||
basicIdentity.value = newValue.identity || ''
|
||||
basicSecret.value = newValue.secret || ''
|
||||
break
|
||||
case 'TA':
|
||||
bearerToken.value = newValue.token || ''
|
||||
break
|
||||
case 'OA':
|
||||
oauthAccessToken.value = newValue.accessToken || ''
|
||||
oauthRefreshToken.value = newValue.refreshToken || ''
|
||||
oauthSuccess.value = !!newValue.accessToken
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Prefill identity when email address is provided
|
||||
watch(
|
||||
() => props.emailAddress,
|
||||
(email) => {
|
||||
if (email && !basicIdentity.value) {
|
||||
basicIdentity.value = email
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// OAuth flow (stub for now)
|
||||
async function initiateOAuth() {
|
||||
oauthLoading.value = true
|
||||
try {
|
||||
// TODO: Implement OAuth flow when backend is ready
|
||||
emit('error', 'OAuth implementation pending')
|
||||
throw new Error('OAuth implementation pending')
|
||||
} catch (error: any) {
|
||||
emit('error', error.message)
|
||||
} finally {
|
||||
oauthLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jmap-auth-panel {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.text-h6 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 2rem;
|
||||
letter-spacing: 0.0125em;
|
||||
}
|
||||
|
||||
.text-subtitle-2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.375rem;
|
||||
letter-spacing: 0.00714em;
|
||||
}
|
||||
|
||||
.text-body-2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: 0.0178571429em;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
}
|
||||
</style>
|
||||
363
src/components/JmapConfigPanel.vue
Normal file
363
src/components/JmapConfigPanel.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="jmap-config-panel">
|
||||
<h3 class="text-h6 mb-4">JMAP Connection Settings</h3>
|
||||
<p class="text-body-2 mb-6">Configure how to connect to your JMAP server.</p>
|
||||
|
||||
<!-- Manual Configuration Toggle -->
|
||||
<v-switch
|
||||
v-model="configureManually"
|
||||
label="Configure server manually"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Session URL (Simple Mode) -->
|
||||
<template v-if="!configureManually">
|
||||
<v-text-field
|
||||
v-model="sessionUrl"
|
||||
label="JMAP Session URL"
|
||||
hint="e.g., https://jmap.example.com/.well-known/jmap"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-link"
|
||||
class="mb-4"
|
||||
:rules="[rules.required, rules.url]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Manual Configuration Fields -->
|
||||
<template v-if="configureManually">
|
||||
<v-text-field
|
||||
v-model="serviceHost"
|
||||
label="Service Address"
|
||||
hint="Domain or IP Address"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-server"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 mb-2 d-block">Service Protocol</label>
|
||||
<v-btn-toggle
|
||||
v-model="serviceProtocol"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
mandatory
|
||||
divided
|
||||
>
|
||||
<v-btn value="http">http</v-btn>
|
||||
<v-btn value="https">https</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<v-switch
|
||||
v-model="verifyPeer"
|
||||
label="Secure Transport Verification (SSL Certificate Verification)"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
hint="Should always be ON, unless connecting to a service over a secure internal network"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="servicePort"
|
||||
label="Service Port"
|
||||
hint="Leave empty for default. http (80) https (443)"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-numeric"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="servicePath"
|
||||
label="Service Path"
|
||||
hint="Leave empty for default path (/.well-known/jmap)"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-folder"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<v-expansion-panels class="mt-4">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon start>mdi-cog</v-icon>
|
||||
Advanced Settings
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-select
|
||||
v-model="capabilities"
|
||||
:items="jmapCapabilities"
|
||||
label="Enabled Capabilities"
|
||||
multiple
|
||||
chips
|
||||
variant="outlined"
|
||||
hint="Select which JMAP capabilities to enable"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model.number="timeout"
|
||||
type="number"
|
||||
label="Timeout (seconds)"
|
||||
variant="outlined"
|
||||
class="mt-4"
|
||||
hint="Connection timeout in seconds"
|
||||
persistent-hint
|
||||
:min="5"
|
||||
:max="300"
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="verifyHost"
|
||||
label="Verify SSL Hostname"
|
||||
color="primary"
|
||||
hint="Verify the certificate matches the hostname"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<!-- Info Alert -->
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-4"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
</template>
|
||||
<div class="text-caption">
|
||||
JMAP is a modern protocol for mail access. Most JMAP servers use
|
||||
<code>/.well-known/jmap</code> for autodiscovery.
|
||||
</div>
|
||||
</v-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { ServiceLocationUri, ServiceLocation } from '@KTXM/MailManager/types/service'
|
||||
import type { ProviderConfigPanelProps, ProviderConfigPanelEmits } from '@KTXM/MailManager/types/integration'
|
||||
|
||||
const props = defineProps<ProviderConfigPanelProps>()
|
||||
const emit = defineEmits<ProviderConfigPanelEmits>()
|
||||
|
||||
// Helper to build session URL from location
|
||||
function buildSessionUrl(location?: ServiceLocation): string {
|
||||
if (!location || location.type !== 'URI') return ''
|
||||
|
||||
const protocol = location.scheme || 'https'
|
||||
const host = location.host || ''
|
||||
const port = location.port || (protocol === 'https' ? 443 : 80)
|
||||
const path = location.path || '/.well-known/jmap'
|
||||
|
||||
// Don't include port if it's the default for the protocol
|
||||
const portStr = (protocol === 'https' && port === 443) || (protocol === 'http' && port === 80)
|
||||
? ''
|
||||
: `:${port}`
|
||||
|
||||
return `${protocol}://${host}${portStr}${path}`
|
||||
}
|
||||
|
||||
// Helper to parse session URL into location
|
||||
function parseSessionUrl(url: string): ServiceLocationUri {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return {
|
||||
type: 'URI',
|
||||
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname || '/.well-known/jmap',
|
||||
verifyPeer: verifyPeer.value,
|
||||
verifyHost: verifyHost.value
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
type: 'URI',
|
||||
scheme: 'https',
|
||||
host: '',
|
||||
port: 443,
|
||||
path: '/.well-known/jmap',
|
||||
verifyPeer: verifyPeer.value,
|
||||
verifyHost: verifyHost.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to extract URI properties safely
|
||||
function getUriVerifyPeer(location?: ServiceLocation): boolean {
|
||||
return (location?.type === 'URI' ? location.verifyPeer : undefined) ?? true
|
||||
}
|
||||
|
||||
function getUriVerifyHost(location?: ServiceLocation): boolean {
|
||||
return (location?.type === 'URI' ? location.verifyHost : undefined) ?? true
|
||||
}
|
||||
|
||||
// Manual configuration toggle and fields
|
||||
const configureManually = ref(false)
|
||||
const serviceHost = ref('')
|
||||
const serviceProtocol = ref<'http' | 'https'>('https')
|
||||
const servicePort = ref('')
|
||||
const servicePath = ref('')
|
||||
|
||||
// Local state - protocol settings only
|
||||
const sessionUrl = ref(buildSessionUrl(props.modelValue || props.discoveredLocation))
|
||||
const capabilities = ref<string[]>(['urn:ietf:params:jmap:mail'])
|
||||
const timeout = ref(30)
|
||||
const verifyPeer = ref(getUriVerifyPeer(props.modelValue || props.discoveredLocation))
|
||||
const verifyHost = ref(getUriVerifyHost(props.modelValue || props.discoveredLocation))
|
||||
|
||||
// Validation rules
|
||||
const rules = {
|
||||
required: (value: any) => !!value || 'This field is required',
|
||||
url: (value: string) => {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return 'Please enter a valid URL'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build location from current state
|
||||
const currentLocation = computed((): ServiceLocationUri | null => {
|
||||
if (configureManually.value) {
|
||||
// Build from manual fields
|
||||
if (!serviceHost.value) return null
|
||||
|
||||
const port = servicePort.value
|
||||
? parseInt(servicePort.value)
|
||||
: (serviceProtocol.value === 'https' ? 443 : 80)
|
||||
|
||||
return {
|
||||
type: 'URI',
|
||||
scheme: serviceProtocol.value,
|
||||
host: serviceHost.value,
|
||||
port,
|
||||
path: servicePath.value || '/.well-known/jmap',
|
||||
verifyPeer: verifyPeer.value,
|
||||
verifyHost: verifyHost.value
|
||||
}
|
||||
} else {
|
||||
// Build from session URL
|
||||
if (!sessionUrl.value) return null
|
||||
return parseSessionUrl(sessionUrl.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Validation state
|
||||
const isValid = computed(() => {
|
||||
if (configureManually.value) {
|
||||
return !!serviceHost.value
|
||||
} else {
|
||||
return !!sessionUrl.value && rules.url(sessionUrl.value) === true
|
||||
}
|
||||
})
|
||||
|
||||
// Emit location whenever it changes
|
||||
watch(
|
||||
currentLocation,
|
||||
(newLocation) => {
|
||||
if (newLocation) {
|
||||
emit('update:modelValue', newLocation)
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// Emit validation state
|
||||
watch(
|
||||
isValid,
|
||||
(valid) => {
|
||||
emit('valid', valid)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Update local state when props change
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.type === 'URI') {
|
||||
sessionUrl.value = buildSessionUrl(newValue)
|
||||
verifyPeer.value = newValue.verifyPeer ?? true
|
||||
verifyHost.value = newValue.verifyHost ?? true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.discoveredLocation,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.type === 'URI' && !props.modelValue) {
|
||||
sessionUrl.value = buildSessionUrl(newValue)
|
||||
verifyPeer.value = newValue.verifyPeer ?? true
|
||||
verifyHost.value = newValue.verifyHost ?? true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const jmapCapabilities = [
|
||||
{ title: 'Mail', value: 'urn:ietf:params:jmap:mail' },
|
||||
{ title: 'Contacts', value: 'urn:ietf:params:jmap:contacts' },
|
||||
{ title: 'Calendars', value: 'urn:ietf:params:jmap:calendars' },
|
||||
{ title: 'Tasks', value: 'urn:ietf:params:jmap:tasks' },
|
||||
{ title: 'Notes', value: 'urn:ietf:params:jmap:notes' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jmap-config-panel {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.text-h6 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 2rem;
|
||||
letter-spacing: 0.0125em;
|
||||
}
|
||||
|
||||
.text-subtitle-2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.375rem;
|
||||
letter-spacing: 0.00714em;
|
||||
}
|
||||
|
||||
.text-body-2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: 0.0178571429em;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
}
|
||||
</style>
|
||||
57
src/integrations.ts
Normal file
57
src/integrations.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
|
||||
import type { ProviderMetadata } from "@KTXM/MailManager/types/provider";
|
||||
import type { ServiceInterface } from "@KTXM/MailManager/types/service";
|
||||
import { JmapServiceObject } from './models/JmapServiceObject'
|
||||
|
||||
const integrations: ModuleIntegrations = {
|
||||
mail_account_config_panels: [
|
||||
{
|
||||
id: 'jmap',
|
||||
label: 'JMAP',
|
||||
icon: 'mdi-api',
|
||||
caption: 'Modern JSON-based mail protocol',
|
||||
component: () => import('@/components/JmapConfigPanel.vue'),
|
||||
priority: 10,
|
||||
}
|
||||
],
|
||||
mail_account_auth_panels: [
|
||||
{
|
||||
id: 'jmap',
|
||||
component: () => import('@/components/JmapAuthPanel.vue'),
|
||||
}
|
||||
],
|
||||
mail_service_factory: [
|
||||
{
|
||||
id: 'jmap',
|
||||
factory: (data: ServiceInterface) => new JmapServiceObject().fromJson(data)
|
||||
}
|
||||
],
|
||||
mail_provider_metadata: [
|
||||
{
|
||||
id: 'jmap',
|
||||
label: 'JMAP',
|
||||
description: 'Modern JSON-based mail API protocol',
|
||||
icon: 'mdi-api',
|
||||
auth: {
|
||||
methods: ['BA', 'OA', 'TA'],
|
||||
default: 'BA',
|
||||
allowMethodSelection: true,
|
||||
oauth: {
|
||||
// OAuth config will be provider-specific
|
||||
// Some JMAP providers use OAuth (e.g., Fastmail)
|
||||
authorizeUrl: '', // Configured per-instance
|
||||
tokenUrl: '',
|
||||
scopes: ['mail'],
|
||||
flowType: 'authorization_code'
|
||||
}
|
||||
},
|
||||
supportsDiscovery: true,
|
||||
meta: {
|
||||
protocol: 'JMAP',
|
||||
wellKnownPath: '/.well-known/jmap'
|
||||
}
|
||||
} as ProviderMetadata
|
||||
]
|
||||
};
|
||||
|
||||
export default integrations;
|
||||
14
src/main.ts
Normal file
14
src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import routes from '@/routes'
|
||||
import integrations from '@/integrations'
|
||||
import type { App as Vue } from 'vue'
|
||||
|
||||
// CSS filename is injected by the vite plugin at build time
|
||||
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||
|
||||
export { routes, integrations }
|
||||
|
||||
export default {
|
||||
install(app: Vue) {
|
||||
// No additional plugins needed for this module
|
||||
}
|
||||
}
|
||||
55
src/models/JmapServiceObject.ts
Normal file
55
src/models/JmapServiceObject.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* JMAP-specific ServiceObject implementation
|
||||
* Extends base ServiceObject with JMAP-specific functionality
|
||||
*/
|
||||
|
||||
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||
import type { JmapAuxiliary } from '../types/auxiliary'
|
||||
|
||||
/**
|
||||
* JMAP Service Object
|
||||
* Provides typed access to JMAP-specific auxiliary data
|
||||
*/
|
||||
export class JmapServiceObject extends ServiceObject {
|
||||
/**
|
||||
* Type-safe access to JMAP-specific auxiliary data
|
||||
*/
|
||||
get jmapAuxiliary(): JmapAuxiliary {
|
||||
return (this._data.auxiliary ?? {}) as JmapAuxiliary
|
||||
}
|
||||
|
||||
get hasCore(): boolean {
|
||||
return this.jmapAuxiliary.capable?.core === true;
|
||||
}
|
||||
|
||||
get hasMail(): boolean {
|
||||
return this.jmapAuxiliary.capable?.mail === true;
|
||||
}
|
||||
|
||||
get hasCalendar(): boolean {
|
||||
return this.jmapAuxiliary.capable?.calendar === true;
|
||||
}
|
||||
|
||||
get hasContacts(): boolean {
|
||||
return this.jmapAuxiliary.capable?.contacts === true;
|
||||
}
|
||||
|
||||
get hasDocuments(): boolean {
|
||||
return this.jmapAuxiliary.capable?.documents === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JMAP session URL
|
||||
*/
|
||||
get sessionUrl(): string | undefined {
|
||||
return this.jmapAuxiliary.sessionUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JMAP account ID
|
||||
*/
|
||||
get accountId(): string | undefined {
|
||||
return this.jmapAuxiliary.accountId
|
||||
}
|
||||
|
||||
}
|
||||
7
src/routes.ts
Normal file
7
src/routes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Routes removed - JMAP accounts are now managed through the unified mail_manager
|
||||
// Users should access accounts via: /m/mail_manager/accounts
|
||||
|
||||
const routes = [];
|
||||
|
||||
export default routes;
|
||||
|
||||
81
src/services/serviceService.ts
Normal file
81
src/services/serviceService.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
|
||||
import type {
|
||||
Service,
|
||||
ConnectionTestRequest,
|
||||
ConnectionTestResponse,
|
||||
CollectionsResponse,
|
||||
DiscoverResponse
|
||||
} from '@/models/service';
|
||||
|
||||
const BASE_PATH = '/m/provider_jmapc';
|
||||
|
||||
export const serviceService = {
|
||||
/**
|
||||
* List all JMAP services for current user
|
||||
*/
|
||||
async list(capability?: string): Promise<{ services: Service[] }> {
|
||||
const params = capability ? `?capability=${encodeURIComponent(capability)}` : '';
|
||||
return fetchWrapper.get(`${BASE_PATH}/services${params}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single service by ID
|
||||
*/
|
||||
async fetch(id: string): Promise<Service> {
|
||||
return fetchWrapper.get(`${BASE_PATH}/services/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new service
|
||||
*/
|
||||
async create(service: Partial<Service>): Promise<Service> {
|
||||
return fetchWrapper.post(`${BASE_PATH}/services`, service);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing service
|
||||
*/
|
||||
async update(id: string, service: Partial<Service>): Promise<Service> {
|
||||
return fetchWrapper.put(`${BASE_PATH}/services/${id}`, service);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a service
|
||||
*/
|
||||
async destroy(id: string): Promise<{ success: boolean }> {
|
||||
return fetchWrapper.delete(`${BASE_PATH}/services/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Test JMAP connection
|
||||
*/
|
||||
async test(request: ConnectionTestRequest): Promise<ConnectionTestResponse> {
|
||||
return fetchWrapper.post(`${BASE_PATH}/services/test`, request);
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-discover JMAP endpoint from hostname
|
||||
*/
|
||||
async discover(hostname: string, protocol?: string, port?: number, path?: string): Promise<DiscoverResponse> {
|
||||
return fetchWrapper.post(`${BASE_PATH}/services/discover`, {
|
||||
hostname,
|
||||
protocol,
|
||||
port,
|
||||
path
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch collections for a service
|
||||
*/
|
||||
async fetchCollections(id: string): Promise<CollectionsResponse> {
|
||||
return fetchWrapper.get(`${BASE_PATH}/services/${id}/collections`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh collections for a service (re-query remote server)
|
||||
*/
|
||||
async refreshCollections(id: string): Promise<CollectionsResponse> {
|
||||
return fetchWrapper.post(`${BASE_PATH}/services/${id}/collections/refresh`, {});
|
||||
},
|
||||
};
|
||||
208
src/stores/servicesStore.ts
Normal file
208
src/stores/servicesStore.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
import { ServiceModel } from '@/models/service';
|
||||
import type { Service } from '@/models/service';
|
||||
|
||||
export const useServicesStore = defineStore('jmapc_services', {
|
||||
state: () => ({
|
||||
services: [] as ServiceModel[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* Get services filtered by capability
|
||||
*/
|
||||
byCapability: (state) => (capability: string) => {
|
||||
return state.services.filter(s => s.capabilities.includes(capability));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get mail services
|
||||
*/
|
||||
mailServices: (state) => {
|
||||
return state.services.filter(s => s.hasMail());
|
||||
},
|
||||
|
||||
/**
|
||||
* Get contact services
|
||||
*/
|
||||
contactServices: (state) => {
|
||||
return state.services.filter(s => s.hasContacts());
|
||||
},
|
||||
|
||||
/**
|
||||
* Get calendar services
|
||||
*/
|
||||
calendarServices: (state) => {
|
||||
return state.services.filter(s => s.hasCalendars());
|
||||
},
|
||||
|
||||
/**
|
||||
* Get service by ID
|
||||
*/
|
||||
getById: (state) => (id: string) => {
|
||||
return state.services.find(s => s.id === id);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Load all services from API
|
||||
*/
|
||||
async loadServices(capability?: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await serviceService.list(capability);
|
||||
this.services = response.services.map(s => new ServiceModel(s));
|
||||
} catch (error: any) {
|
||||
this.error = error.message || 'Failed to load services';
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a single service
|
||||
*/
|
||||
async loadService(id: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const service = await serviceService.fetch(id);
|
||||
const model = new ServiceModel(service);
|
||||
|
||||
// Update or add to store
|
||||
const index = this.services.findIndex(s => s.id === id);
|
||||
if (index >= 0) {
|
||||
this.services[index] = model;
|
||||
} else {
|
||||
this.services.push(model);
|
||||
}
|
||||
|
||||
return model;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || 'Failed to load service';
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new service
|
||||
*/
|
||||
async createService(service: Partial<Service>) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const created = await serviceService.create(service);
|
||||
const model = new ServiceModel(created);
|
||||
this.services.push(model);
|
||||
return model;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || 'Failed to create service';
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing service
|
||||
*/
|
||||
async updateService(id: string, service: Partial<Service>) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const updated = await serviceService.update(id, service);
|
||||
const model = new ServiceModel(updated);
|
||||
|
||||
const index = this.services.findIndex(s => s.id === id);
|
||||
if (index >= 0) {
|
||||
this.services[index] = model;
|
||||
}
|
||||
|
||||
return model;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || 'Failed to update service';
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a service
|
||||
*/
|
||||
async deleteService(id: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await serviceService.destroy(id);
|
||||
this.services = this.services.filter(s => s.id !== id);
|
||||
} catch (error: any) {
|
||||
this.error = error.message || 'Failed to delete service';
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Test connection with provided settings
|
||||
*/
|
||||
async testConnection(config: any) {
|
||||
return serviceService.test(config);
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-discover JMAP endpoint
|
||||
*/
|
||||
async discover(hostname: string, protocol?: string, port?: number, path?: string) {
|
||||
return serviceService.discover(hostname, protocol, port, path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch collections for a service
|
||||
*/
|
||||
async fetchCollections(id: string) {
|
||||
return serviceService.fetchCollections(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh collections for a service
|
||||
*/
|
||||
async refreshCollections(id: string) {
|
||||
const result = await serviceService.refreshCollections(id);
|
||||
|
||||
// Update local service with fresh collection data
|
||||
const service = this.getById(id);
|
||||
if (service && result.success) {
|
||||
service.collections = {
|
||||
contacts: result.contacts || [],
|
||||
calendars: result.calendars || []
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all services
|
||||
*/
|
||||
reset() {
|
||||
this.services = [];
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
1
src/style.css
Normal file
1
src/style.css
Normal file
@@ -0,0 +1 @@
|
||||
/* jmap client provider module styles */
|
||||
28
src/types/auxiliary.ts
Normal file
28
src/types/auxiliary.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* JMAP-specific auxiliary data types
|
||||
* Stored in ServiceInterface.auxiliary field
|
||||
*/
|
||||
|
||||
/**
|
||||
* JMAP-specific auxiliary data
|
||||
* Contains provider-specific metadata and capabilities
|
||||
*/
|
||||
export interface JmapAuxiliary {
|
||||
/** JMAP capability flags */
|
||||
capable?: {
|
||||
core?: boolean;
|
||||
mail?: boolean;
|
||||
calendar?: boolean;
|
||||
contacts?: boolean;
|
||||
documents?: boolean;
|
||||
};
|
||||
|
||||
/** JMAP session URL */
|
||||
sessionUrl?: string;
|
||||
|
||||
/** JMAP account ID */
|
||||
accountId?: string;
|
||||
|
||||
/** Allow additional custom fields */
|
||||
[key: string]: any;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user