Merge pull request #3348 from modoboa/frontend_module_federation

Integrate user plugins (contacts, calendars)
This commit is contained in:
Antoine Nguyen
2025-02-14 14:56:02 +01:00
committed by GitHub
192 changed files with 23977 additions and 6162 deletions
+1
View File
@@ -23,6 +23,7 @@ node_modules
/static/dist
/frontend/webpack-stats.json
/frontend/src/translations.json
/frontend/.__mf__temp/
# TOX stuff
test_project/modoboa.db
+51 -9
View File
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --extra=dev --output-file=dev-requirements.txt pyproject.toml
@@ -13,7 +13,9 @@ asgiref==3.8.1
# modoboa (pyproject.toml)
astroid==3.3.8
# via pylint
attrs==25.1.0
async-timeout==5.0.1
# via redis
attrs==24.3.0
# via
# jsonschema
# referencing
@@ -21,7 +23,9 @@ bcrypt==4.2.1
# via modoboa (pyproject.toml)
black==25.1.0
# via modoboa (pyproject.toml)
certifi==2025.1.31
caldav==1.3.9
# via modoboa (pyproject.toml)
certifi==2024.12.14
# via requests
cffi==1.17.1
# via cryptography
@@ -37,6 +41,7 @@ click==8.1.8
# via
# black
# rq
# x-wr-timezone
cryptography==44.0.1
# via
# fido2
@@ -69,6 +74,7 @@ django==4.2.18
# django-rq
# django-xforwardedfor-middleware
# djangorestframework
# drf-nested-routers
# drf-spectacular
# modoboa (pyproject.toml)
django-bower==5.2.0
@@ -103,10 +109,13 @@ django-xforwardedfor-middleware==2.0
# via modoboa (pyproject.toml)
djangorestframework==3.15.2
# via
# drf-nested-routers
# drf-spectacular
# modoboa (pyproject.toml)
dnspython==2.7.0
# via modoboa (pyproject.toml)
drf-nested-routers==0.94.1
# via modoboa (pyproject.toml)
drf-spectacular==0.28.0
# via modoboa (pyproject.toml)
feedparser==6.0.11
@@ -121,7 +130,12 @@ gevent==24.11.1
# via modoboa (pyproject.toml)
greenlet==3.1.1
# via gevent
identify==2.6.7
icalendar==6.1.1
# via
# caldav
# recurring-ical-events
# x-wr-timezone
identify==2.6.6
# via pre-commit
idna==3.10
# via
@@ -137,8 +151,10 @@ jsonschema-specifications==2024.10.1
# via jsonschema
jwcrypto==1.5.6
# via django-oauth-toolkit
lxml==5.3.1
# via lxml-html-clean
lxml==5.3.0
# via
# caldav
# lxml-html-clean
lxml-html-clean==0.4.1
# via modoboa (pyproject.toml)
markupsafe==3.0.2
@@ -185,17 +201,26 @@ pylint==3.3.4
pyopenssl==25.0.0
# via modoboa (pyproject.toml)
python-dateutil==2.9.0.post0
# via modoboa (pyproject.toml)
# via
# icalendar
# modoboa (pyproject.toml)
# recurring-ical-events
# vobject
python-magic==0.4.27
# via modoboa (pyproject.toml)
pytz==2025.1
# via modoboa (pyproject.toml)
pytz==2024.2
# via
# caldav
# modoboa (pyproject.toml)
# vobject
pyyaml==6.0.2
# via
# drf-spectacular
# pre-commit
qrcode==8.0
# via modoboa (pyproject.toml)
recurring-ical-events==3.4.1
# via caldav
redis==5.2.1
# via
# django-rq
@@ -209,6 +234,7 @@ reportlab==4.3.1
# via modoboa (pyproject.toml)
requests==2.32.3
# via
# caldav
# django-oauth-toolkit
# modoboa (pyproject.toml)
# ovh
@@ -237,6 +263,7 @@ six==1.17.0
# via
# django-bower
# python-dateutil
# vobject
sqlparse==0.5.3
# via
# django
@@ -249,7 +276,16 @@ typing-extensions==4.12.2
# via
# dj-database-url
# jwcrypto
# pyopenssl
# referencing
# sievelib
tzdata==2025.1
# via
# icalendar
# recurring-ical-events
# x-wr-timezone
tzlocal==5.2
# via caldav
uritemplate==4.1.1
# via
# drf-spectacular
@@ -258,8 +294,14 @@ urllib3==2.3.0
# via requests
virtualenv==20.29.2
# via pre-commit
vobject==0.9.9
# via
# caldav
# modoboa (pyproject.toml)
werkzeug==3.1.3
# via modoboa (pyproject.toml)
x-wr-timezone==2.0.0
# via recurring-ical-events
zope-event==5.0
# via gevent
zope-interface==7.2
+19 -3
View File
@@ -9,10 +9,11 @@ services:
context: .
dockerfile: docker/Dockerfile.dev
environment:
PYTHONPATH: ".."
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: ".."
PYTHONUNBUFFERED: 1
command: >
sh -c "cd test_project; python3 manage.py rqworker dkim modoboa"
volumes:
@@ -29,16 +30,19 @@ services:
env_file:
- test_project/oidc_key
environment:
PYTHONPATH: ".."
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: ".."
PYTHONUNBUFFERED: 1
command: >
sh -c "cd test_project
python3 manage.py migrate
python3 manage.py load_initial_data --dev
django-admin compilemessages
python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt"
depends_on:
- radicale
volumes:
- .:/code
@@ -53,13 +57,16 @@ services:
env_file:
- test_project/oidc_key
environment:
PYTHONPATH: ".."
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: ".."
PYTHONUNBUFFERED: 1
command: >
sh -c "cd test_project
python3 manage.py runserver_plus 0.0.0.0:8001"
depends_on:
- radicale
volumes:
- .:/code
@@ -89,3 +96,12 @@ services:
- api
volumes:
- ./docker/dovecot_config:/etc/dovecot/conf.d
radicale:
container_name: modoboa_radicale
restart: unless-stopped
build:
context: .
dockerfile: docker/Dockerfile.radicale.dev
volumes:
- ./docker/radicale_config:/etc/radicale
+1 -1
View File
@@ -1,6 +1,7 @@
FROM python:3.12-alpine3.20 AS base
LABEL org.opencontainers.image.authors="Antoine Nguyen <tonio@ngyn.org>"
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
@@ -16,7 +17,6 @@ COPY test-requirements.txt /tmp
COPY dev-requirements.txt /tmp
RUN python -m pip install -U pip
RUN pip install -r requirements.txt -r test-requirements.txt -r dev-requirements.txt
FROM base AS run
COPY docker/doveadm /usr/local/bin
RUN chmod +x /usr/local/bin/doveadm
+1 -1
View File
@@ -7,4 +7,4 @@ RUN apk add --no-cache --update gettext \
WORKDIR $HOME
RUN yarn install
RUN yarn install --dev
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-alpine3.20
LABEL org.opencontainers.image.authors="Antoine Nguyen <tonio@ngyn.org>"
WORKDIR /app
RUN pip install Radicale pytz radicale-modoboa-auth-oauth2
EXPOSE 5232
CMD python3 -m radicale
+159
View File
@@ -0,0 +1,159 @@
# -*- mode: conf -*-
# vim:ft=cfg
# Config file for Radicale - A simple calendar server
#
# Place it into /etc/radicale/config (global)
# or ~/.config/radicale/config (user)
#
# The current values are the default ones
[server]
# CalDAV server hostnames separated by a comma
# IPv4 syntax: address:port
# IPv6 syntax: [address]:port
# For example: 0.0.0.0:9999, [::]:9999
hosts = 0.0.0.0:5232
# Daemon flag
#daemon = False
# File storing the PID in daemon mode
#pid =
# Max parallel connections
#max_connections = 20
# Max size of request body (bytes)
#max_content_length = 10000000
# Socket timeout (seconds)
#timeout = 10
# SSL flag, enable HTTPS protocol
#ssl = False
# SSL certificate path
#certificate = /etc/ssl/radicale.cert.pem
# SSL private key
#key = /etc/ssl/radicale.key.pem
# CA certificate for validating clients. This can be used to secure
# TCP traffic between Radicale and a reverse proxy
#certificate_authority =
# SSL Protocol used. See python's ssl module for available values
#protocol = PROTOCOL_TLSv1_2
# Available ciphers. See python's ssl module for available ciphers
#ciphers =
# Reverse DNS to resolve client address in logs
#dns_lookup = True
# Message displayed in the client when a password is needed
#realm = Radicale - Password Required
[encoding]
# Encoding for responding requests
#request = utf-8
# Encoding for storing local collections
#stock = utf-8
[auth]
# Authentication method
# Value: none | htpasswd | remote_user | http_x_remote_user
type = radicale_modoboa_auth_oauth2
# Htpasswd filename
# htpasswd_filename = users
# Htpasswd encryption method
# Value: plain | sha1 | ssha | crypt | bcrypt | md5
# Only bcrypt can be considered secure.
# bcrypt and md5 require the passlib library to be installed.
# htpasswd_encryption = plain
# Incorrect authentication delay (seconds)
#delay = 1
oauth2_introspection_endpoint = http://dovecot:Toto12345@api-unsecured:8001/api/o/introspect/
[rights]
# Rights backend
# Value: none | authenticated | owner_only | owner_write | from_file
type = from_file
# File for rights management from_file
file = /etc/radicale/rights
[storage]
# Storage backend
# Value: multifilesystem
# type =
# radicale_storage_by_index_fields = dtstart, dtend, uid, summary
# Folder for storing local collections, created if not present
filesystem_folder = /collections
# Lock the storage. Never start multiple instances of Radicale or edit the
# storage externally while Radicale is running if disabled.
#filesystem_locking = True
# Sync all changes to disk during requests. (This can impair performance.)
# Disabling it increases the risk of data loss, when the system crashes or
# power fails!
#filesystem_fsync = True
# Delete sync token that are older (seconds)
#max_sync_token_age = 2592000
# Close the lock file when no more clients are waiting.
# This option is not very useful in general, but on Windows files that are
# opened cannot be deleted.
#filesystem_close_lock_file = False
# Command that is run after changes to storage
# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by "%%(user)s)
#hook =
[web]
# Web interface backend
# Value: none | internal
type = none
[logging]
# Logging configuration file
# If no config is given, simple information is printed on the standard output
# For more information about the syntax of the configuration file, see:
# http://docs.python.org/library/logging.config.html
#config = /etc/radicale/logging
# Store all environment variables (including those set in the shell)
#full_environment = False
# Don't include passwords in logs
#mask_passwords = True
level = debug
[headers]
# Additional HTTP headers
#Access-Control-Allow-Origin = *
+135
View File
@@ -0,0 +1,135 @@
# -*- mode: conf -*-
# vim:ft=cfg
# Allow all rights for the Administrator
#[root]
#user: Administrator
#collection: .*
#permissions: RW
# Allow reading principal collection (same as username)
#[principal]
#user: .+
#collection: {user}
#permissions: R
# Allow reading and writing private collection (same as username)
#[private]
#user: .+
#collection: {user}/private/
#permissions: RW
# Allow reading calendars and address books that are direct
# children of the principal collection for other users
#[calendarsReader]
#user: .+
#collection: {user}/[^/]+
#permissions: r
# Rights management file for Radicale - A simple calendar server
#
# The default path for this file is /etc/radicale/rights
# The path can be specified in the rights section of the configuration file
#
# Section names are used for naming rules and must be unique.
# The first rule matching both user and collection patterns will be used.
# Example: owner_only plugin
# Allow reading root collection for authenticated users
#[root]
#user: .+
#collection:
#permissions: R
# Allow reading and writing principal collection (same as username)
[principal]
user: .+
collection: {user}
permissions: RW
# Allow reading and writing calendars and address books that are direct
# children of the principal collection
[calendars]
user: .+
collection: {user}/[^/]+
permissions: rw
# Example: owner_write plugin
# Only listed additional rules for the owner_only plugin example.
# Allow reading principal collections of all users
#[read-all-principals]
#user: .+
#collection: [^/]+
#permissions: R
# Allow reading all calendars and address books that are direct children of any
# principal collection
#[read-all-calendars]
#user: .+
#collection: [^/]+/[^/]+
#permissions: r
# Example: authenticated plugin
# Allow reading and writing root and principal collections of all users
#[root-and-principals]
#user: .+
#collection: [^/]*
#permissions: RW
# Allow reading and writing all calendars and address books that are direct
# children of any principal collection
#[calendars]
#user: .+
#collection: [^/]+/[^/]+
#permissions: rw
# Example: Allow user "admin" to read everything
#[admin-read-all]
#user: admin
#collection: .*
#permissions: Rr
# Example: Allow everybody (including unauthenticated users) to read
# the collection "public"
# Allow reading collection "public" for authenticated users
#[public-principal]
#user: .+
#collection: public
#permissions: R
# Allow reading all calendars and address books that are direct children of
# the collection "public" for authenticated users
#[public-calendars]
#user: .+
#collection: public/[^/]+
#permissions: r
# Allow access to public calendars and address books via HTTP GET for everyone
#[public-calendars-restricted]
#user: .*
#collection: public/[^/]+
#permissions: i
# Example: Grant users of the form user@domain.tld read access to the
# collection "domain.tld"
# Allow reading the domain collection
#[read-domain-principal]
#user: .+@([^@]+)
#collection: {0}
#permissions: R
# Allow reading all calendars and address books that are direct children of
# the domain collection
#[read-domain-calendars]
#user: .+@([^@]+)
#collection: {0}/[^/]+
#permissions: r
+5
View File
@@ -15,6 +15,11 @@
"gettext:compile": "vue-gettext-compile"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@github/webauthn-json": "^2.1.1",
"@mdi/font": "^7.3.67",
"apexcharts": "^3.50.0",
+3
View File
@@ -114,4 +114,7 @@ export default {
`${resource}/filtersets/${filterSetName}/filters/${filter}/`
)
},
getAvailableApplications() {
return repository.get(`${resource}/available_applications/`)
},
}
+58
View File
@@ -0,0 +1,58 @@
import repository from './repository'
export default {
getUserCalendars() {
return repository.get('/user-calendars/')
},
getUserCalendar(pk) {
return repository.get(`/user-calendars/${pk}/`)
},
createUserCalendar(data) {
return repository.post('/user-calendars/', data)
},
updateUserCalendar(pk, data) {
return repository.put(`/user-calendars/${pk}/`, data)
},
deleteUserCalendar(pk) {
return repository.delete(`/user-calendars/${pk}/`)
},
getAccessRules(calendarPk) {
return repository.get('/accessrules/')
},
createAccessRule(data) {
return repository.post('/accessrules/', data)
},
updateAccessRule(ruleId, data) {
return repository.put(`/accessrules/${ruleId}/`, data)
},
deleteAccessRule(ruleId) {
return repository.delete(`/accessrules/${ruleId}/`)
},
getUserCalendarEvents(calendarPk, params) {
return repository.get(`/user-calendars/${calendarPk}/events/`, { params })
},
getUserEvent(calendarPk, eventId) {
return repository.get(`/user-calendars/${calendarPk}/events/${eventId}/`)
},
createUserEvent(calendarPk, data) {
return repository.post(`/user-calendars/${calendarPk}/events/`, data)
},
patchUserEvent(calendarPk, eventId, data) {
return repository.patch(
`/user-calendars/${calendarPk}/events/${eventId}/`,
data
)
},
importUserEvents(calendarPk, data) {
return repository.post(
`/user-calendars/${calendarPk}/events/import_from_file/`,
data
)
},
getAttendees() {
return repository.get('/attendees/')
},
}
+37
View File
@@ -0,0 +1,37 @@
import repository from './repository'
export default {
getDefaultAddressBook() {
return repository.get('/address-books/default/')
},
synchronizeToAddressBook() {
return repository.get('/address-books/sync_to_cdav/')
},
synchronizeFromAddressBook() {
return repository.get('/address-books/sync_from_cdav/')
},
getContacts(params) {
return repository.get('/contacts/', { params })
},
createContact(data) {
return repository.post('/contacts/', data)
},
updateContact(contactId, data) {
return repository.patch(`/contacts/${contactId}/`, data)
},
deleteContact(contactId) {
return repository.delete(`/contacts/${contactId}/`)
},
getCategories() {
return repository.get('/categories/')
},
addCategory(data) {
return repository.post('/categories/', data)
},
updateCategory(categoryId, data) {
return repository.patch(`/categories/${categoryId}/`, data)
},
deleteCategory(categoryId) {
return repository.delete(`/categories/${categoryId}/`)
},
}
+9
View File
@@ -0,0 +1,9 @@
import repository from './repository'
const resource = 'mailboxes'
export default {
getAll() {
return repository.get(`${resource}/`)
},
}
+21 -8
View File
@@ -3,16 +3,29 @@ import repository from './repository'
const resource = 'parameters'
export default {
getApplications() {
return repository.get(`/${resource}/applications/`)
getGlobalApplications() {
return repository.get(`/${resource}/global/applications/`)
},
getApplication(app) {
return repository.get(`/${resource}/${app}/`)
getGlobalApplication(app) {
return repository.get(`/${resource}/global/${app}/`)
},
getApplicationStructure(app) {
return repository.get(`/${resource}/structure/?app=${app}`)
getGlobalApplicationStructure(app) {
return repository.get(`/${resource}/global/structure/?app=${app}`)
},
saveApplication(app, data) {
return repository.put(`/${resource}/${app}/`, data)
saveGlobalApplication(app, data) {
return repository.put(`/${resource}/global/${app}/`, data)
},
getUserApplications() {
return repository.get(`/${resource}/user/applications/`)
},
getUserApplication(app) {
return repository.get(`/${resource}/user/${app}/`)
},
getUserApplicationStructure(app) {
return repository.get(`/${resource}/user/structure/?app=${app}`)
},
saveUserApplication(app, data) {
return repository.put(`/${resource}/user/${app}/`, data)
},
}
@@ -14,6 +14,17 @@
>
<template #top>
<v-toolbar color="white" flat>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
placeholder="Search"
density="compact"
variant="outlined"
hide-details
flat
single-line
class="flex-grow-0 w-33 mr-4"
></v-text-field>
<v-menu location="bottom">
<template #activator="{ props }">
<v-btn v-bind="props" size="small">
@@ -25,17 +36,6 @@
<MenuItems :items="getActionMenuItems()" />
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
placeholder="Search"
density="compact"
variant="outlined"
hide-details
flat
single-line
></v-text-field>
</v-toolbar>
</template>
<template #[`item.status`]="{ item }">
@@ -110,7 +110,7 @@ const domain = ref(defaultDomain)
onMounted(() => {
//TODO : Store this in Pinia
ParametersApi.getApplication('admin').then((resp) => {
ParametersApi.getGlobalApplication('admin').then((resp) => {
const params = resp.data.params
domain.value.quota = params.default_domain_quota
domain.value.default_mailbox_quota = params.default_mailbox_quota
@@ -261,7 +261,7 @@ async function save() {
}
}
onMounted(() => {
parametersApi.getApplication('limits').then((resp) => {
parametersApi.getGlobalApplication('limits').then((resp) => {
limitsConfig.value.data = resp.data
})
})
@@ -13,6 +13,18 @@
>
<template #top>
<v-toolbar color="white" flat>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
single-line
flat
hide-details
density="compact"
class="flex-grow-0 w-33 mr-4"
></v-text-field>
<slot name="extraActions" />
<v-menu location="bottom">
<template #activator="{ props }">
<v-btn
@@ -31,16 +43,6 @@
icon="mdi-reload"
@click="reloadDomains"
></v-btn>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
single-line
flat
hide-details
></v-text-field>
</v-toolbar>
</template>
<template
@@ -316,7 +316,7 @@ onMounted(() => {
.getAccount(route.params.id)
.then((response) => (editedAccount.value = { ...response.data }))
parametersApi.getApplication('limits').then((response) => {
parametersApi.getGlobalApplication('limits').then((response) => {
limitsConfig.value = response.data
})
})
@@ -12,6 +12,18 @@
>
<template #top>
<v-toolbar flat color="white">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
hide-details
single-line
flat
density="compact"
class="flex-grow-0 w-33 mr-4"
></v-text-field>
<slot name="extraActions" />
<v-menu location="bottom">
<template #activator="{ props }">
<v-btn
@@ -33,15 +45,6 @@
@click="fetchIdentities"
></v-btn>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
hide-details
single-line
flat
></v-text-field>
</v-toolbar>
</template>
<template #[`item.identity`]="{ item }">
@@ -10,13 +10,14 @@
>
<template #top>
<v-toolbar flat color="white">
<v-spacer></v-spacer>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
hide-details
density="compact"
class="flex-grow-0 w-33"
/>
</v-toolbar>
</template>
@@ -10,13 +10,14 @@
>
<template #top>
<v-toolbar flat color="white">
<v-spacer></v-spacer>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
hide-details
density="compact"
class="flex-grow-0 w-33"
/>
</v-toolbar>
</template>
@@ -0,0 +1,150 @@
<template>
<v-card>
<v-card-title>
<span class="headline">{{ $gettext('Calendar sharing') }}</span>
</v-card-title>
<v-card-text>
<v-form ref="formRef" @submit.prevent="submit">
<div class="d-flex">
<v-select
v-model="currentRule.mailbox"
:label="$gettext('Full address')"
:form-errors="formErrors['mailbox']"
:items="mailboxes"
item-title="full_address"
return-object
variant="outlined"
density="compact"
/>
<v-checkbox
v-model="currentRule.read"
:label="$gettext('Read')"
class="ml-4"
/>
<v-checkbox
v-model="currentRule.write"
:label="$gettext('Write')"
class="ml-4"
/>
<v-spacer />
<v-btn
color="primary"
icon="mdi-content-save-outline"
type="submit"
size="small"
/>
</div>
</v-form>
<br />
<v-table>
<thead>
<tr>
<th>{{ $gettext('Account') }}</th>
<th>{{ $gettext('Read') }}</th>
<th>{{ $gettext('Write') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rule in accessRules" :key="rule.pk">
<td>{{ rule.mailbox.full_address }}</td>
<td>
<v-icon :icon="rule.read ? 'mdi-check' : 'mdi-close'" />
</td>
<td>
<v-icon :icon="rule.write ? 'mdi-check' : 'mdi-close'" />
</td>
<td>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="flat"
@click="editRule(rule)"
/>
<v-btn
icon="mdi-trash-can"
size="x-small"
variant="flat"
@click="deleteRule(rule)"
/>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import api from '@/api/calendars'
import mailboxesApi from '@/api/mailboxes'
const props = defineProps({
calendarPk: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['close'])
const accessRules = ref([])
const mailboxes = ref([])
const currentRule = ref({})
const formErrors = ref({})
api.getAccessRules(props.calendarPk).then((response) => {
accessRules.value = response.data
if (!accessRules.value.length) {
accessRules.value = []
}
})
mailboxesApi.getAll().then((response) => {
mailboxes.value = response.data
})
async function submit() {
if (!currentRule.value.pk) {
currentRule.value.calendar = props.calendarPk
api.createAccessRule(currentRule.value).then((response) => {
accessRules.value.push(response.data)
resetForm()
}, onError)
} else {
api.updateAccessRule(currentRule.value.pk, currentRule.value).then(() => {
accessRules.value.filter((item, pos) => {
if (item.pk === currentRule.value.pk) {
accessRules.value[pos] = currentRule.value
}
})
resetForm()
}, onError)
}
}
function resetForm() {
currentRule.value = {}
formErrors.value = {}
}
function onError(response) {
formErrors.value = response.data
}
function editRule(rule) {
currentRule.value = JSON.parse(JSON.stringify(rule))
}
function deleteRule(rule) {
api.deleteAccessRule(rule.pk).then(() => {
accessRules.value = accessRules.value.filter((localRule) => {
return localRule.pk !== rule.pk
})
})
}
function close() {
formErrors.value = {}
emit('close')
}
</script>
@@ -0,0 +1,50 @@
<template>
<v-card v-if="calendar">
<v-card-title>
<span class="headline">{{ $gettext('Calendar information') }}</span>
</v-card-title>
<v-card-text>
<p>
{{
$gettext(
'To access this calendar from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:'
)
}}
</p>
<v-alert type="info" class="my-4" variant="tonal">
{{ calendar.full_url }}
</v-alert>
<p>
{{
$gettext(
'The credentials are the same than the ones you use to access Modoboa.'
)
}}
</p>
<p>
{{
$gettext(
'You can also share a read-only version of this calendar using the following URL: '
)
}}
</p>
<v-alert type="info" class="my-4" variant="tonal">
{{ calendar.share_url }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('close')">{{ $gettext('Close') }}</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
const props = defineProps({
calendar: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close'])
</script>
@@ -0,0 +1,111 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="submit">
<v-card-title>
<span v-if="calendar.pk" class="headline">{{
$gettext('Edit calendar')
}}</span>
<span v-else class="modal-title">{{ $gettext('New calendar') }}</span>
</v-card-title>
<v-card-text class="py-4">
<v-row no-gutters>
<v-col cols="12">
<v-text-field
v-model="calendar.name"
:label="$gettext('Name')"
variant="outlined"
density="compact"
:rules="[rules.required]"
:error-messages="formErrors.name"
/>
</v-col>
<v-col cols="12">
<v-color-picker
v-model="calendar.color"
:label="$gettext('Color')"
class="ma-2"
swatches-max-height="200px"
show-swatches
hide-inputs
:rules="[rules.required]"
></v-color-picker>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
<v-btn color="primary" type="submit">
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import rules from '@/plugins/rules'
import calendarsApi from '@/api/calendars'
const emit = defineEmits(['close', 'colorChanged'])
const props = defineProps({
initialCalendar: {
type: Object,
default: () => {
return { color: '' }
},
},
})
const { $gettext } = useGettext()
const busStore = useBusStore()
onMounted(() => {
calendar.value = { ...props.initialCalendar }
shared.value = props.initialCalendar.domain !== undefined
})
const calendar = ref({})
const shared = ref(false)
const formErrors = ref({})
const formRef = ref()
const submitLabel = computed(() => {
if (calendar.value.pk) {
return $gettext('Update')
}
return $gettext('Create')
})
function close() {
calendar.value = {}
shared.value = false
formErrors.value = {}
emit('close')
}
async function submit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
let msg
if (calendar.value.pk) {
await calendarsApi.updateUserCalendar(calendar.value.pk, calendar.value)
msg = $gettext('Calendar updated')
} else {
await calendarsApi.createUserCalendar(calendar.value)
msg = $gettext('Calendar created')
}
if (props.initialCalendar.color !== calendar.value.color) {
emit('colorChanged', calendar.value)
}
close()
busStore.displayNotification({
msg,
})
}
</script>
@@ -0,0 +1,186 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="saveEvent">
<v-card-title>
<span class="headline">{{ title }}</span>
</v-card-title>
<v-card-text class="py-4">
<v-row class="mb-2">
<v-col cols="12">
<v-text-field
v-model="form.title"
:label="$gettext('Title')"
:rules="[rules.required]"
:error-messages="formErrors.title"
density="compact"
variant="outlined"
/>
</v-col>
</v-row>
<div class="d-flex mb-3">
<v-icon icon="mdi-clock-outline" class="mr-4" />
<v-text-field
v-model="form.start"
:label="$gettext('From')"
:rules="[rules.required]"
:error-messages="formErrors.start"
density="compact"
variant="outlined"
type="datetime-local"
class="mr-4"
/>
<v-text-field
v-model="form.end"
:label="$gettext('To')"
:rules="[rules.required]"
:error-messages="formErrors.end"
density="compact"
variant="outlined"
type="datetime-local"
/>
</div>
<div class="d-flex mb-3">
<v-icon icon="mdi-calendar" class="mr-4" />
<v-select
v-model="form.calendar"
:items="userCalendars"
item-title="name"
item-value="pk"
:label="$gettext('Calendar')"
:rules="[rules.required]"
:error-messages="formErrors.calendar"
density="compact"
variant="outlined"
/>
</div>
<template v-if="event">
<div class="d-flex mb-3">
<v-icon icon="mdi-account-multiple-outline" class="mr-4" />
<v-select
v-model="form.attendees"
:label="$gettext('Attendees')"
:items="attendees"
item-title="display_name"
item-value="email"
return-object
variant="outlined"
density="compact"
multiple
chips
/>
</div>
<v-textarea
v-model="form.description"
:label="$gettext('Description')"
auto-grow
rows="2"
variant="outlined"
density="compact"
class="mb-3"
/>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
<v-btn color="primary" type="submit">
{{ $gettext('Save') }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { DateTime } from 'luxon'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import api from '@/api/calendars'
import rules from '@/plugins/rules'
const props = defineProps({
info: {
type: Object,
default: null,
},
event: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close'])
const { $gettext } = useGettext()
const busStore = useBusStore()
const attendees = ref([])
const userCalendars = ref([])
const form = ref({})
const formErrors = ref({})
const formRef = ref()
const title = computed(() => {
return props.event ? $gettext('Edit event') : $gettext('New event')
})
watch(
() => props.info,
(value) => {
if (value) {
form.value = {
start: DateTime.fromISO(value.startStr).toFormat('yyyy-MM-dd HH:mm'),
end: DateTime.fromISO(value.endStr).toFormat('yyyy-MM-dd HH:mm'),
allDay: value.allDay,
}
}
},
{ immediate: true }
)
watch(
() => props.event,
(value) => {
if (value) {
form.value = { ...value }
form.value.calendar = form.value.calendar.pk
form.value.start = DateTime.fromISO(value.start).toFormat(
'yyyy-MM-dd HH:mm'
)
form.value.end = DateTime.fromISO(value.end).toFormat('yyyy-MM-dd HH:mm')
console.log(form.value)
api.getAttendees().then((resp) => {
attendees.value = resp.data
})
}
},
{ immediate: true }
)
function close() {
formErrors.value = {}
emit('close')
}
async function saveEvent() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
var data = JSON.parse(JSON.stringify(form.value))
if (!props.event) {
api.createUserEvent(form.value.calendar, data).then(() => {
busStore.displayNotification({ msg: $gettext('Event added') })
close()
})
} else {
api.patchUserEvent(form.value.calendar, props.event.id, data).then(() => {
busStore.displayNotification({ msg: $gettext('Event updated') })
close()
})
}
}
api.getUserCalendars().then((resp) => {
userCalendars.value = resp.data
})
</script>
@@ -0,0 +1,94 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="sendFile">
<v-card-title>
<span class="headline">{{ $gettext('Import events') }}</span>
</v-card-title>
<v-card-text>
<v-alert type="info" variant="tonal" class="my-4">
{{
$gettext(
'Select an ICS file to import and click on the Send button'
)
}}
</v-alert>
<v-file-input
v-model="file"
:label="$gettext('Select an ICS file')"
variant="outlined"
density="compact"
:rules="[rules.required]"
:error-messages="formErrors['ics_file']"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :disabled="runningUpload" @click="close">{{
$gettext('Close')
}}</v-btn>
<v-btn type="submit" color="primary" :disabled="runningUpload">
{{ $gettext('Send') }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useBusStore } from '@/stores'
import { useGettext } from 'vue3-gettext'
import api from '@/api/calendars'
import rules from '@/plugins/rules'
const props = defineProps({
calendar: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close', 'eventsImported'])
const { $ngettext } = useGettext()
const busStore = useBusStore()
const file = ref(null)
const formErrors = ref({})
const formRef = ref()
const runningUpload = ref(false)
function close() {
formErrors.value = {}
emit('close')
}
function onSendError(response) {
formErrors.value = response.data
runningUpload.value = false
}
async function sendFile() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
let data = new FormData()
data.append('ics_file', file.value)
runningUpload.value = true
try {
const response = await api.importUserEvents(props.calendar, data)
close()
var msg = $ngettext(
'%{ n } event imported',
'%{ n } events imported',
response.data.counter
)
busStore.displayNotification({ msg })
emit('eventsImported', props.calendar)
} catch (err) {
onSendError(err.response)
} finally {
runningUpload.value = false
}
}
</script>
@@ -0,0 +1,40 @@
<template>
<v-card>
<v-card-title>
<span class="headline">{{ $gettext('Address book information') }}</span>
</v-card-title>
<v-card-text>
<p>
{{
$gettext(
'To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:'
)
}}
</p>
<v-alert type="info" class="my-4" variant="tonal">
{{ addressBookUrl }}
</v-alert>
<p>
{{
$gettext(
'The credentials are the same than the ones you use to access Modoboa.'
)
}}
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('close')">{{ $gettext('Close') }}</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
const props = defineProps({
addressBookUrl: {
type: String,
default: null,
},
})
const emit = defineEmits(['close'])
</script>
@@ -0,0 +1,108 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="submit">
<v-card-title>
<span class="headline">{{ title }}</span>
</v-card-title>
<v-card-text>
<v-text-field
v-model="form.name"
:label="$gettext('Name')"
variant="outlined"
density="compact"
:rules="[rules.required]"
/>
</v-card-text>
<v-card-actions>
<v-btn v-if="category" color="red" @click="deleteCategory">
{{ $gettext('Delete') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn :loading="working" @click="close">
{{ $gettext('Close') }}
</v-btn>
<v-btn color="primary" type="submit" :loading="working">
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import contactsApi from '@/api/contacts'
import rules from '@/plugins/rules'
const props = defineProps({
category: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close', 'updated'])
const router = useRouter()
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const form = ref({})
const formRef = ref()
const working = ref(false)
const title = computed(() => {
return props.category ? $gettext('Edit category') : $gettext('Add category')
})
const submitLabel = computed(() => {
return props.category ? $gettext('Update') : $gettext('Add')
})
function close() {
form.value = {}
emit('close')
}
async function submit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
working.value = true
try {
if (props.category) {
await contactsApi.updateCategory(props.category.pk, form.value)
} else {
await contactsApi.addCategory(form.value)
}
emit('updated')
emit('close')
} catch (err) {
console.log(err)
} finally {
working.value = false
}
}
function deleteCategory() {
contactsApi.deleteCategory(props.category.pk).then(() => {
close()
emit('updated')
displayNotification({ msg: $gettext('Category deleted') })
router.push({ name: 'ContactList' })
})
}
watch(
() => props.category,
(value) => {
if (value) {
form.value = JSON.parse(JSON.stringify(value))
} else {
form.value = {}
}
},
{ immediate: true }
)
</script>
@@ -0,0 +1,78 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="submit">
<v-card-title>
<span class="headline">{{ $gettext('Categories') }}</span>
</v-card-title>
<v-card-text>
<v-checkbox
v-for="category in categories"
:key="category.pk"
v-model="selectedCategories"
:label="category.name"
hide-details
:value="category.pk"
color="primary"
/>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn :loading="working" @click="close">
{{ $gettext('Close') }}
</v-btn>
<v-btn color="primary" type="submit" :loading="working">
{{ $gettext('Update') }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { ref, watch } from 'vue'
import contactsApi from '@/api/contacts'
const props = defineProps({
contact: {
type: Object,
default: null,
},
})
const emit = defineEmits(['close'])
const categories = ref([])
const selectedCategories = ref([])
const working = ref(false)
function close() {
emit('close', 'updated')
}
async function submit() {
const data = { categories: selectedCategories.value }
working.value = true
try {
await contactsApi.updateContact(props.contact.pk, data)
emit('updated')
close()
} finally {
working.value = false
}
}
watch(
() => props.contact,
(value) => {
if (value) {
selectedCategories.value = value.categories
} else {
selectedCategories.value = []
}
},
{ immediate: true }
)
contactsApi.getCategories().then((resp) => {
categories.value = resp.data
})
</script>
@@ -0,0 +1,254 @@
<template>
<v-card>
<v-form ref="formRef" @submit.prevent="submit">
<v-card-title>
<span class="headline">{{ title }}</span>
</v-card-title>
<v-card-text>
<v-row no-gutters>
<v-col cols="6">
<v-text-field
v-model="form.first_name"
:label="$gettext('First name')"
variant="outlined"
prepend-icon="mdi-account"
density="compact"
:error-messages="formErrors.first_name"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.last_name"
:label="$gettext('Last name')"
variant="outlined"
density="compact"
:error-messages="formErrors.last_name"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.display_name"
:label="$gettext('Display name')"
variant="outlined"
prepend-icon="toto"
density="compact"
:error-messages="formErrors.display_name"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="form.company"
:label="$gettext('Company')"
variant="outlined"
prepend-icon="mdi-office-building"
density="compact"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.position"
:label="$gettext('Position')"
variant="outlined"
density="compact"
/>
</v-col>
<EmailField
v-for="(email, index) in form.emails"
:key="`email-${index}`"
v-model="form.emails[index]"
:index="index"
@add="addEmailField"
@delete="deleteEmailField"
/>
<PhoneNumberField
v-for="(phone, index) in form.phone_numbers"
:key="`phone-${index}`"
v-model="form.phone_numbers[index]"
:index="index"
@add="addPhoneNumberField"
@delete="deletePhoneNumberField"
/>
<template v-if="showExtraFields">
<v-col cols="6">
<v-text-field
v-model="form.birth_date"
:label="$gettext('Birth date')"
type="date"
prepend-icon="mdi-calendar"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.address"
:label="$gettext('Address')"
prepend-icon="mdi-location"
variant="outlined"
density="compact"
rows="2"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="form.zipcode"
:label="$gettext('Zip code')"
prepend-icon="toto"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.city"
:label="$gettext('City')"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="form.country"
:label="$gettext('Country')"
prepend-icon="toto"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6" class="pl-4">
<v-text-field
v-model="form.state"
:label="$gettext('State/Province')"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.note"
:label="$gettext('Note')"
prepend-icon="mdi-file"
variant="outlined"
density="compact"
rows="2"
/>
</v-col>
</template>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn v-if="!showExtraFields" @click="showExtraFields = true">
{{ $gettext('More') }}
</v-btn>
<v-btn v-else @click="showExtraFields = false">
{{ $gettext('Less') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn :loading="working" @click="emit('close')">
{{ $gettext('Close') }}
</v-btn>
<v-btn color="primary" type="submit" :loading="working">
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import contactsApi from '@/api/contacts'
import EmailField from './EmailField.vue'
import PhoneNumberField from './PhoneNumberField.vue'
const emit = defineEmits(['close', 'added', 'updated'])
const props = defineProps({
contact: {
type: Object,
required: false,
},
})
const { $gettext } = useGettext()
const form = ref(initialForm())
const showExtraFields = ref(false)
const formRef = ref()
const formErrors = ref({})
const working = ref(false)
const title = computed(() => {
return props.contact ? $gettext('Edit contact') : $gettext('New contact')
})
const submitLabel = computed(() => {
return props.contact ? $gettext('Update') : $gettext('Add')
})
function initialForm() {
return {
emails: [{}],
phone_numbers: [{}],
}
}
function addEmailField() {
form.value.emails.push({})
}
function deleteEmailField(index) {
form.value.emails.splice(index, 1)
}
function addPhoneNumberField() {
form.value.phone_numbers.push({})
}
function deletePhoneNumberField(index) {
form.value.phone_numbers.splice(index, 1)
}
async function submit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
working.value = true
const data = JSON.parse(JSON.stringify(form.value))
if (!Object.keys(data.phone_numbers[0]).length) {
data.phone_numbers.splice(0, 1)
}
try {
let resp
if (props.contact) {
resp = await contactsApi.updateContact(props.contact.pk, data)
emit('updated', resp.data)
} else {
resp = await contactsApi.createContact(data)
emit('added', resp.data)
}
emit('close')
} catch (err) {
if (err.response?.status === 400) {
formErrors.value = err.response.data
}
} finally {
working.value = false
}
}
watch(
() => props.contact,
(value) => {
if (value) {
form.value = JSON.parse(JSON.stringify(value))
if (!form.value.phone_numbers.length) {
form.value.phone_numbers = [{}]
}
} else {
form.value = initialForm()
}
},
{ immediate: true }
)
</script>
@@ -0,0 +1,223 @@
<template>
<v-card class="mt-6">
<v-data-table :headers="headers" :items="contacts">
<template #top>
<v-toolbar color="white" flat>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="$gettext('Search')"
variant="outlined"
single-line
flat
hide-details
density="compact"
class="flex-grow-0 w-33 mr-4"
></v-text-field>
<v-btn color="primary" variant="outlined" @click="showForm = true">
{{ $gettext('Add') }}
</v-btn>
<template v-if="preferences.enable_carddav_sync">
<v-btn
icon="mdi-information-outline"
:title="$gettext('Display address book information')"
class="ml-2"
@click="showAddressBookInfo = true"
/>
<v-btn
v-if="!addressBook.last_sync"
icon="mdi-sync"
color="success"
:title="$gettext('Synchronize contacts with address book')"
@click="syncToAddressBook"
/>
</template>
<v-chip
v-if="$route.params.category"
class="my-2 mx-4"
label
size="small"
color="green"
prepend-icon="mdi-label"
@click="editCategory"
>
{{ $route.params.category }}
</v-chip>
<v-spacer></v-spacer>
</v-toolbar>
</template>
<template #[`item.emails`]="{ item }">
{{ displayEmails(item.emails) }}
</template>
<template #[`item.phone_numbers`]="{ item }">
{{ displayPhoneNumbers(item.phone_numbers) }}
</template>
<template #[`item.actions`]="{ item }">
<div class="text-right">
<v-menu offset-y>
<template #activator="{ props }">
<v-btn icon="mdi-dots-horizontal" variant="text" v-bind="props">
</v-btn>
</template>
<MenuItems :items="contactActions" :obj="item" />
</v-menu>
</div>
</template>
</v-data-table>
</v-card>
<v-dialog v-model="showForm" persistent max-width="800px">
<ContactForm
:contact="selectedContact"
@close="closeForm"
@added="fetchContacts"
@updated="fetchContacts"
/>
</v-dialog>
<v-dialog v-model="showCategoriesForm" persistent max-width="800px">
<ContactCategoriesForm
:contact="selectedContact"
@updated="fetchContacts"
@close="closeCategoriesForm"
/>
</v-dialog>
<v-dialog v-model="showAddressBookInfo" persistent max-width="800px">
<AddressBookInfo
:address-book-url="addressBook.url"
@close="showAddressBookInfo = false"
/>
</v-dialog>
</template>
<script setup>
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import contactsApi from '@/api/contacts'
import parametersApi from '@/api/parameters'
import AddressBookInfo from './AddressBookInfo.vue'
import ContactForm from './ContactForm.vue'
import ContactCategoriesForm from './ContactCategoriesForm.vue'
import MenuItems from '@/components/tools/MenuItems'
const emit = defineEmits(['editCategory'])
const route = useRoute()
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const addressBook = ref({})
const contacts = ref([])
const search = ref('')
const selectedContact = ref(null)
const showAddressBookInfo = ref(false)
const showForm = ref(false)
const showCategoriesForm = ref(false)
const preferences = ref({})
const headers = [
{ key: 'display_name', title: $gettext('Display name') },
{ key: 'emails', title: $gettext('E-mail') },
{ key: 'phone_numbers', title: $gettext('Phone') },
{ key: 'actions', title: $gettext('Actions'), align: 'end' },
]
const contactActions = [
{
label: $gettext('Categories'),
icon: 'mdi-label',
onClick: editCategories,
},
{
label: $gettext('Edit'),
icon: 'mdi-circle-edit-outline',
onClick: editContact,
},
{
label: $gettext('Delete'),
icon: 'mdi-delete-outline',
onClick: deleteContact,
color: 'red',
},
]
let intervalId
onBeforeUnmount(() => {
if (intervalId) clearInterval(intervalId)
})
function closeForm() {
showForm.value = false
selectedContact.value = null
}
function displayEmails(emails) {
return emails.map((email) => email.address).join(', ')
}
function displayPhoneNumbers(phoneNumbers) {
return phoneNumbers.map((phoneNumber) => phoneNumber.number).join(', ')
}
function editContact(contact) {
selectedContact.value = contact
showForm.value = true
}
function editCategory() {
emit('editCategory')
}
function editCategories(contact) {
selectedContact.value = contact
showCategoriesForm.value = true
}
function closeCategoriesForm() {
showCategoriesForm.value = false
selectedContact.value = null
}
function deleteContact(contact) {
contactsApi.deleteContact(contact.pk).then(() => {
fetchContacts()
})
}
function fetchContacts() {
contactsApi.getContacts(route.params).then((resp) => {
contacts.value = resp.data
})
}
function syncToAddressBook() {
contactsApi.synchronizeToAddressBook().then(() => {
displayNotification({ msg: $gettext('Synchronization started') })
})
}
contactsApi.getDefaultAddressBook().then((resp) => {
addressBook.value = resp.data
})
parametersApi.getUserApplication('contacts').then((resp) => {
preferences.value = resp.data.params
})
fetchContacts()
watch(preferences, (value) => {
if (value) {
intervalId = setInterval(
contactsApi.synchronizeFromAddressBook,
value.sync_frequency * 1000
)
}
})
watch(
() => route.params.category,
() => {
fetchContacts()
}
)
</script>
@@ -0,0 +1,65 @@
<template>
<v-col cols="6">
<v-text-field
v-model="email.address"
:label="$gettext('Address')"
variant="outlined"
prepend-icon="mdi-email"
density="compact"
:rules="[rules.required]"
/>
</v-col>
<v-col cols="4" class="pl-4">
<v-select
v-model="email.type"
:items="types"
density="compact"
variant="outlined"
:rules="[rules.required]"
/>
</v-col>
<v-col cols="2" class="pl-4">
<v-btn icon="mdi-plus" size="x-small" flat @click="$emit('add')" />
<v-btn
v-if="index"
icon="mdi-trash-can"
color="red"
size="x-small"
flat
variant="text"
@click="$emit('delete', index)"
/>
</v-col>
</template>
<script setup>
import { computed } from 'vue'
import rules from '@/plugins/rules'
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
errors: {
type: Object,
default: null,
},
index: {
type: Number,
required: true,
},
})
const emit = defineEmits(['add', 'delete', 'update:modelValue'])
const email = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
const types = ['home', 'work', 'other']
</script>
@@ -0,0 +1,62 @@
<template>
<v-col cols="6">
<v-text-field
v-model="phone.number"
:label="$gettext('Phone number')"
variant="outlined"
prepend-icon="mdi-phone"
density="compact"
/>
</v-col>
<v-col cols="4" class="pl-4">
<v-select
v-model="phone.type"
:items="types"
density="compact"
variant="outlined"
/>
</v-col>
<v-col cols="2" class="pl-4">
<v-btn icon="mdi-plus" size="x-small" flat @click="$emit('add')" />
<v-btn
v-if="index"
icon="mdi-trash-can"
color="red"
size="x-small"
flat
variant="text"
@click="$emit('delete', index)"
/>
</v-col>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
errors: {
type: Object,
default: null,
},
index: {
type: Number,
required: true,
},
modelValue: {
type: Object,
default: null,
},
})
const emit = defineEmits(['add', 'delete', 'update:modelValue'])
const phone = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
const types = ['cell', 'fax', 'home', 'main', 'pager', 'work', 'other']
</script>
+166
View File
@@ -0,0 +1,166 @@
<template>
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
:color="color"
app
>
<div class="d-flex align-center">
<v-img
src="@/assets/Modoboa_RVB-BLANC-SANS.png"
max-width="190"
class="logo"
@click="router.push(props.logoRoute)"
/>
<v-btn
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
variant="text"
@click.stop="rail = !rail"
>
</v-btn>
</div>
<v-list nav>
<template v-for="item in menuItems" :key="item.text">
<template v-if="displayMenuItem(item)">
<v-list-subheader v-if="item.subheader" class="text-white">
{{ item.text.toUpperCase() }}
</v-list-subheader>
<v-list-item
v-else-if="item.action"
:value="item"
:exact="item.exact"
:title="item.text"
:prepend-icon="item.icon"
@click="item.action"
>
</v-list-item>
<v-list-item
v-else-if="!item.children"
:value="item"
:to="item.to"
link
:exact="item.exact"
:title="item.text"
:prepend-icon="item.icon"
>
<template v-if="item.withBell && displayInformationBell" #append>
<v-icon color="red" icon="mdi-bell-alert-outline" />
</template>
</v-list-item>
<v-list-group v-else :value="item.text">
<template #activator="{ props }">
<v-list-item
v-bind="props"
:key="item.text"
:title="item.text"
color="white"
:prepend-icon="item.icon"
>
</v-list-item>
</template>
<template v-for="subitem in item.children" :key="subitem.text">
<template v-if="displayMenuItem(subitem)">
<v-list-item
v-if="subitem.action"
:title="subitem.text"
:value="subitem"
:prepend-icon="subitem.icon"
@click="subitem.action"
></v-list-item>
<v-list-item
v-else
:to="subitem.to"
link
:title="subitem.text"
:value="subitem"
:prepend-icon="subitem.icon"
></v-list-item>
</template>
</template>
</v-list-group>
</template>
</template>
</v-list>
</v-navigation-drawer>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useGlobalStore, useAuthStore } from '@/stores'
const props = defineProps({
color: {
type: String,
default: 'primary',
},
menuItems: {
type: Array,
required: false,
default: null,
},
logoRoute: {
type: Object,
default: null,
},
})
const globalStore = useGlobalStore()
const authStore = useAuthStore()
const router = useRouter()
const rail = ref(false)
const drawer = ref(true)
const authUser = computed(() => authStore.authUser)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const displayInformationBell = computed(
() =>
globalStore.notifications.length !== undefined &&
globalStore.notifications.length !== 0
)
function displayMenuItem(item) {
if (isAuthenticated.value) {
const condition =
(item.roles === undefined ||
item.roles.indexOf(authUser.value.role) !== -1) &&
(item.condition === undefined || item.condition()) &&
item.activated !== false
return condition
}
return false
}
onMounted(() => {
if (authUser.value.role === 'SuperAdmins') {
globalStore.fetchNotifications()
}
})
</script>
<style lang="scss" scoped>
.v-list-item {
&--active {
&::before {
opacity: 0;
}
background-color: #034bad !important;
color: white;
opacity: 1;
}
}
.v-list-subheader {
&__text {
color: white !important;
}
}
.logo {
cursor: pointer;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div v-if="user" class="top-menu">
<v-menu activator="parent" location="bottom">
<template #activator="{ props }">
<v-btn icon="mdi-apps" flat class="mr-4" v-bind="props"></v-btn>
</template>
<v-card min-width="350" max-width="400" class="pa-4">
<v-row class="justify-center">
<v-col
v-for="application in applications"
:key="application.name"
cols="6"
>
<v-sheet
rounded
class="application text-center pa-4 text-body-2"
@click="$router.push(application.url)"
>
<v-icon color="primary" :icon="application.icon" size="x-large" />
<div class="mt-2">{{ application.label }}</div>
</v-sheet>
</v-col>
</v-row>
</v-card>
</v-menu>
<v-btn icon flat color="primary">
{{ userInitials }}
<v-menu activator="parent" location="bottom">
<v-card min-width="300" max-width="350">
<v-list>
<div class="text-center">
<v-avatar color="primary">
<span class="text-h5">{{ userInitials }}</span>
</v-avatar>
<v-list-item :title="user.username" />
</div>
<v-divider></v-divider>
<v-list-item
v-for="item in userMenuItems"
:key="item.text"
:to="item.to"
:href="item.href"
link
@click="item.click"
>
<template #prepend>
<v-icon :icon="item.icon"></v-icon>
</template>
<v-list-item-title>{{ item.text }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-btn>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getActivePinia } from 'pinia'
import { useGettext } from 'vue3-gettext'
import accountApi from '@/api/account'
const props = defineProps({
user: {
type: Object,
default: null,
},
remote: {
type: Boolean,
default: false,
},
})
const { $gettext } = useGettext()
const applications = ref([])
const userInitials = computed(() => {
return props.user.first_name && props.user.last_name
? `${props.user.first_name[0].toUpperCase()}${props.user.last_name[0].toUpperCase()}`
: props.user.username.slice(0, 2).toUpperCase()
})
const userMenuItems = computed(() => {
return [
{
text: $gettext('Account'),
icon: 'mdi-account-circle-outline',
to: !props.remote ? { name: 'AccountSettings' } : null,
href: props.remote ? 'https://localhost:3000/account' : '',
click: () => null,
},
{
text: $gettext('Logout'),
icon: 'mdi-logout',
click: logout,
},
]
})
async function logout() {
getActivePinia()._s.forEach(async (store) => await store.$reset())
}
onMounted(() => {
accountApi.getAvailableApplications().then((resp) => {
applications.value = resp.data
})
})
</script>
<style scoped lang="scss">
.top-menu {
position: fixed;
top: 10px;
right: 10px;
z-index: 100;
}
.application {
cursor: pointer;
}
.application:hover {
background-color: rgb(var(--v-theme-surface));
}
</style>
@@ -0,0 +1,38 @@
<template>
<v-btn icon flat color="primary">
{{ userInitials }}
<v-menu activator="parent" location="bottom">
<v-card min-width="300" max-width="350">
<v-list>
<div class="text-center">
<v-avatar color="primary">
<span class="text-h5">{{ userInitials }}</span>
</v-avatar>
<v-list-item :title="user.username" />
</div>
<v-divider></v-divider>
</v-list>
</v-card>
</v-menu>
</v-btn>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
user: {
type: Object,
default: null,
},
})
const userInitials = computed(() => {
if (!props.user) {
return ''
}
return props.user.first_name && props.user.last_name
? `${props.user.first_name[0].toUpperCase()}${props.user.last_name[0].toUpperCase()}`
: props.user.username.slice(0, 2).toUpperCase()
})
</script>
@@ -32,7 +32,7 @@
</div>
</template>
<script setup lang="js">
<script setup>
import { ref, onMounted, computed } from 'vue'
const props = defineProps({
@@ -43,7 +43,11 @@ const props = defineProps({
type: Boolean,
default: false,
},
choicesPerLine: { type: Number, default: undefined },
choicesPerLine: {
type: Number,
required: false,
default: null,
},
})
const emit = defineEmits(['update:modelValue'])
@@ -0,0 +1,187 @@
<template>
<v-tabs v-model="active" bg-color="white">
<v-tab
v-for="(element, index) in displayableElements"
:key="index"
color="primary"
:class="tab_error.includes(index) ? 'error_tab' : ''"
>
{{ element.label }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="active">
<v-tabs-window-item
v-for="element in displayableElements"
:key="element.label"
>
<v-container fluid class="pa-0">
<v-card>
<v-card-text>
<template
v-for="param in displayableParams(element.parameters)"
:key="param.name"
>
<div class="my-4">
<template v-if="param.widget === 'SeparatorField'">
<h2>{{ param.label }}</h2>
</template>
<template v-else-if="param.widget === 'BooleanField'">
<v-switch
v-if="param.widget === 'BooleanField'"
v-model="parameters[param.name]"
:label="param.label"
:hint="param.help_text"
:error="formErrors[param.name] !== undefined"
color="primary"
density="compact"
:error-messages="formErrors[param.name]"
persistent-hint
/>
</template>
<template v-else>
<label class="m-label">{{ param.label }}</label>
<v-select
v-if="param.widget === 'ChoiceField'"
v-model="parameters[param.name]"
:items="param.choices"
item-title="text"
:hint="param.help_text"
persistent-hint
density="compact"
variant="outlined"
/>
<v-text-field
v-else
v-model="parameters[param.name]"
:hint="param.help_text"
persistent-hint
:error="formErrors[param.name] !== undefined"
:error-messages="formErrors[param.name]"
:type="
param.widget === 'PasswordField' ? 'password' : 'text'
"
density="compact"
variant="outlined"
/>
</template>
</div>
</template>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
</v-tabs-window>
<v-btn
icon="mdi-content-save"
class="ma-5"
position="fixed"
location="bottom right"
color="green"
size="large"
@click="save"
/>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
const props = defineProps({
app: {
type: String,
default: '',
},
structure: {
type: Array,
default: null,
},
values: {
type: Object,
default: null,
},
saveFunction: {
type: Function,
default: null,
},
})
const emit = defineEmits(['success'])
const { $gettext } = useGettext()
const { displayNotification } = useBusStore()
const displayableElements = computed(() =>
props.structure.filter((element) => display(element))
)
const active = ref(0)
const formErrors = ref({})
const parameters = ref({})
const tab_error = ref([])
watch(
() => props.values,
(newValue) => {
if (newValue) {
parameters.value = { ...newValue }
}
}
)
watch(parameters, () => {
tab_error.value = []
})
function display(element) {
if (element.display === '') {
return true
}
let result = true
element.display.split('&').forEach((rule) => {
let [field, value] = rule.split('=')
if (value === 'true' || value === 'false') {
value = Boolean(value)
}
result &= parameters.value[field] === value
})
return result
}
function displayableParams(params) {
return params.filter((param) => display(param))
}
async function save() {
formErrors.value = {}
tab_error.value = []
try {
await props.saveFunction(props.app, parameters.value)
displayNotification({
msg: $gettext('Parameters updated'),
})
emit('success', parameters.value)
} catch (error) {
displayNotification({
msg: $gettext('Could not save parameters'),
type: 'error',
})
formErrors.value = error.response.data
Object.keys(formErrors.value).forEach((element) => {
for (let i = 0; i < props.structure.length; i++) {
const isIn =
props.structure[i].parameters.filter(
(param) => param.name === element
).length > 0
if (isIn) {
tab_error.value.push(i)
}
}
})
}
}
</script>
<style scoped>
.error_tab {
color: #ff5252 !important;
}
</style>
@@ -0,0 +1,50 @@
<template>
<ConnectedLayout color="grey" :menu-items="userSettingsMenuItems" />
</template>
<script setup>
import { computed, ref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore } from '@/stores'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
import parametersApi from '@/api/parameters'
const authStore = useAuthStore()
const { $gettext } = useGettext()
const applications = ref([])
const authUser = computed(() => authStore.authUser)
const userSettingsMenuItems = computed(() => {
const result = []
// Temporary solution...
const appIcons = {
contacts: 'mdi-contacts-outline',
}
result.push({
text: $gettext('Settings'),
to: { name: 'AccountSettings' },
icon: 'mdi-cog',
})
if (authUser.value.mailbox) {
result.push({
text: $gettext('Filters'),
to: { name: 'AccountFilters' },
icon: 'mdi-filter',
})
}
for (const app of applications.value) {
result.push({
icon: appIcons[app.name],
text: app.label,
to: { name: 'AccountParametersEdit', params: { app: app.name } },
})
}
return result
})
parametersApi.getUserApplications().then((resp) => {
applications.value = resp.data
})
</script>
+119
View File
@@ -0,0 +1,119 @@
<template>
<ConnectedLayout :menu-items="adminMenuItems" />
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useParametersStore } from '@/stores'
import parametersApi from '@/api/parameters'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
const { $gettext } = useGettext()
const authStore = useAuthStore()
const parametersStore = useParametersStore()
const authUser = computed(() => authStore.authUser)
const imapMigrationEnabled = computed(
() => parametersStore.imapMigrationEnabled
)
const settings = []
const adminMenuItems = [
{
text: $gettext('Dashboard'),
to: { name: 'Dashboard' },
exact: true,
icon: 'mdi-view-dashboard-outline',
},
{
text: $gettext('Domains'),
to: { name: 'DomainList' },
icon: 'mdi-domain',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Identities'),
to: { name: 'Identities' },
icon: 'mdi-account',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Alarms'),
to: { name: 'Alarms' },
icon: 'mdi-bell',
},
{
icon: 'mdi-history',
text: $gettext('Monitoring'),
roles: ['SuperAdmins', 'Resellers', 'DomainAdmins'],
children: [
{
text: $gettext('Statistics'),
to: { name: 'Statistics' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Audit trail'),
to: { name: 'AuditTrail' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Messages'),
to: { name: 'MessageLog' },
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-email-sync-outline',
text: $gettext('IMAP Migration'),
roles: ['SuperAdmins', 'Resellers'],
condition: () => imapMigrationEnabled.value,
children: [
{
text: $gettext('Email providers'),
to: { name: 'ProvidersList' },
roles: ['SuperAdmins', 'Resellers'],
},
{
text: $gettext('Migrations'),
to: { name: 'MigrationsList' },
roles: ['Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-cog',
text: $gettext('Settings'),
children: settings,
roles: ['SuperAdmins'],
},
{
icon: 'mdi-information',
text: $gettext('Information'),
roles: ['SuperAdmins'],
to: { name: 'Information' },
withBell: true,
},
]
onMounted(() => {
if (authUser.value.role === 'SuperAdmins') {
parametersApi.getGlobalApplications().then((response) => {
response.data.forEach((item) => {
settings.push({
text: item.label,
to: { name: 'ParametersEdit', params: { app: item.name } },
})
})
})
if (parametersStore.imapMigrationEnabled === null) {
parametersApi.getGlobalApplication('imap_migration').then((response) => {
parametersStore.imapMigrationEnabled =
response.data.params.enabled_imapmigration
})
}
}
})
</script>
@@ -1,7 +1,8 @@
<template>
<v-app>
<NavBar />
<DashboardView />
<NavBar :color="color" :menu-items="menuItems" />
<TopMenu :user="authUser" />
<ConnectedView />
<v-snackbar
v-model="snackbar"
:color="notificationColor"
@@ -19,21 +20,35 @@
</template>
<script setup>
import DashboardView from './DashboardView.vue'
import NavBar from './NavBar.vue'
import { ref, computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useBusStore } from '@/stores'
import ConnectedView from './ConnectedView.vue'
import NavBar from '@/components/shared/NavBar.vue'
import TopMenu from '@/components/shared/TopMenu.vue'
import { useBusStore } from '@/stores'
const authStore = useAuthStore()
const busStore = useBusStore()
const { $gettext } = useGettext()
const props = defineProps({
color: {
type: String,
default: 'primary',
},
menuItems: {
type: Array,
default: null,
},
})
const authUser = computed(() => authStore.authUser)
const notificationColor = computed(() => busStore.notificationColor)
const notificationTimeout = 2000
const notification = computed(() => busStore.notification)
const snackbar = ref(false)
const notificationTimeout = 2000
busStore.$onAction(({ name, after }) => {
if (name === 'displayNotification') {
after(() => {
@@ -1,11 +1,9 @@
<template>
<v-main>
<v-container fluid>
<v-container fluid style="height: 100%">
<router-view v-slot="{ Component }">
<transition name="fade">
<div v-show="true">
<component :is="Component" />
</div>
<component :is="Component" />
</transition>
</router-view>
</v-container>
-353
View File
@@ -1,353 +0,0 @@
<template>
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
:color="mainColor"
app
>
<div class="d-flex align-center">
<v-img
src="@/assets/Modoboa_RVB-BLANC-SANS.png"
max-width="190"
class="logo"
@click="router.push({ name: 'Dashboard' })"
/>
<v-btn
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
variant="text"
@click.stop="rail = !rail"
>
</v-btn>
</div>
<v-list nav>
<template v-for="item in menuItems" :key="item.text">
<template v-if="displayMenuItem(item)">
<v-list-item
v-if="!item.children"
:value="item"
:to="item.to"
link
:exact="item.exact"
:title="item.text"
:prepend-icon="item.icon"
>
<template v-if="item.withBell && displayInformationBell" #append>
<v-icon color="red" icon="mdi-bell-alert-outline" />
</template>
</v-list-item>
<v-list-group v-else :value="item.text">
<template #activator="{ props }">
<v-list-item
v-bind="props"
:key="item.text"
:title="item.text"
color="white"
:prepend-icon="item.icon"
>
</v-list-item>
</template>
<template v-for="subitem in item.children" :key="subitem.text">
<v-list-item
v-if="displayMenuItem(subitem)"
:to="subitem.to"
link
:title="subitem.text"
:value="subitem"
></v-list-item>
</template>
</v-list-group>
</template>
</template>
</v-list>
<template #append>
<v-menu v-if="isAuthenticated" rounded="lg" offset-y top>
<template #activator="{ props }">
<div
:class="[
backgroundColor,
'd-flex',
'justify-center',
'align-center',
'pa-2',
]"
>
<v-btn
v-bind="props"
:class="[backgroundColor, 'text-white']"
rounded
density="compact"
height="48"
elevation="0"
>
<v-avatar size="40" color="primary">
<span class="text-white headline">{{ userInitials }}</span>
</v-avatar>
<template v-if="!rail">
<span class="mx-2">{{ displayName }}</span>
<v-icon class="float-right">mdi-chevron-up</v-icon>
</template>
</v-btn>
</div>
</template>
<v-list>
<v-list-item
v-for="item in userMenuItems"
:key="item.text"
:to="item.to"
link
@click="item.click"
>
<template #prepend>
<v-icon :icon="item.icon"></v-icon>
</template>
<v-list-item-title>{{ item.text }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-navigation-drawer>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { getActivePinia } from 'pinia'
import { useGlobalStore, useAuthStore, useParametersStore } from '@/stores'
import parametersApi from '@/api/parameters'
const globalStore = useGlobalStore()
const authStore = useAuthStore()
const parametersStore = useParametersStore()
const route = useRoute()
const router = useRouter()
const { $gettext } = useGettext()
const rail = ref(false)
const drawer = ref(true)
const authUser = computed(() => authStore.authUser)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const backgroundColor = computed(() =>
route.meta.layout === 'account' ? 'bg-grey-darken-1' : 'bg-primary-darken-1'
)
const userInitials = computed(() => {
let initials = null
if (authUser.value.first_name) {
initials = authUser.value.first_name[0]
}
if (authUser.value.last_name) {
initials = initials
? initials + authUser.value.last_name[0]
: authUser.value.last_name[0]
}
if (!initials) {
initials = authUser.value.username[0]
}
return initials
})
const displayName = computed(() => {
return authUser.value.first_name || authUser.value.last_name
? `${authUser.value.first_name} ${authUser.value.last_name}`
: authUser.value.username
})
const menuItems = computed(() => {
if (route.meta.layout === 'account') {
return getUserSettingsMenuItems()
}
return mainMenuItems
})
const mainColor = computed(() => {
if (route.meta.layout === 'account') {
return 'grey'
}
return 'primary'
})
const imapMigration = computed(() => parametersStore.imapMigrationEnabled)
const displayInformationBell = computed(
() =>
globalStore.notifications.length !== undefined &&
globalStore.notifications.length !== 0
)
const settings = []
const mainMenuItems = [
{
text: $gettext('Dashboard'),
to: { name: 'Dashboard' },
exact: true,
icon: 'mdi-view-dashboard-outline',
},
{
text: $gettext('Domains'),
to: { name: 'DomainList' },
icon: 'mdi-domain',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Identities'),
to: { name: 'Identities' },
icon: 'mdi-account',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
{
text: $gettext('Alarms'),
to: { name: 'Alarms' },
icon: 'mdi-bell',
},
{
icon: 'mdi-history',
text: $gettext('Monitoring'),
roles: ['SuperAdmins', 'Resellers', 'DomainAdmins'],
children: [
{
text: $gettext('Statistics'),
to: { name: 'Statistics' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Audit trail'),
to: { name: 'AuditTrail' },
roles: ['SuperAdmins'],
},
{
text: $gettext('Messages'),
to: { name: 'MessageLog' },
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-email-sync-outline',
text: $gettext('IMAP Migration'),
roles: ['SuperAdmins', 'Resellers'],
children: [
{
text: $gettext('Email providers'),
to: { name: 'ProvidersList' },
roles: ['SuperAdmins', 'Resellers'],
},
{
text: $gettext('Migrations'),
to: { name: 'MigrationsList' },
roles: ['Resellers', 'SuperAdmins'],
},
],
},
{
icon: 'mdi-cog',
text: $gettext('Settings'),
children: settings,
roles: ['SuperAdmins'],
},
{
icon: 'mdi-information',
text: $gettext('Information'),
roles: ['SuperAdmins'],
to: { name: 'Information' },
withBell: true,
},
]
const userMenuItems = [
{
text: $gettext('Account'),
icon: 'mdi-account-circle-outline',
to: { name: 'AccountSettings' },
click: () => null,
},
{
text: $gettext('Logout'),
icon: 'mdi-logout',
click: logout,
},
]
if (isAuthenticated.value && authUser.value.role !== 'SimpleUsers') {
userMenuItems.unshift({
text: $gettext('Admin'),
to: { name: 'Dashboard' },
icon: 'mdi-view-dashboard-outline',
})
}
function getUserSettingsMenuItems() {
const result = []
if (authUser.value.mailbox) {
result.push({
text: $gettext('Filters'),
to: { name: 'AccountFilters' },
icon: 'mdi-filter',
})
}
result.push({
text: $gettext('Settings'),
to: { name: 'AccountSettings' },
icon: 'mdi-cog',
})
return result
}
function displayMenuItem(item) {
if (isAuthenticated.value) {
const condition =
(item.roles === undefined ||
item.roles.indexOf(authUser.value.role) !== -1) &&
(item.condition === undefined || item.condition()) &&
item.activated !== false
if (item.icon === 'mdi-email-sync-outline') {
// For imapMigration
return condition && imapMigration.value
}
return condition
}
}
async function logout() {
getActivePinia()._s.forEach(async (store) => await store.$reset())
}
onMounted(() => {
if (authUser.value.role === 'SuperAdmins') {
parametersApi.getApplications().then((response) => {
response.data.forEach((item) => {
settings.push({
text: item.label,
to: { name: 'ParametersEdit', params: { app: item.name } },
})
})
})
globalStore.fetchNotifications()
if (parametersStore.imapMigrationEnabled === null) {
parametersApi.getApplication('imap_migration').then((response) => {
parametersStore.imapMigrationEnabled =
response.data.params.enabled_imapmigration
})
}
}
})
</script>
<style lang="scss" scoped>
.v-list-item {
&--active {
&::before {
opacity: 0;
}
background-color: #034bad !important;
color: white;
opacity: 1;
}
}
.logo {
cursor: pointer;
}
</style>
@@ -1,9 +1,9 @@
<template>
<v-app>
<DefaultView />
<EmptyView />
</v-app>
</template>
<script setup>
import DefaultView from './DefaultView.vue'
import EmptyView from './EmptyView.vue'
</script>
+10
View File
@@ -0,0 +1,10 @@
<template>
<ConnectedLayout :menu-items="layoutStore.leftMenuItems" />
</template>
<script setup>
import { useLayoutStore } from '@/stores'
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
const layoutStore = useLayoutStore()
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,6 +9,7 @@ import vuetify from './vuetify'
import router from '../router'
import gettext from './gettext'
import filters from './filters'
import permissions from './permissions'
import pinia from '@/stores'
import VueApexCharts from 'vue3-apexcharts'
@@ -19,5 +20,6 @@ export function registerPlugins(app) {
.use(pinia)
.use(router)
.use(filters)
.use(permissions)
.use(VueApexCharts)
}
+32
View File
@@ -0,0 +1,32 @@
import { useAuthStore } from '@/stores'
export default {
install: (app) => {
const authStore = useAuthStore()
app.directive('can', (el, binding) => {
const permission = binding.value
if (authStore.authUser) {
if (
authUser.authUser.role !== 'SuperAdmins' &&
authStore.authUser.permissions.indexOf(permission) === -1
) {
el.style.display = 'none'
}
}
})
app.config.globalProperties.$can = function (permission) {
if (!authStore.authUser) {
return false
}
if (
authStore.authUser.role === 'SuperAdmins' ||
authStore.authUser.permissions.indexOf(permission) !== -1
) {
return true
}
return false
}
},
}
+5 -1
View File
@@ -24,7 +24,11 @@ function samePassword(value, confirmation) {
export default {
required: (value) =>
(value !== '' && value != null) || $gettext('Field is required'),
(value !== '' &&
value != null &&
value !== undefined &&
(!Array.isArray(value) || value.length)) ||
$gettext('Field is required'),
email: (value) => validateEmail(value),
emailOrNull: (value) => value != null || value !== '' || validateEmail(value),
minLength: (len) => (value) =>
+68 -11
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores'
const routes = [
{
path: '/login',
component: () => import('@/layouts/default/DefaultLayout.vue'),
component: () => import('@/layouts/empty/EmptyLayout.vue'),
children: [
{
path: '',
@@ -16,24 +16,27 @@ const routes = [
path: 'logged',
name: 'LoginCallback',
component: () => import('@/views/login/LoginCallbackView.vue'),
props: {
redirectUrl: '/admin',
},
},
],
},
{
path: '/admin',
component: () => import('@/layouts/dashboard/DashboardLayout.vue'),
component: () => import('@/layouts/admin/AdminLayout.vue'),
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
component: () => import('@/views/DashboardView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: 'domains',
component: () => import('@/layouts/default/DefaultLayout.vue'),
component: () => import('@/layouts/empty/EmptyLayout.vue'),
meta: {
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
@@ -70,7 +73,7 @@ const routes = [
},
{
path: 'imap_migration',
component: () => import('@/layouts/default/DefaultLayout.vue'),
component: () => import('@/layouts/empty/EmptyLayout.vue'),
meta: {
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
@@ -88,7 +91,7 @@ const routes = [
},
{
path: 'providers',
component: () => import('@/layouts/default/DefaultLayout.vue'),
component: () => import('@/layouts/empty/EmptyLayout.vue'),
meta: {
requiresAuth: true,
allowedRoles: ['Resellers', 'SuperAdmins'],
@@ -120,7 +123,7 @@ const routes = [
},
{
path: 'identities',
component: () => import('@/layouts/default/DefaultLayout.vue'),
component: () => import('@/layouts/empty/EmptyLayout.vue'),
meta: {
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
@@ -233,9 +236,8 @@ const routes = [
},
{
path: '/account',
component: () => import('@/layouts/dashboard/DashboardLayout.vue'),
component: () => import('@/layouts/account/AccountLayout.vue'),
meta: {
layout: 'account',
requiresAuth: true,
},
children: [
@@ -244,22 +246,77 @@ const routes = [
name: 'AccountFilters',
component: () => import('@/views/account/FiltersView.vue'),
meta: {
layout: 'account',
requiresAuth: true,
requiresMailbox: true,
},
},
{
path: 'parameters/:app',
name: 'AccountParametersEdit',
component: () => import('@/views/account/ParametersView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: ':tab?',
name: 'AccountSettings',
component: () => import('@/views/account/SettingsView.vue'),
meta: {
layout: 'account',
requiresAuth: true,
},
},
],
},
{
path: '/user',
component: () => import('@/layouts/user/UserLayout.vue'),
meta: {
requiresAuth: true,
},
children: [
{
path: '',
name: 'UserDashboard',
component: () => import('@/views/DashboardView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: 'contacts',
meta: {
requiresAuth: true,
},
children: [
{
path: ':category?',
name: 'ContactList',
component: () => import('@/views/contacts/AddressBook.vue'),
meta: {
requiresAuth: true,
},
},
],
},
{
path: 'calendars',
meta: {
requiresAuth: true,
},
children: [
{
path: '',
name: 'CalendarView',
component: () => import('@/views/calendars/CalendarView.vue'),
meta: {
requiresAuth: true,
},
},
],
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
+3 -3
View File
@@ -1,7 +1,7 @@
import Cookies from 'js-cookie'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, inject, ref } from 'vue'
import gettext from '@/plugins/gettext'
import router from '@/router/index.js'
import { UserManager } from 'oidc-client-ts'
@@ -130,7 +130,7 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function completeLogin() {
async function completeLogin(redirectUrl) {
try {
const user = await manager.signinRedirectCallback()
isAuthenticated.value = true
@@ -140,7 +140,7 @@ export const useAuthStore = defineStore('auth', () => {
window.location.href = previousPage
} else {
// Redirect to a default page if the previous page is not available
router.push({ name: 'Dashboard' })
router.push(redirectUrl)
}
return user
} catch (error) {
+20
View File
@@ -0,0 +1,20 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useContactsStore = defineStore('contacts', () => {
const currentCategory = ref(null)
function setCurrentCategory(category) {
currentCategory.value = category
}
async function $reset() {
currentCategory.value = null
}
return {
currentCategory,
setCurrentCategory,
$reset,
}
})
+5 -1
View File
@@ -14,5 +14,9 @@ export const useGlobalStore = defineStore('global', () => {
return notifications.value.find((item) => item.id === id)
}
return { notifications, fetchNotifications, getNotificationById }
async function $reset() {
notifications.value = []
}
return { notifications, fetchNotifications, getNotificationById, $reset }
})
+2
View File
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia'
export * from './global.store'
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'
export default createPinia()
+20
View File
@@ -0,0 +1,20 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useLayoutStore = defineStore('layout', () => {
const leftMenuItems = ref([])
function setLeftMenuItems(items) {
leftMenuItems.value = items
}
async function $reset() {
leftMenuItems.value = []
}
return {
leftMenuItems,
setLeftMenuItems,
$reset,
}
})
@@ -0,0 +1,55 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title>{{ title }}</v-toolbar-title>
</v-toolbar>
<ParametersForm
:app="$route.params.app"
:structure="structure"
:values="parameters"
:save-function="parametersApi.saveUserApplication"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useRoute } from 'vue-router'
import ParametersForm from '@/components/tools/ParametersForm'
import parametersApi from '@/api/parameters'
const { $gettext } = useGettext()
const route = useRoute()
const label = ref('')
const structure = ref([])
const parameters = ref({})
const title = computed(() => $gettext('Settings: ' + label.value))
function loadParams(app) {
parametersApi.getUserApplicationStructure(app).then((response) => {
structure.value = response.data
})
parametersApi.getUserApplication(app).then((response) => {
parameters.value = response.data.params
label.value = response.data.label
})
}
loadParams(route.params.app)
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
<style>
.v-window__container {
width: 100% !important;
}
</style>
+14 -145
View File
@@ -4,188 +4,57 @@
<v-toolbar-title>{{ title }}</v-toolbar-title>
</v-toolbar>
<v-tabs v-model="active" bg-color="white">
<v-tab
v-for="(element, index) in displayableElements"
:key="index"
color="primary"
:class="tab_error.includes(index) ? 'error_tab' : ''"
>
{{ element.label }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="active">
<v-tabs-window-item
v-for="element in displayableElements"
:key="element.label"
>
<v-container fluid class="pa-0">
<v-card>
<v-card-text>
<template
v-for="param in displayableParams(element.parameters)"
:key="param.name"
>
<div class="my-4">
<template v-if="param.widget === 'SeparatorField'">
<h2>{{ param.label }}</h2>
</template>
<template v-else-if="param.widget === 'BooleanField'">
<v-switch
v-if="param.widget === 'BooleanField'"
v-model="parameters[param.name]"
:label="param.label"
:hint="param.help_text"
:error="formErrors[param.name] !== undefined"
color="primary"
density="compact"
:error-messages="formErrors[param.name]"
persistent-hint
/>
</template>
<template v-else>
<label class="m-label">{{ param.label }}</label>
<v-select
v-if="param.widget === 'ChoiceField'"
v-model="parameters[param.name]"
:items="param.choices"
item-title="text"
:hint="param.help_text"
persistent-hint
density="compact"
variant="outlined"
/>
<v-text-field
v-else
v-model="parameters[param.name]"
:hint="param.help_text"
persistent-hint
:error="formErrors[param.name] !== undefined"
:error-messages="formErrors[param.name]"
:type="
param.widget === 'PasswordField' ? 'password' : 'text'
"
density="compact"
variant="outlined"
/>
</template>
</div>
</template>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
</v-tabs-window>
<v-btn
icon="mdi-content-save"
class="ma-5"
position="fixed"
location="bottom right"
color="green"
size="large"
@click="save"
<ParametersForm
:app="$route.params.app"
:structure="structure"
:values="parameters"
:save-function="parametersApi.saveGlobalApplication"
@success="onSuccess"
/>
</div>
</template>
<script setup lang="js">
<script setup>
import { useBusStore } from '@/stores'
import parametersApi from '@/api/parameters'
import { ref, computed, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useRoute } from 'vue-router'
import { useGlobalStore, useParametersStore } from '@/stores'
import ParametersForm from '@/components/tools/ParametersForm'
const { $gettext } = useGettext()
const busStore = useBusStore()
const globalStore = useGlobalStore()
const route = useRoute()
const parametersStore = useParametersStore()
const active = ref(0)
const structure = ref([])
const parameters = ref({})
const formErrors = ref({})
const label = ref('')
const tab_error = ref([])
const displayableElements = computed(() =>
structure.value.filter((element) => display(element))
)
const title = computed(() => $gettext('Settings: ' + label.value))
function loadParams(app) {
parametersApi.getApplicationStructure(app).then((response) => {
parametersApi.getGlobalApplicationStructure(app).then((response) => {
structure.value = response.data
})
parametersApi.getApplication(app).then((response) => {
parametersApi.getGlobalApplication(app).then((response) => {
parameters.value = response.data.params
label.value = response.data.label
})
}
function display(element) {
if (element.display === '') {
return true
function onSuccess(newValues) {
globalStore.fetchNotifications()
if (route.params.app === 'imap_migration') {
parametersStore.imapMigrationEnabled = newValues.enabled_imapmigration
}
let result = true
element.display.split('&').forEach((rule) => {
let [field, value] = rule.split('=')
if (value === 'true' || value === 'false') {
value = Boolean(value)
}
result &= parameters.value[field] === value
})
return result
}
function displayableParams(params) {
return params.filter((param) => display(param))
}
async function save() {
formErrors.value = {}
tab_error.value = []
parametersApi
.saveApplication(route.params.app, parameters.value)
.then(() => {
busStore.displayNotification({
msg: $gettext('Parameters updated'),
})
globalStore.fetchNotifications()
if (route.params.app === 'imap_migration') {
parametersStore.imapMigrationEnabled =
parameters.value.enabled_imapmigration
}
})
.catch((error) => {
busStore.displayNotification({
msg: $gettext('Could not save parameters'),
type: 'error',
})
formErrors.value = error.response.data
Object.keys(formErrors.value).forEach((element) => {
for (let i = 0; i < structure.value.length; i++) {
const isIn =
structure.value[i].parameters.filter(
(param) => param.name === element
).length > 0
if (isIn) {
tab_error.value.push(i)
}
}
})
})
}
watch(route, (toRoute) => {
loadParams(toRoute.params.app)
})
watch(parameters, () => {
tab_error.value = []
})
loadParams(route.params.app)
</script>
@@ -2,44 +2,47 @@
<div>
<v-toolbar flat>
<v-toolbar-title>{{ $gettext('Domains') }}</v-toolbar-title>
<v-spacer />
<v-btn
class="mr-2"
:title="$gettext('Import domains and aliases from CSV file')"
variant="elevated"
icon="mdi-file-import-outline"
@click="showImportForm = true"
></v-btn>
<v-btn
class="mr-2"
:title="$gettext('Export domains and aliases to CSV')"
variant="elevated"
icon="mdi-file-export-outline"
@click="exportDomains"
></v-btn>
<v-menu offset-y>
<template #activator="{ props }">
<v-btn
color="primary"
variant="flat"
v-bind="props"
prepend-icon="mdi-plus"
>
{{ $gettext('New') }}
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="showDomainWizard = true">
<v-list-item-title>{{ $gettext('Domain') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="showAliasForm = true">
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar>
<DomainList />
<DomainList>
<template #extraActions>
<v-menu offset-y>
<template #activator="{ props }">
<v-btn
color="primary"
variant="outlined"
v-bind="props"
prepend-icon="mdi-plus"
class="mr-4"
>
{{ $gettext('New') }}
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="showDomainWizard = true">
<v-list-item-title>{{ $gettext('Domain') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="showAliasForm = true">
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn
class="mr-2"
:title="$gettext('Import domains and aliases from CSV file')"
variant="flat"
icon="mdi-file-import-outline"
@click="showImportForm = true"
></v-btn>
<v-btn
class="mr-2"
:title="$gettext('Export domains and aliases to CSV')"
variant="flat"
icon="mdi-file-export-outline"
@click="exportDomains"
></v-btn>
</template>
</DomainList>
<v-dialog v-model="showDomainWizard" fullscreen scrollable z-index="10">
<DomainCreationForm @close="showDomainWizard = false" />
@@ -62,7 +62,7 @@ const account = computed(() => {
})
const limitsConfig = ref({})
parametersApi.getApplication('limits').then((resp) => {
parametersApi.getGlobalApplication('limits').then((resp) => {
limitsConfig.value = resp.data
})
@@ -2,44 +2,48 @@
<div>
<v-toolbar flat>
<v-toolbar-title>{{ $gettext('Identities') }}</v-toolbar-title>
<v-spacer />
<v-btn
class="mr-2"
:title="$gettext('Import accounts and aliases from CSV file')"
variant="elevated"
icon="mdi-file-import-outline"
@click="showImportForm = true"
></v-btn>
<v-btn
class="mr-2"
:title="$gettext('Export accounts and aliases to CSV')"
icon="mdi-file-export-outline"
variant="elevated"
@click="exportIdentities"
></v-btn>
<v-menu offset-y>
<template #activator="{ props }">
<v-btn
color="primary"
variant="flat"
v-bind="props"
prepend-icon="mdi-plus"
>
{{ $gettext('New') }}
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="showCreationWizard = true">
<v-list-item-title>{{ $gettext('Account') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="showAliasCreationWizard = true">
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar>
<IdentityList />
<IdentityList>
<template #extraActions>
<v-menu offset-y>
<template #activator="{ props }">
<v-btn
color="primary"
variant="outlined"
v-bind="props"
prepend-icon="mdi-plus"
class="mr-4"
>
{{ $gettext('New') }}
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="showCreationWizard = true">
<v-list-item-title>{{ $gettext('Account') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="showAliasCreationWizard = true">
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn
class="mr-2"
:title="$gettext('Import accounts and aliases from CSV file')"
variant="flat"
icon="mdi-file-import-outline"
@click="showImportForm = true"
></v-btn>
<v-btn
class="mr-2"
:title="$gettext('Export accounts and aliases to CSV')"
icon="mdi-file-export-outline"
variant="flat"
@click="exportIdentities"
></v-btn>
</template>
</IdentityList>
<v-dialog
v-model="showAliasCreationWizard"
fullscreen
@@ -0,0 +1,314 @@
<template>
<div class="text-h5 ml-4 mb-6">
{{ $gettext('Calendar') }}
</div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<v-dialog v-model="showInformation" persistent max-width="800px">
<CalendarDetail :calendar="selectedCalendar" @close="closeInformation" />
</v-dialog>
<v-dialog v-model="showCalendarForm" persistent max-width="800px">
<CalendarForm
:initial-calendar="selectedCalendar"
@color-changed="refreshCalendarEvents"
@close="closeCalendarForm"
/>
</v-dialog>
<v-dialog v-model="showEventForm" persistent max-width="800px">
<EventForm
:info="selectInfo"
:event="selectedEvent"
@close="closeEventForm"
/>
</v-dialog>
<v-dialog v-model="showAccessRulesForm" persistent max-width="800px">
<CalendarAccessRulesForm
v-if="selectedCalendar"
:calendar-pk="selectedCalendar.pk"
@close="closeAccessRulesForm"
/>
</v-dialog>
<v-dialog v-model="showImportEventsForm" persistent max-width="800px">
<ImportEventsForm
v-if="selectedCalendar"
:calendar="selectedCalendar"
@events-imported="refreshCalendarEvents"
@close="closeImportEventsForm"
/>
</v-dialog>
<ConfirmDialog ref="confirm" />
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useLayoutStore } from '@/stores'
import { useBusStore } from '@/stores'
import { DateTime } from 'luxon'
import api from '@/api/calendars'
import CalendarAccessRulesForm from '@/components/calendars/CalendarAccessRulesForm'
import CalendarForm from '@/components/calendars/CalendarForm'
import CalendarDetail from '@/components/calendars/CalendarDetail'
import ConfirmDialog from '@/components/tools/ConfirmDialog'
import EventForm from '@/components/calendars/EventForm'
import ImportEventsForm from '@/components/calendars/ImportEventsForm'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'
const { $gettext, current } = useGettext()
const layoutStore = useLayoutStore()
const busStore = useBusStore()
const calendarRef = ref()
const confirm = ref()
const selectInfo = ref()
const selectedCalendar = ref(null)
const selectedEvent = ref(null)
const userCalendars = ref([])
const showAccessRulesForm = ref(false)
const showCalendarForm = ref(false)
const showInformation = ref(false)
const showEventForm = ref(false)
const showImportEventsForm = ref(false)
const leftMenuItems = computed(() => {
const result = [
{
icon: 'mdi-plus',
text: $gettext('New calendar'),
action: openCalendarForm,
},
{
text: $gettext('My calendars'),
subheader: true,
},
]
for (const calendar of userCalendars.value) {
result.push({
text: calendar.name,
children: [
{
text: $gettext('Information'),
icon: 'mdi-information-outline',
action: () => openInformation(calendar),
},
{
text: $gettext('Edit'),
icon: 'mdi-pencil',
action: () => editCalendar(calendar),
},
{
text: $gettext('Access rules'),
icon: 'mdi-filter-outline',
action: () => openAccessRulesForm(calendar),
},
{
text: $gettext('Delete'),
icon: 'mdi-trash-can-outline',
action: () => deleteCalendar(calendar.pk),
},
{
text: $gettext('Import'),
icon: 'mdi-calendar-import-outline',
action: () => openImportEventsForm(calendar),
},
],
})
}
return result
})
async function fetchUserCalendarEvents(
calendarPk,
info,
successCallback,
failureCallback
) {
try {
const resp = await api.getUserCalendarEvents(calendarPk, {
start: info.startStr,
end: info.endStr,
})
successCallback(resp.data)
} catch (err) {
failureCallback(err)
}
}
function openCalendarForm() {
showCalendarForm.value = true
}
function closeCalendarForm() {
selectedCalendar.value = null
showCalendarForm.value = false
fetchUserCalendars()
}
function openInformation(calendar) {
selectedCalendar.value = calendar
showInformation.value = true
}
function closeInformation() {
selectedCalendar.value = null
showInformation.value = false
}
function openAccessRulesForm(calendar) {
selectedCalendar.value = calendar
showAccessRulesForm.value = true
}
function closeAccessRulesForm() {
selectedCalendar.value = null
showAccessRulesForm.value = false
}
function openImportEventsForm(calendar) {
selectedCalendar.value = calendar
showImportEventsForm.value = true
}
function closeImportEventsForm() {
selectedCalendar.value = null
showImportEventsForm.value = false
}
function fetchUserCalendars() {
api.getUserCalendars().then((resp) => {
userCalendars.value = resp.data
})
}
function refreshCalendarEvents(calendar) {
const fullc = calendarRef.value.getApi()
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
evtSource.refetch()
}
function selectCallback(info) {
selectInfo.value = info
showEventForm.value = true
}
async function eventClickCallback(info) {
const resp = await api.getUserEvent(
info.event.extendedProps.calendar.pk,
info.event.id
)
selectedEvent.value = resp.data
showEventForm.value = true
}
function updateEventDates(calEvent) {
var data = {
start: calEvent.start,
end: calEvent.end,
}
const evtCalendar = calEvent.extendedProps.calendar
if (calEvent.allDay) {
const end = DateTime.fromJSDate(data.start)
end.plus({ days: 1 })
data.start.setHours(0, 0, 0)
data.end = end
data.allDay = calEvent.allDay
} else if (!calEvent.end) {
const end = DateTime.fromJSDate(data.start)
end.plus({ hours: 1 })
data.end = end
}
api.patchUserEvent(evtCalendar.pk, calEvent.id, data).then((response) => {
busStore.displayNotification({ msg: $gettext('Event updated') })
})
}
function eventDropCallback(info) {
updateEventDates(info.event)
}
function eventResizeCallback(info) {
updateEventDates(info.event)
}
function closeEventForm() {
showEventForm.value = false
selectedEvent.value = null
}
function editCalendar(calendar) {
selectedCalendar.value = calendar
showCalendarForm.value = true
}
async function deleteCalendar(calendarPk) {
const result = await confirm.value.open(
$gettext('Warning'),
$gettext('Do you really want to delete this calendar?'),
{ color: 'warning' }
)
if (!result) {
return
}
await api.deleteUserCalendar(calendarPk)
const fullc = calendarRef.value.getApi()
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
evtSource.remove()
busStore.displayNotification({ msg: $gettext('Calendar deleted') })
}
const calendarOptions = {
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
scrollTime: '09:00:00',
height: '100%',
firstDay: 1,
initialView: 'timeGridWeek',
locale: current,
selectable: true,
selectMirror: true,
editable: true,
dayMaxEventRows: true,
businessHours: {
daysOfWeek: [1, 2, 3, 4, 5],
startTime: '09:00',
endTime: '18:00',
},
select: selectCallback,
eventClick: eventClickCallback,
eventDrop: eventDropCallback,
eventResize: eventResizeCallback,
}
watch(
leftMenuItems,
(value) => {
layoutStore.setLeftMenuItems(value)
},
{ immediate: true }
)
watch(userCalendars, (value) => {
const fullc = calendarRef.value.getApi()
if (!fullc) {
return
}
for (const calendar of value) {
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
if (!evtSource) {
fullc.addEventSource({
id: `user-${calendar.pk}`,
events: fetchUserCalendarEvents.bind(this, calendar.pk),
})
}
}
})
onMounted(() => {
fetchUserCalendars()
})
</script>
@@ -0,0 +1,89 @@
<template>
<div class="text-h5 ml-4">
{{ $gettext('Contacts') }}
</div>
<ContactList @edit-category="openCategoryForm" />
<v-dialog v-model="showCategoryForm" persistent max-width="800px">
<CategoryForm
:category="contactsStore.currentCategory"
@close="closeForm"
@added="fetchCategories"
@updated="fetchCategories"
/>
</v-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useContactsStore, useLayoutStore } from '@/stores'
import contactsApi from '@/api/contacts'
import ContactList from '@/components/contacts/ContactList.vue'
import CategoryForm from '@/components/contacts/CategoryForm.vue'
const router = useRouter()
const { $gettext } = useGettext()
const contactsStore = useContactsStore()
const layoutStore = useLayoutStore()
const categories = ref([])
const showCategoryForm = ref(false)
const leftMenuItems = computed(() => {
const result = [
{
icon: 'mdi-contacts',
text: $gettext('Contacts'),
to: { name: 'ContactList' },
},
{
text: $gettext('Categories'),
subheader: true,
},
]
for (const category of categories.value) {
result.push({
icon: 'mdi-label',
text: category.name,
action: () => filterByCategory(category),
})
}
result.push({
icon: 'mdi-plus',
text: $gettext('Add a category'),
action: openCategoryForm,
})
return result
})
function openCategoryForm() {
showCategoryForm.value = true
}
function closeForm() {
showCategoryForm.value = false
}
function filterByCategory(category) {
contactsStore.setCurrentCategory(category)
router.push({
name: 'ContactList',
params: {
category: category.name,
},
})
}
function fetchCategories() {
contactsApi.getCategories().then((resp) => {
categories.value = resp.data
})
}
fetchCategories()
watch(leftMenuItems, (value) => {
layoutStore.setLeftMenuItems(value)
})
</script>
@@ -1,10 +1,15 @@
<script setup lang="js">
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores'
const props = defineProps({
redirectUrl: String,
})
const authStore = useAuthStore()
onMounted(() => {
authStore.completeLogin()
authStore.completeLogin(props.redirectUrl)
})
</script>
+5
View File
@@ -53,6 +53,11 @@ export default defineConfig({
},
},
https: true,
cors: {
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['X-Requested-With', 'content-type', 'Authorization'],
},
},
build: {
emptyOutDir: true,
+512 -542
View File
File diff suppressed because it is too large Load Diff
+47 -6
View File
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --extra=ldap --output-file=ldap-requirements.txt pyproject.toml
@@ -11,13 +11,17 @@ asgiref==3.8.1
# django
# django-cors-headers
# modoboa (pyproject.toml)
async-timeout==5.0.1
# via redis
attrs==25.1.0
# via
# jsonschema
# referencing
bcrypt==4.2.1
# via modoboa (pyproject.toml)
certifi==2025.1.31
caldav==1.3.9
# via modoboa (pyproject.toml)
certifi==2024.12.14
# via requests
cffi==1.17.1
# via cryptography
@@ -54,6 +58,7 @@ django==4.2.18
# django-rq
# django-xforwardedfor-middleware
# djangorestframework
# drf-nested-routers
# drf-spectacular
# modoboa (pyproject.toml)
django-auth-ldap==4.8.0
@@ -84,10 +89,13 @@ django-xforwardedfor-middleware==2.0
# via modoboa (pyproject.toml)
djangorestframework==3.15.2
# via
# drf-nested-routers
# drf-spectacular
# modoboa (pyproject.toml)
dnspython==2.7.0
# via modoboa (pyproject.toml)
drf-nested-routers==0.94.1
# via modoboa (pyproject.toml)
drf-spectacular==0.28.0
# via modoboa (pyproject.toml)
feedparser==6.0.11
@@ -100,6 +108,11 @@ gevent==24.11.1
# via modoboa (pyproject.toml)
greenlet==3.1.1
# via gevent
icalendar==6.1.1
# via
# caldav
# recurring-ical-events
# x-wr-timezone
idna==3.10
# via
# requests
@@ -113,7 +126,9 @@ jsonschema-specifications==2024.10.1
jwcrypto==1.5.6
# via django-oauth-toolkit
lxml==5.3.1
# via lxml-html-clean
# via
# caldav
# lxml-html-clean
lxml-html-clean==0.4.1
# via modoboa (pyproject.toml)
oath==1.4.4
@@ -143,17 +158,26 @@ pyasn1-modules==0.4.1
pycparser==2.22
# via cffi
python-dateutil==2.9.0.post0
# via modoboa (pyproject.toml)
# via
# icalendar
# modoboa (pyproject.toml)
# recurring-ical-events
# vobject
python-ldap==3.4.4
# via django-auth-ldap
python-magic==0.4.27
# via modoboa (pyproject.toml)
pytz==2025.1
# via modoboa (pyproject.toml)
# via
# caldav
# modoboa (pyproject.toml)
# vobject
pyyaml==6.0.2
# via drf-spectacular
qrcode==8.0
# via modoboa (pyproject.toml)
recurring-ical-events==3.4.1
# via caldav
redis==5.2.1
# via
# django-rq
@@ -167,6 +191,7 @@ reportlab==4.3.1
# via modoboa (pyproject.toml)
requests==2.32.3
# via
# caldav
# django-oauth-toolkit
# modoboa (pyproject.toml)
# ovh
@@ -192,7 +217,9 @@ sgmllib3k==1.0.0
sievelib==1.4.2
# via modoboa (pyproject.toml)
six==1.17.0
# via python-dateutil
# via
# python-dateutil
# vobject
sqlparse==0.5.3
# via django
tldextract==5.1.3
@@ -201,13 +228,27 @@ typing-extensions==4.12.2
# via
# dj-database-url
# jwcrypto
# referencing
# sievelib
tzdata==2025.1
# via
# icalendar
# recurring-ical-events
# x-wr-timezone
tzlocal==5.2
# via caldav
uritemplate==4.1.1
# via
# drf-spectacular
# modoboa (pyproject.toml)
urllib3==2.3.0
# via requests
vobject==0.9.9
# via
# caldav
# modoboa (pyproject.toml)
x-wr-timezone==2.0.0
# via recurring-ical-events
zope-event==5.0
# via gevent
zope-interface==7.2
+19 -1
View File
@@ -1,7 +1,6 @@
"""Admin API v2 serializers."""
import ipaddress
import os
from typing import List
from django.conf import settings
@@ -11,6 +10,7 @@ from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from django.contrib.auth import password_validation
from django.contrib.auth.models import Permission
from rest_framework import serializers
@@ -353,6 +353,24 @@ class AccountResourceSerializer(serializers.ModelSerializer):
model = limits_models.UserObjectLimit
class AccountMeSerializer(v1_serializers.AccountSerializer):
"""Custom Account serializer for connected user."""
permissions = serializers.SerializerMethodField()
class Meta(v1_serializers.AccountSerializer.Meta):
fields = v1_serializers.AccountSerializer.Meta.fields + ("permissions",)
def get_permissions(self, user) -> List[str]:
"""Return available permissions for given user."""
permissions = (
user.user_permissions.all() | Permission.objects.filter(group__user=user)
).select_related("content_type")
return [
"{}.{}".format(p.content_type.app_label, p.codename) for p in permissions
]
class AccountSerializer(v1_serializers.AccountSerializer):
"""Add support for user resources."""
+1
View File
@@ -0,0 +1 @@
default_app_config = "modoboa.calendars.apps.CalendarsConfig"
+92
View File
@@ -0,0 +1,92 @@
import collections
from django.utils.translation import gettext_lazy
GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict(
[
(
"server",
{
"label": gettext_lazy("Server settings"),
"params": collections.OrderedDict(
[
(
"server_location",
{
"label": gettext_lazy("Server URL"),
"help_text": gettext_lazy(
"The URL of your Radicale server. "
"It will be used to construct calendar URLs."
),
},
)
]
),
},
),
(
"rights",
{
"label": gettext_lazy("Rights management"),
"params": collections.OrderedDict(
[
(
"rights_file_path",
{
"label": gettext_lazy("Rights file's path"),
"help_text": gettext_lazy(
"Path to the file that contains rights definition"
),
},
),
(
"allow_calendars_administration",
{
"label": gettext_lazy("Allow calendars administration"),
"help_text": gettext_lazy(
"Allow domain administrators to manage user calendars "
"(read and write)"
),
},
),
]
),
},
),
(
"misc",
{
"label": gettext_lazy("Miscellaneous"),
"params": collections.OrderedDict(
[
(
"max_ics_file_size",
{
"label": gettext_lazy("Maximum size of ICS files"),
"help_text": gettext_lazy(
"Maximum size in bytes of imported ICS files "
"(or KB, MB, GB if specified)"
),
},
)
]
),
},
),
]
)
def load_settings():
"""Load calendars settings."""
from modoboa.parameters import tools as param_tools
from modoboa.calendars import serializers
param_tools.registry.add2(
"global",
"calendars",
gettext_lazy("Calendars"),
GLOBAL_PARAMETERS_STRUCT,
serializers.GlobalParametersSerializer,
)
+16
View File
@@ -0,0 +1,16 @@
"""AppConfig for radicale."""
from django.apps import AppConfig
class CalendarsConfig(AppConfig):
"""App configuration."""
name = "modoboa.calendars"
verbose_name = "Modoboa calendars"
def ready(self):
from modoboa.calendars import handlers
from modoboa.calendars.app_settings import load_settings
load_settings()
+41
View File
@@ -0,0 +1,41 @@
"""Calendar backend definition."""
from importlib import import_module
from modoboa.lib.cryptutils import decrypt
class CalendarBackend(object):
"""Base backend class."""
def __init__(self, calendar=None):
"""Default constructor."""
self.calendar = calendar
def create_event(self, event):
"""Create a new event."""
raise NotImplementedError
def get_event(self, uid):
"""Retrieve an even using its uid."""
raise NotImplementedError
def get_events(self, start, end):
"""Retrieve a list of event."""
raise NotImplementedError
def delete_event(self, uid):
"""Delete an event using its uid."""
raise NotImplementedError
def get_backend(name, *args, **kwargs):
"""Return a backend instance."""
module = import_module("modoboa.calendars.backends.{}".format(name))
return getattr(module, "{}Backend".format(name.capitalize()))(*args, **kwargs)
def get_backend_from_request(name, request, calendar=None):
"""Return a backend instance from a request."""
# password = decrypt(request.session["password"])
return get_backend(name, request.user.email, request.auth, calendar=calendar)
+163
View File
@@ -0,0 +1,163 @@
"""CalDAV calendar backend."""
import datetime
import uuid
import caldav
from caldav.elements import dav, ical
from caldav import Calendar
import vobject
from django.utils import timezone
from django.utils.encoding import smart_str
from modoboa.parameters import tools as param_tools
from . import CalendarBackend
class Caldav_Backend(CalendarBackend):
"""CalDAV calendar backend."""
def __init__(self, username, password, calendar=None):
"""Constructor."""
super().__init__(calendar)
server_url = smart_str(param_tools.get_global_parameter("server_location"))
self.client = caldav.DAVClient(server_url, username=username, password=password)
if self.calendar:
self.remote_cal = Calendar(self.client, calendar.encoded_path)
def _serialize_event(self, event):
"""Convert a vevent to a dictionary."""
vevent = event.vobject_instance.vevent
description = (
vevent.description.value if "description" in vevent.contents else ""
)
result = {
"id": vevent.uid.value,
"title": vevent.summary.value,
"color": self.calendar.color,
"description": description,
"calendar": self.calendar,
"attendees": [],
}
if isinstance(vevent.dtstart.value, datetime.datetime):
all_day = False
start = vevent.dtstart.value
end = vevent.dtend.value
else:
tz = timezone.get_current_timezone()
all_day = True
start = datetime.datetime.combine(
vevent.dtstart.value, datetime.time.min
).replace(tzinfo=tz)
end = datetime.datetime.combine(
vevent.dtend.value, datetime.time.min
).replace(tzinfo=tz)
result.update({"allDay": all_day, "start": start, "end": end})
if "attendee" in vevent.contents:
for attendee in vevent.contents["attendee"]:
email = attendee.value.replace("mailto:", "").replace("MAILTO:", "")
cn = attendee.params.get("CN")
result["attendees"].append(
{"display_name": cn[0] if cn else "", "email": email}
)
return result
def create_calendar(self, url):
"""Create a new calendar."""
self.client.mkcalendar(url)
def update_calendar(self, calendar):
"""Update an existing calendar."""
remote_cal = Calendar(self.client, calendar.encoded_path)
remote_cal.set_properties(
[dav.DisplayName(calendar.name), ical.CalendarColor(calendar.color)]
)
def create_event(self, data):
"""Create a new event."""
uid = uuid.uuid4()
cal = vobject.iCalendar()
cal.add("vevent")
cal.vevent.add("uid").value = str(uid)
cal.vevent.add("summary").value = data["title"]
if not data["allDay"]:
cal.vevent.add("dtstart").value = data["start"]
cal.vevent.add("dtend").value = data["end"]
else:
cal.vevent.add("dtstart").value = data["start"].date()
cal.vevent.add("dtend").value = data["end"].date()
self.remote_cal.add_event(cal)
return uid
def update_event(self, uid, original_data):
"""Update an existing event."""
data = dict(original_data)
url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid)
cal = self.remote_cal.event_by_url(url)
orig_evt = cal.vobject_instance.vevent
if "title" in data:
orig_evt.summary.value = data["title"]
if data.get("allDay"):
data["start"] = data["start"].date()
data["end"] = data["end"].date()
if "start" in data:
del orig_evt.contents["dtstart"]
orig_evt.add("dtstart").value = data["start"]
if "end" in data:
del orig_evt.contents["dtend"]
orig_evt.add("dtend").value = data["end"]
if "description" in data:
if "description" in orig_evt.contents:
orig_evt.description.value = data["description"]
else:
orig_evt.add("description").value = data["description"]
if "attendees" in data:
if "attendee" in orig_evt.contents:
del orig_evt.contents["attendee"]
for attdef in data.get("attendees", []):
attendee = orig_evt.add("attendee")
attendee.value = "MAILTO:{}".format(attdef["email"])
attendee.params["CN"] = [attdef["display_name"]]
attendee.params["ROLE"] = ["REQ-PARTICIPANT"]
if "calendar" in data and self.calendar.pk != data["calendar"].pk:
# Calendar has been changed, remove old event first.
self.remote_cal.client.delete(url)
remote_cal = Calendar(self.client, data["calendar"].encoded_path)
url = "{}/{}.ics".format(remote_cal.url.geturl(), uid)
else:
remote_cal = self.remote_cal
remote_cal.add_event(cal.instance)
return uid
def get_event(self, uid):
"""Retrieve and event using its uid."""
url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid)
event = self.remote_cal.event_by_url(url)
return self._serialize_event(event)
def get_events(self, start, end):
"""Retrieve a list of events."""
orig_events = self.remote_cal.date_search(start, end)
events = []
for event in orig_events:
events.append(self._serialize_event(event))
return events
def delete_event(self, uid):
"""Delete an event using its uid."""
url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid)
self.remote_cal.client.delete(url)
def import_events(self, fp):
"""Import events from file."""
content = smart_str(fp.read())
counter = 0
for cal in vobject.base.readComponents(content):
for event in cal.vevent_list:
ical = vobject.iCalendar()
ical.add(event)
self.remote_cal.add_event(ical.serialize())
counter += 1
return counter
+34
View File
@@ -0,0 +1,34 @@
"""Fixtures factories."""
import factory
from modoboa.admin import factories as admin_factories
from . import models
class UserCalendarFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.UserCalendar
name = factory.Sequence(lambda n: "User calendar %s" % n)
mailbox = factory.SubFactory(admin_factories.MailboxFactory)
class SharedCalendarFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.SharedCalendar
name = factory.Sequence(lambda n: "Shared calendar %s" % n)
domain = factory.SubFactory(admin_factories.DomainFactory)
class AccessRuleFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.AccessRule
mailbox = factory.SubFactory(admin_factories.MailboxFactory)
calendar = factory.SubFactory(UserCalendarFactory)
+63
View File
@@ -0,0 +1,63 @@
"""Radicale signals handlers."""
try:
from secrets import token_hex
except ImportError:
from os import urandom
def token_hex(nbytes=None):
return urandom(nbytes).hex()
from django.db.models import signals
from django.dispatch import receiver
from modoboa.core import signals as core_signals
from . import models
PERMISSIONS = [
("calendars", "usercalendar", "add_usercalendar"),
("calendars", "usercalendar", "change_usercalendar"),
("calendars", "usercalendar", "delete_usercalendar"),
("calendars", "sharedcalendar", "add_sharedcalendar"),
("calendars", "sharedcalendar", "change_sharedcalendar"),
("calendars", "sharedcalendar", "delete_sharedcalendar"),
]
ROLES_PERMISSIONS = {
"DomainAdmins": PERMISSIONS,
"Resellers": PERMISSIONS,
"SimpleUsers": [
("calendars", "usercalendar", "add_usercalendar"),
("calendars", "usercalendar", "change_usercalendar"),
("calendars", "usercalendar", "delete_usercalendar"),
],
}
@receiver(core_signals.extra_role_permissions)
def extra_permissions(sender, role, **kwargs):
"""Extra permissions."""
return ROLES_PERMISSIONS.get(role, [])
@receiver(signals.pre_save, sender=models.UserCalendar)
def set_user_calendar_path(sender, instance, **kwargs):
"""Set path at creation."""
if instance.pk:
return
instance._path = "{}/{}".format(instance.mailbox.full_address, instance.name)
if not instance.access_token:
instance.access_token = token_hex(16)
@receiver(signals.pre_save, sender=models.SharedCalendar)
def set_shared_calendar_path(sender, instance, **kwargs):
"""Set path at creation."""
if instance.pk:
return
instance._path = "{}/{}".format(instance.domain.name, instance.name)
if not instance.access_token:
instance.access_token = token_hex(16)
@@ -0,0 +1,145 @@
"""Management command to handle file generation."""
import os
import datetime
from django.core.management.base import BaseCommand
from django.utils import timezone
from modoboa.admin.models import Domain
from modoboa.core.models import User
from modoboa.parameters import tools as param_tools
from ... import models
class Command(BaseCommand):
"""Management command to handle file generation."""
help = "Generate Radicale rights file"
def add_arguments(self, parser):
"""Add extra arguments to command line."""
parser.add_argument(
"--force",
action="store_true",
default=False,
help="Force generation of rights file",
)
def _generate_acr(self, name, user, collection, perm="rw", comment=None):
"""Write a new access control rule to the config file."""
if comment is not None:
self._cfgfile.write("\n# %s" % comment)
self._cfgfile.write(
"""
[%s]
user = %s
collection = %s
permissions = %s
"""
% (name, user, collection, perm)
)
def _user_access_rules(self):
"""Create user access rules."""
for acr in models.AccessRule.objects.select_related().all():
section = "%s-to-%s-acr" % (acr.mailbox, acr.calendar)
permission = ""
if acr.read:
permission += "r"
if acr.write:
permission += "w"
self._generate_acr(
section,
acr.mailbox.full_address,
acr.calendar.path,
permission,
)
def _token_access_rules(self):
"""Create token access rules."""
for model in [models.UserCalendar, models.SharedCalendar]:
for calendar in model.objects.all():
section = "token-{}-access".format(calendar._path)
self._generate_acr(
section,
calendar.access_token,
calendar._path,
perm="r",
comment="Read-only access using a token",
)
def _super_admin_rules(self):
"""Generate access rules for super administrators."""
for sa in User.objects.filter(is_superuser=True):
section = "sa-%s-acr" % sa.username
self._generate_acr(section, sa.username, ".*")
def _domain_admin_rules(self):
"""Generate access rules for domain adminstrators."""
for da in User.objects.filter(groups__name="DomainAdmins"):
for domain in Domain.objects.get_for_admin(da):
section = "da-%s-to-%s-acr" % (da.email, domain.name)
self._generate_acr(section, da.email, "%s/user/.*" % domain.name)
def _generate_file(self, target):
"""
A user must not declare a rule for his direct admin!
"""
self._cfgfile = open(target, "w")
self._cfgfile.write(
"""# Rights management file for Radicale
# This file was generated by Modoboa on %s
# DO NOT EDIT MANUALLY!
"""
% datetime.datetime.today()
)
allow_calendars_administration = param_tools.get_global_parameter(
"allow_calendars_administration", app="calendars"
)
if allow_calendars_administration:
self._super_admin_rules()
self._domain_admin_rules()
# self._generate_acr(
# "domain-shared-calendars", r"^(.+)@(.+)$", r"{1}/.+$",
# comment="Access rule to domain shared calendars"
# )
self._generate_acr(
"principal",
r".+",
r"{user}",
perm="RW",
comment="Allow reading and writing principal collection "
"(same as user name)",
)
self._generate_acr(
"calendars",
r".+",
r"{user}/[^/]+",
comment="Allow reading and writing calendars and address books "
"that are direct children of the principal collection",
)
self._user_access_rules()
self._token_access_rules()
self._cfgfile.close()
def handle(self, *args, **options):
"""Command entry point."""
path = param_tools.get_global_parameter("rights_file_path", app="calendars")
if not options["force"]:
try:
mtime = datetime.datetime.fromtimestamp(int(os.path.getmtime(path)))
except OSError:
pass
else:
tz = timezone.get_current_timezone()
qset = models.AccessRule.objects.filter(
last_update__gt=mtime.replace(tzinfo=tz)
)
if not qset.exists():
return
self._generate_file(path)
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
("admin", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="AccessRule",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("read", models.BooleanField(default=False)),
("write", models.BooleanField(default=False)),
("last_update", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "radicale_accessrule",
},
bases=(models.Model,),
),
migrations.CreateModel(
name="SharedCalendar",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("name", models.CharField(max_length=200)),
(
"domain",
models.ForeignKey(to="admin.Domain", on_delete=models.CASCADE),
),
],
options={
"abstract": False,
"db_table": "radicale_sharedcalendar",
},
bases=(models.Model,),
),
migrations.CreateModel(
name="UserCalendar",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("name", models.CharField(max_length=200)),
(
"mailbox",
models.ForeignKey(to="admin.Mailbox", on_delete=models.CASCADE),
),
],
options={
"abstract": False,
"db_table": "radicale_usercalendar",
},
bases=(models.Model,),
),
migrations.AddField(
model_name="accessrule",
name="calendar",
field=models.ForeignKey(
related_name="rules",
to="calendars.UserCalendar",
on_delete=models.CASCADE,
),
preserve_default=True,
),
migrations.AddField(
model_name="accessrule",
name="mailbox",
field=models.ForeignKey(to="admin.Mailbox", on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AlterUniqueTogether(
name="accessrule",
unique_together=set([("mailbox", "calendar")]),
),
]
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-08-31 15:21
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("calendars", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="sharedcalendar",
name="color",
field=models.CharField(default="#3a87ad", max_length=7),
),
migrations.AddField(
model_name="usercalendar",
name="color",
field=models.CharField(default="#3a87ad", max_length=7),
),
]
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-03-24 17:34
from __future__ import unicode_literals
from django.db import migrations, models
def set_path(apps, schema_editor):
"""Set path."""
UserCalendar = apps.get_model("calendars", "UserCalendar")
for calendar in UserCalendar.objects.select_related("mailbox__domain"):
calendar._path = "{}@{}/{}".format(
calendar.mailbox.address, calendar.mailbox.domain.name, calendar.name
)
calendar.save()
SharedCalendar = apps.get_model("calendars", "SharedCalendar")
for calendar in SharedCalendar.objects.select_related("domain"):
calendar._path = "{}/{}".format(calendar.domain.name, calendar.name)
calendar.save()
def backwards(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("calendars", "0002_auto_20170831_1721"),
]
operations = [
migrations.AddField(
model_name="sharedcalendar",
name="_path",
field=models.TextField(default=""),
preserve_default=False,
),
migrations.AddField(
model_name="usercalendar",
name="_path",
field=models.TextField(default=""),
preserve_default=False,
),
migrations.RunPython(set_path, backwards),
]

Some files were not shown because too many files have changed in this diff Show More