Merge pull request #3554 from modoboa/fix/domainadmins_and_permissions

Various frontend fixes when connected user is a domain admin
This commit is contained in:
Antoine Nguyen
2025-06-13 17:03:43 +02:00
committed by GitHub
39 changed files with 428 additions and 774 deletions
+1
View File
@@ -38,6 +38,7 @@ modoboa.kdev4
test_project/cert.crt
test_project/cert.key
test_project/envfile
test_project/media
# Docker
docker/collections
+5
View File
@@ -9,6 +9,11 @@ export default {
checkPassword(value) {
return repository.post(`/${resource}/me/password/`, { password: value })
},
setPassword(data) {
return repository.post(`/${resource}/me/password/change/`, data, {
ignoreErrors: true,
})
},
verifyTFACode(code) {
const payload = { code }
return repository.post(`${resource}/tfa/verify/`, payload)
+9 -2
View File
@@ -36,9 +36,16 @@ _axios.interceptors.response.use(
return Promise.reject(error)
}
if (error.response.status !== 401 || router.currentRoute.name === 'Login') {
if (error.response.status !== 518) {
if (
error.response.status !== 518 &&
error.config &&
!error.config.ignoreErrors
) {
const busStore = useBusStore()
busStore.displayNotification({ msg: error.response.data, type: 'error' })
busStore.displayNotification({
msg: error.response.data,
type: 'error',
})
}
return Promise.reject(error)
}
@@ -70,7 +70,11 @@
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-form ref="passwordForm" @submit.prevent="updatePassword">
<AccountPasswordForm v-model="form" with-password-check />
<AccountPasswordForm
v-model="form"
:form-errors="passwordFormErrors"
with-password-check
/>
<v-btn color="success" type="submit" :loading="loadingPasswordUpdate">
{{ $gettext('Update Password') }}
</v-btn>
@@ -97,6 +101,7 @@ const account = computed(() => authStore.authUser)
const form = ref({})
const profileForm = ref()
const passwordForm = ref()
const passwordFormErrors = ref({})
const languages = ref([])
const panel = ref(0)
const loadingUpdateProfile = ref(false)
@@ -136,12 +141,20 @@ async function updatePassword() {
return
}
loadingPasswordUpdate.value = true
authStore
.updateAccount({ password: form.value.password })
.then(() => {
busStore.displayNotification({ msg: $gettext('Password updated') })
try {
await authStore.updatePassword({
password: form.value.password,
new_password: form.value.new_password,
})
.finally(() => (loadingPasswordUpdate.value = false))
busStore.displayNotification({ msg: $gettext('Password updated') })
passwordForm.value.reset()
} catch (err) {
if (err.status === 400) {
passwordFormErrors.value = err.response.data
}
} finally {
loadingPasswordUpdate.value = false
}
}
onMounted(() => {
@@ -19,6 +19,7 @@
v-model="selectedAccount"
:label="$gettext('Select an account')"
:items="accounts"
variant="outlined"
item-title="username"
class="mt-5"
return-object
@@ -8,36 +8,37 @@
<label class="m-label">{{ $gettext('Name') }}</label>
<v-text-field
v-model="form.name"
dense
variant="outlined"
density="compact"
:rules="[rules.required]"
/>
<label class="m-label">{{ $gettext('Choose a domain') }}</label>
<v-select
v-model="form.target"
:items="Object.values(domainsStore.domains)"
:items="domains"
item-title="name"
item-value="pk"
:rules="[rules.required]"
single-line
dense
variant="outlined"
density="compact"
/>
<v-switch v-model="form.enabled" label="Enabled" color="primary" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="emit('close')">
<v-btn :loading="working" @click="emit('close')">
{{ $gettext('Close') }}
</v-btn>
<v-btn
v-if="domainAlias && domainAlias.pk"
color="error"
:loading="working"
@click="deleteAlias"
>
{{ $gettext('Delete') }}
</v-btn>
<v-btn color="primary" type="submit">
<v-btn color="primary" type="submit" :loading="working">
{{ submitLabel }}
</v-btn>
</v-card-actions>
@@ -47,21 +48,23 @@
<script setup lang="js">
import { useGettext } from 'vue3-gettext'
import { useDomainsStore } from '@/stores'
import { computed, ref, onMounted } from 'vue'
import { useBusStore } from '@/stores'
import rules from '@/plugins/rules.js'
import domainsApi from '@/api/domains'
const { $gettext } = useGettext()
const domainsStore = useDomainsStore()
const emit = defineEmits(['close'])
const { displayNotification } = useBusStore()
const props = defineProps({
domainAlias: { type: Object, default: () => null },
})
const domains = ref([])
const form = ref({ enabled: true })
const working = ref(false)
const vFormRef = ref()
const submitLabel = computed(() => {
@@ -74,8 +77,15 @@ const title = computed(() =>
: $gettext('Add a new domain alias')
)
function deleteAlias() {
domainsStore.deleteAlias(props.domainAlias).then(() => emit('close'))
async function deleteAlias() {
working.value = true
try {
await domainsApi.deleteDomainAlias(props.domainAlias.pk)
displayNotification({ msg: $gettext('Domain alias deleted') })
emit('close')
} finally {
working.value = false
}
}
async function submit() {
@@ -83,24 +93,26 @@ async function submit() {
if (!valid) {
return
}
working.value = true
try {
if (!form.value.pk) {
// Create
domainsStore.createAlias(form.value).then(() => {
emit('close')
})
await domainsApi.createDomainAlias(form.value)
displayNotification({ msg: $gettext('Domain alias created') })
} else {
// Update
const data = JSON.parse(JSON.stringify(form.value))
domainsStore.updateAlias(data).then(() => emit('close'))
await domainsApi.updateDomainAlias(props.domainAlias.pk, data)
displayNotification({ msg: $gettext('Domain alias updated') })
}
} catch (error) {
//TODO
console.error(error)
emit('close')
} finally {
working.value = false
}
}
onMounted(() => {
domainsApi.getDomains().then((resp) => {
domains.value = resp.data
})
if (props.domainAlias) {
form.value = JSON.parse(JSON.stringify(props.domainAlias))
}
@@ -69,7 +69,8 @@
</template>
<script setup lang="js">
import ParametersApi from '@/api/parameters.js'
import domainsApi from '@/api/domains'
import ParametersApi from '@/api/parameters'
import CreationForm from '@/components/tools/CreationForm.vue'
import DomainGeneralForm from './form_steps/DomainGeneralForm.vue'
import DomainDNSForm from './form_steps/DomainDNSForm.vue'
@@ -78,15 +79,14 @@ import DomainOptionsForm from './form_steps/DomainOptionsForm.vue'
import DomainTransportForm from './form_steps/DomainTransportForm.vue'
import { useGettext } from 'vue3-gettext'
import { ref, computed, onMounted } from 'vue'
import { useBusStore, useDomainsStore } from '@/stores'
import { useBusStore } from '@/stores'
import { useRouter } from 'vue-router'
const { $gettext } = useGettext()
const busStore = useBusStore()
const domainsStore = useDomainsStore()
const router = useRouter()
const emit = defineEmits(['close'])
const emit = defineEmits(['close', 'created'])
const defaultDomain = {
name: '',
@@ -123,6 +123,7 @@ onMounted(() => {
const createAdmin = ref(false)
// Reference to steps components
const form = ref()
const general = ref()
const dns = ref()
const limitations = ref()
@@ -298,7 +299,7 @@ function updateCreateAdmin(value) {
createAdmin.value = value
}
function submit() {
async function submit() {
const data = JSON.parse(JSON.stringify(domain.value))
if (!createAdmin.value) {
delete data.domain_admin
@@ -311,11 +312,14 @@ function submit() {
} else {
delete data.transport
}
domainsStore.createDomain(data).then((resp) => {
try {
const resp = await domainsApi.createDomain(data)
router.push({ name: 'DomainDetail', params: { id: resp.data.pk } })
busStore.displayNotification({ msg: $gettext('Domain created') })
close()
})
} finally {
form.value.working = false
}
}
</script>
@@ -1,5 +1,5 @@
<template>
<LoadingData v-if="!domainsStore.domainsLoaded || working" />
<LoadingData v-if="working" />
<div v-else>
<v-expansion-panels v-model="panel" :multiple="formErrors">
<v-expansion-panel eager value="generalForm">
@@ -181,13 +181,13 @@ import parametersApi from '@/api/parameters'
import ResourcesForm from '@/components/tools/ResourcesForm.vue'
import { computed, ref, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useDomainsStore } from '@/stores'
import { useAuthStore } from '@/stores'
import { useRouter } from 'vue-router'
import domainsApi from '@/api/domains'
const router = useRouter()
const { $gettext } = useGettext()
const authStore = useAuthStore()
const domainsStore = useDomainsStore()
const props = defineProps({ domain: { type: Object, default: null } })
@@ -253,8 +253,13 @@ async function save() {
if (data.type === 'relaydomain') {
transportForm.value.checkSettingTypes(data)
}
domainsStore.updateDomain(data).then(() => {
router.go(-1)
if (data.message_limit === '') {
data.message_limit = null
}
await domainsApi.updateDomain(editedDomain.value.pk, data)
router.push({
name: 'DomainDetail',
params: { id: editedDomain.value.pk },
})
} finally {
working.value = false
@@ -4,12 +4,12 @@
v-model="selected"
:expanded="expanded"
:headers="domainHeaders"
:items="Object.values(domains)"
:items="domains"
item-value="name"
:search="search"
show-select
expand-on-click
:loading="!domainsLoaded"
:loading="loading"
>
<template #top>
<v-toolbar color="white" flat>
@@ -19,7 +19,6 @@
:placeholder="$gettext('Search')"
variant="outlined"
single-line
flat
hide-details
density="compact"
class="flex-grow-0 w-33 mr-4"
@@ -106,7 +105,7 @@
<td>
<v-progress-linear v-model="item.allocated_quota_in_percent" />
</td>
<td>
<td v-if="canSetRole">
<div class="text-right">
<v-menu offset-y>
<template #activator="{ props }">
@@ -149,7 +148,7 @@
<a href="#" class="mr-2" @click="editDomainAlias(item, alias)">{{
alias.name
}}</a>
<v-chip variant="elevated" size="x-small" color="success"
<v-chip variant="flat" size="x-small" color="success"
>DNS OK</v-chip
>
</span>
@@ -181,25 +180,24 @@
</template>
<script setup lang="js">
import { useBusStore, useDomainsStore } from '@/stores'
import { useBusStore } from '@/stores'
import { useGettext } from 'vue3-gettext'
import { useRouter } from 'vue-router'
import { usePermissions } from '@/composables/permissions'
import DomainAdminList from './DomainAdminList.vue'
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
import DNSStatusChip from './DNSStatusChip.vue'
import DomainAliasForm from './DomainAliasForm.vue'
import MenuItems from '@/components/tools/MenuItems.vue'
import domainsApi from '@/api/domains'
import { computed, ref, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
const { $gettext } = useGettext()
const router = useRouter()
const { canSetRole } = usePermissions()
const busStore = useBusStore()
const domainStore = useDomainsStore()
const domains = computed(() => domainStore.domains)
const domainsLoaded = computed(() => domainStore.domainsLoaded)
const aliases = computed(() => domainStore.domainAliases)
const domainHeaders = [
{ title: $gettext('Name'), key: 'name' },
@@ -211,16 +209,22 @@ const domainHeaders = [
},
{ title: $gettext('Sending limit'), key: 'message_limit' },
{ title: $gettext('Quota'), key: 'allocated_quota_in_percent' },
{
]
if (canSetRole.value) {
domainHeaders.push({
title: $gettext('Actions'),
value: 'actions',
sortable: false,
align: 'end',
},
]
})
}
const aliases = ref({})
const confirm = ref()
const domains = ref([])
const keepDomainFolder = ref(false)
const loading = ref(false)
const selectedDomain = ref(null)
const selectedDomainAlias = ref(null)
const showAdminList = ref(false)
@@ -229,8 +233,15 @@ const search = ref('')
const selected = ref([])
const expanded = ref([])
function reloadDomains() {
domainStore.getDomains()
async function reloadDomains() {
aliases.value = {}
loading.value = true
try {
const resp = await domainsApi.getDomains()
domains.value = resp.data
} finally {
loading.value = false
}
}
function closeDomainAliasForm() {
@@ -255,10 +266,10 @@ async function deleteDomain(domain) {
return
}
const data = { keep_folder: keepDomainFolder.value }
domainStore.deleteDomain({ id: domain.pk, data }).then(() => {
busStore.displayNotification({ msg: $gettext('Domain deleted') })
keepDomainFolder.value = false
})
await domainsApi.deleteDomain(domain.pk, data)
reloadDomains()
busStore.displayNotification({ msg: $gettext('Domain deleted') })
keepDomainFolder.value = false
}
function editDomain(domain) {
@@ -277,7 +288,8 @@ function loadAliases(item, internalItem, isItemExpanded, toggleExpand) {
toggleExpand(internalItem)
return
}
domainStore.getAliases(item.name).then(() => {
domainsApi.getDomainAliases(item.name).then((resp) => {
aliases.value[item.name] = resp.data
toggleExpand(internalItem)
})
}
@@ -320,6 +332,10 @@ function getDomainMenuItems(domain) {
onMounted(() => {
reloadDomains()
})
defineExpose({
reloadDomains,
})
</script>
<style scoped>
@@ -35,6 +35,7 @@
'Number of messages this domain can send per day. Leave empty for no limit.'
)
"
type="number"
persistent-hint
variant="outlined"
:rules="[rules.numericOrNull]"
@@ -43,7 +43,7 @@
:title="$gettext('Copy to clipboard')"
@click="copyPassword"
>
{{ account.password }}
{{ account.new_password }}
</v-btn>
</v-col>
</v-row>
@@ -70,13 +70,14 @@ import AccountMailboxForm from './form_steps/AccountMailboxForm.vue'
import AccountAliasForm from './form_steps/AccountAliasForm.vue'
import { useGettext } from 'vue3-gettext'
import { ref, computed, onMounted } from 'vue'
import { useBusStore, useDomainsStore, useIdentitiesStore } from '@/stores'
import { ref, computed } from 'vue'
import { useBusStore, useDomainsStore } from '@/stores'
import { usePermissions } from '@/composables/permissions'
const { $gettext } = useGettext()
const { canSetRole } = usePermissions()
const busStore = useBusStore()
const domainsStore = useDomainsStore()
const identitiesStore = useIdentitiesStore()
const emit = defineEmits(['close', 'created'])
const needsMailbox = computed(
@@ -157,16 +158,17 @@ const summarySections = computed(() => {
})
const steps = computed(() => {
const result = [
{
const result = []
if (canSetRole.value) {
result.push({
name: 'role',
title: $gettext('Role'),
},
{
name: 'identification',
title: $gettext('Identification'),
},
]
})
}
result.push({
name: 'identification',
title: $gettext('Identification'),
})
if (needsMailbox.value) {
return result.concat([
{
@@ -193,6 +195,7 @@ const account = ref({
})
// Form refs
const form = ref()
const role = ref()
const identification = ref()
const mailbox = ref()
@@ -206,7 +209,7 @@ const formStepsComponents = {
}
function copyPassword() {
navigator.clipboard.writeText(account.value.password).then(() => {
navigator.clipboard.writeText(account.value.new_password).then(() => {
busStore.displayNotification({
msg: $gettext('Password copied to clipboard'),
})
@@ -252,17 +255,21 @@ function close() {
emit('close')
}
function submit() {
async function submit() {
const data = preparePayload({ ...account.value })
identitiesStore.createIdentity('account', data).then(() => {
data.password = data.new_password
delete data.new_password
delete data.password_confirmation
try {
await accountsApi.create(data)
emit('created')
close()
})
} finally {
form.value.working = false
}
}
onMounted(() => {
domainsStore.getDomains()
})
domainsStore.getDomains()
</script>
<style lang="scss">
@@ -1,8 +1,8 @@
<template>
<LoadingData v-if="!accountsStore.accountsLoaded || working" />
<LoadingData v-if="working" />
<div v-else>
<v-expansion-panels v-model="panel" :multiple="formErrors">
<v-expansion-panel eager value="roleForm">
<v-expansion-panel v-if="canSetRole" eager value="roleForm">
<v-expansion-panel-title v-slot="{ expanded }">
<v-row no-gutters>
<v-col cols="4">
@@ -12,7 +12,7 @@
<v-fade-transition leave-absolute>
<span v-if="expanded"></span>
<v-row v-else no-gutters style="width: 100%">
<v-col cols="6">
<v-col v-if="roleForm" cols="6">
{{ roleForm.roleLabel }}
</v-col>
</v-row>
@@ -30,7 +30,7 @@
<v-col cols="4">
{{ $gettext('Identification') }}
</v-col>
<v-col cols="8" class="text-secondary">
<v-col cols="8" class="">
<v-fade-transition leave-absolute>
<span v-if="expanded"></span>
<v-row v-else no-gutters style="width: 100%">
@@ -68,7 +68,7 @@
<v-col cols="4">
{{ $gettext('Mailbox') }}
</v-col>
<v-col cols="8" class="text-secondary">
<v-col cols="8" class="">
<v-fade-transition leave-absolute>
<span v-if="expanded"></span>
<v-row v-else no-gutters style="width: 100%">
@@ -115,14 +115,13 @@
<v-col cols="4">
{{ $gettext('Alias') }}
</v-col>
<v-col cols="8" class="text-secondary">
<v-col cols="8" class="">
<v-fade-transition leave-absolute>
<span v-if="expanded"></span>
<v-row v-else no-gutters style="width: 100%">
<template v-if="editedAccount.aliases">
<v-col cols="6">
<div class="mr-2">
{{ $gettext('Aliases:') }}
{{ $gettext('Number of associated alias:') }}
{{ editedAccount.aliases.length }}
</div>
@@ -143,7 +142,7 @@
<v-col cols="4">
{{ $gettext('Resources') }}
</v-col>
<v-col cols="8" class="text-secondary">
<v-col cols="8" class="">
<v-fade-transition leave-absolute>
<span v-if="expanded"></span>
<v-row v-else no-gutters style="width: 100%">
@@ -155,14 +154,12 @@
>
<v-col cols="6">
<div class="mr-2">
{{ $gettext('Mailbox:') }}
{{ $gettext('Number of allowed mailboxes:') }}
{{ editedAccount.resources[0].max_value }}
</div>
</v-col>
<v-col cols="6">
<div class="mr-2">
{{ $gettext('Mailbox aliases:') }}
{{ $gettext('Number of allowed mailbox aliases:') }}
{{ editedAccount.resources[1].max_value }}
</div>
@@ -182,12 +179,13 @@
</v-expansion-panel>
</v-expansion-panels>
<div class="mt-4 d-flex justify-end">
<v-btn :loading="working" @click="$router.go(-1)">
<v-btn :loading="working" variant="outlined" @click="$router.go(-1)">
{{ $gettext('Cancel') }}
</v-btn>
<v-btn
class="ml-4"
color="primary darken-1"
variant="outlined"
:loading="working"
@click="save"
>
@@ -198,25 +196,25 @@
</template>
<script setup lang="js">
import { useAccountsStore, useIdentitiesStore } from '@/stores'
import { useAuthStore } from '@/stores'
import AccountGeneralForm from './form_steps/AccountGeneralForm.vue'
import AccountMailboxForm from './form_steps/AccountMailboxForm.vue'
import AccountAliasForm from './form_steps/AccountAliasForm.vue'
import AccountRoleForm from './form_steps/AccountRoleForm.vue'
import ResourcesForm from '@/components/tools/ResourcesForm.vue'
import LoadingData from '@/components/tools/LoadingData.vue'
import accountsApi from '@/api/accounts.js'
import parametersApi from '@/api/parameters'
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { watch } from 'vue'
import { usePermissions } from '@/composables/permissions'
const { $gettext } = useGettext()
const accountsStore = useAccountsStore()
const identitiesStore = useIdentitiesStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const { canSetRole } = usePermissions()
const editedAccount = ref({ pk: route.params.id })
@@ -290,8 +288,9 @@ async function save() {
} else {
delete data.mailbox.full_address
}
if (!data.password) {
delete data.password
if (data.new_password) {
data.password = data.new_password
delete data.new_password
delete data.password_confirmation
}
if (needsResources.value && resourcesForm.value !== null) {
@@ -300,11 +299,17 @@ async function save() {
if (data.aliases === null) {
delete data.aliases
}
identitiesStore.updateIdentity('account', data).then(() => {
router.push({
name: 'AccountDetail',
params: { id: route.params.id },
})
if (!canSetRole.value) {
if (editedAccount.value.pk === authStore.authUser.pk) {
data.role = authStore.authUser.role
} else {
data.role = 'SimpleUsers'
}
}
await accountsApi.patch(editedAccount.value.pk, data)
router.push({
name: 'AccountDetail',
params: { id: route.params.id },
})
} finally {
working.value = false
@@ -312,13 +317,16 @@ async function save() {
}
onMounted(() => {
accountsStore
.getAccount(route.params.id)
.then((response) => (editedAccount.value = { ...response.data }))
parametersApi.getGlobalApplication('limits').then((response) => {
limitsConfig.value = response.data
accountsApi.get(route.params.id).then((response) => {
editedAccount.value = response.data
working.value = false
})
if (canSetRole.value) {
parametersApi.getGlobalApplication('limits').then((response) => {
limitsConfig.value = response.data
})
}
})
watch(
@@ -20,7 +20,7 @@
</template>
<script setup lang="js">
import { useBusStore, useIdentitiesStore } from '@/stores'
import { useBusStore } from '@/stores'
import { ref, computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useRouter } from 'vue-router'
@@ -33,12 +33,12 @@ import CreationForm from '@/components/tools/CreationForm.vue'
const { $gettext } = useGettext()
const busStore = useBusStore()
const identitiesStore = useIdentitiesStore()
const router = useRouter()
const emit = defineEmits(['close', 'created'])
//Forms refs
const form = ref()
const general = ref()
const recipients = ref()
@@ -139,13 +139,17 @@ function validateAlias() {
return validation
}
function submit() {
async function submit() {
if (alias.value.domain !== undefined) {
delete alias.value.domain
}
identitiesStore.createIdentity('alias', alias.value).then(() => {
try {
await aliasesApi.create(alias.value)
emit('created')
close()
})
} finally {
form.value.working = false
}
}
</script>
@@ -1,5 +1,5 @@
<template>
<LoadingData v-if="!aliasesStore.aliasesLoaded || working" />
<LoadingData v-if="working" />
<div v-else>
<v-expansion-panels v-model="panel" :multiple="formErrors">
<v-expansion-panel eager value="generalForm">
@@ -81,24 +81,22 @@
</template>
<script setup lang="js">
import { useIdentitiesStore, useAliasesStore } from '@/stores'
import AliasGeneralForm from './form_steps/AliasGeneralForm.vue'
import AliasRecipientForm from './form_steps/AliasRecipientForm'
import LoadingData from '@/components/tools/LoadingData.vue'
import { useGettext } from 'vue3-gettext'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import aliasesApi from '@/api/aliases'
const { $gettext } = useGettext()
const identitiesStore = useIdentitiesStore()
const aliasesStore = useAliasesStore()
const route = useRoute()
const router = useRouter()
const editedAlias = ref({ pk: route.params.id })
const panel = ref(0)
const working = ref(false)
const working = ref(true)
const formErrors = ref(false)
// refs
@@ -126,20 +124,21 @@ async function save() {
working.value = true
identitiesStore
.updateIdentity('alias', editedAlias.value)
.then(() =>
router.push({
name: 'AliasDetail',
params: { id: route.params.id },
})
)
.finally(() => (working.value = false))
try {
await aliasesApi.patch(route.params.id, editedAlias.value)
router.push({
name: 'AliasDetail',
params: { id: route.params.id },
})
} finally {
working.value = false
}
}
onMounted(() => {
aliasesStore.getAlias(route.params.id).then((response) => {
editedAlias.value = { ...response.data }
aliasesApi.get(route.params.id).then((response) => {
editedAlias.value = response.data
working.value = false
})
})
</script>
@@ -5,7 +5,7 @@
:headers="headers"
:items="identities"
:search="search"
:loading="!identitiesLoaded"
:loading="loading"
item-value="identity"
class="elevation-0"
show-select
@@ -77,7 +77,7 @@
item.possible_actions.length !== 0
"
>
<v-icon size="2.5em" color="blue">mdi-circle-small</v-icon>
<v-icon size="large" color="blue">mdi-circle-small</v-icon>
</template>
<v-menu location="bottom">
<template #activator="{ props }">
@@ -107,13 +107,14 @@
<script setup lang="js">
import { ref, computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useIdentitiesStore } from '@/stores'
import MenuItems from '@/components/tools/MenuItems.vue'
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
import { useRouter } from 'vue-router'
import accountsApi from '@/api/accounts'
import aliasesApi from '@/api/aliases'
import identitiesApi from '@/api/identities'
const { $gettext } = useGettext()
const identitiesStore = useIdentitiesStore()
const router = useRouter()
const headers = ref([
@@ -128,18 +129,23 @@ const headers = ref([
},
])
const identities = computed(() => identitiesStore.identities)
const identitiesLoaded = computed(() => identitiesStore.identitiesLoaded)
const identities = ref([])
const loading = ref(false)
const search = ref('')
const selected = ref([])
const keepAccountFolder = ref(false)
const confirmAlias = ref()
const confirmAccount = ref()
function fetchIdentities() {
identitiesStore.getIdentities().then(() => {
async function fetchIdentities() {
loading.value = true
try {
const resp = await identitiesApi.getAll()
identities.value = resp.data
selected.value = []
})
} finally {
loading.value = false
}
}
function getMenuItems(item) {
@@ -214,13 +220,14 @@ async function deleteAccount(account) {
if (!result) {
return
}
identitiesStore
.deleteIdentity(account.type, account.pk, {
keepdir: keepAccountFolder.value,
})
.then(() => {
keepAccountFolder.value = false
})
loading.value = false
try {
await accountsApi.delete(account.pk, { keepdir: keepAccountFolder.value })
fetchIdentities()
} finally {
loading.value = false
}
}
function editAlias() {}
@@ -240,12 +247,20 @@ async function deleteAlias(alias) {
if (!result) {
return
}
identitiesStore.deleteIdentity(alias.type, alias.pk)
loading.value = false
try {
await aliasesApi.delete(alias.pk)
fetchIdentities()
} finally {
loading.value = false
}
}
onMounted(() => {
if (identities.value.length < 1) {
fetchIdentities()
}
fetchIdentities()
})
defineExpose({
fetchIdentities,
})
</script>
@@ -31,11 +31,15 @@
/>
<AccountPasswordSubForm
v-if="authStore.authUser.pk !== account.pk"
ref="passwordForm"
v-model="account"
:editing="editing"
:form-errors="formErrors"
/>
<v-alert v-else type="info" variant="tonal" class="py-2">
{{ $gettext('You can update your password from the Account section') }}
</v-alert>
<v-switch
v-model="account.is_active"
@@ -51,9 +55,12 @@ import AccountPasswordSubForm from './AccountPasswordSubForm.vue'
import EmailField from '@/components/tools/EmailField.vue'
import { ref, computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore } from '@/stores'
import rules from '@/plugins/rules'
const { $gettext } = useGettext()
const authStore = useAuthStore()
const props = defineProps({
modelValue: { type: Object, default: null },
editing: { type: Boolean, default: false },
@@ -53,8 +53,11 @@ import rules from '@/plugins/rules'
const { $gettext } = useGettext()
const emit = defineEmits(['update:modelValue'])
const props = defineProps({ modelValue: { type: Object, default: null } })
const domainsStore = useDomainsStore()
const domains = computed(() => domainsStore.domains)
const vFormRef = ref()
const form = ref({
mailbox: {},
@@ -94,7 +97,9 @@ watch(
const domainQuota = computed(() => {
const email = form.value.mailbox.full_address
if (email && email.indexOf('@') !== -1) {
const domain = domainsStore.getDomainByName(email.split('@')[1])
const domain = domains.value.find(
(item) => item.name === email.split('@')[1]
)
if (domain) {
return parseInt(domain.default_mailbox_quota)
}
@@ -122,7 +127,7 @@ function cleanQuota(value) {
function cleanMessageLimit(value) {
if (value === '') {
delete form.value.mailbox.message_limit
form.value.mailbox.message_limit = null
}
}
@@ -3,14 +3,15 @@
<template v-if="withPasswordCheck">
<label class="m-label">{{ $gettext('Current password') }}</label>
<v-text-field
v-model="account.currentPassword"
v-model="account.password"
autocomplete="new-password"
variant="outlined"
type="password"
density="compact"
validate-on="submit"
:rules="disabled ? [] : [rules.required, validPassword]"
:rules="disabled ? [] : [rules.required]"
:disabled="disabled"
:error-messages="props.formErrors ? props.formErrors.password : []"
/>
</template>
<v-row justify="center">
@@ -32,7 +33,7 @@
class="ml-6"
density="compact"
>
<span class="text-white mr-4">{{ account.password }}</span>
<span class="text-white mr-4">{{ account.new_password }}</span>
<v-btn
size="small"
color="white"
@@ -48,7 +49,7 @@
</v-row>
<template v-if="!account.random_password">
<v-text-field
v-model="account.password"
v-model="account.new_password"
variant="outlined"
:label="$gettext('Password')"
type="password"
@@ -65,9 +66,7 @@
density="compact"
:disabled="disabled"
:rules="isRuleActive ? [rules.required, passwordConfirmationRules] : []"
:error-messages="
formErrors && formErrors.value ? formErrors.value.password : []
"
:error-messages="props.formErrors ? props.formErrors.new_password : []"
/>
</template>
</div>
@@ -76,7 +75,6 @@
<script setup lang="js">
import { useBusStore } from '@/stores'
import accountsApi from '@/api/accounts'
import accountApi from '@/api/account'
import { computed } from 'vue'
import rules from '@/plugins/rules'
import { useGettext } from 'vue3-gettext'
@@ -98,10 +96,10 @@ const isRuleActive = computed(
)
const passwordConfirmationRules = (value) =>
value === account.value.password || $gettext('Password mismatch')
value === account.value.new_password || $gettext('Password mismatch')
function copyPassword() {
navigator.clipboard.writeText(account.value.password).then(() => {
navigator.clipboard.writeText(account.value.new_password).then(() => {
busStore.displayNotification({
msg: $gettext('Password copied to clipboard'),
})
@@ -111,21 +109,11 @@ function copyPassword() {
function updatePassword(value) {
if (value) {
accountsApi.getRandomPassword().then((resp) => {
account.value.password = resp.data.password
account.value.new_password = resp.data.password
})
} else {
account.value.password = null
account.value.new_password = null
account.value.password_confirmation = null
}
}
async function validPassword(value) {
if (value) {
return accountApi
.checkPassword(value)
.then(() => true)
.catch(() => $gettext('Invalid password'))
}
return true
}
</script>
@@ -21,7 +21,7 @@
<label class="m-label">{{ $gettext('Domain') }}</label>
<v-select
v-model="alias.domain"
:items="Object.values(domains)"
:items="domains"
item-title="name"
return-object
variant="outlined"
@@ -36,6 +36,7 @@
<v-text-field
v-model="alias.address"
:label="$gettext('Generated address')"
variant="outlined"
readonly
>
<template #append-inner>
@@ -68,16 +69,14 @@
import aliasesApi from '@/api/aliases'
import EmailField from '@/components/tools/EmailField'
import { ref, computed } from 'vue'
import { useDomainsStore } from '@/stores'
import rules from '@/plugins/rules'
import domainsApi from '@/api/domains'
const domainsStore = useDomainsStore()
const props = defineProps({ modelValue: { type: Object, default: null } })
const alias = computed(() => props.modelValue)
const domains = computed(() => domainsStore.domains)
const domains = ref([])
const vFormRef = ref()
function copyAlias() {
@@ -93,5 +92,9 @@ async function updateAddress() {
})
}
domainsApi.getDomains().then((resp) => {
domains.value = resp.data
})
defineExpose({ vFormRef })
</script>
@@ -164,6 +164,10 @@ function create() {
working.value = true
emit('create')
}
defineExpose({
working,
})
</script>
<style lang="scss">
+19
View File
@@ -0,0 +1,19 @@
import { computed } from 'vue'
import { useAuthStore } from '@/stores'
export function usePermissions() {
const authStore = useAuthStore()
const canSetRole = computed(() => {
return ['Resellers', 'SuperAdmins'].includes(authStore.authUser.role)
})
const canAddDomain = computed(() => {
return ['Resellers', 'SuperAdmins'].includes(authStore.authUser.role)
})
return {
canSetRole,
canAddDomain,
}
}
-83
View File
@@ -1,83 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useBusStore } from './bus.store'
import accountsApi from '@/api/accounts'
import gettext from '@/plugins/gettext'
export const useAccountsStore = defineStore('accounts', () => {
const busStore = useBusStore()
const { $gettext } = gettext
const accounts = ref({})
const accountsLoaded = ref(false)
async function $reset() {
accounts.value = {}
accountsLoaded.value = false
}
async function getAll() {
accounts.value = {}
return accountsApi.getAll().then((response) => {
for (const account of response.data) {
accounts.value[account.pk] = account
}
return response
})
}
async function getAccount(pk) {
accountsLoaded.value = false
return accountsApi
.get(pk)
.then((response) => {
accounts.value[pk] = response.data
return response
})
.finally(() => {
accountsLoaded.value = true
})
}
async function updateAccount(data) {
return accountsApi.patch(data.pk, data).then((response) => {
accounts.value[data.pk] = response.data
busStore.displayNotification({ msg: $gettext('Account updated') })
return response
})
}
async function createAccount(data) {
return accountsApi.create(data).then((response) => {
accounts.value[response.data.pk] = response.data
busStore.displayNotification({ msg: $gettext('Account created') })
return response
})
}
async function deleteAccount(pk, options) {
accountsLoaded.value = false
return accountsApi
.delete(pk, options)
.then((response) => {
busStore.displayNotification({
msg: $gettext('Account successfully deleted'),
})
return response
})
.finally(() => (accountsLoaded.value = true))
}
return {
accounts,
accountsLoaded,
getAll,
getAccount,
updateAccount,
createAccount,
deleteAccount,
$reset,
}
})
-94
View File
@@ -1,94 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import aliasesApi from '@/api/aliases'
import { useBusStore } from './bus.store'
import gettext from '@/plugins/gettext'
export const useAliasesStore = defineStore('aliases', () => {
const busStore = useBusStore()
const { $gettext } = gettext
const aliases = ref({})
const aliasesLoaded = ref(false)
async function $reset() {
aliases.value = {}
aliasesLoaded.value = false
}
async function getAll() {
aliasesLoaded.value = false
return aliasesApi
.getAll()
.then((response) => {
aliases.value = {}
for (const account of response.data) {
aliases.value[account.pk] = account
}
return response
})
.finally(() => (aliasesLoaded.value = true))
}
async function getAlias(pk) {
aliasesLoaded.value = false
return aliasesApi
.get(pk)
.then((response) => {
aliases.value[pk] = response.data
return response
})
.finally(() => (aliasesLoaded.value = true))
}
async function createAlias(data) {
aliasesLoaded.value = false
return aliasesApi
.create(data)
.then((response) => {
aliases.value[response.data.pk] = response.data
busStore.displayNotification({
msg: $gettext('Alias successfully created'),
})
return response
})
.finally(() => (aliasesLoaded.value = true))
}
async function deleteAlias(pk) {
aliasesLoaded.value = false
return aliasesApi
.delete(pk)
.then((response) => {
busStore.displayNotification({
msg: $gettext('Alias successfully deleted'),
})
return response
})
.finally(() => (aliasesLoaded.value = true))
}
async function updateAlias(data) {
aliasesLoaded.value = false
return aliasesApi
.patch(data.pk, data)
.then((response) => {
aliases.value[data.pk] = response.data
busStore.displayNotification({ msg: $gettext('Alias updated') })
return response
})
.finally(() => (aliasesLoaded.value = true))
}
return {
aliases,
aliasesLoaded,
getAll,
getAlias,
createAlias,
deleteAlias,
updateAlias,
$reset,
}
})
+5
View File
@@ -170,6 +170,10 @@ export const useAuthStore = defineStore('auth', () => {
})
}
async function updatePassword(data) {
return accountApi.setPassword(data)
}
function finalizeTFASetup(pinCode) {
return accountApi.finalizeTFASetup(pinCode).then((response) => {
const cookie = Cookies.withAttributes({ sameSite: 'strict' })
@@ -198,6 +202,7 @@ export const useAuthStore = defineStore('auth', () => {
login,
$reset,
updateAccount,
updatePassword,
finalizeTFASetup,
}
})
+5 -129
View File
@@ -1,155 +1,31 @@
import { defineStore } from 'pinia'
import { useBusStore } from './bus.store'
import { ref } from 'vue'
import gettext from '@/plugins/gettext'
import domainApi from '@/api/domains'
export const useDomainsStore = defineStore('domains', () => {
const busStore = useBusStore()
const { $gettext } = gettext
const domainsLoaded = ref(false)
const domains = ref({})
const domainAliases = ref({})
async function $reset() {
domainsLoaded.value = false
domains.value = {}
domainAliases.value = {}
}
async function getDomains() {
domainsLoaded.value = false
return domainApi.getDomains().then((response) => {
domains.value = {}
const _domains = {}
response.data.forEach(function (domain) {
_domains[domain.pk] = domain
})
domains.value = _domains
domainsLoaded.value = true
return response
})
}
async function getDomain(pk) {
domainsLoaded.value = false
return domainApi.getDomain(pk).then((response) => {
domains.value[pk] = response.data
domainsLoaded.value = true
return response
})
}
function getDomainByName(name) {
for (const domain of Object.values(domains.value)) {
if (domain.name === name) {
return domain
}
try {
const resp = await domainApi.getDomains()
domains.value = resp.data
} finally {
domainsLoaded.value = false
}
}
async function createDomain(data) {
return domainApi.createDomain(data).then((response) => {
domains.value[response.data.pk] = response.data
return response
})
}
async function updateDomain(data) {
return domainApi.updateDomain(data.pk, data).then((response) => {
domains.value[data.pk] = response.data
busStore.displayNotification({ msg: $gettext('Domain updated') })
return response
})
}
async function deleteDomain({ id, data }) {
return domainApi.deleteDomain(id, data).then((response) => {
delete domains.value[id]
return response
})
}
async function getAliases(name) {
return domainApi.getDomainAliases(name).then((response) => {
domainAliases.value[name] = response.data
return response
})
}
async function _filterAlias(domainName, pk) {
if (domainAliases.value[domainName] !== undefined) {
return domainAliases.value[domainName].filter((al) => al.pk != pk)
}
}
async function createAlias(data) {
return domainApi.createDomainAlias(data).then((response) => {
busStore.displayNotification({
msg: $gettext('Domain alias created'),
})
if (domains.value[data.target] != null) {
const domain = domains.value[data.target]
if (domainAliases.value[domain.name] !== undefined) {
domainAliases.value[domain.name].push(response.data)
} else {
domainAliases.value[domain.name] = response.data
}
domain.domainalias_count++
}
return response
})
}
async function deleteAlias(alias) {
const domain = domains.value[alias.target]
const apiRequest = domainApi.deleteDomainAlias(alias.pk)
const filter = _filterAlias(domain.name, alias.pk)
Promise.all([filter, apiRequest]).then((results) => {
const newList = results[0]
busStore.displayNotification({
msg: $gettext('Domain alias deleted'),
})
if (newList != null) {
domainAliases.value[domain.name] = newList
}
domain.domainalias_count--
})
}
async function updateAlias(data) {
const domain = domains.value[data.target]
const filter = _filterAlias(domain.name, data.pk)
const apiRequest = domainApi.updateDomainAlias(data.pk, data)
Promise.all([filter, apiRequest]).then((results) => {
busStore.displayNotification({
msg: $gettext('Domain alias updated'),
})
const newList = results[0]
const response = results[1]
if (newList != null) {
domainAliases.value[domain.name] = newList
domainAliases.value[domain.name].push(response.data)
} else {
domainAliases.value[domain.name] = response.data
}
})
}
return {
domainsLoaded,
domains,
domainAliases,
getDomain,
getDomainByName,
getDomains,
createDomain,
updateDomain,
deleteDomain,
createAlias,
getAliases,
deleteAlias,
updateAlias,
$reset,
}
})
-182
View File
@@ -1,182 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAccountsStore } from './accounts.store'
import { useAliasesStore } from './aliases.store'
import identitiesApi from '@/api/identities'
import gettext from '@/plugins/gettext'
export const useIdentitiesStore = defineStore('identities', () => {
const aliasesStore = useAliasesStore()
const accountsStore = useAccountsStore()
const { $gettext } = gettext
const identitiesLoaded = ref(false)
const identities = ref([])
async function $reset() {
identitiesLoaded.value = false
identities.value = []
}
async function _getIndexByPk(type, pk) {
for (let i = 0; i < identities.value.length; i++) {
if (identities.value[i].pk === pk && identities.value[i].type === type) {
return i
}
}
}
async function _deleteIdByPkAndType(type, pk) {
_getIndexByPk(type, pk).then((index) => {
if (index !== null) {
identities.value.splice(index, 1)
}
})
}
async function getIdentities() {
identitiesLoaded.value = false
return identitiesApi.getAll().then((response) => {
identities.value = response.data
identitiesLoaded.value = true
return response
})
}
async function _accountToId(data, update = false) {
const account = data
let indexSearcher = null
if (update) {
indexSearcher = _getIndexByPk('account', data.pk)
}
let name_or_rcpt = '---'
if (account.first_name !== '') {
name_or_rcpt = account.first_name
}
if (account.last_name !== '') {
name_or_rcpt =
name_or_rcpt === ''
? account.last_name
: `${name_or_rcpt} ${account.last_name}`
}
const newIdentity = {
pk: account.pk,
type: account.type,
identity: account.username,
name_or_rcpt: name_or_rcpt,
tags: [
{ name: 'account', label: $gettext('account'), type: 'idt' },
{
name: account.role,
label: $gettext(account.role),
type: 'grp',
},
],
possible_actions: [],
}
if (update) {
indexSearcher.then((index) => {
identities.value[index] = newIdentity
return
})
}
identities.value.push(newIdentity)
}
async function _aliasToId(data, update = false) {
let indexSearcher = null
const alias = data
if (update) {
indexSearcher = _getIndexByPk('alias', data.pk)
}
const name_or_rcpt =
alias.recipients.length > 1
? `${alias.recipients[0]}, ...`
: alias.recipients[0]
const newIdentity = {
pk: alias.pk,
type: alias.type,
identity: alias.address,
name_or_rcpt: name_or_rcpt,
tags: [{ name: 'alias', label: $gettext('alias'), type: 'idt' }],
possible_actions: [],
}
if (update) {
indexSearcher.then((index) => {
identities.value[index] = newIdentity
return
})
}
identities.value.push(newIdentity)
}
async function createIdentity(type, data) {
identitiesLoaded.value = false
if (type === 'alias') {
return aliasesStore.createAlias(data).then((response) => {
_aliasToId(response.data)
identitiesLoaded.value = true
return response
})
} else if (type === 'account') {
return accountsStore.createAccount(data).then((response) => {
_accountToId(response.data)
identitiesLoaded.value = true
return response
})
}
}
async function updateIdentity(type, data) {
identitiesLoaded.value = false
if (type === 'alias') {
return aliasesStore
.updateAlias(data)
.then((response) => {
_aliasToId(response.data, true)
return response
})
.finally(() => (identitiesLoaded.value = true))
} else if (type === 'account') {
return accountsStore
.updateAccount(data)
.then((response) => {
_accountToId(response.data, true)
return response
})
.finally(() => (identitiesLoaded.value = true))
}
}
async function deleteIdentity(type, pk, options) {
identitiesLoaded.value = false
if (type === 'alias') {
return aliasesStore.deleteAlias(pk).then((response) => {
_deleteIdByPkAndType(type, pk)
identitiesLoaded.value = true
return response
})
} else if (type === 'account') {
return accountsStore.deleteAccount(pk, options).then((response) => {
_deleteIdByPkAndType(type, pk)
identitiesLoaded.value = true
return response
})
}
}
return {
identities,
getIdentities,
identitiesLoaded,
deleteIdentity,
createIdentity,
updateIdentity,
$reset,
}
})
-3
View File
@@ -5,9 +5,6 @@ export * from './auth.store'
export * from './bus.store'
export * from './layout.store'
export * from './domains.store'
export * from './accounts.store'
export * from './aliases.store'
export * from './identities.store'
export * from './parameters.store'
export * from './providers.store'
export * from './contacts.store'
@@ -7,20 +7,19 @@
<script setup lang="js">
import { useGettext } from 'vue3-gettext'
import { useDomainsStore } from '@/stores'
import DomainEditForm from '@/components/admin/domains/DomainEditForm'
import { useRoute } from 'vue-router'
import { ref } from 'vue'
import domainsApi from '@/api/domains'
const { $gettext } = useGettext()
const domainsStore = useDomainsStore()
const route = useRoute()
const domain = ref({ pk: route.params.id })
const domain = ref({})
domainsStore
.getDomain(route.params.id)
.then((response) => (domain.value = response.data))
domainsApi.getDomain(route.params.id).then((resp) => {
domain.value = resp.data
})
</script>
<style scoped>
+11 -13
View File
@@ -1,5 +1,6 @@
<template>
<div>
<LoadingData v-if="!domain" />
<div v-else>
<v-toolbar flat>
<v-toolbar-title>
{{ $gettext('Domain') }} {{ domain.name }}
@@ -83,31 +84,28 @@ import DomainAdminList from '@/components/admin/domains/DomainAdminList.vue'
import DmarcAligmentChart from '@/components/admin/dmarc/DmarcAligmentChart.vue'
import DNSDetail from '@/components/admin/domains/DNSDetail.vue'
import DomainSummary from '@/components/admin/domains/DomainSummary.vue'
import { useDomainsStore } from '@/stores'
import LoadingData from '@/components/tools/LoadingData.vue'
import ResourcesForm from '@/components/tools/ResourcesForm.vue'
import TimeSerieChart from '@/components/tools/TimeSerieChart.vue'
import { useGettext } from 'vue3-gettext'
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import domainsApi from '@/api/domains'
const { $gettext } = useGettext()
const route = useRoute()
const domainsStore = useDomainsStore()
const domain = computed(() => {
if (domainsStore.domains[route.params.id] !== undefined) {
return domainsStore.domains[route.params.id]
}
refreshDomain()
return { pk: route.params.id }
})
const domain = ref(null)
const limitsConfig = ref({})
const tab = ref(null)
function refreshDomain() {
domainsStore.getDomain(route.params.id)
domainsApi.getDomain(route.params.id).then((resp) => {
domain.value = resp.data
})
}
refreshDomain()
</script>
<style scoped>
@@ -4,9 +4,9 @@
<v-toolbar-title>{{ $gettext('Domains') }}</v-toolbar-title>
</v-toolbar>
<DomainList>
<DomainList ref="domainList">
<template #extraActions>
<v-menu offset-y>
<v-menu v-if="canAddDomain" offset-y>
<template #activator="{ props }">
<v-btn
color="primary"
@@ -28,6 +28,7 @@
</v-list>
</v-menu>
<v-btn
v-if="canAddDomain"
class="mr-2"
:title="$gettext('Import domains and aliases from CSV file')"
variant="flat"
@@ -48,7 +49,7 @@
<DomainCreationForm @close="showDomainWizard = false" />
</v-dialog>
<v-dialog v-model="showAliasForm" persistent max-width="800px">
<DomainAliasForm @close="showAliasForm = false" />
<DomainAliasForm @close="closeAliasForm()" />
</v-dialog>
<v-dialog v-model="showImportForm" persistent max-width="800px">
<ImportForm
@@ -82,17 +83,20 @@
import { ref } from 'vue'
import domainApi from '@/api/domains'
import { useGettext } from 'vue3-gettext'
import { usePermissions } from '@/composables/permissions'
import DomainAliasForm from '@/components/admin/domains/DomainAliasForm'
import DomainCreationForm from '@/components/admin/domains/DomainCreationForm'
import DomainList from '@/components/admin/domains/DomainList'
import ImportForm from '@/components/tools/ImportForm'
import { importExportMixin } from '@/mixins/importExport'
const domainList = ref()
const showAliasForm = ref(false)
const showDomainWizard = ref(false)
const showImportForm = ref(false)
const { $gettext } = useGettext()
const { canAddDomain } = usePermissions()
const importForm = ref()
@@ -107,6 +111,11 @@ function exportDomains() {
function importDomains(data) {
importContent(domainApi, data, importForm, $gettext)
}
const closeAliasForm = () => {
showAliasForm.value = false
domainList.value.reloadDomains()
}
</script>
<style scoped>
@@ -42,7 +42,8 @@
<script setup lang="js">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAccountsStore } from '@/stores'
import { usePermissions } from '@/composables/permissions'
import accountsApi from '@/api/accounts'
import parametersApi from '@/api/parameters'
import AccountAliases from '@/components/admin/identities/AccountAliases.vue'
import AccountSenderAddresses from '@/components/admin/identities/AccountSenderAddresses.vue'
@@ -51,24 +52,24 @@ import DomainAdminDomains from '@/components/admin/identities/DomainAdminDomains
import ResourcesView from '@/components/tools/ResourcesView.vue'
const route = useRoute()
const accountsStore = useAccountsStore()
const { canSetRole } = usePermissions()
const account = computed(() => {
if (accountsStore.accounts[route.params.id] !== undefined) {
return accountsStore.accounts[route.params.id]
}
refreshAccount()
return { pk: route.params.id }
})
const account = ref({ pk: route.params.id })
const limitsConfig = ref({})
parametersApi.getGlobalApplication('limits').then((resp) => {
limitsConfig.value = resp.data
})
function refreshAccount() {
accountsStore.getAccount(route.params.id)
accountsApi.get(route.params.id).then((resp) => {
account.value = resp.data
})
}
if (canSetRole.value) {
parametersApi.getGlobalApplication('limits').then((resp) => {
limitsConfig.value = resp.data
})
}
refreshAccount()
</script>
<style scoped>
@@ -24,25 +24,22 @@
import AliasRecipientsSummary from '@/components/admin/identities/AliasRecipientsSummary.vue'
import AliasSummary from '@/components/admin/identities/AliasSummary.vue'
import { useGettext } from 'vue3-gettext'
import { computed } from 'vue'
import { useAliasesStore } from '@/stores'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import aliasesApi from '@/api/aliases'
const { $gettext } = useGettext()
const aliasesStore = useAliasesStore()
const route = useRoute()
const alias = computed(() => {
if (aliasesStore.aliases[route.params.id] !== undefined) {
return aliasesStore.aliases[route.params.id]
}
refreshAlias()
return { pk: route.params.id }
})
const alias = ref({ pk: route.params.id })
function refreshAlias() {
aliasesStore.getAlias(route.params.id)
aliasesApi.get(route.params.id).then((resp) => {
alias.value = resp.data
})
}
refreshAlias()
</script>
<style scoped>
@@ -4,7 +4,7 @@
<v-toolbar-title>{{ $gettext('Identities') }}</v-toolbar-title>
</v-toolbar>
<IdentityList>
<IdentityList ref="idList">
<template #extraActions>
<v-menu offset-y>
<template #activator="{ props }">
@@ -50,11 +50,17 @@
scrollable
z-index="10"
>
<AliasCreationForm @close="showAliasCreationWizard = false" />
<AliasCreationForm
@created="idList.fetchIdentities()"
@close="showAliasCreationWizard = false"
/>
</v-dialog>
<v-dialog v-model="showCreationWizard" fullscreen scrollable z-index="10">
<AccountCreationForm @close="showCreationWizard = false" />
<AccountCreationForm
@created="idList.fetchIdentities()"
@close="showCreationWizard = false"
/>
</v-dialog>
<v-dialog v-model="showImportForm" persistent max-width="800px">
@@ -115,6 +121,7 @@ const showCreationWizard = ref(false)
const showAliasCreationWizard = ref(false)
const showImportForm = ref(false)
const importForm = ref()
const idList = ref()
function exportIdentities() {
identitiesApi.exportAll().then((resp) => {
+15 -10
View File
@@ -352,12 +352,9 @@ class IdentitySerializer(serializers.Serializer):
identity = serializers.CharField()
name_or_rcpt = serializers.CharField()
tags = TagSerializer(many=True)
possible_actions = serializers.SerializerMethodField()
def __init__(self, instance=None, data=..., **kwargs):
super().__init__(instance, data, **kwargs)
self.fields["possible_actions"] = serializers.SerializerMethodField()
def get_possible_actions(self, identity):
def get_possible_actions(self, identity) -> list[IdPossibleActionsSerializer]:
if not isinstance(identity, core_models.User):
# Return empty action list if identity type is an alias
# (not used for now)
@@ -478,21 +475,31 @@ class WritableAccountSerializer(v1_serializers.WritableAccountSerializer):
fields = tuple(
field
for field in v1_serializers.WritableAccountSerializer.Meta.fields
if field != "random_password"
if field not in ["random_password", "domains"]
) + ("aliases",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not param_tools.get_global_parameter("enable_admin_limits", app="limits"):
return
self.fields["resources"] = WritableResourceSerializer(many=True, required=False)
del self.fields["domains"]
if self.context["request"].user.role in ["Resellers", "SuperAdmins"]:
self.fields["resources"] = WritableResourceSerializer(
many=True, required=False
)
def validate_aliases(self, value):
"""Check if required domains are locals and user can access them."""
aliases = []
existing_aliases = (
self.instance.mailbox.alias_addresses if self.instance else []
)
for alias in value:
localpart, domain = models.validate_alias_address(
alias, self.context["request"].user, ignore_existing=True
alias,
self.context["request"].user,
ignore_existing=True,
check_limits=alias not in existing_aliases,
)
aliases.append({"localpart": localpart, "domain": domain})
return aliases
@@ -609,7 +616,6 @@ class WritableAccountSerializer(v1_serializers.WritableAccountSerializer):
"""Update account and associated objects."""
mailbox_data = validated_data.pop("mailbox", None)
password = validated_data.pop("password", None)
domains = validated_data.pop("domains", [])
aliases = validated_data.pop("aliases", None)
for key, value in validated_data.items():
setattr(instance, key, value)
@@ -657,7 +663,6 @@ class WritableAccountSerializer(v1_serializers.WritableAccountSerializer):
allocate_resources_from_user(limit, owner, resource["max_value"])
limit.max_value = resource["max_value"]
limit.save()
self.set_permissions(instance, domains)
return instance
+1 -1
View File
@@ -217,8 +217,8 @@ class IdentityViewSet(GetThrottleViewsetMixin, viewsets.ViewSet):
"""Viewset for identities."""
permission_classes = (permissions.IsAuthenticated,)
serializer_class = None
@extend_schema(responses=serializers.IdentitySerializer(many=True))
def list(self, request, **kwargs):
"""Return all identities."""
serializer = serializers.IdentitySerializer(
+9 -3
View File
@@ -2,6 +2,7 @@
import hashlib
import random
from typing import Optional
from django.core.exceptions import ValidationError
from django.db import models
@@ -27,8 +28,13 @@ from .mailbox import Mailbox
def validate_alias_address(
address, creator, internal=False, instance=None, ignore_existing=False
):
address: str,
creator,
internal: bool = False,
instance: Optional["Alias"] = None,
ignore_existing: bool = False,
check_limits: bool = True,
) -> tuple[str, Domain]:
"""Check if the given alias address can be created by creator."""
local_part, domain = split_mailbox(address.lower())
domain = Domain.objects.filter(name=domain).first()
@@ -41,7 +47,7 @@ def validate_alias_address(
condition = alias.exists() and not ignore_existing
if condition:
raise AliasExists(alias.first().pk)
if instance is None:
if instance is None and check_limits:
try:
# Check creator limits
core_signals.can_create_object.send(
+4 -16
View File
@@ -149,31 +149,19 @@ class PermissionsTestCase(ModoAPITestCase):
{"name": "mailboxes", "max_value": 0},
{"name": "mailbox_aliases", "max_value": 0},
]
self.values["domains"] = ["test.com", "test2.com"]
response = self.client.put(
reverse("v2:account-detail", args=[self.user.id]),
self.values,
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertIn(
self.user, models.Domain.objects.get(name="test.com").admins.all()
)
self.assertIn(
self.user, models.Domain.objects.get(name="test2.com").admins.all()
)
for name in ["test.com", "test2.com"]:
domain = models.Domain.objects.get(name=name)
domain.add_admin(self.user)
self.values["domains"].remove("test2.com")
response = self.client.put(
reverse("v2:account-detail", args=[self.user.id]),
self.values,
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertIn(
self.user, models.Domain.objects.get(name="test.com").admins.all()
)
self.assertNotIn(
self.assertIn(
self.user, models.Domain.objects.get(name="test2.com").admins.all()
)
+1 -1
View File
@@ -21,7 +21,7 @@ class AddressBookViewSet(viewsets.GenericViewSet):
"""Return default user address book."""
abook = request.user.addressbook_set.first()
if not abook:
return response.Response(status_code=404)
return response.Response(status=404)
serializer = serializers.AddressBookSerializer(abook)
return response.Response(serializer.data)
+24 -23
View File
@@ -165,29 +165,30 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
"url": "/admin",
}
)
apps += [
{
"name": "calendar",
"label": _("Calendars"),
"icon": "mdi-calendar",
"description": _("Calendar"),
"url": "/user/calendars",
},
{
"name": "contacts",
"label": _("Contacts"),
"icon": "mdi-contacts-outline",
"description": _("Address book"),
"url": "/user/contacts",
},
{
"name": "webmail",
"label": _("Webmail"),
"icon": "mdi-at",
"description": _("Webmail"),
"url": "/user/webmail",
},
]
if hasattr(request.user, "mailbox"):
apps += [
{
"name": "calendar",
"label": _("Calendars"),
"icon": "mdi-calendar",
"description": _("Calendar"),
"url": "/user/calendars",
},
{
"name": "contacts",
"label": _("Contacts"),
"icon": "mdi-contacts-outline",
"description": _("Address book"),
"url": "/user/contacts",
},
{
"name": "webmail",
"label": _("Webmail"),
"icon": "mdi-at",
"description": _("Webmail"),
"url": "/user/webmail",
},
]
apps += exts_pool.get_available_apps()
serializer = serializers.ModoboaApplicationSerializer(apps, many=True)
return response.Response(serializer.data)