mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Merge pull request #3607 from modoboa/manage-identities-on-large-db
Manage identities on large db
This commit is contained in:
@@ -3,9 +3,9 @@ import repository from './repository'
|
||||
const resource = 'accounts'
|
||||
|
||||
export default {
|
||||
getAll({ domain, role }) {
|
||||
getAll(params) {
|
||||
return repository.get(`/${resource}/`, {
|
||||
params: { domain, role },
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
|
||||
@@ -3,8 +3,8 @@ import repository from './repository'
|
||||
const resource = 'aliases'
|
||||
|
||||
export default {
|
||||
getAll() {
|
||||
return repository.get(`${resource}/`)
|
||||
getAll(params) {
|
||||
return repository.get(`${resource}/`, { params })
|
||||
},
|
||||
get(aliasId) {
|
||||
return repository.get(`${resource}/${aliasId}/`)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Search"
|
||||
:placeholder="$gettext('Search')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
|
||||
@@ -130,9 +130,12 @@ function fetchAdministrators(domain) {
|
||||
|
||||
function fetchAccounts() {
|
||||
accountApi
|
||||
.getAll({ role: [constants.RESELLER, constants.DOMAIN_ADMIN] })
|
||||
.getAll({
|
||||
role: [constants.RESELLER, constants.DOMAIN_ADMIN],
|
||||
page_size: 50,
|
||||
})
|
||||
.then((resp) => {
|
||||
accounts.value = resp.data.filter(
|
||||
accounts.value = resp.data.results.filter(
|
||||
(account) =>
|
||||
administrators.value.find((admin) => admin.id === account.pk) ===
|
||||
undefined
|
||||
|
||||
@@ -348,11 +348,20 @@ defineExpose({
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.v-text-field--outlined :deep(fieldset) {
|
||||
border-color: #bfc5d2;
|
||||
}
|
||||
.v-input--checkbox :deep(.v-label) {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
|
||||
&:visited {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<template>
|
||||
<v-card class="mt-6">
|
||||
<v-data-table
|
||||
<v-data-table-server
|
||||
v-model="selected"
|
||||
:headers="headers"
|
||||
:items="identities"
|
||||
:search="search"
|
||||
:loading="loading"
|
||||
item-value="identity"
|
||||
class="elevation-0"
|
||||
:items-length="totalIdentities"
|
||||
:page="currentPage"
|
||||
:items-per-page="itemsPerPageR"
|
||||
item-value="pk"
|
||||
elevation="0"
|
||||
show-select
|
||||
:sort-by="sortByR"
|
||||
@update:options="updatedOptions"
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar flat color="white">
|
||||
@@ -24,7 +29,13 @@
|
||||
class="flex-grow-0 w-33 mr-4"
|
||||
></v-text-field>
|
||||
<slot name="extraActions" />
|
||||
<v-menu location="bottom">
|
||||
<v-btn
|
||||
variant="text"
|
||||
icon="mdi-format-columns"
|
||||
:title="$gettext('Customize displayed columns')"
|
||||
@click="showColumnsForm = true"
|
||||
></v-btn>
|
||||
<v-menu v-if="selected.length > 1" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
append-icon="mdi-chevron-down"
|
||||
@@ -44,20 +55,45 @@
|
||||
icon="mdi-reload"
|
||||
@click="fetchIdentities"
|
||||
></v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<template #extension>
|
||||
<v-radio-group
|
||||
v-model="identityType"
|
||||
inline
|
||||
:readonly="loading"
|
||||
hide-details
|
||||
@update:model-value="updateType"
|
||||
>
|
||||
<v-radio
|
||||
color="primary"
|
||||
:label="$gettext('Accounts')"
|
||||
value="account"
|
||||
></v-radio>
|
||||
<v-radio
|
||||
color="primary"
|
||||
:label="$gettext('Alias')"
|
||||
value="alias"
|
||||
></v-radio>
|
||||
</v-radio-group>
|
||||
</template>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template #[`item.identity`]="{ item }">
|
||||
<template v-if="item.type === 'account'">
|
||||
<router-link :to="{ name: 'AccountDetail', params: { id: item.pk } }">
|
||||
{{ item.identity }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link :to="{ name: 'AliasDetail', params: { id: item.pk } }">
|
||||
{{ item.identity }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template #[`item.username`]="{ item }">
|
||||
<router-link :to="{ name: 'AccountDetail', params: { id: item.pk } }">
|
||||
{{ item.username }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template #[`item.address`]="{ item }">
|
||||
<router-link :to="{ name: 'AliasDetail', params: { id: item.pk } }">
|
||||
{{ item.address }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template #[`item.is_active`]="{ item }">
|
||||
<v-icon
|
||||
:color="item.is_active ? 'success' : 'error'"
|
||||
:icon="item.is_active ? 'mdi-check-circle' : 'mdi-close-circle'"
|
||||
variant="flat"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.enabled`]="{ item }">
|
||||
<v-icon
|
||||
@@ -66,15 +102,24 @@
|
||||
variant="flat"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.tags`]="{ item }">
|
||||
<v-chip
|
||||
v-for="(tag, index) in item.tags"
|
||||
:key="tag.name"
|
||||
:color="tag.type !== 'idt' ? 'primary' : 'default'"
|
||||
:class="index > 0 ? 'ml-2' : ''"
|
||||
size="small"
|
||||
>
|
||||
{{ tag.label }}
|
||||
<template #[`item.recipients`]="{ item }">
|
||||
{{ displayRecipients(item) }}
|
||||
</template>
|
||||
<template #[`item.date_joined`]="{ item }">
|
||||
{{ $date(item.date_joined) }}
|
||||
</template>
|
||||
<template #[`item.creation`]="{ item }">
|
||||
{{ $date(item.creation) }}
|
||||
</template>
|
||||
<template #[`item.last_modification`]="{ item }">
|
||||
{{ $date(item.last_modification) }}
|
||||
</template>
|
||||
<template #[`item.last_login`]="{ item }">
|
||||
{{ $date(item.last_login) }}
|
||||
</template>
|
||||
<template #[`item.role`]="{ item }">
|
||||
<v-chip color="default" size="small">
|
||||
{{ item.role }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
@@ -99,7 +144,7 @@
|
||||
<MenuItems :items="getMenuItems(item)" :obj="item" />
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-data-table-server>
|
||||
<ConfirmDialog ref="confirmAlias" />
|
||||
<ConfirmDialog ref="confirmAccount">
|
||||
<v-checkbox
|
||||
@@ -108,37 +153,115 @@
|
||||
hide-details
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
<v-dialog v-model="showColumnsForm" max-width="500">
|
||||
<ChooseColumnsForm
|
||||
:columns="columns"
|
||||
:available-columns="currentAvailableColumns"
|
||||
:mandatory-columns="mandatoryColumns"
|
||||
@apply="updateDisplayedColumns"
|
||||
/>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import MenuItems from '@/components/tools/MenuItems.vue'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
|
||||
import ChooseColumnsForm from '@/components/tools/ChooseColumnsForm.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useBusStore } from '@/stores'
|
||||
import accountsApi from '@/api/accounts'
|
||||
import aliasesApi from '@/api/aliases'
|
||||
import identitiesApi from '@/api/identities'
|
||||
import debounce from 'debounce'
|
||||
|
||||
const { $gettext, $pgettext } = useGettext()
|
||||
const { $gettext } = useGettext()
|
||||
const router = useRouter()
|
||||
const { displayNotification } = useBusStore()
|
||||
|
||||
const headers = ref([
|
||||
{ title: $gettext('Name'), key: 'identity' },
|
||||
{ title: $pgettext('female', 'Enabled'), key: 'enabled', width: '5%' },
|
||||
{ title: $gettext('Fullname/recipient'), key: 'name_or_rcpt' },
|
||||
{ title: $gettext('Tags'), key: 'tags' },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
align: 'end',
|
||||
const availableAliasesColumns = {
|
||||
address: { title: $gettext('Address'), key: 'address' },
|
||||
enabled: { title: $gettext('Enabled'), key: 'enabled' },
|
||||
recipients: {
|
||||
title: $gettext('Recipients'),
|
||||
key: 'recipients',
|
||||
},
|
||||
])
|
||||
expire_at: {
|
||||
title: $gettext('Expire at'),
|
||||
key: 'expire_at',
|
||||
},
|
||||
description: {
|
||||
title: $gettext('Description'),
|
||||
key: 'description',
|
||||
},
|
||||
creation: {
|
||||
title: $gettext('Creation date'),
|
||||
key: 'creation',
|
||||
},
|
||||
last_modification: {
|
||||
title: $gettext('Last modification'),
|
||||
key: 'last_modification',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultAliasColumns = [
|
||||
availableAliasesColumns.address,
|
||||
availableAliasesColumns.enabled,
|
||||
availableAliasesColumns.recipients,
|
||||
]
|
||||
|
||||
const availableAccountsColumns = {
|
||||
username: { title: $gettext('Name'), key: 'username' },
|
||||
first_name: {
|
||||
title: $gettext('First name'),
|
||||
key: 'first_name',
|
||||
},
|
||||
last_name: {
|
||||
title: $gettext('Last Name'),
|
||||
key: 'last_name',
|
||||
},
|
||||
is_active: { title: $gettext('Enabled'), key: 'is_active' },
|
||||
role: { title: $gettext('Role'), key: 'role' },
|
||||
language: { title: $gettext('Language'), key: 'language' },
|
||||
phone_number: {
|
||||
title: $gettext('Phone number'),
|
||||
key: 'phone_number',
|
||||
},
|
||||
secondary_email: {
|
||||
title: $gettext('Secondary email'),
|
||||
key: 'secondary_email',
|
||||
},
|
||||
tfa_enabled: {
|
||||
title: $gettext('TFA enabled'),
|
||||
key: 'tfa_enabled',
|
||||
},
|
||||
totp_enabled: {
|
||||
title: $gettext('TOTP enabled'),
|
||||
key: 'totp_enabled',
|
||||
},
|
||||
webauth_enabled: {
|
||||
title: $gettext('WebAutn enabled'),
|
||||
key: 'webauth_enabled',
|
||||
},
|
||||
date_joined: {
|
||||
title: $gettext('Creation date'),
|
||||
key: 'date_joined',
|
||||
},
|
||||
last_login: {
|
||||
title: $gettext('Last login'),
|
||||
key: 'last_login',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultAccountsColumns = [
|
||||
availableAccountsColumns.username,
|
||||
availableAccountsColumns.is_active,
|
||||
availableAccountsColumns.role,
|
||||
]
|
||||
|
||||
const identityType = ref('account')
|
||||
const totalIdentities = ref(0)
|
||||
const identities = ref([])
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
@@ -146,21 +269,95 @@ const selected = ref([])
|
||||
const keepAccountFolder = ref(false)
|
||||
const confirmAlias = ref()
|
||||
const confirmAccount = ref()
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPageR = ref(10)
|
||||
const sortByR = ref([])
|
||||
const columns = ref(defaultAccountsColumns)
|
||||
const showColumnsForm = ref(false)
|
||||
|
||||
const headers = computed(() => {
|
||||
const result = columns.value
|
||||
return result.concat([
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
align: 'end',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
const currentAvailableColumns = computed(() => {
|
||||
if (identityType.value === 'account') {
|
||||
return Object.values(availableAccountsColumns)
|
||||
} else {
|
||||
return Object.values(availableAliasesColumns)
|
||||
}
|
||||
})
|
||||
|
||||
const mandatoryColumns = computed(() => {
|
||||
if (identityType.value === 'account') {
|
||||
return ['username']
|
||||
}
|
||||
return ['address']
|
||||
})
|
||||
|
||||
async function updateType(value) {
|
||||
loading.value = true
|
||||
identities.value = []
|
||||
sortByR.value = []
|
||||
if (value === 'account') {
|
||||
columns.value = defaultAccountsColumns
|
||||
} else {
|
||||
columns.value = defaultAliasColumns
|
||||
}
|
||||
await fetchIdentities()
|
||||
}
|
||||
|
||||
async function updatedOptions({ page, itemsPerPage, sortBy }) {
|
||||
currentPage.value = page
|
||||
itemsPerPageR.value = itemsPerPage
|
||||
sortByR.value = sortBy
|
||||
fetchIdentities()
|
||||
}
|
||||
|
||||
async function fetchIdentities() {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: currentPage.value || 1,
|
||||
page_size: itemsPerPageR.value || 10,
|
||||
}
|
||||
if (sortByR.value && sortByR.value.length) {
|
||||
params.ordering = sortByR.value
|
||||
.map((item) => (item.order !== 'asc' ? `-${item.key}` : item.key))
|
||||
.join(',')
|
||||
}
|
||||
if (search.value !== '') {
|
||||
params.search = search.value
|
||||
}
|
||||
try {
|
||||
const resp = await identitiesApi.getAll()
|
||||
identities.value = resp.data
|
||||
const resp =
|
||||
identityType.value === 'account'
|
||||
? await accountsApi.getAll(params)
|
||||
: await aliasesApi.getAll(params)
|
||||
identities.value = resp.data.results
|
||||
totalIdentities.value = resp.data.count
|
||||
selected.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateDisplayedColumns(selection) {
|
||||
columns.value = currentAvailableColumns.value.filter((col) =>
|
||||
selection.includes(col.key)
|
||||
)
|
||||
showColumnsForm.value = false
|
||||
}
|
||||
|
||||
function getMenuItems(item) {
|
||||
const result = []
|
||||
if (item.type === 'account') {
|
||||
if (identityType.value === 'account') {
|
||||
item.possible_actions.forEach((element) => {
|
||||
result.push({
|
||||
label: element.label,
|
||||
@@ -184,7 +381,7 @@ function getMenuItems(item) {
|
||||
onClick: deleteAccount,
|
||||
color: 'red',
|
||||
})
|
||||
} else if (item.type === 'alias') {
|
||||
} else if (identityType.value === 'alias') {
|
||||
result.push({
|
||||
label: $gettext('Edit'),
|
||||
icon: 'mdi-circle-edit-outline',
|
||||
@@ -268,6 +465,13 @@ async function deleteAlias(alias) {
|
||||
}
|
||||
}
|
||||
|
||||
function displayRecipients(alias) {
|
||||
if (!alias.recipients) {
|
||||
return ''
|
||||
}
|
||||
return alias.recipients.join(', ')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchIdentities()
|
||||
})
|
||||
@@ -275,4 +479,17 @@ onMounted(() => {
|
||||
defineExpose({
|
||||
fetchIdentities,
|
||||
})
|
||||
|
||||
fetchIdentities = debounce(fetchIdentities, 500)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
|
||||
&:visited {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<v-card :title="$gettext('Choose columns to display')">
|
||||
<v-card-text>
|
||||
<div v-for="column in props.availableColumns" :key="column.key">
|
||||
<v-checkbox
|
||||
v-model="model"
|
||||
:value="column.key"
|
||||
:label="column.title"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
:disabled="props.mandatoryColumns.includes(column.key)"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="outlined" @click="apply">
|
||||
{{ $gettext('Apply') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
availableColumns: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
mandatoryColumns: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const model = defineModel({ type: Array })
|
||||
const emit = defineEmits(['apply'])
|
||||
|
||||
const apply = () => {
|
||||
emit('apply', model.value)
|
||||
}
|
||||
|
||||
model.value = props.columns.map((col) => col.key)
|
||||
</script>
|
||||
@@ -402,9 +402,13 @@ class AccountSerializer(v1_serializers.AccountSerializer):
|
||||
aliases = serializers.ListField(
|
||||
child=lib_fields.DRFEmailFieldUTF8(), source="mailbox.alias_addresses"
|
||||
)
|
||||
possible_actions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(v1_serializers.AccountSerializer.Meta):
|
||||
fields = v1_serializers.AccountSerializer.Meta.fields + ("aliases",)
|
||||
fields = v1_serializers.AccountSerializer.Meta.fields + (
|
||||
"aliases",
|
||||
"possible_actions",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -412,6 +416,19 @@ class AccountSerializer(v1_serializers.AccountSerializer):
|
||||
return
|
||||
self.fields["resources"] = serializers.SerializerMethodField()
|
||||
|
||||
def get_possible_actions(self, identity) -> list[IdPossibleActionsSerializer]:
|
||||
actions = admin_signals.extra_account_identities_actions.send(
|
||||
self.__class__, account=identity
|
||||
)
|
||||
cleaned_actions = []
|
||||
for action in actions:
|
||||
try:
|
||||
serialized_data = IdPossibleActionsSerializer(action[1]).data
|
||||
cleaned_actions.append(serialized_data)
|
||||
except (ValidationError, AttributeError):
|
||||
continue
|
||||
return cleaned_actions
|
||||
|
||||
def get_resources(self, account) -> list[AccountResourceSerializer]:
|
||||
if account.role == "SimpleUsers":
|
||||
return []
|
||||
|
||||
@@ -168,7 +168,13 @@ class AccountFilterSet(dj_filters.FilterSet):
|
||||
class AccountViewSet(v1_viewsets.AccountViewSet):
|
||||
"""ViewSet for User/Mailbox."""
|
||||
|
||||
filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend)
|
||||
filter_backends = (
|
||||
filters.OrderingFilter,
|
||||
filters.SearchFilter,
|
||||
dj_filters.DjangoFilterBackend,
|
||||
)
|
||||
search_fields = ["^first_name", "^last_name", "^email", "username"]
|
||||
pagination_class = pagination.CustomPageNumberPagination
|
||||
filterset_class = AccountFilterSet
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -258,6 +264,13 @@ class IdentityViewSet(GetThrottleViewsetMixin, viewsets.ViewSet):
|
||||
class AliasViewSet(v1_viewsets.AliasViewSet):
|
||||
"""Viewset for Alias."""
|
||||
|
||||
filter_backends = (
|
||||
filters.OrderingFilter,
|
||||
filters.SearchFilter,
|
||||
dj_filters.DjangoFilterBackend,
|
||||
)
|
||||
pagination_class = pagination.CustomPageNumberPagination
|
||||
search_fields = ["address"]
|
||||
serializer_class = serializers.AliasSerializer
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
|
||||
@@ -119,12 +119,14 @@ class Command(BaseCommand):
|
||||
redirect_uris = " ".join([f"{uri}/login/logged" for uri in base_uris_list])
|
||||
redirect_uri = redirect_uris.split(" ")[0]
|
||||
client_id = ""
|
||||
if options["dev"]:
|
||||
base_uri = "https://localhost:3000/"
|
||||
base_uris = base_uri
|
||||
redirect_uri = "https://localhost:3000/login/logged"
|
||||
redirect_uris = redirect_uri
|
||||
client_id = "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD"
|
||||
if not frontend_application.exists():
|
||||
if options["dev"]:
|
||||
base_uri = "https://localhost:3000/"
|
||||
redirect_uri = "https://localhost:3000/login/logged"
|
||||
client_id = "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD"
|
||||
else:
|
||||
if not options["dev"]:
|
||||
client_id = str(uuid.uuid4())
|
||||
call_command(
|
||||
"createapplication",
|
||||
|
||||
Reference in New Issue
Block a user