mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Migrating frontend to Vue3
This commit is contained in:
@@ -180,9 +180,9 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get install gettext
|
||||
cd frontend
|
||||
nvm install 14
|
||||
nvm install 20
|
||||
yarn install
|
||||
make translations
|
||||
yarn gettext:compile
|
||||
yarn build
|
||||
cd ..
|
||||
- name: Build packages
|
||||
|
||||
+2
-2
@@ -49,10 +49,10 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.front.dev
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ./frontend/:/home/node/
|
||||
command: "yarn serve"
|
||||
command: "yarn dev --host"
|
||||
environment:
|
||||
DOCKER: "yes"
|
||||
depends_on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16.10-alpine
|
||||
FROM node:20.11-alpine
|
||||
|
||||
ENV HOME=/home/node/
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
not ie 11
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
+10
-25
@@ -1,32 +1,17 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/essential',
|
||||
'@vue/standard'
|
||||
],
|
||||
parserOptions: {
|
||||
parser: '@babel/eslint-parser'
|
||||
node: true,
|
||||
},
|
||||
extends: ['plugin:vue/vue3-recommended', 'eslint:recommended', 'prettier'],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true
|
||||
}],
|
||||
'vue/multi-word-component-names': 'off'
|
||||
'vue/component-name-in-template-casing': [
|
||||
'error',
|
||||
'PascalCase',
|
||||
{
|
||||
registeredComponentsOnly: true,
|
||||
ignores: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/__tests__/*.{j,t}s?(x)',
|
||||
'**/tests/unit/**/*.spec.{j,t}s?(x)'
|
||||
],
|
||||
env: {
|
||||
mocha: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
lastUpdateCheck 1676556135834
|
||||
@@ -1,57 +0,0 @@
|
||||
# On OSX the PATH variable isn't exported unless "SHELL" is also set, see: http://stackoverflow.com/a/25506676
|
||||
SHELL = /bin/bash
|
||||
NODE_BINDIR = ./node_modules/.bin
|
||||
export PATH := $(NODE_BINDIR):$(PATH)
|
||||
|
||||
INPUT_DIR = src
|
||||
# Where to write the files generated by this makefile.
|
||||
OUTPUT_DIR = .
|
||||
|
||||
# Available locales for the app.
|
||||
LOCALES = br cs de el es en fi fr it ja nl pt pt-br pl po ru sv tr zh-hant
|
||||
|
||||
# Name of the generated .po files for each available locale.
|
||||
LOCALE_FILES ?= $(patsubst %,$(OUTPUT_DIR)/locale/%/LC_MESSAGES/app.po,$(LOCALES))
|
||||
|
||||
GETTEXT_HTML_SOURCES = $(shell find $(INPUT_DIR) -name '*.vue' -o -name '*.html' 2> /dev/null)
|
||||
GETTEXT_VUE_SOURCES = $(shell find $(INPUT_DIR) -name '*.vue')
|
||||
GETTEXT_JS_SOURCES = $(shell find $(INPUT_DIR) -name '*.vue' -o -name '*.js')
|
||||
|
||||
# Makefile Targets
|
||||
.PHONY: clean makemessages translations
|
||||
|
||||
clean:
|
||||
rm -f /tmp/template.pot $(INPUT_DIR)/translations.json
|
||||
|
||||
makemessages: /tmp/template.pot
|
||||
|
||||
translations: ./$(INPUT_DIR)/translations.json
|
||||
|
||||
# Create a main .pot template, then generate .po files for each available language.
|
||||
# Thanx to Systematic: https://github.com/Polyconseil/systematic/blob/866d5a/mk/main.mk#L167-L183
|
||||
/tmp/template.pot: $(GETTEXT_HTML_SOURCES)
|
||||
# `dir` is a Makefile built-in expansion function which extracts the directory-part of `$@`.
|
||||
# `$@` is a Makefile automatic variable: the file name of the target of the rule.
|
||||
# => `mkdir -p /tmp/`
|
||||
mkdir -p $(dir $@)
|
||||
which gettext-extract
|
||||
# Extract gettext strings from templates files and create a POT dictionary template.
|
||||
gettext-extract --quiet --output $@ $(GETTEXT_HTML_SOURCES)
|
||||
gettext-extract --startDelimiter "" --endDelimiter "" --quiet --output $@ $(GETTEXT_VUE_SOURCES)
|
||||
# Extract gettext strings from JavaScript files.
|
||||
xgettext --language=JavaScript --keyword=npgettext:1c,2,3 \
|
||||
--from-code=utf-8 --join-existing --no-wrap \
|
||||
--package-name=$(shell node -e "console.log(require('./package.json').name);") \
|
||||
--package-version=$(shell node -e "console.log(require('./package.json').version);") \
|
||||
--output $@ $(GETTEXT_JS_SOURCES)
|
||||
# Generate .po files for each available language.
|
||||
@for lang in $(LOCALES); do \
|
||||
export PO_FILE=$(OUTPUT_DIR)/locale/$$lang/LC_MESSAGES/app.po; \
|
||||
echo "msgmerge --backup=off --update $$PO_FILE $@"; \
|
||||
mkdir -p $$(dirname $$PO_FILE); \
|
||||
[ -f $$PO_FILE ] && msgmerge --backup=off --lang=$$lang --update $$PO_FILE $@ || msginit --no-translator --locale=$$lang --input=$@ --output-file=$$PO_FILE; \
|
||||
msgattrib --no-wrap --no-obsolete -o $$PO_FILE $$PO_FILE; \
|
||||
done;
|
||||
|
||||
$(INPUT_DIR)/translations.json: clean /tmp/template.pot
|
||||
gettext-compile --output $@ $(LOCALE_FILES)
|
||||
+49
-9
@@ -1,29 +1,69 @@
|
||||
# frontend
|
||||
# base
|
||||
|
||||
## Project setup
|
||||
|
||||
```
|
||||
yarn install
|
||||
# yarn
|
||||
yarn
|
||||
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# pnpm
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
|
||||
```
|
||||
yarn serve
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Run your unit tests
|
||||
```
|
||||
yarn test:unit
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
|
||||
```
|
||||
# yarn
|
||||
yarn lint
|
||||
|
||||
# npm
|
||||
npm run lint
|
||||
|
||||
# pnpm
|
||||
pnpm lint
|
||||
|
||||
# bun
|
||||
bun run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
|
||||
See [Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
output: {
|
||||
path: './src/locale',
|
||||
locales: [
|
||||
'en',
|
||||
'br',
|
||||
'cs',
|
||||
'de',
|
||||
'el',
|
||||
'es',
|
||||
'fi',
|
||||
'fr',
|
||||
'it',
|
||||
'ja',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
'ru',
|
||||
'sv',
|
||||
'tr',
|
||||
'zh',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to Modoboa</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
|
||||
}
|
||||
}
|
||||
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import os
|
||||
|
||||
import polib
|
||||
|
||||
|
||||
LOCALES = [
|
||||
"br",
|
||||
"cs",
|
||||
"de",
|
||||
"el",
|
||||
"es",
|
||||
"fi",
|
||||
"fr",
|
||||
"it",
|
||||
"ja",
|
||||
"nl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"pl",
|
||||
"po",
|
||||
"ru",
|
||||
"sv",
|
||||
"tr",
|
||||
"zh-hant",
|
||||
]
|
||||
|
||||
OLD_TRANSLATION_MODULES = [
|
||||
"",
|
||||
"admin",
|
||||
"dnstools",
|
||||
"ldapsync",
|
||||
"limits",
|
||||
"maillog",
|
||||
"policyd",
|
||||
"relaydomains",
|
||||
"transport",
|
||||
"dmarc",
|
||||
]
|
||||
|
||||
|
||||
def merge_translations():
|
||||
for locale in LOCALES:
|
||||
target = f"locale/{locale}/LC_MESSAGES/app.po"
|
||||
print(f"Opening {target}")
|
||||
new_po = polib.pofile(target)
|
||||
for module in OLD_TRANSLATION_MODULES:
|
||||
prefix = f"../modoboa/{module}" if module else "../modoboa"
|
||||
old_file = f"{prefix}/locale/{locale}/LC_MESSAGES/django.po"
|
||||
if not os.path.exists(old_file):
|
||||
continue
|
||||
print(f"Opening {old_file}")
|
||||
old_po = polib.pofile(old_file)
|
||||
for entry in new_po:
|
||||
old_entry = old_po.find(entry.msgid)
|
||||
if old_entry:
|
||||
entry.msgstr = old_entry.msgstr
|
||||
new_po.save()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
merge_translations()
|
||||
+35
-47
@@ -1,56 +1,44 @@
|
||||
{
|
||||
"name": "Modoboa",
|
||||
"version": "0.1.0",
|
||||
"name": "frontend",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"lint": "vue-cli-service lint"
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .js,.vue --fix src",
|
||||
"lint-no-fix": "eslint --ext .js,.vue src",
|
||||
"format": "prettier . --write",
|
||||
"format-specific": "prettier --write",
|
||||
"gettext:extract": "vue-gettext-extract",
|
||||
"gettext:compile": "vue-gettext-compile"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/rubik": "^4.5.0",
|
||||
"@mdi/font": "^5.9.55",
|
||||
"apexcharts": "^3.25.0",
|
||||
"core-js": "^3.6.5",
|
||||
"debounce": "^1.2.1",
|
||||
"js-cookie": "^2.2.1",
|
||||
"luxon": "^1.28.1",
|
||||
"qrcode.vue": "~1.7.0",
|
||||
"vee-validate": "^3.4.5",
|
||||
"vue": "^2.6.14",
|
||||
"vue-apexcharts": "^1.6.0",
|
||||
"vue-cookie": "^1.1.4",
|
||||
"vue-gettext": "^2.1.12",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuetify": "^2.6.10",
|
||||
"vuex": "^3.4.0"
|
||||
"@mdi/font": "^7.3.67",
|
||||
"apexcharts": "^3.44.0",
|
||||
"axios": "^1.6.2",
|
||||
"core-js": "^3.29.0",
|
||||
"debounce": "^2.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"luxon": "^3.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"roboto-fontface": "*",
|
||||
"vue": "^3.4.19",
|
||||
"vue-router": "^4.0.0",
|
||||
"vue3-apexcharts": "^1.4.4",
|
||||
"vue3-gettext": "^3.0.0-beta.4",
|
||||
"vuetify": "^3.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.16",
|
||||
"@babel/eslint-parser": "^7.12.16",
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
||||
"@vue/cli-plugin-router": "~5.0.8",
|
||||
"@vue/cli-plugin-unit-mocha": "~5.0.8",
|
||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-standard": "^6.1.0",
|
||||
"@vue/test-utils": "^1.0.3",
|
||||
"axios": "^1.6.0",
|
||||
"chai": "^4.1.2",
|
||||
"easygettext": "^2.15.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"sass": "~1.32.12",
|
||||
"sass-loader": "^8.0.2",
|
||||
"vue-cli-plugin-axios": "~0.0.4",
|
||||
"vue-cli-plugin-vuetify": "~2.5.8",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vuetify-loader": "^1.3.0"
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"prettier": "^3.1.1",
|
||||
"sass": "^1.69.5",
|
||||
"unplugin-fonts": "^1.0.3",
|
||||
"vite": "^4.5.1",
|
||||
"vite-plugin-vuetify": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"API_BASE_URL": "/api/v2/",
|
||||
"API_DOC_URL": "/api/schema-v2/swagger/"
|
||||
"API_BASE_URL": "/api/v2/",
|
||||
"API_DOC_URL": "/api/schema-v2/swagger/"
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
+2
-121
@@ -1,124 +1,5 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<template v-if="layoutTemplate === 'empty'">
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<navbar v-if="isAuthenticated" />
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<transition name="fade">
|
||||
<router-view/>
|
||||
</transition>
|
||||
</v-container>
|
||||
</v-main>
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="notificationColor"
|
||||
:timeout="notificationTimeout"
|
||||
top
|
||||
>
|
||||
{{ notification }}
|
||||
|
||||
<template v-slot:action="{ attrs }">
|
||||
<v-btn
|
||||
color="white"
|
||||
text
|
||||
v-bind="attrs"
|
||||
@click="snackbar = false"
|
||||
>
|
||||
<translate>Close</translate>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
</v-app>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import alarms from '@/api/alarms'
|
||||
import { bus } from '@/main'
|
||||
import Navbar from '@/components/layout/Navbar'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isAuthenticated: 'auth/isAuthenticated'
|
||||
}),
|
||||
layoutTemplate () {
|
||||
return this.$route.meta.layoutTemplate || '2cols'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Navbar
|
||||
},
|
||||
created () {
|
||||
// this.$store.dispatch('auth/initialize')
|
||||
bus.$on('notification', this.showNotification)
|
||||
bus.$on('loggedIn', this.checkAlarms)
|
||||
},
|
||||
beforeDestroy () {
|
||||
clearInterval(this.alarmChecker)
|
||||
},
|
||||
data: () => ({
|
||||
snackbar: false,
|
||||
notification: '',
|
||||
notificationColor: 'success',
|
||||
notificationTimeout: 2000,
|
||||
alarmNotified: [],
|
||||
alarmChecker: null,
|
||||
lastAlarmDate: '',
|
||||
alarmSleepTime: 10000
|
||||
}),
|
||||
methods: {
|
||||
showNotification (options) {
|
||||
if (this.isAuthenticated) {
|
||||
this.notification = options.msg
|
||||
this.notificationColor = (options.type) ? options.type : 'success'
|
||||
this.snackbar = true
|
||||
}
|
||||
},
|
||||
checkAlarms () {
|
||||
if (this.isAuthenticated) {
|
||||
if (this.alarmChecker !== null) {
|
||||
return
|
||||
}
|
||||
this.alarmChecker = window.setInterval(() => {
|
||||
const params = {}
|
||||
if (this.lastAlarmDate instanceof Date) {
|
||||
params.min_date = this.lastAlarmDate
|
||||
}
|
||||
alarms.getAll(params).then(resp => {
|
||||
let count = 0
|
||||
for (const alarm of resp.data.results) {
|
||||
if (alarm.status === 1 && !this.alarmNotified.includes(alarm.id)) {
|
||||
if (this.lastAlarmDate instanceof Date && new Date(alarm.created) > this.lastAlarmDate) {
|
||||
this.lastAlarmDate = new Date(alarm.created)
|
||||
} else if (!(this.lastAlarmDate instanceof Date)) {
|
||||
this.lastAlarmDate = new Date(alarm.created)
|
||||
}
|
||||
count++
|
||||
this.alarmNotified.push(alarm.id)
|
||||
}
|
||||
}
|
||||
if (count !== 0) {
|
||||
bus.$emit('notification', { msg: this.$gettext('You have one or more opened alarms'), type: 'error' })
|
||||
}
|
||||
})
|
||||
}, this.alarmSleepTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-main {
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
</style>
|
||||
<script setup></script>
|
||||
|
||||
+14
-14
@@ -3,45 +3,45 @@ import repository from './repository'
|
||||
const resource = 'account'
|
||||
|
||||
export default {
|
||||
getMe () {
|
||||
getMe() {
|
||||
return repository.get(`/${resource}/me/`)
|
||||
},
|
||||
checkPassword (value) {
|
||||
checkPassword(value) {
|
||||
return repository.post(`/${resource}/me/password/`, { password: value })
|
||||
},
|
||||
verifyTFACode (code) {
|
||||
verifyTFACode(code) {
|
||||
const payload = { code }
|
||||
return repository.post(`${resource}/tfa/verify/`, payload)
|
||||
},
|
||||
startTFASetup () {
|
||||
startTFASetup() {
|
||||
return repository.post(`${resource}/tfa/setup/`)
|
||||
},
|
||||
getKeyForTFASetup () {
|
||||
getKeyForTFASetup() {
|
||||
return repository.get(`${resource}/tfa/setup/key/`)
|
||||
},
|
||||
finalizeTFASetup (pinCode) {
|
||||
finalizeTFASetup(pinCode) {
|
||||
const payload = { pin_code: pinCode }
|
||||
return repository.post(`${resource}/tfa/setup/check/`, payload)
|
||||
},
|
||||
disableTFA (data) {
|
||||
disableTFA(data) {
|
||||
return repository.post(`${resource}/tfa/disable/`, data)
|
||||
},
|
||||
resetRecoveryCodes (data) {
|
||||
resetRecoveryCodes(data) {
|
||||
return repository.post(`${resource}/tfa/reset_codes/`, data)
|
||||
},
|
||||
getForward () {
|
||||
getForward() {
|
||||
return repository.get(`${resource}/forward/`)
|
||||
},
|
||||
setForward (data) {
|
||||
setForward(data) {
|
||||
return repository.post(`${resource}/forward/`, data)
|
||||
},
|
||||
getAPIToken () {
|
||||
getAPIToken() {
|
||||
return repository.get(`${resource}/api_token/`)
|
||||
},
|
||||
createAPIToken () {
|
||||
createAPIToken() {
|
||||
return repository.post(`${resource}/api_token/`)
|
||||
},
|
||||
deleteAPIToken () {
|
||||
deleteAPIToken() {
|
||||
return repository.delete(`${resource}/api_token/`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,25 +3,25 @@ import repository from './repository'
|
||||
const resource = 'accounts'
|
||||
|
||||
export default {
|
||||
getAll ({ domain, role }) {
|
||||
getAll({ domain, role }) {
|
||||
return repository.get(`/${resource}/`, { params: { domain, role } })
|
||||
},
|
||||
get (accountId) {
|
||||
get(accountId) {
|
||||
return repository.get(`${resource}/${accountId}/`)
|
||||
},
|
||||
getRandomPassword () {
|
||||
return repository.get(`${resource}/random_password`)
|
||||
getRandomPassword() {
|
||||
return repository.get(`${resource}/random_password/`)
|
||||
},
|
||||
validate (data) {
|
||||
validate(data) {
|
||||
return repository.post(`${resource}/validate/`, data)
|
||||
},
|
||||
create (data) {
|
||||
create(data) {
|
||||
return repository.post(`${resource}/`, data)
|
||||
},
|
||||
patch (accountId, data) {
|
||||
patch(accountId, data) {
|
||||
return repository.patch(`${resource}/${accountId}/`, data)
|
||||
},
|
||||
delete (accountId, data) {
|
||||
delete(accountId, data) {
|
||||
return repository.post(`${resource}/${accountId}/delete/`, data)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import repository from './repository'
|
||||
|
||||
export default {
|
||||
getComponentsInformation () {
|
||||
return repository.get('/admin/components')
|
||||
}
|
||||
getComponentsInformation() {
|
||||
return repository.get('/admin/components/')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,16 +3,18 @@ import repository from './repository'
|
||||
const resource = 'alarms'
|
||||
|
||||
export default {
|
||||
getAll (params) {
|
||||
getAll(params) {
|
||||
return repository.get(`${resource}/`, { params })
|
||||
},
|
||||
switchStatus (alarmId, data) {
|
||||
switchStatus(alarmId, data) {
|
||||
return repository.patch(`${resource}/${alarmId}/switch/`, data)
|
||||
},
|
||||
delete (alarmId) {
|
||||
delete(alarmId) {
|
||||
return repository.delete(`${resource}/${alarmId}/`)
|
||||
},
|
||||
bulkDelete (alarmsIds) {
|
||||
return repository.delete(`${resource}/bulk_delete/`, { params: { ids: alarmsIds } })
|
||||
}
|
||||
bulkDelete(alarmsIds) {
|
||||
return repository.delete(`${resource}/bulk_delete/`, {
|
||||
params: { ids: alarmsIds },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,22 +3,25 @@ import repository from './repository'
|
||||
const resource = 'aliases'
|
||||
|
||||
export default {
|
||||
get (aliasId) {
|
||||
getAll() {
|
||||
return repository.get(`${resource}/`)
|
||||
},
|
||||
get(aliasId) {
|
||||
return repository.get(`${resource}/${aliasId}/`)
|
||||
},
|
||||
getRandomAddress () {
|
||||
getRandomAddress() {
|
||||
return repository.get(`${resource}/random_address/`)
|
||||
},
|
||||
validate (data) {
|
||||
validate(data) {
|
||||
return repository.post(`${resource}/validate/`, data)
|
||||
},
|
||||
create (data) {
|
||||
create(data) {
|
||||
return repository.post(`${resource}/`, data)
|
||||
},
|
||||
patch (aliasId, data) {
|
||||
patch(aliasId, data) {
|
||||
return repository.patch(`${resource}/${aliasId}/`, data)
|
||||
},
|
||||
delete (aliasId) {
|
||||
delete(aliasId) {
|
||||
return repository.delete(`${resource}/${aliasId}/`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import repository from './repository'
|
||||
|
||||
export default {
|
||||
requestToken (data) {
|
||||
requestToken(data) {
|
||||
return repository.post('/token/', data)
|
||||
},
|
||||
recoverPassword (data) {
|
||||
recoverPassword(data) {
|
||||
return repository.post('/password_reset/', data)
|
||||
},
|
||||
checkSmsTotp (data) {
|
||||
checkSmsTotp(data) {
|
||||
return repository.post('/sms_totp/', data)
|
||||
},
|
||||
changePassword (data) {
|
||||
changePassword(data) {
|
||||
return repository.post('/reset_confirm/', data)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+28
-22
@@ -4,66 +4,72 @@ const domainResource = 'domains'
|
||||
const domainAliasResource = 'domainaliases'
|
||||
|
||||
export default {
|
||||
getDomains () {
|
||||
getDomains() {
|
||||
return repository.get(`/${domainResource}/`)
|
||||
},
|
||||
getDomain (domainId) {
|
||||
getDomain(domainId) {
|
||||
return repository.get(`/${domainResource}/${domainId}/`)
|
||||
},
|
||||
getDomainDNSDetail (domainId) {
|
||||
getDomainDNSDetail(domainId) {
|
||||
return repository.get(`/${domainResource}/${domainId}/dns_detail/`)
|
||||
},
|
||||
getDomainDmarcAlignment (domainId, period) {
|
||||
getDomainDmarcAlignment(domainId, period) {
|
||||
return repository.get(
|
||||
`${domainResource}/${domainId}/dmarc/alignment_stats/`,
|
||||
{ params: { period } }
|
||||
)
|
||||
},
|
||||
getDomainAliases (domain) {
|
||||
getDomainAliases(domain) {
|
||||
let url = `/${domainAliasResource}/`
|
||||
if (domain !== undefined) {
|
||||
url += `?domain=${domain}`
|
||||
}
|
||||
return repository.get(url)
|
||||
},
|
||||
getDomainAdministrators (domainId) {
|
||||
getDomainAdministrators(domainId) {
|
||||
return repository.get(`/${domainResource}/${domainId}/administrators/`)
|
||||
},
|
||||
addDomainAdministrator (domainId, accountId) {
|
||||
return repository.post(`/${domainResource}/${domainId}/administrators/add/`, { account: accountId })
|
||||
addDomainAdministrator(domainId, accountId) {
|
||||
return repository.post(
|
||||
`/${domainResource}/${domainId}/administrators/add/`,
|
||||
{ account: accountId }
|
||||
)
|
||||
},
|
||||
removeDomainAdministrator (domainId, accountId) {
|
||||
return repository.post(`/${domainResource}/${domainId}/administrators/remove/`, { account: accountId })
|
||||
removeDomainAdministrator(domainId, accountId) {
|
||||
return repository.post(
|
||||
`/${domainResource}/${domainId}/administrators/remove/`,
|
||||
{ account: accountId }
|
||||
)
|
||||
},
|
||||
createDomain (data) {
|
||||
createDomain(data) {
|
||||
return repository.post(`/${domainResource}/`, data)
|
||||
},
|
||||
updateDomain (domainId, data) {
|
||||
updateDomain(domainId, data) {
|
||||
return repository.put(`/${domainResource}/${domainId}/`, data)
|
||||
},
|
||||
patchDomain (domainId, data) {
|
||||
patchDomain(domainId, data) {
|
||||
return repository.patch(`/${domainResource}/${domainId}/`, data)
|
||||
},
|
||||
deleteDomain (domainId, data) {
|
||||
deleteDomain(domainId, data) {
|
||||
return repository.post(`/${domainResource}/${domainId}/delete/`, data)
|
||||
},
|
||||
createDomainAlias (data) {
|
||||
createDomainAlias(data) {
|
||||
return repository.post(`/${domainAliasResource}/`, data)
|
||||
},
|
||||
updateDomainAlias (aliasId, data) {
|
||||
updateDomainAlias(aliasId, data) {
|
||||
return repository.put(`/${domainAliasResource}/${aliasId}/`, data)
|
||||
},
|
||||
deleteDomainAlias (aliasId) {
|
||||
deleteDomainAlias(aliasId) {
|
||||
return repository.delete(`/${domainAliasResource}/${aliasId}/`)
|
||||
},
|
||||
exportAll () {
|
||||
exportAll() {
|
||||
return repository.get(`/${domainResource}/export/`)
|
||||
},
|
||||
importFromCSV (data) {
|
||||
importFromCSV(data) {
|
||||
return repository.post(`/${domainResource}/import/`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import repository from './repository'
|
||||
const resource = 'identities'
|
||||
|
||||
export default {
|
||||
getAll () {
|
||||
getAll() {
|
||||
return repository.get(`${resource}/`)
|
||||
},
|
||||
exportAll () {
|
||||
exportAll() {
|
||||
return repository.get(`/${resource}/export/`)
|
||||
},
|
||||
importFromCSV (data) {
|
||||
importFromCSV(data) {
|
||||
return repository.post(`/${resource}/import/`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import repository from '@/api/repository'
|
||||
const resource = 'migrations'
|
||||
|
||||
export default {
|
||||
getMigrations (params) {
|
||||
getMigrations(params) {
|
||||
return repository.get(`/${resource}/`, { params })
|
||||
},
|
||||
getMigration (slug, query) {
|
||||
getMigration(slug, query) {
|
||||
return repository.get(`/${resource}/${slug}/`, query)
|
||||
},
|
||||
deleteMigration (slug) {
|
||||
deleteMigration(slug) {
|
||||
return repository.delete(`/${resource}/${slug}/`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,28 +3,28 @@ import repository from '@/api/repository'
|
||||
const resource = 'email-providers'
|
||||
|
||||
export default {
|
||||
getProviders (params) {
|
||||
getProviders(params) {
|
||||
return repository.get(`/${resource}/`, { params })
|
||||
},
|
||||
getProvider (providerId) {
|
||||
getProvider(providerId) {
|
||||
return repository.get(`/${resource}/${providerId}/`)
|
||||
},
|
||||
createProvider (params) {
|
||||
createProvider(params) {
|
||||
return repository.post(`/${resource}/`, params)
|
||||
},
|
||||
updateProvider (slug, params) {
|
||||
updateProvider(slug, params) {
|
||||
return repository.put(`/${resource}/${slug}/`, params)
|
||||
},
|
||||
patchProvider (providerId, params) {
|
||||
patchProvider(providerId, params) {
|
||||
return repository.patch(`/${resource}/${providerId}/`, params)
|
||||
},
|
||||
deleteProvider (slug) {
|
||||
deleteProvider(slug) {
|
||||
return repository.delete(`/${resource}/${slug}/`)
|
||||
},
|
||||
checkProvider (params) {
|
||||
checkProvider(params) {
|
||||
return repository.post(`/${resource}/check_connection/`, params)
|
||||
},
|
||||
checkAssociatedDomain (params) {
|
||||
checkAssociatedDomain(params) {
|
||||
return repository.post(`/${resource}/check_associated_domain/`, params)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import repository from './repository'
|
||||
const resource = 'languages'
|
||||
|
||||
export default {
|
||||
getAll () {
|
||||
getAll() {
|
||||
return repository.get(`/${resource}/`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import repository from './repository'
|
||||
const resource = 'logs'
|
||||
|
||||
export default {
|
||||
getAuditTrail (params) {
|
||||
getAuditTrail(params) {
|
||||
return repository.get(`/${resource}/audit-trail/`, { params })
|
||||
},
|
||||
getMessages (params) {
|
||||
getMessages(params) {
|
||||
return repository.get(`/${resource}/messages/`, { params })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import repository from './repository'
|
||||
const resource = 'parameters'
|
||||
|
||||
export default {
|
||||
getApplications () {
|
||||
getApplications() {
|
||||
return repository.get(`/${resource}/applications/`)
|
||||
},
|
||||
getApplication (app) {
|
||||
getApplication(app) {
|
||||
return repository.get(`/${resource}/${app}/`)
|
||||
},
|
||||
getApplicationStructure (app) {
|
||||
getApplicationStructure(app) {
|
||||
return repository.get(`/${resource}/structure/?app=${app}`)
|
||||
},
|
||||
saveApplication (app, data) {
|
||||
saveApplication(app, data) {
|
||||
return repository.put(`/${resource}/${app}/`, data)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import axios from 'axios'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
import router from '../router'
|
||||
import store from '../store'
|
||||
import { translate } from 'vue-gettext'
|
||||
import { bus } from '@/main'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
const _axios = axios.create()
|
||||
const { gettext: $gettext } = translate
|
||||
|
||||
_axios.interceptors.request.use(
|
||||
function (config) {
|
||||
const authStore = useAuthStore()
|
||||
// Do something before request is sent
|
||||
if (store.state.auth.isAuthenticated) {
|
||||
config.headers['Accept-Language'] = store.state.auth.authUser.language
|
||||
if (authStore.isAuthenticated) {
|
||||
config.headers['Accept-Language'] = authStore.authUser.language
|
||||
}
|
||||
return config
|
||||
},
|
||||
@@ -35,35 +32,41 @@ _axios.interceptors.response.use(
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (error.response.status === 429) {
|
||||
bus.$emit('notification', { msg: $gettext('You are throttled, please try later.'), type: 'error' })
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (error.response.status !== 401 || router.currentRoute.path === '/login/') {
|
||||
if (error.response.status !== 401 || router.currentRoute.name === 'Login') {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
const refreshToken = Cookies.get('refreshToken')
|
||||
const authStore = useAuthStore()
|
||||
if (error.config.url.endsWith('/token/refresh/') || !refreshToken) {
|
||||
store.dispatch('auth/logout')
|
||||
if (router.history.current.name !== 'Login') {
|
||||
authStore.$reset()
|
||||
if (router.currentRoute.name !== 'Login') {
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return _axios.post('/token/refresh/', { refresh: refreshToken }).then(resp => {
|
||||
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
|
||||
_axios.defaults.headers.common.Authorization = `Bearer ${resp.data.access}`
|
||||
const config = error.config
|
||||
config.headers.Authorization = `Bearer ${resp.data.access}`
|
||||
return new Promise((resolve, reject) => {
|
||||
_axios.request(config).then(resp => {
|
||||
resolve(resp)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
return _axios
|
||||
.post('/token/refresh/', { refresh: refreshToken })
|
||||
.then((resp) => {
|
||||
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
|
||||
_axios.defaults.headers.common.Authorization = `Bearer ${resp.data.access}`
|
||||
const config = error.config
|
||||
config.headers.Authorization = `Bearer ${resp.data.access}`
|
||||
return new Promise((resolve, reject) => {
|
||||
_axios
|
||||
.request(config)
|
||||
.then((resp) => {
|
||||
resolve(resp)
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
}).catch(error => {
|
||||
Promise.reject(error)
|
||||
})
|
||||
.catch((error) => {
|
||||
Promise.reject(error)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@ import repository from './repository'
|
||||
const resource = 'senderaddresses'
|
||||
|
||||
export default {
|
||||
getAll () {
|
||||
getAll() {
|
||||
return repository.get(`${resource}/`)
|
||||
},
|
||||
getAllForMailbox (mailboxId) {
|
||||
getAllForMailbox(mailboxId) {
|
||||
return repository.get(`${resource}/`, { params: { mailbox: mailboxId } })
|
||||
},
|
||||
create (data) {
|
||||
create(data) {
|
||||
return repository.post(`${resource}/`, data)
|
||||
},
|
||||
delete (addressId) {
|
||||
delete(addressId) {
|
||||
return repository.delete(`${resource}/${addressId}/`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import repository from './repository'
|
||||
const resource = 'statistics'
|
||||
|
||||
export default {
|
||||
getStatistics ({ graphSet, period, graphicName, searchQuery, start, end }) {
|
||||
getStatistics({ graphSet, period, graphicName, searchQuery, start, end }) {
|
||||
const args = {
|
||||
gset: graphSet,
|
||||
period,
|
||||
graphic: graphicName,
|
||||
searchquery: searchQuery,
|
||||
start: start,
|
||||
end: end
|
||||
end: end,
|
||||
}
|
||||
return repository.get(`${resource}/`, { params: args })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import repository from './repository'
|
||||
const resource = 'transports'
|
||||
|
||||
export default {
|
||||
getAll () {
|
||||
getAll() {
|
||||
return repository.get(`/${resource}/`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
|
||||
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
|
||||
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
|
||||
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="43" height="52" viewBox="0 0 43 52">
|
||||
<g fill="#FFF">
|
||||
<path d="M21.9964108,21 L40,10.0508068 C36.1185185,3.7978167 29.3170048,0 22,0 C14.6829952,0 7.88148146,3.7978167 4,10.0508068 L21.9964108,21 Z"/>
|
||||
<path d="M21.4961468,25.9648072 L1.22178013,14 C-1.09566722,20.4385601 -0.0784112625,27.5770277 3.95066026,33.1495782 C7.97973179,38.7221288 14.5202355,42.0366892 21.4961468,42.0411831 L21.4961468,52 L37.2852914,35.2976413 C42.7029462,29.5713312 44.4301418,21.3781287 41.7705135,14.0213403 L21.4961468,25.9648072 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 589 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="144" height="23" viewBox="0 0 144 23">
|
||||
<g fill="#4067F3">
|
||||
<path d="M36.4615385 23C31.618426 23 27.6923077 19.0869687 27.6923077 14.26 27.6923077 9.43303129 31.618426 5.52 36.4615385 5.52 41.3046509 5.52 45.2307692 9.43303129 45.2307692 14.26 45.2273536 19.0855579 41.3032354 22.9965957 36.4615385 23zM36.4615385 8.58764124C34.1590485 8.58514416 32.0818832 9.96558568 31.1990219 12.085002 30.3161605 14.2044183 30.8015567 16.6452127 32.4287774 18.268776 34.0559981 19.8923393 36.5044257 20.3787746 38.6318903 19.5011641 40.759355 18.6235536 42.146674 16.5548164 42.1466754 14.26 42.1466772 11.1296516 39.6023544 8.5910475 36.4615385 8.58764124zM75.2307692 23C70.3876568 23 66.4615385 19.0869687 66.4615385 14.26 66.4615385 9.43303129 70.3876568 5.52 75.2307692 5.52 80.0738817 5.52 84 9.43303129 84 14.26 83.9931779 19.0841495 80.071053 22.9932006 75.2307692 23zM75.2307692 8.58764124C72.9282793 8.58514416 70.851114 9.96558568 69.9682526 12.085002 69.0853913 14.2044183 69.5707875 16.6452127 71.1980082 18.268776 72.8252289 19.8923393 75.2736565 20.3787746 77.4011211 19.5011641 79.5285857 18.6235536 80.9159048 16.5548164 80.9159061 14.26 80.9125011 11.1310581 78.3701703 8.59444145 75.2307692 8.58764124zM114.916884 23C110.074579 22.9965917 106.151568 19.082052 106.153847 14.2558869 106.156127 9.42972191 110.082834 5.51886495 114.92514 5.52000025 119.767446 5.52113555 123.692306 9.43383334 123.692308 14.2599989 123.688889 19.0879665 119.760999 23.0000012 114.916884 23zM114.916884 8.58763945C112.614956 8.58763945 110.539691 9.96966546 109.658781 12.0892809 108.777871 14.2088964 109.264797 16.6486793 110.892506 18.2709628 112.520215 19.8932462 114.968157 20.3785492 117.094862 19.5005757 119.221566 18.6226023 120.608214 16.5542542 120.608214 14.2599989 120.608214 11.1272413 118.060119 8.58763945 114.916884 8.58763945zM55.8492514 23C51.0044283 23 47.0769231 18.8810197 47.0769231 13.8 47.0769231 8.7189803 51.0044283 4.6 55.8492514 4.6 56.8766002 4.5965405 57.8962614 4.78577094 58.8600929 5.15875706 59.4051364 5.33485793 59.8130608 5.81197803 59.9207598 6.39934362 60.0284588 6.98670921 59.8183572 7.58846885 59.3744629 7.96401239 58.9305687 8.33955594 58.3253202 8.42759892 57.8007228 8.19293785 55.4709655 7.29069588 52.8523972 8.07824875 51.332652 10.1382557 49.8129068 12.1982627 49.7434905 15.0542594 51.1611883 17.1927933 52.5788861 19.3313273 55.1557949 20.2577715 57.5263569 19.4811837 59.8969188 18.7045959 61.5128404 16.4045952 61.517811 13.8L61.517811 1.62429379C61.517811.727221098 62.2112265 0 63.0665978 0 63.9219692 0 64.6153846.727221098 64.6153846 1.62429379L64.6153846 13.8C64.6119731 18.877001 60.690239 22.9928354 55.8492514 23zM1.60193429 22.9999875C1.17655379 23.0016372.768099475 22.8401747.467307584 22.5514683.166515693 22.2627618-.00170568823 21.8707187.0000130419293 21.4624295L.0000130419293 11.3540664C-.00197082639 8.50221341 1.86380136 5.95694981 4.66108711 4.99548616 7.45837285 4.03402251 10.583378 4.86389073 12.4679784 7.06866384 14.3583384 4.88137793 17.47279 4.06353266 20.2599678 5.02251172 23.0471455 5.98149079 24.9109863 8.51220784 24.9230769 11.3540664L24.9230769 21.4006802C24.9230769 22.2532603 24.202992 22.9444131 23.3147223 22.9444131 22.4264525 22.9444131 21.7063676 22.2532603 21.7063676 21.4006802L21.7063676 11.3540664C21.4804587 9.48564675 19.8317477 8.07620561 17.8720501 8.07620561 15.9123524 8.07620561 14.2636415 9.48564675 14.0377326 11.3540664 14.0377326 12.2066465 13.3176476 12.8977993 12.4293779 12.8977993 11.5411081 12.8977993 10.8210232 12.2066465 10.8210232 11.3540664 11.0034574 9.93240119 10.3157373 8.53716424 9.05485875 7.77089671 7.7939802 7.00462918 6.18586463 7.00462918 4.92498608 7.77089671 3.66410753 8.53716424 2.97638739 9.93240119 3.1588216 11.3540664L3.1588216 21.4624295C3.15916444 22.2950016 2.46901563 22.9765819 1.60193429 22.9999875z"/>
|
||||
<path d="M12.9156923 22.9998804C11.9001681 22.9998804 11.0769231 22.2748918 11.0769231 21.3805731L11.0769231 10.8258106C11.0769231 9.9279002 11.9034743 9.2 12.9230769 9.2 13.9426795 9.2 14.7692308 9.9279002 14.7692308 10.8258106L14.7692308 21.406586C14.7633906 21.8343382 14.564818 22.2425241 14.2172043 22.5413315 13.8695906 22.8401388 13.4014162 23.0050861 12.9156923 22.9998804zM95.5446545 23C90.7019524 22.9964221 86.7760599 18.8805854 86.7692308 13.8L86.7692308 1.62429379C86.7692308.727221098 87.4624014 0 88.3174707 0 89.1725399 0 89.8657106.727221098 89.8657106 1.62429379L89.8657106 13.8C89.868798 16.4082726 91.4852597 18.7126023 93.8580458 19.4912311 96.2308319 20.2698598 98.8107647 19.3425752 100.229961 17.2010245 101.649157 15.0594737 101.579147 12.1993156 100.057048 10.1370089 98.5349498 8.07470227 95.9130515 7.28756489 93.5814863 8.19293785 92.7974423 8.44634809 91.9615409 8.01134264 91.679451 7.20311338 91.3973611 6.39488412 91.7678578 5.49643578 92.5224902 5.15875706 93.4879673 4.78514308 94.5094074 4.59590492 95.5384615 4.6 100.381574 4.6 104.307692 8.7189803 104.307692 13.8 104.307692 18.8810197 100.381574 23 95.5384615 23L95.5446545 23zM142.445556 22.9993388C141.593908 22.9993388 140.903511 22.3112896 140.903511 21.4625378L140.903511 14.2599403C140.901244 11.7851712 139.287553 9.59820438 136.917889 8.85839914 134.548226 8.11859391 131.970942 8.99714448 130.552311 11.0283119 129.133681 13.0594793 129.201983 15.7732422 130.721008 17.7309752 132.240032 19.6887083 134.858272 20.4373832 137.187739 19.5801109 137.708256 19.3810598 138.296049 19.4775172 138.725046 19.8323847 139.154043 20.1872523 139.35743 20.74526 139.256982 21.2917934 139.156534 21.8383268 138.767896 22.2882643 138.240541 22.4685562 134.644788 23.7780393 130.612248 22.6115365 128.278807 19.586901 125.945366 16.5622654 125.850397 12.3786386 128.044202 9.25193189 130.238007 6.12522521 134.213489 4.77817384 137.864984 5.92426076 141.516478 7.07034769 143.999946 10.4446561 143.999987 14.2599403L143.999987 21.4625378C144.001677 21.8727833 143.838048 22.2665335 143.545807 22.5554607 143.253565 22.8443878 142.857175 23.004305 142.445556 22.9993388z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<label class="m-label">{{ $gettext('Recipient(s)') }}</label>
|
||||
<v-textarea
|
||||
v-model="form.recipients"
|
||||
:hint="
|
||||
$gettext('Indicate one or more recipients separated by a comma (,)')
|
||||
"
|
||||
persistent-hint
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="form.keepcopies"
|
||||
:label="$gettext('Keep local copies')"
|
||||
:hint="
|
||||
$gettext('Forward messages and store copies into your local mailbox')
|
||||
"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn color="success" variant="flat" :loading="loading" @click="submit">
|
||||
{{ $gettext('Update') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import accountApi from '@/api/account'
|
||||
import { useBusStore } from '@/stores/bus.store'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const form = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
function submit() {
|
||||
loading.value = true
|
||||
accountApi.setForward(form.value).then(() => {
|
||||
loading.value = false
|
||||
busStore.displayNotification({
|
||||
type: 'success',
|
||||
msg: $gettext('Forward updated'),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
accountApi.getForward().then((resp) => {
|
||||
form.value = resp.data
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<v-expansion-panels v-model="panel" flat>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
{{ $gettext('General settings') }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-form ref="profileForm" @submit.prevent="updateGeneralSettings">
|
||||
<label class="m-label">{{ $gettext('First name') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.first_name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<label class="m-label"> {{ $gettext('Last name') }} </label>
|
||||
<v-text-field
|
||||
v-model="form.last_name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<label class="m-label">
|
||||
{{ $gettext('Language') }}
|
||||
<p class="text-disabled">
|
||||
{{ $gettext('Feel free to improve the translations') }}
|
||||
<a
|
||||
href="https://explore.transifex.com/tonio/modoboa/"
|
||||
target="_blank"
|
||||
>{{ $gettext('here') }}</a
|
||||
>
|
||||
{{ $gettext('or suggest a new language') }}
|
||||
<a
|
||||
href="https://github.com/modoboa/modoboa/issues/new/choose"
|
||||
target="_blank"
|
||||
>{{ $gettext('there') }}</a
|
||||
>
|
||||
</p>
|
||||
</label>
|
||||
<v-autocomplete
|
||||
v-model="form.language"
|
||||
:items="languages"
|
||||
item-title="label"
|
||||
item-value="code"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:error-messages="languageError"
|
||||
/>
|
||||
<label class="m-label">{{ $gettext('Phone number') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.phone_number"
|
||||
:label="$gettext('Phone number')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<label class="m-label">{{ $gettext('Secondary email') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.secondary_email"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.emailOrNull]"
|
||||
/>
|
||||
<v-btn color="success" type="submit" :loading="loadingUpdateProfile">
|
||||
{{ $gettext('Update settings') }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
{{ $gettext('Password') }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-form ref="passwordForm" @submit.prevent="updatePassword">
|
||||
<AccountPasswordForm v-model="form" with-password-check />
|
||||
<v-btn color="success" type="submit" :loading="loadingPasswordUpdate">
|
||||
{{ $gettext('Update Password') }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useBusStore, useAuthStore } from '@/stores'
|
||||
import languagesApi from '@/api/languages'
|
||||
import AccountPasswordForm from '@/components/admin/identities/form_steps/AccountPasswordSubForm.vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const { $gettext, available } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const account = computed(() => authStore.authUser)
|
||||
|
||||
const form = ref({})
|
||||
const profileForm = ref()
|
||||
const passwordForm = ref()
|
||||
const languages = ref([])
|
||||
const panel = ref(0)
|
||||
const loadingUpdateProfile = ref(false)
|
||||
const loadingPasswordUpdate = ref(false)
|
||||
const languageError = ref('')
|
||||
|
||||
function initFormFromAccount(account) {
|
||||
form.value = {
|
||||
first_name: account.first_name,
|
||||
last_name: account.last_name,
|
||||
language: account.language,
|
||||
phone_number: account.phone_number,
|
||||
secondary_email: account.secondary_email,
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGeneralSettings() {
|
||||
const { valid } = await profileForm.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
loadingUpdateProfile.value = true
|
||||
authStore
|
||||
.updateAccount(form.value)
|
||||
.then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Profile updated') })
|
||||
if (!(authStore.authUser.language in available)) {
|
||||
languageError.value =
|
||||
'<a href="google.com">language does not exists</a>'
|
||||
}
|
||||
})
|
||||
.finally(() => (loadingUpdateProfile.value = false))
|
||||
}
|
||||
|
||||
async function updatePassword() {
|
||||
const { valid } = await passwordForm.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
loadingPasswordUpdate.value = true
|
||||
authStore
|
||||
.updateAccount({ password: form.value.password })
|
||||
.then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Password updated') })
|
||||
})
|
||||
.finally(() => (loadingPasswordUpdate.value = false))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initFormFromAccount(account.value)
|
||||
languagesApi.getAll().then((resp) => {
|
||||
languages.value = resp.data
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => account,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
initFormFromAccount(newValue)
|
||||
} else {
|
||||
form.value = {}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline"> {{ $gettext('New recovery codes') }} </span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="info">
|
||||
{{
|
||||
$gettext(
|
||||
"The following recovery codes can be used one time each to let you regain access to your account, in case you lose your phone for example. Make sure to save them in a safe place, otherwise you won't be able to access your account anymore."
|
||||
)
|
||||
}}
|
||||
</v-alert>
|
||||
<ul>
|
||||
<li v-for="token in tokens" :key="token">
|
||||
{{ token }}
|
||||
</li>
|
||||
</ul>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey darken-1" text @click="close">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
defineProps({ tokens: { type: Array, default: null } })
|
||||
const emit = defineEmits(['close'])
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card flat>
|
||||
<v-card-title>
|
||||
<span class="text-subtitle-1">
|
||||
{{ $gettext('Two factor authentication') }}
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="qrURL">
|
||||
<v-row>
|
||||
<v-col cols="4">
|
||||
<v-row rows="2">
|
||||
<QrcodeVue
|
||||
:value="qrURL"
|
||||
:size="250"
|
||||
render-as="svg"
|
||||
level="H"
|
||||
class="qrcode"
|
||||
/>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-btn color="primary" class="key" @click="copyKey">
|
||||
{{ $gettext('Click here to copy the key') }}
|
||||
<v-icon v-if="clicked" color="success">
|
||||
mdi-check-all
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-row>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-alert type="info" class="ma-4">
|
||||
{{
|
||||
$gettext(
|
||||
'Install a soft token authenticator like FreeOTP or Google Authenticator from your application repository and use that app to scan this QR code.'
|
||||
)
|
||||
}}
|
||||
</v-alert>
|
||||
<label class="m-label">{{ $gettext('Pin code') }}</label>
|
||||
<v-otp-input v-model="pinCode" />
|
||||
<v-btn color="primary" @click="finalizeTFASetup">
|
||||
{{ $gettext('Register') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<template v-else-if="tokens.length">
|
||||
<v-alert type="success">
|
||||
{{
|
||||
$gettext(
|
||||
'Congratulations! Two-Factor Authentication is now enabled for your account.'
|
||||
)
|
||||
}}
|
||||
</v-alert>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
"The following recovery codes can be used one time each to let you regain access to your account, in case you lose your phone for example. Make sure to save them in a safe place, otherwise you won't be able to access your account anymore."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="token in tokens" :key="token">
|
||||
{{ token }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="tfa_enabled">
|
||||
<v-alert type="info">
|
||||
{{
|
||||
$gettext('Two-Factor Authentication is enabled for your account.')
|
||||
}}
|
||||
</v-alert>
|
||||
<v-form ref="editTFAForm">
|
||||
<label class="m-label">{{ $gettext('Password') }} </label>
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="passwordError"
|
||||
/>
|
||||
<div class="mt-5">
|
||||
<v-btn
|
||||
color="error"
|
||||
class="mr-2"
|
||||
:loading="loadingDisable"
|
||||
@click="disableTFA"
|
||||
>
|
||||
{{ $gettext('Disable 2FA') }}
|
||||
</v-btn>
|
||||
<v-btn :loading="loadingReset" @click="resetRecoveryCodes">
|
||||
{{ $gettext('Reset recovery codes') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div tag="p" class="my-4">
|
||||
{{
|
||||
$gettext(
|
||||
"Two-Factor Authentication (2FA) is not yet activated for your account. Enabling this feature will increase your account's security."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<v-btn color="success" @click="startTFASetup">
|
||||
{{ $gettext('Enable 2FA') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-dialog v-model="showCodesResetDialog" max-width="800px" persistent>
|
||||
<RecoveryCodesResetDialog
|
||||
:tokens="newTokens"
|
||||
@close="closeRecoveryCodesResetDialog"
|
||||
/>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import accountApi from '@/api/account'
|
||||
import RecoveryCodesResetDialog from './RecoveryCodesResetDialog'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { useBusStore, useAuthStore } from '@/stores'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const editTFAForm = ref()
|
||||
|
||||
const newTokens = ref([])
|
||||
const pinCode = ref('')
|
||||
const pinCodeErrors = ref()
|
||||
const key = ref(null)
|
||||
const qrURL = ref(null)
|
||||
const clicked = ref(false)
|
||||
const showCodesResetDialog = ref(false)
|
||||
const loadingDisable = ref(false)
|
||||
const loadingReset = ref(false)
|
||||
const password = ref('')
|
||||
const passwordError = ref()
|
||||
const tokens = ref([])
|
||||
|
||||
const tfa_enabled = computed(() => authStore.authUser.tfa_enabled)
|
||||
|
||||
function getKey() {
|
||||
accountApi.getKeyForTFASetup().then((resp) => {
|
||||
key.value = resp.data.key
|
||||
qrURL.value = resp.data.url
|
||||
})
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard.writeText(key.value)
|
||||
clicked.value = true
|
||||
}
|
||||
|
||||
function startTFASetup() {
|
||||
accountApi.startTFASetup().then(() => {
|
||||
getKey()
|
||||
})
|
||||
}
|
||||
|
||||
async function finalizeTFASetup() {
|
||||
authStore
|
||||
.finalizeTFASetup(pinCode.value)
|
||||
.then((response) => {
|
||||
key.value = null
|
||||
qrURL.value = null
|
||||
tokens.value = response.data.tokens
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 400) {
|
||||
pinCodeErrors.value = error.response.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function disableTFA() {
|
||||
const isValid = await editTFAForm.value.validate()
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
password: password.value,
|
||||
}
|
||||
loadingDisable.value = true
|
||||
accountApi
|
||||
.disableTFA(payload)
|
||||
.then(() => {
|
||||
authStore.fetchUser()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 400) {
|
||||
passwordError.value = error.response.data
|
||||
} else {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext(error.response.data),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingDisable.value = false
|
||||
editTFAForm.value.reset()
|
||||
})
|
||||
}
|
||||
|
||||
async function resetRecoveryCodes() {
|
||||
const isValid = await editTFAForm.value.validate()
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
password: password.value,
|
||||
}
|
||||
loadingReset.value = true
|
||||
|
||||
accountApi
|
||||
.resetRecoveryCodes(payload)
|
||||
.then((response) => {
|
||||
newTokens.value = response.data.tokens
|
||||
showCodesResetDialog.value = true
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 400) {
|
||||
passwordError.value = error.response.data
|
||||
} else {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext(error.response.data),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingReset.value = false
|
||||
editTFAForm.value.reset()
|
||||
})
|
||||
}
|
||||
|
||||
function closeRecoveryCodesResetDialog() {
|
||||
newTokens.value = []
|
||||
showCodesResetDialog.value = false
|
||||
}
|
||||
if (!tfa_enabled.value) {
|
||||
getKey()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.qrcode {
|
||||
margin-left: 20px;
|
||||
margin-top: 2em;
|
||||
}
|
||||
.key {
|
||||
margin-left: 10px;
|
||||
margin-top: 1.75em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<v-card class="mt-6">
|
||||
<v-card-title>
|
||||
<translate>Installed components</translate>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert
|
||||
outlined
|
||||
type="success"
|
||||
text
|
||||
border="left"
|
||||
v-if="updatesAvailable"
|
||||
>
|
||||
<translate tag="p">One or more updates are available</translate>
|
||||
<translate tag="p" class="text-body-2">
|
||||
Check out the following list to find related components
|
||||
</translate>
|
||||
</v-alert>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="components"
|
||||
:item-class="getItemBackground"
|
||||
hide-default-footer
|
||||
>
|
||||
<template v-slot:item.last_version="{ item }">
|
||||
<template v-if="item.changelog_url">
|
||||
<a :href="item.changelog_url" target="_blank">{{ item.last_version }}</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.last_version }}
|
||||
</template>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import adminApi from '@/api/admin'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
updatesAvailable () {
|
||||
return this.components.filter(item => item.update).length > 0
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
components: [],
|
||||
headers: [
|
||||
{ text: this.$gettext('Name'), value: 'name' },
|
||||
{ text: this.$gettext('Installed version'), value: 'version' },
|
||||
{ text: this.$gettext('Latest version'), value: 'last_version' },
|
||||
{ text: this.$gettext('Description'), value: 'description' }
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getItemBackground (item) {
|
||||
if (item.update) {
|
||||
return 'green lighten-5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
adminApi.getComponentsInformation().then(resp => {
|
||||
this.components = resp.data
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<v-card class="mt-6">
|
||||
<v-data-table-server
|
||||
v-model="selected"
|
||||
:headers="headers"
|
||||
:items="alarms"
|
||||
:search="search"
|
||||
item-value="id"
|
||||
:items-length="totalAlarms"
|
||||
:loading="loading"
|
||||
elevation="0"
|
||||
show-select
|
||||
@update:options="fetchAlarms"
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar color="white" flat>
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" size="small">
|
||||
{{ $gettext('Actions') }}
|
||||
<v-icon right>mdi-chevron-down</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<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 }">
|
||||
<v-chip v-if="item.status === 1" color="warning" size="small">
|
||||
{{ $gettext('Opened') }}
|
||||
</v-chip>
|
||||
<v-chip v-else color="info" size="small">
|
||||
{{ $gettext('Closed') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.mailbox`]="{ item }">
|
||||
<template v-if="item.mailbox">
|
||||
{{ item.mailbox.address }}@{{ item.domain.name }}
|
||||
</template>
|
||||
<template v-else> / </template>
|
||||
</template>
|
||||
<template #[`item.created`]="{ item }">
|
||||
{{ $date(item.created) }}
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<div class="text-right">
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon="mdi-dots-horizontal" v-bind="props" variant="text">
|
||||
</v-btn>
|
||||
</template>
|
||||
<MenuItems :items="getMenuItems(item)" :obj="item" />
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { ref } from 'vue'
|
||||
import debounce from 'debounce'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import alarmsApi from '@/api/alarms'
|
||||
import constants from '@/constants.json'
|
||||
import MenuItems from '@/components/tools/MenuItems'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const busStore = useBusStore()
|
||||
const alarms = ref([])
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const selected = ref([])
|
||||
const totalAlarms = ref(0)
|
||||
|
||||
const headers = [
|
||||
{ title: $gettext('Triggered'), key: 'created' },
|
||||
{ title: $gettext('Status'), key: 'status' },
|
||||
{ title: $gettext('Domain'), key: 'domain.name', sortable: false },
|
||||
{ title: $gettext('Mailbox'), key: 'mailbox', sortable: false },
|
||||
{ title: $gettext('Message'), key: 'title' },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
|
||||
function getActionMenuItems() {
|
||||
const result = []
|
||||
if (selected.value.length > 0) {
|
||||
result.push({
|
||||
label: $gettext('Delete'),
|
||||
icon: 'mdi-delete-outline',
|
||||
onClick: deleteAlarms,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
result.push({
|
||||
label: $gettext('Reload'),
|
||||
icon: 'mdi-reload',
|
||||
onClick: fetchAlarms,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function getMenuItems(item) {
|
||||
const result = []
|
||||
result.push({
|
||||
label: $gettext('Delete'),
|
||||
icon: 'mdi-delete-outline',
|
||||
onClick: deleteAlarm,
|
||||
color: 'red',
|
||||
})
|
||||
if (item.status === constants.ALARM_OPENED) {
|
||||
result.push({
|
||||
label: $gettext('Close alarm'),
|
||||
icon: 'mdi-check',
|
||||
onClick: closeAlarm,
|
||||
color: 'green',
|
||||
})
|
||||
} else {
|
||||
result.push({
|
||||
label: $gettext('Reopen alarm'),
|
||||
icon: 'mdi-alert-circle-outline',
|
||||
onClick: openAlarm,
|
||||
color: 'orange',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function fetchAlarms({ page, itemsPerPage, sortBy }) {
|
||||
const params = {
|
||||
page: page || 1,
|
||||
page_size: itemsPerPage || 10,
|
||||
}
|
||||
if (sortBy) {
|
||||
params.ordering = sortBy
|
||||
.map((item) => (this.options.sortDesc[0] ? `-${item}` : item))
|
||||
.join(',')
|
||||
}
|
||||
if (search.value !== '') {
|
||||
params.search = search.value
|
||||
}
|
||||
loading.value = true
|
||||
alarmsApi.getAll(params).then((resp) => {
|
||||
alarms.value = resp.data.results
|
||||
totalAlarms.value = resp.data.count
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteAlarm(alarm, single = true) {
|
||||
alarmsApi.delete(alarm.id).then(() => {
|
||||
if (single) {
|
||||
busStore.displayNotification({ msg: $gettext('Alarm deleted') })
|
||||
fetchAlarms({})
|
||||
}
|
||||
})
|
||||
}
|
||||
async function deleteAlarms() {
|
||||
await alarmsApi.bulkDelete(selected.value.map((alarm) => alarm.id))
|
||||
busStore.displayNotification({ msg: this.$gettext('Alarms deleted') })
|
||||
selected.value = []
|
||||
fetchAlarms({})
|
||||
}
|
||||
async function closeAlarm(alarm) {
|
||||
alarmsApi
|
||||
.switchStatus(alarm.id, { status: constants.ALARM_CLOSED })
|
||||
.then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Alarm closed') })
|
||||
fetchAlarms({})
|
||||
})
|
||||
}
|
||||
async function openAlarm(alarm) {
|
||||
alarmsApi
|
||||
.switchStatus(alarm.id, { status: constants.ALARM_OPENED })
|
||||
.then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Alarm re-opened') })
|
||||
fetchAlarms({})
|
||||
})
|
||||
}
|
||||
|
||||
fetchAlarms = debounce(fetchAlarms, 500)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-text-field--outlined :deep(fieldset) {
|
||||
border-color: #bfc5d2;
|
||||
}
|
||||
.v-input--checkbox :deep(.v-label) {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div v-if="alignments">
|
||||
<v-card>
|
||||
<v-toolbar dense elevation="0" class="mr-2">
|
||||
<v-toolbar-title>$gettext('Alignment')</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-arrow-left" size="x-small" @click="previousWeek">
|
||||
</v-btn>
|
||||
<span class="mx-2"
|
||||
>{{ weekStart.toLocaleString() }} -
|
||||
{{ weekEnd.toLocaleString() }}</span
|
||||
>
|
||||
<v-btn icon="mdi-arrow-right" size="x-small" @click="nextWeek"> </v-btn>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="5">
|
||||
<apexchart
|
||||
v-if="series"
|
||||
type="pie"
|
||||
height="350"
|
||||
:options="options"
|
||||
:series="series"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="7">
|
||||
<v-row>
|
||||
<v-col v-for="box in boxes" :key="box.key" cols="6">
|
||||
<v-sheet outlined :color="box.color" rounded>
|
||||
<v-card outlined class="pa-10 text-center">
|
||||
<span :class="'text-h4 ' + getTextColors(box.color)">{{
|
||||
stats[box.key]
|
||||
}}</span
|
||||
><br />
|
||||
<span class="text-subtitle-1">{{ box.label }}</span>
|
||||
</v-card>
|
||||
</v-sheet>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-expansion-panels v-model="panel" class="mt-2">
|
||||
<v-expansion-panel :title="$gettext('Detail')">
|
||||
<v-expansion-panel-text>
|
||||
<DmarcSourceTable
|
||||
:title="$gettext('Trusted sources')"
|
||||
color="green"
|
||||
:total="stats.aligned"
|
||||
:sources="alignments.aligned"
|
||||
/>
|
||||
<DmarcSourceTable
|
||||
:title="$gettext('Partially trusted sources / Forwarders')"
|
||||
color="orange"
|
||||
:total="stats.trusted"
|
||||
:sources="alignments.trusted"
|
||||
class="mt-4"
|
||||
/>
|
||||
<DmarcSourceTable
|
||||
:title="$gettext('Forwarders with ARC')"
|
||||
color="blue"
|
||||
:total="stats.forwarded"
|
||||
:sources="alignments.forwarded"
|
||||
class="mt-4"
|
||||
/>
|
||||
<DmarcSourceTable
|
||||
:title="$gettext('Unknown sources / Threats')"
|
||||
color="red"
|
||||
:total="stats.failed"
|
||||
:sources="alignments.failed"
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-alert
|
||||
v-if="dmarcDisabled"
|
||||
type="info"
|
||||
variant="outlined"
|
||||
prominent
|
||||
border="start"
|
||||
class="mt-6"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
$gettext('DMARC support does not seem to be enabled for this domain.')
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
$gettext(
|
||||
'If you configured it recently, please wait for the first report to be received and processed.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</v-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import colors from 'vuetify/lib/util/colors'
|
||||
import DmarcSourceTable from './DmarcSourceTable.vue'
|
||||
import domainsApi from '@/api/domains'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { DateTime } from 'luxon'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const order = ['aligned', 'trusted', 'forwarded', 'failed']
|
||||
|
||||
const props = defineProps({
|
||||
domain: { type: Object, default: () => null },
|
||||
})
|
||||
|
||||
const options = ref({
|
||||
colors: [
|
||||
colors.green.lighten2,
|
||||
colors.orange.lighten2,
|
||||
colors.blue.lighten2,
|
||||
colors.red.lighten2,
|
||||
],
|
||||
labels: [
|
||||
$gettext('Fully aligned'),
|
||||
$gettext('Partially aligned'),
|
||||
$gettext('Forwarded'),
|
||||
$gettext('Failed'),
|
||||
],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
})
|
||||
|
||||
const alignments = ref(null)
|
||||
|
||||
const boxes = ref([
|
||||
{ key: 'total', label: $gettext('Total'), color: 'primary lighten-2' },
|
||||
{
|
||||
key: 'aligned',
|
||||
label: $gettext('Fully aligned'),
|
||||
color: 'green lighten-2',
|
||||
},
|
||||
{
|
||||
key: 'trusted',
|
||||
label: $gettext('Partially aligned'),
|
||||
color: 'orange lighten-2',
|
||||
},
|
||||
{ key: 'forwarded', label: $gettext('Forwarded'), color: 'blue lighten-2' },
|
||||
{ key: 'failed', label: $gettext('Failed'), color: 'red lighten-2' },
|
||||
])
|
||||
|
||||
const now = DateTime.now()
|
||||
const currentWeek = ref(now.year)
|
||||
const currentYear = ref(now.weekNumber)
|
||||
const dmarcDisabled = ref(false)
|
||||
const loading = ref(false)
|
||||
const panel = ref(null)
|
||||
const series = ref([])
|
||||
const stats = ref({})
|
||||
|
||||
const weekStart = computed(() => {
|
||||
const dt = DateTime.fromObject({
|
||||
weekYear: currentYear.value,
|
||||
weekNumber: currentWeek.value,
|
||||
})
|
||||
return dt.startOf('week')
|
||||
})
|
||||
|
||||
const weekEnd = computed(() => {
|
||||
const dt = DateTime.fromObject({
|
||||
weekYear: currentYear.value,
|
||||
weekNumber: currentWeek.value,
|
||||
})
|
||||
return dt.endOf('week')
|
||||
})
|
||||
|
||||
function getTextColors(value) {
|
||||
let result = ''
|
||||
for (const color of value.split(' ')) {
|
||||
if (result !== '') {
|
||||
result += ' '
|
||||
}
|
||||
result += 'text-' + color
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function nextWeek() {
|
||||
if (currentWeek.value === 52) {
|
||||
currentYear.value += 1
|
||||
currentWeek.value = 1
|
||||
} else {
|
||||
currentWeek.value += 1
|
||||
}
|
||||
}
|
||||
function previousWeek() {
|
||||
if (currentWeek.value === 1) {
|
||||
currentYear.value -= 1
|
||||
currentWeek.value = 52
|
||||
} else {
|
||||
currentWeek.value -= 1
|
||||
}
|
||||
}
|
||||
function fetchAlignmentStats() {
|
||||
if (loading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const period = `${currentYear.value}-${currentWeek.value}`
|
||||
loading.value = true
|
||||
domainsApi.getDomainDmarcAlignment(props.domain.pk, period).then((resp) => {
|
||||
loading.value = false
|
||||
if (resp.status === 204) {
|
||||
dmarcDisabled.value = true
|
||||
return
|
||||
}
|
||||
stats.value = { total: 0 }
|
||||
alignments.value = resp.data
|
||||
series.value = []
|
||||
for (const key of order) {
|
||||
let total = 0
|
||||
for (const name in resp.data[key]) {
|
||||
for (const ip in resp.data[key][name]) {
|
||||
total += resp.data[key][name][ip].total
|
||||
}
|
||||
}
|
||||
stats.value.total += total
|
||||
stats.value[key] = total
|
||||
series.value.push(total)
|
||||
}
|
||||
})
|
||||
}
|
||||
onMounted(() => fetchAlignmentStats())
|
||||
|
||||
watch(currentWeek, () => {
|
||||
fetchAlignmentStats()
|
||||
})
|
||||
|
||||
watch(currentYear, () => {
|
||||
fetchAlignmentStats()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-h6">
|
||||
{{ title }} <span :class="text - `${color}-lighten-2`">{{ total }}</span>
|
||||
</div>
|
||||
<v-simple-table>
|
||||
<tbody>
|
||||
<template v-for="(source, name) in sources" :key="name">
|
||||
<tr>
|
||||
<th width="40%">{{ name }}</th>
|
||||
<th>{{ $gettext('Total') }}</th>
|
||||
<th>SPF</th>
|
||||
<th>DKIM</th>
|
||||
</tr>
|
||||
<tr v-for="(counters, ip) in source" :key="ip">
|
||||
<td>{{ ip }}</td>
|
||||
<td>{{ counters.total }}</td>
|
||||
<td>
|
||||
<v-avatar color="green lighten-2" size="24">
|
||||
<span class="text-white">{{ counters.spf.success }}</span>
|
||||
</v-avatar>
|
||||
<v-avatar color="red lighten-2 ml-1" size="24">
|
||||
<span class="text-white">{{ counters.spf.failure }}</span>
|
||||
</v-avatar>
|
||||
</td>
|
||||
<td>
|
||||
<v-avatar color="green lighten-2" size="24">
|
||||
<span class="text-white">{{ counters.dkim.success }}</span>
|
||||
</v-avatar>
|
||||
<v-avatar color="red lighten-2 ml-1" size="24">
|
||||
<span class="text-white">{{ counters.dkim.failure }}</span>
|
||||
</v-avatar>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</v-simple-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
const { $gettext } = useGettext()
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
color: { type: String, default: '' },
|
||||
total: { type: Number, default: null },
|
||||
sources: { type: Object, default: null },
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<button class="dkimbutton" @click="copyDKIM()">
|
||||
<pre id="dkimdns" class="dkim"
|
||||
>{{ domain.dkim_key_selector }}._domainkey.{{ domain.name }}. IN TXT ({{
|
||||
splitKey
|
||||
}})
|
||||
}})</pre
|
||||
>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
domain: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const splitKey = computed(() => {
|
||||
let key = `v=DKIM1;k=rsa;p=${props.domain.dkim_public_key}`
|
||||
const result = []
|
||||
while (key.length > 0) {
|
||||
if (key.length > 74) {
|
||||
result.push(` "${key.substring(0, 74)}"`)
|
||||
key = key.substring(74)
|
||||
} else {
|
||||
result.push(` "${key}"`)
|
||||
break
|
||||
}
|
||||
}
|
||||
return result.join('\n')
|
||||
})
|
||||
|
||||
function copyDKIM() {
|
||||
navigator.clipboard.writeText(document.getElementById('dkimdns').textContent)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<v-card v-if="domain">
|
||||
<v-card-title>
|
||||
<span> DNS </span>
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
icon="mdi-information-outline"
|
||||
:title="$gettext('DNS configuration help')"
|
||||
variant="text"
|
||||
density="compact"
|
||||
@click="showConfigHelp = true"
|
||||
></v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<translate class="overline">MX records</translate>
|
||||
<template v-if="domain.dns_global_status == 'pending'">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-chip color="info" size="small">
|
||||
{{ $gettext('Pending') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-data-table-virtual
|
||||
:headers="mxRecordHeaders"
|
||||
:items="detail.mx_records"
|
||||
>
|
||||
<template #[`item.updated`]="{ item }">
|
||||
{{ $date(item.updated) }}
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
</template>
|
||||
<div class="overline">{{ $gettext('Auto configuration') }}</div>
|
||||
<template v-if="domain.dns_global_status == 'pending'">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-chip color="info" size="small">
|
||||
{{ $gettext('Pending') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-chip v-if="detail.autoconfig_record" color="success">
|
||||
{{
|
||||
$gettext('autoconfig record (Mozilla): %{ value }', {
|
||||
value: detail.autoconfig_record.value,
|
||||
})
|
||||
}}
|
||||
</v-chip>
|
||||
<v-chip v-else color="warning">
|
||||
{{ $gettext('autoconfig record (Mozilla) not found') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-chip v-if="detail.autodiscover_record" color="success">
|
||||
{{
|
||||
$gettext('autodiscover record (Microsoft): %{ value }', {
|
||||
value: detail.autodiscover_record.value,
|
||||
})
|
||||
}}
|
||||
</v-chip>
|
||||
<v-chip v-else color="warning">
|
||||
{{ $gettext('autodiscover record (Microsoft) not found') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="overline">{{ $gettext('Authentication') }}</div>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-chip v-if="detail.spf_record" color="success">
|
||||
{{ $gettext('SPF record found') }}
|
||||
</v-chip>
|
||||
<v-chip v-else color="error">
|
||||
{{ $gettext('SPF record not found') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-chip v-if="detail.dkim_record" color="success">
|
||||
{{ $gettext('DKIM record found') }}
|
||||
</v-chip>
|
||||
<v-chip v-else color="error">
|
||||
{{ $gettext('DKIM record not found') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-chip v-if="detail.dmarc_record" color="success">
|
||||
{{ $gettext('DMARC record found') }}
|
||||
</v-chip>
|
||||
<v-chip v-else color="error">
|
||||
{{ $gettext('DMARC record not found') }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<template v-if="domain.enable_dkim">
|
||||
<div class="d-flex align-center flex-nowrap mt-4">
|
||||
<div class="text-subtitle-2">
|
||||
{{ $gettext('DKIM key') }}
|
||||
</div>
|
||||
<div class="ml-6">
|
||||
<div v-if="domain.dkim_private_key_path && domain.dkim_public_key">
|
||||
<v-btn color="primary" size="small" @click="showDKIMKey = true">
|
||||
{{ $gettext('Show key') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
:title="$gettext('Copy key to clipboard')"
|
||||
@click="copyPubKey"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
:title="$gettext('Generate a new DKIM key')"
|
||||
variant="text"
|
||||
:loading="keyLoading"
|
||||
@click="generateNewKey"
|
||||
></v-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ $gettext('Not generated') }}
|
||||
<v-btn
|
||||
icon="mdi-reload"
|
||||
color="primary"
|
||||
variant="text"
|
||||
:loading="keyLoading"
|
||||
@click="retryKeyGeneration"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-dialog v-model="showConfigHelp" max-width="800px" persistent>
|
||||
<DomainDNSConfig :domain="domain" @close="showConfigHelp = false" />
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showDKIMKey" max-width="800px" persistent>
|
||||
<DomainDKIMKey :domain="domain" @close="showDKIMKey = false" />
|
||||
</v-dialog>
|
||||
<ConfirmDialog ref="dialog" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import domainsApi from '@/api/domains'
|
||||
import DomainDKIMKey from './DomainDKIMKey.vue'
|
||||
import DomainDNSConfig from './DomainDNSConfig.vue'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const domain = computed(() => props.modelValue)
|
||||
|
||||
const dialog = ref()
|
||||
const detail = ref({})
|
||||
const mxRecordHeaders = ref([
|
||||
{ title: $gettext('Name'), value: 'name' },
|
||||
{ title: $gettext('Address'), value: 'address' },
|
||||
{ title: $gettext('Updated'), value: 'updated' },
|
||||
])
|
||||
const keyLoading = ref(false)
|
||||
|
||||
const showConfigHelp = ref(false)
|
||||
const showDKIMKey = ref(false)
|
||||
|
||||
function copyPubKey() {
|
||||
navigator.clipboard.writeText(domain.value.dkim_public_key)
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('DKIM key copied to clipboard'),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
function confirmGenNewKey() {
|
||||
const payload = {
|
||||
dkim_private_key_path: '',
|
||||
}
|
||||
domainsApi
|
||||
.patchDomain(domain.value.pk, payload)
|
||||
.then(() => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext(
|
||||
'A new key will be generated soon. Refresh the page in a moment to see it.'
|
||||
),
|
||||
type: 'success',
|
||||
})
|
||||
domain.value.dkim_private_key_path = ''
|
||||
keyLoading.value = false
|
||||
})
|
||||
.catch(() => {
|
||||
keyLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function cancelDKIMGen() {
|
||||
keyLoading.value = false
|
||||
}
|
||||
|
||||
async function retryKeyGeneration() {
|
||||
keyLoading.value = true
|
||||
domainsApi.getDomainDNSDetail(domain.value.pk).then(async (resp) => {
|
||||
detail.value = resp.data
|
||||
const result = await dialog.value.open(
|
||||
$gettext('warning'),
|
||||
$gettext(
|
||||
'DKIM key does not seem to be genrated yet or has failed. Do you want to requeue the job?'
|
||||
),
|
||||
{
|
||||
color: 'warning',
|
||||
cancelLabel: $gettext('No'),
|
||||
agreeLabel: $gettext('Yes'),
|
||||
}
|
||||
)
|
||||
if (result) {
|
||||
confirmGenNewKey()
|
||||
} else {
|
||||
cancelDKIMGen()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function generateNewKey() {
|
||||
dialog.value.open(
|
||||
$gettext('Warning'),
|
||||
$gettext(
|
||||
'DKIM keys already exist for this domain. Do you want to overwrite them?'
|
||||
),
|
||||
{
|
||||
color: 'error',
|
||||
cancelLabel: $gettext('No'),
|
||||
agreeLabel: $gettext('Yes'),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
watch(domain, (newDomain) => {
|
||||
if (props.modelValue) {
|
||||
domainsApi.getDomainDNSDetail(newDomain.pk).then((resp) => {
|
||||
detail.value = resp.data
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<v-chip :color="chipType" size="small">
|
||||
{{ label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: 'pending' },
|
||||
})
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.status === 'disabled') {
|
||||
return $gettext('Disabled')
|
||||
}
|
||||
if (props.status === 'pending') {
|
||||
return $gettext('Pending')
|
||||
}
|
||||
if (props.status === 'critical') {
|
||||
return $gettext('Problem')
|
||||
}
|
||||
if (props.status === 'ok') {
|
||||
return $gettext('Valid')
|
||||
}
|
||||
return $gettext('Unknown')
|
||||
})
|
||||
|
||||
const chipType = computed(() => {
|
||||
if (props.status === 'disabled') {
|
||||
return ''
|
||||
}
|
||||
if (props.status === 'pending') {
|
||||
return 'info'
|
||||
}
|
||||
if (props.status === 'critical') {
|
||||
return 'error'
|
||||
}
|
||||
if (props.status === 'ok') {
|
||||
return 'success'
|
||||
}
|
||||
return 'warning'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">
|
||||
<div v-if="dialogMode" class="headline">
|
||||
{{ $gettext('Administrators of') }} {{ domain.name }}
|
||||
</div>
|
||||
<div v-else class="headline">
|
||||
{{ $gettext('Administrators') }}
|
||||
</div>
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-data-table-virtual :headers="adminHeaders" :items="administrators">
|
||||
<template #[`item.username`]="{ item }">
|
||||
<span v-if="item.id">{{ item.username }}</span>
|
||||
<v-autocomplete
|
||||
v-else
|
||||
v-model="selectedAccount"
|
||||
:label="$gettext('Select an account')"
|
||||
:items="accounts"
|
||||
item-title="username"
|
||||
class="mt-5"
|
||||
return-object
|
||||
>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="selectedAccount"
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="x-large"
|
||||
@click="addAdministrator"
|
||||
>
|
||||
<v-icon>mdi-content-save</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
<template #[`item.name`]="{ item }">
|
||||
{{ item.first_name }} {{ item.last_name }}
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<v-btn
|
||||
v-if="item.id"
|
||||
variant="text"
|
||||
color="red"
|
||||
:title="$gettext('Remove this administrator')"
|
||||
size="x-large"
|
||||
@click="removeAdministrator(item)"
|
||||
>
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-row>
|
||||
<v-col :align="dialogMode ? 'left' : 'right'">
|
||||
<v-btn
|
||||
v-if="!hideAddBtn"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="addRow"
|
||||
>
|
||||
<v-icon small>mdi-plus</v-icon> {{ $gettext('Add administrator') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col v-if="dialogMode" align="right">
|
||||
<v-btn color="grey darken-1" variant="text" @click="close">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import accountApi from '@/api/accounts'
|
||||
import domainApi from '@/api/domains'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const busStore = useBusStore()
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({
|
||||
domain: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
dialogMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const accounts = ref([])
|
||||
const administrators = ref([])
|
||||
|
||||
const adminHeaders = ref([
|
||||
{ title: $gettext('Username'), value: 'username' },
|
||||
{ title: $gettext('Name'), value: 'name' },
|
||||
{ title: '', value: 'actions', align: 'end', sortable: false },
|
||||
])
|
||||
|
||||
const hideAddBtn = ref(false)
|
||||
const selectedAccount = ref(null)
|
||||
|
||||
function addRow() {
|
||||
administrators.value.push({})
|
||||
hideAddBtn.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
hideAddBtn.value = false
|
||||
emit('close')
|
||||
}
|
||||
function fetchAdministrators(domain) {
|
||||
domainApi.getDomainAdministrators(domain.pk).then((resp) => {
|
||||
administrators.value = resp.data
|
||||
})
|
||||
}
|
||||
|
||||
function fetchAccounts() {
|
||||
accountApi.getAll({ role: 'DomainAdmins' }).then((resp) => {
|
||||
accounts.value = resp.data.filter(
|
||||
(account) =>
|
||||
administrators.value.find((admin) => admin.id === account.pk) ===
|
||||
undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
function addAdministrator() {
|
||||
domainApi
|
||||
.addDomainAdministrator(props.domain.pk, selectedAccount.value.pk)
|
||||
.then(() => {
|
||||
fetchAdministrators(props.domain)
|
||||
accounts.value = accounts.value.filter(
|
||||
(account) => account.pk !== selectedAccount.value.pk
|
||||
)
|
||||
selectedAccount.value = null
|
||||
hideAddBtn.value = false
|
||||
busStore.displayNotification({ msg: $gettext('Administrator added') })
|
||||
})
|
||||
}
|
||||
|
||||
function removeAdministrator(admin) {
|
||||
domainApi.removeDomainAdministrator(props.domain.pk, admin.id).then(() => {
|
||||
fetchAdministrators(props.domain)
|
||||
fetchAccounts()
|
||||
busStore.displayNotification({ msg: $gettext('Administrator removed') })
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAdministrators(props.domain)
|
||||
fetchAccounts()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="vFormRef" @submit.prevent="submit">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<label class="m-label">{{ $gettext('Name') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
dense
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
<label class="m-label">{{ $gettext('Choose a domain') }}</label>
|
||||
<v-select
|
||||
v-model="form.target"
|
||||
:items="Object.values(domainsStore.domains)"
|
||||
item-title="name"
|
||||
item-value="pk"
|
||||
:rules="[rules.required]"
|
||||
single-line
|
||||
dense
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-switch v-model="form.enabled" label="Enabled" color="primary" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="emit('close')">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="domainAlias && domainAlias.pk"
|
||||
color="error"
|
||||
@click="deleteAlias"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" type="submit">
|
||||
{{ submitLabel }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useDomainsStore } from '@/stores'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import rules from '@/plugins/rules.js'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const domainsStore = useDomainsStore()
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const props = defineProps({
|
||||
domainAlias: { type: Object, default: () => null },
|
||||
})
|
||||
|
||||
const form = ref({ enabled: true })
|
||||
|
||||
const vFormRef = ref()
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
return props.domainAlias ? $gettext('Update') : $gettext('Add')
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
props.domainAlias
|
||||
? $gettext('Edit domain alias')
|
||||
: $gettext('Add a new domain alias')
|
||||
)
|
||||
|
||||
function deleteAlias() {
|
||||
domainsStore.deleteAlias(props.domainAlias).then(() => emit('close'))
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const { valid } = await vFormRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!form.value.pk) {
|
||||
// Create
|
||||
domainsStore.createAlias(form.value).then(() => {
|
||||
emit('close')
|
||||
})
|
||||
} else {
|
||||
// Update
|
||||
const data = JSON.parse(JSON.stringify(form.value))
|
||||
domainsStore.updateAlias(data).then(() => emit('close'))
|
||||
}
|
||||
} catch (error) {
|
||||
//TODO
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.domainAlias) {
|
||||
form.value = JSON.parse(JSON.stringify(props.domainAlias))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<CreationForm
|
||||
ref="form"
|
||||
:title="$gettext('New domain')"
|
||||
:steps="steps"
|
||||
:form-getter="getForm"
|
||||
:get-v-form-ref="getVFormRef"
|
||||
:validate-object="validateDomain"
|
||||
:summary-sections="summarySections"
|
||||
@close="close"
|
||||
@create="submit"
|
||||
>
|
||||
<template #[`form.general`]>
|
||||
<DomainGeneralForm ref="general" v-model="domain" class="ml-4" />
|
||||
</template>
|
||||
|
||||
<template #[`form.dns`]>
|
||||
<DomainDNSForm ref="dns" v-model="domain" class="ml-4" />
|
||||
</template>
|
||||
<template #[`form.limitations`]>
|
||||
<DomainLimitationsForm ref="limitations" v-model="domain" class="ml-4" />
|
||||
</template>
|
||||
<template #[`form.options`]>
|
||||
<DomainOptionsForm
|
||||
ref="options"
|
||||
v-model="domain"
|
||||
class="ml-4"
|
||||
@create-admin="updateCreateAdmin"
|
||||
/>
|
||||
</template>
|
||||
<template #[`form.transport`]>
|
||||
<DomainTransportForm
|
||||
ref="transport"
|
||||
v-model="domain.transport"
|
||||
class="ml-4"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.random_password`]="{ item }">
|
||||
<template v-if="item.value">
|
||||
<v-col cols="12" class="highligth text-white">
|
||||
<v-row>
|
||||
<v-col
|
||||
><span>{{ item.key }}</span></v-col
|
||||
>
|
||||
<v-col class="text-right">{{ $yesno(item.value) }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-right py-1">
|
||||
{{ domain.domain_admin.password }}
|
||||
<v-btn
|
||||
icon="mdi-clipboard-multiple-outline"
|
||||
color="white"
|
||||
variant="text"
|
||||
:title="$gettext('Copy to clipboard')"
|
||||
@click="copyPassword"
|
||||
></v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-col
|
||||
><span class="text-grey">{{ item.key }}</span></v-col
|
||||
>
|
||||
<v-col class="text-right">{{ $yesno(item.value) }}</v-col>
|
||||
</template>
|
||||
</template>
|
||||
</CreationForm>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import ParametersApi from '@/api/parameters.js'
|
||||
import CreationForm from '@/components/tools/CreationForm.vue'
|
||||
import DomainGeneralForm from './form_steps/DomainGeneralForm.vue'
|
||||
import DomainDNSForm from './form_steps/DomainDNSForm.vue'
|
||||
import DomainLimitationsForm from './form_steps/DomainLimitationsForm.vue'
|
||||
import DomainOptionsForm from './form_steps/DomainOptionsForm.vue'
|
||||
import DomainTransportForm from './form_steps/DomainTransportForm.vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useBusStore, useDomainsStore } from '@/stores'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const domainsStore = useDomainsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const defaultDomain = {
|
||||
name: '',
|
||||
type: 'domain',
|
||||
enabled: true,
|
||||
enable_dns_checks: true,
|
||||
enable_dkim: false,
|
||||
dkim_key_selector: 'modoboa',
|
||||
quota: 0,
|
||||
default_mailbox_quota: 0,
|
||||
domain_admin: {
|
||||
username: 'admin',
|
||||
random_password: false,
|
||||
with_mailbox: false,
|
||||
with_aliases: false,
|
||||
},
|
||||
transport: { settings: {} },
|
||||
}
|
||||
|
||||
const domain = ref(defaultDomain)
|
||||
|
||||
onMounted(() => {
|
||||
//TODO : Store this in Pinia
|
||||
ParametersApi.getApplication('admin').then((resp) => {
|
||||
const params = resp.data.params
|
||||
domain.value.quota = params.default_domain_quota
|
||||
domain.value.default_mailbox_quota = params.default_mailbox_quota
|
||||
if (params.default_domain_message_limit !== null) {
|
||||
domain.value.message_limit = params.default_domain_message_limit
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const createAdmin = ref(false)
|
||||
|
||||
// Reference to steps components
|
||||
const general = ref()
|
||||
const dns = ref()
|
||||
const limitations = ref()
|
||||
const options = ref()
|
||||
const transport = ref()
|
||||
|
||||
// Object to reference
|
||||
const formStepsComponents = {
|
||||
general: general,
|
||||
dns: dns,
|
||||
limitations: limitations,
|
||||
options: options,
|
||||
transport: transport,
|
||||
}
|
||||
|
||||
const domainSteps = [
|
||||
{ name: 'general', title: $gettext('General') },
|
||||
{ name: 'dns', title: $gettext('DNS') },
|
||||
{ name: 'limitations', title: $gettext('Limitations') },
|
||||
{ name: 'options', title: $gettext('Options') },
|
||||
]
|
||||
const relaySteps = [
|
||||
{ name: 'general', title: $gettext('General') },
|
||||
{ name: 'dns', title: $gettext('DNS') },
|
||||
{ name: 'limitations', title: $gettext('Limitations') },
|
||||
{ name: 'transport', title: $gettext('Transport') },
|
||||
]
|
||||
|
||||
const steps = computed(() => {
|
||||
return domain.value.type === 'domain' ? domainSteps : relaySteps
|
||||
})
|
||||
|
||||
const summarySections = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
title: $gettext('General'),
|
||||
items: [
|
||||
{ key: $gettext('Name'), value: domain.value.name },
|
||||
{ key: $gettext('Type'), value: domain.value.type },
|
||||
{
|
||||
key: $gettext('Enabled'),
|
||||
value: domain.value.enabled,
|
||||
type: 'yesno',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $gettext('DNS'),
|
||||
items: [
|
||||
{
|
||||
key: $gettext('Enable DNS checks'),
|
||||
value: domain.value.enable_dns_checks,
|
||||
type: 'yesno',
|
||||
},
|
||||
{
|
||||
key: $gettext('Enable DKIM signing'),
|
||||
value: domain.value.enable_dkim,
|
||||
type: 'yesno',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $gettext('Limitations'),
|
||||
items: [
|
||||
{ key: $gettext('Quota'), value: domain.value.quota },
|
||||
{
|
||||
key: $gettext('Default mailbox quota'),
|
||||
value: domain.value.default_mailbox_quota,
|
||||
},
|
||||
{
|
||||
key: $gettext('Message sending limit'),
|
||||
value: domain.value.message_limit,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
if (domain.value.enable_dkim) {
|
||||
result[1].items.push({
|
||||
key: $gettext('DKIM key selector'),
|
||||
value: domain.value.dkim_key_selector,
|
||||
})
|
||||
result[1].items.push({
|
||||
key: $gettext('DKIM key length'),
|
||||
value: domain.value.dkim_key_length,
|
||||
})
|
||||
}
|
||||
if (domain.value.type === 'relaydomain' && domain.value.transport.service) {
|
||||
const relayEntry = {
|
||||
title: $gettext('Transport'),
|
||||
items: [
|
||||
{ key: $gettext('Service'), value: domain.value.transport.service },
|
||||
],
|
||||
}
|
||||
for (const setting of transport.value.service.settings) {
|
||||
const item = {
|
||||
key: setting.label,
|
||||
value:
|
||||
domain.value.transport.settings[
|
||||
`${domain.value.transport.service}_${setting.name}`
|
||||
],
|
||||
}
|
||||
if (setting.type === 'boolean') {
|
||||
item.type = 'yesno'
|
||||
}
|
||||
relayEntry.items.push(item)
|
||||
}
|
||||
result.push(relayEntry)
|
||||
} else if (domain.value.type === 'domain') {
|
||||
const options = {
|
||||
title: $gettext('Options'),
|
||||
items: [
|
||||
{
|
||||
key: $gettext('Create a domain administrator'),
|
||||
value: createAdmin.value,
|
||||
type: 'yesno',
|
||||
},
|
||||
],
|
||||
}
|
||||
if (createAdmin.value) {
|
||||
options.items.push(
|
||||
{
|
||||
key: $gettext('Administrator name'),
|
||||
value: domain.value.domain_admin.username,
|
||||
},
|
||||
{
|
||||
name: 'random_password',
|
||||
key: $gettext('Random password'),
|
||||
value: domain.value.domain_admin.random_password,
|
||||
type: 'yesno',
|
||||
},
|
||||
{
|
||||
key: $gettext('With mailbox'),
|
||||
value: domain.value.domain_admin.with_mailbox,
|
||||
type: 'yesno',
|
||||
},
|
||||
{
|
||||
key: $gettext('Create aliases'),
|
||||
value: domain.value.domain_admin.with_aliases,
|
||||
type: 'yesno',
|
||||
}
|
||||
)
|
||||
}
|
||||
result.push(options)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function copyPassword() {
|
||||
navigator.clipboard.writeText(domain.value.domain_admin.password).then(() => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Password copied to clipboard'),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getForm(step) {
|
||||
return formStepsComponents.value[step.name]
|
||||
}
|
||||
function getVFormRef(step) {
|
||||
return formStepsComponents[step.name].value.vFormRef
|
||||
}
|
||||
|
||||
async function validateDomain() {}
|
||||
|
||||
function updateCreateAdmin(value) {
|
||||
createAdmin.value = value
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const data = JSON.parse(JSON.stringify(domain.value))
|
||||
if (!createAdmin.value) {
|
||||
delete data.domain_admin
|
||||
}
|
||||
if (data.message_limit === '') {
|
||||
delete data.message_limit
|
||||
}
|
||||
if (data.type === 'relaydomain') {
|
||||
transport.value.checkSettingTypes(data)
|
||||
} else {
|
||||
delete data.transport
|
||||
}
|
||||
domainsStore.createDomain(data).then((resp) => {
|
||||
router.push({ name: 'DomainDetail', params: { id: resp.data.pk } })
|
||||
busStore.displayNotification({ msg: $gettext('Domain created') })
|
||||
close()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.highligth {
|
||||
background-color: #515d78;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">
|
||||
{{ $gettext('DKIM public key for') }} {{ domain.name }}
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>{{ $gettext('Raw format. Click to copy') }}</p>
|
||||
<v-alert
|
||||
border="start"
|
||||
colored-border
|
||||
border-color="primary lighten-3"
|
||||
elevation="2"
|
||||
dense
|
||||
>
|
||||
<button class="dkimbutton" @click="copyPubDKIM()">
|
||||
<pre id="dkimpub" class="dkim">{{ domain.dkim_public_key }}</pre>
|
||||
</button>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
<p>{{ $gettext('Bind/named format. Click to copy') }}</p>
|
||||
<v-alert
|
||||
border="start"
|
||||
colored-border
|
||||
border-color="primary lighten-3"
|
||||
elevation="2"
|
||||
dense
|
||||
>
|
||||
<DKIMKeyViewer :domain="domain" />
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="emit('close')">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import DKIMKeyViewer from './DKIMKeyViewer.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
defineProps({
|
||||
domain: { type: Object, default: null },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
function copyPubDKIM() {
|
||||
navigator.clipboard.writeText(document.getElementById('dkimpub').textContent)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dkimbutton {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.dkimbutton:hover {
|
||||
background-color: rgba(131, 131, 250, 0.283);
|
||||
transition: all 0.8s;
|
||||
}
|
||||
.dkimbutton:active {
|
||||
background-color: rgba(60, 255, 60, 0.136);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.dkim {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">
|
||||
<span> {{ $gettext('DNS configuration for ') }} {{ domain.name }} </span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-subtitle-1 text-grey-darken-2 mb-4">
|
||||
{{
|
||||
$gettext(
|
||||
'Here is an example of DNS configuration (bind format). Replace values between [ ] by the appropriate ones.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<v-alert
|
||||
class="mb-4"
|
||||
border="start"
|
||||
border-color="primary lighten-3"
|
||||
elevation="2"
|
||||
color="white"
|
||||
>
|
||||
<div class="text-h6">MX</div>
|
||||
<pre>
|
||||
mail.{{ domain.name }}. IN A <strong>[{{$gettext('IP address of your Modoboa server')}}]</strong>
|
||||
@ IN MX 10 mail.{{ domain.name }}.</pre>
|
||||
</v-alert>
|
||||
<v-alert
|
||||
class="mb-4"
|
||||
border="start"
|
||||
border-color="primary lighten-3"
|
||||
elevation="2"
|
||||
dense
|
||||
color="white"
|
||||
>
|
||||
<div class="text-h6">SPF</div>
|
||||
<pre>@ IN TXT "v=spf1 mx ~all"</pre>
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="domain.enable_dkim && domain.dkim_public_key"
|
||||
class="mb-4"
|
||||
border="start"
|
||||
border-color="primary lighten-3"
|
||||
elevation="2"
|
||||
dense
|
||||
color="white"
|
||||
>
|
||||
<div class="text-h6">DKIM</div>
|
||||
<DKIMKeyViewer :domain="domain" />
|
||||
</v-alert>
|
||||
<v-alert
|
||||
class="mb-4"
|
||||
border="start"
|
||||
border-color="primary lighten-3"
|
||||
elevation="2"
|
||||
dense
|
||||
color="white"
|
||||
>
|
||||
<div class="text-h6">DMARC</div>
|
||||
<pre>
|
||||
_dmarc.{{ domain.name }}. IN TXT "v=DMARC1; p=quarantine; pct=100;"</pre
|
||||
>
|
||||
</v-alert>
|
||||
<v-alert
|
||||
class="mb-4"
|
||||
border="start"
|
||||
border-color="primary lighten-3"
|
||||
elevation="2"
|
||||
dense
|
||||
color="white"
|
||||
>
|
||||
<div class="text-h6">{{ $gettext('Auto configuration') }}</div>
|
||||
<pre>
|
||||
autoconfig.{{ domain.name }}. IN CNAME <strong>[{{$gettext('hostname of your automx server')}}]</strong>
|
||||
autodiscover.{{ domain.name }}. IN CNAME <strong>[{{$gettext('hostname of your automx server')}}]</strong></pre>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="emit('close')">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import DKIMKeyViewer from './DKIMKeyViewer.vue'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
defineProps({
|
||||
domain: { type: Object, default: null },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<LoadingData v-if="!domainsStore.domainsLoaded || working" />
|
||||
<div v-else>
|
||||
<v-expansion-panels v-model="panel" :multiple="formErrors">
|
||||
<v-expansion-panel eager value="generalForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('General') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-medium-emphasis">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-col cols="6">
|
||||
{{ $gettext('Name: ') }} {{ editedDomain.name }}
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
{{ $gettext('Type: ') }} {{ editedDomain.type }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<DomainGeneralForm ref="generalForm" v-model="editedDomain" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel eager value="dnsForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('DNS') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-medium-emphasis">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-col cols="6">
|
||||
{{ $gettext('DNS checks') }}
|
||||
<v-icon
|
||||
v-if="editedDomain.enable_dns_checks"
|
||||
color="success"
|
||||
icon="mdi-check-circle-outline"
|
||||
/>
|
||||
<v-icon v-else icon="mdi-check-circle-outline" />
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
{{ $gettext('DKIM signing') }}
|
||||
<v-icon
|
||||
v-if="editedDomain.enable_dkim"
|
||||
color="success"
|
||||
icon="mdi-check-circle-outline"
|
||||
/>
|
||||
<v-icon v-else icon="mdi-close-circle-outline" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<DomainDNSForm ref="dnsForm" v-model="editedDomain" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel eager value="limitationForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Limitations') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-medium-emphasis">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Quota: ') }} {{ editedDomain.quota }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col v-if="editedDomain.message_limit" cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Sending limit: ') }}
|
||||
{{ editedDomain.message_limit }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col v-else cols="6">
|
||||
<div class="mr-2">{{ $gettext('No sending limit') }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<DomainLimitationsForm ref="limitationForm" v-model="editedDomain" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel
|
||||
v-if="editedDomain.type === 'relaydomain'"
|
||||
value="transportForm"
|
||||
eager
|
||||
>
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Transport') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-medium-emphasis">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Service: ') }}
|
||||
{{ editedDomain.transport.service }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<DomainTransportForm
|
||||
ref="transportForm"
|
||||
v-model="editedDomain.transport"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel
|
||||
v-if="
|
||||
limitsConfig.params &&
|
||||
limitsConfig.params.enable_domain_limits &&
|
||||
(authUser.role === 'SuperAdmins' || authUser.role === 'Resellers')
|
||||
"
|
||||
value="resourcesForm"
|
||||
eager
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Resources') }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<ResourcesForm ref="resourcesForm" v-model="editedDomain.resources" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
<div class="mt-4 d-flex justify-end">
|
||||
<v-btn :loading="working" @click="router.go(-1)">
|
||||
{{ $gettext('Cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-4"
|
||||
color="primary darken-1"
|
||||
:loading="working"
|
||||
@click="save"
|
||||
>
|
||||
{{ $gettext('Save') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import DomainDNSForm from './form_steps/DomainDNSForm.vue'
|
||||
import DomainGeneralForm from './form_steps/DomainGeneralForm.vue'
|
||||
import DomainLimitationsForm from './form_steps/DomainLimitationsForm.vue'
|
||||
import DomainTransportForm from './form_steps/DomainTransportForm.vue'
|
||||
import LoadingData from '@/components/tools/LoadingData.vue'
|
||||
import parametersApi from '@/api/parameters'
|
||||
import ResourcesForm from '@/components/tools/ResourcesForm.vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useAuthStore, useDomainsStore } from '@/stores'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { $gettext } = useGettext()
|
||||
const authStore = useAuthStore()
|
||||
const domainsStore = useDomainsStore()
|
||||
|
||||
const props = defineProps({ domain: { type: Object, default: null } })
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
|
||||
const limitsConfig = ref({})
|
||||
const panel = ref(0)
|
||||
const working = ref(false)
|
||||
|
||||
const editedDomain = computed(() => props.domain)
|
||||
|
||||
//refs
|
||||
const generalForm = ref()
|
||||
const resourcesForm = ref()
|
||||
const transportForm = ref()
|
||||
const limitationForm = ref()
|
||||
const dnsForm = ref()
|
||||
|
||||
// Form map
|
||||
const formMap = computed(() => {
|
||||
const map = {
|
||||
generalForm: generalForm,
|
||||
limitationForm: limitationForm,
|
||||
dnsForm: dnsForm,
|
||||
}
|
||||
if (editedDomain.value.type === 'relaydomain') {
|
||||
map.transportForm = transportForm
|
||||
}
|
||||
if (
|
||||
limitsConfig.value.params &&
|
||||
limitsConfig.value.params.enable_domain_limits &&
|
||||
(authUser.value.role === 'SuperAdmins' ||
|
||||
authUser.value.role === 'Resellers')
|
||||
) {
|
||||
map.resourcesForm = resourcesForm
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const formErrors = ref(false)
|
||||
|
||||
async function save() {
|
||||
formErrors.value = false
|
||||
panel.value = []
|
||||
for (const [panelName, formRef] of Object.entries(formMap.value)) {
|
||||
if (formRef.value != null) {
|
||||
const { valid } = await formRef.value.vFormRef.validate()
|
||||
if (!valid) {
|
||||
formErrors.value = true
|
||||
panel.value.push(panelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (formErrors.value) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
try {
|
||||
const data = JSON.parse(JSON.stringify(editedDomain.value))
|
||||
if (data.transport === null) {
|
||||
delete data.transport
|
||||
}
|
||||
if (data.type === 'relaydomain') {
|
||||
transportForm.value.checkSettingTypes(data)
|
||||
}
|
||||
domainsStore.updateDomain(data).then(() => {
|
||||
router.go(-1)
|
||||
})
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
parametersApi.getApplication('limits').then((resp) => {
|
||||
limitsConfig.value.data = resp.data
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<v-card class="mt-6">
|
||||
<v-data-table
|
||||
v-model="selected"
|
||||
:expanded="expanded"
|
||||
:headers="domainHeaders"
|
||||
:items="Object.values(domains)"
|
||||
item-value="name"
|
||||
:search="search"
|
||||
show-select
|
||||
expand-on-click
|
||||
:loading="!domainsLoaded"
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar color="white" flat>
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
append-icon="mdi-chevron-down"
|
||||
v-bind="props"
|
||||
size="small"
|
||||
>
|
||||
{{ $gettext('Actions') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact"> </v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
variant="text"
|
||||
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
|
||||
#item="{
|
||||
item,
|
||||
internalItem,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
isSelected,
|
||||
toggleSelect,
|
||||
}"
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
class="v-data-table__td v-data-table-column--no-padding v-data-table-column--align-start"
|
||||
>
|
||||
<v-checkbox-btn
|
||||
:value="!isSelected(internalItem)"
|
||||
@click="toggleSelect(internalItem)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<router-link
|
||||
:to="{ name: 'DomainDetail', params: { id: item.pk } }"
|
||||
>
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
<v-chip
|
||||
v-if="item.type === 'relaydomain'"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ $gettext('Relay') }}
|
||||
</v-chip>
|
||||
<span v-if="!item.enabled" class="ml-2">
|
||||
({{ $gettext('disabled') }})
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ item.domainalias_count }} aliases
|
||||
<v-btn
|
||||
v-if="item.domainalias_count"
|
||||
variant="text"
|
||||
:icon="
|
||||
isExpanded(internalItem) ? 'mdi-chevron-up' : 'mdi-chevron-down'
|
||||
"
|
||||
@click="loadAliases(item, internalItem, isExpanded, toggleExpand)"
|
||||
>
|
||||
</v-btn>
|
||||
</td>
|
||||
<td>
|
||||
<DNSStatusChip :status="item.dns_global_status" />
|
||||
</td>
|
||||
<td>
|
||||
<v-progress-linear v-model="item.allocated_quota_in_percent" />
|
||||
</td>
|
||||
<td>
|
||||
<v-progress-linear v-model="item.allocated_quota_in_percent" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-right">
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-badge
|
||||
v-if="item.opened_alarms_count"
|
||||
bordered
|
||||
color="error"
|
||||
icon="mdi-bell"
|
||||
overlap
|
||||
>
|
||||
<v-btn
|
||||
icon="mdi-dots-horizontal"
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
>
|
||||
</v-btn>
|
||||
</v-badge>
|
||||
<v-btn
|
||||
v-else
|
||||
icon="mdi-dots-horizontal"
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
>
|
||||
</v-btn>
|
||||
</template>
|
||||
<MenuItems :items="getDomainMenuItems(item)" :obj="item" />
|
||||
</v-menu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template #expanded-row="{ item, columns }">
|
||||
<tr class="grey lighten-4">
|
||||
<td :colspan="columns.length">
|
||||
<span
|
||||
v-for="alias in aliases[item.name]"
|
||||
:key="alias.name"
|
||||
class="mr-4"
|
||||
>
|
||||
<a href="#" class="mr-2" @click="editDomainAlias(item, alias)">{{
|
||||
alias.name
|
||||
}}</a>
|
||||
<v-chip variant="elevated" size="x-small" color="success"
|
||||
>DNS OK</v-chip
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table>
|
||||
<ConfirmDialog ref="confirm">
|
||||
<v-checkbox
|
||||
v-model="keepDomainFolder"
|
||||
:label="$gettext('Do not delete domain folder')"
|
||||
hide-details
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
<v-dialog v-model="showAliasForm" persistent max-width="800px">
|
||||
<DomainAliasForm
|
||||
:domain-alias="selectedDomainAlias"
|
||||
@close="closeDomainAliasForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showAdminList" persistent max-width="800px">
|
||||
<DomainAdminList
|
||||
:domain="selectedDomain"
|
||||
dialog-mode
|
||||
@close="showAdminList = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useBusStore, useDomainsStore } from '@/stores'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DomainAdminList from './DomainAdminList.vue'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
|
||||
import DNSStatusChip from './DNSStatusChip.vue'
|
||||
import DomainAliasForm from './DomainAliasForm.vue'
|
||||
import MenuItems from '@/components/tools/MenuItems.vue'
|
||||
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const router = useRouter()
|
||||
|
||||
const busStore = useBusStore()
|
||||
const domainStore = useDomainsStore()
|
||||
const domains = computed(() => domainStore.domains)
|
||||
const domainsLoaded = computed(() => domainStore.domainsLoaded)
|
||||
const aliases = computed(() => domainStore.domainAliases)
|
||||
|
||||
const domainHeaders = [
|
||||
{ title: $gettext('Name'), key: 'name' },
|
||||
{ title: $gettext('Aliases'), key: 'domainalias_count' },
|
||||
{ title: $gettext('DNS status'), key: 'dns_global_status', sortable: false },
|
||||
{ title: $gettext('Sending limit'), key: 'message_limit' },
|
||||
{ title: $gettext('Quota'), key: 'allocated_quota_in_percent' },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
value: 'actions',
|
||||
sortable: false,
|
||||
align: 'end',
|
||||
},
|
||||
]
|
||||
|
||||
const confirm = ref()
|
||||
const keepDomainFolder = ref(false)
|
||||
const selectedDomain = ref(null)
|
||||
const selectedDomainAlias = ref(null)
|
||||
const showAdminList = ref(false)
|
||||
const showAliasForm = ref(false)
|
||||
const search = ref('')
|
||||
const selected = ref([])
|
||||
const expanded = ref([])
|
||||
|
||||
function reloadDomains() {
|
||||
domainStore.getDomains()
|
||||
}
|
||||
|
||||
function closeDomainAliasForm() {
|
||||
showAliasForm.value = false
|
||||
selectedDomain.value = null
|
||||
selectedDomainAlias.value = null
|
||||
}
|
||||
|
||||
async function deleteDomain(domain) {
|
||||
const result = await confirm.value.open(
|
||||
$gettext('Warning'),
|
||||
$gettext('Do you really want to delete the domain %{ domain }?', {
|
||||
domain: domain.name,
|
||||
}),
|
||||
{
|
||||
color: 'error',
|
||||
cancelLabel: $gettext('No'),
|
||||
agreeLabel: $gettext('Yes'),
|
||||
}
|
||||
)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
const data = { keep_folder: keepDomainFolder.value }
|
||||
domainStore.deleteDomain({ id: domain.pk, data }).then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Domain deleted') })
|
||||
keepDomainFolder.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function editDomain(domain) {
|
||||
router.push({ name: 'DomainEdit', params: { id: domain.pk } })
|
||||
}
|
||||
|
||||
function editDomainAlias(domain, alias) {
|
||||
selectedDomain.value = domain
|
||||
selectedDomainAlias.value = alias
|
||||
showAliasForm.value = true
|
||||
}
|
||||
|
||||
function loadAliases(item, internalItem, isItemExpanded, toggleExpand) {
|
||||
const isExpand = isItemExpanded(internalItem)
|
||||
if (isExpand) {
|
||||
toggleExpand(internalItem)
|
||||
return
|
||||
}
|
||||
domainStore.getAliases(item.name).then(() => {
|
||||
toggleExpand(internalItem)
|
||||
})
|
||||
}
|
||||
|
||||
function openAdminList(domain) {
|
||||
selectedDomain.value = domain
|
||||
showAdminList.value = true
|
||||
}
|
||||
|
||||
function getDomainMenuItems(domain) {
|
||||
const result = [
|
||||
{
|
||||
label: $gettext('Administrators'),
|
||||
icon: 'mdi-account-supervisor',
|
||||
onClick: openAdminList,
|
||||
},
|
||||
{
|
||||
label: $gettext('Edit'),
|
||||
icon: 'mdi-circle-edit-outline',
|
||||
onClick: editDomain,
|
||||
},
|
||||
{
|
||||
label: $gettext('Delete'),
|
||||
icon: 'mdi-delete-outline',
|
||||
onClick: deleteDomain,
|
||||
color: 'red',
|
||||
},
|
||||
]
|
||||
if (domain.opened_alarms_count) {
|
||||
result.push({
|
||||
label: $gettext('Alarms'),
|
||||
icon: 'mdi-bell',
|
||||
color: 'red',
|
||||
onClick: () => router.push({ name: 'Alarms' }),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (Object.values(domains.value).length < 1) {
|
||||
reloadDomains()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-text-field--outlined :deep(fieldset) {
|
||||
border-color: #bfc5d2;
|
||||
}
|
||||
.v-input--checkbox :deep(.v-label) {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<v-card v-if="domain">
|
||||
<v-card-title>
|
||||
<div class="headline">{{ $gettext('General information') }}</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Creation date') }}</v-col>
|
||||
<v-col cols="6">{{ $date(domain.creation) }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Last modification date') }}</v-col>
|
||||
<v-col cols="6">{{ $date(domain.last_modification) }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Quota') }}</v-col>
|
||||
<v-col v-if="domain.quota === '0'" cols="6">{{
|
||||
$gettext('Unlimited')
|
||||
}}</v-col>
|
||||
<v-col v-else cols="6">{{ domain.quota }} {{ $gettext('MB') }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Default mailbox quota') }}</v-col>
|
||||
<v-col v-if="domain.default_mailbox_quota === '0'" cols="6">{{
|
||||
$gettext('Unlimited')
|
||||
}}</v-col>
|
||||
<v-col v-else cols="6">{{ domain.default_mailbox_quota }} MB</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="domain.message_limit !== undefined">
|
||||
<v-col cols="6">{{ $gettext('Daily sending limit') }}</v-col>
|
||||
<v-col cols="6"
|
||||
>{{ domain.message_limit }} {{ $gettext('messages') }}</v-col
|
||||
>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Mailboxes') }}</v-col>
|
||||
<v-col cols="6">{{ domain.mailbox_count }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Aliases') }}</v-col>
|
||||
<v-col cols="6">{{ domain.mbalias_count }}</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
defineProps({ domain: { type: Object, default: null } })
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<div>
|
||||
<v-switch
|
||||
v-model="domain.enable_dns_checks"
|
||||
:label="$gettext('Enable DNS checks')"
|
||||
color="primary"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="domain.enable_dkim"
|
||||
:label="$gettext('Enable DKIM signing')"
|
||||
color="primary"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="domain.dkim_key_selector"
|
||||
:label="$gettext('DKIM key selector')"
|
||||
:disabled="!domain.enable_dkim"
|
||||
variant="outlined"
|
||||
/>
|
||||
<ChoiceField
|
||||
v-model="domain.dkim_key_length"
|
||||
:label="$gettext('DKIM key length')"
|
||||
:choices="dkimKeyLengths"
|
||||
:disabled="!domain.enable_dkim"
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import ChoiceField from '@/components/tools/ChoiceField'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({ modelValue: { type: Object, default: () => {} } })
|
||||
|
||||
const vFormRef = ref()
|
||||
|
||||
const domain = computed(() => props.modelValue)
|
||||
|
||||
const dkimKeyLengths = ref([
|
||||
{
|
||||
label: '1024',
|
||||
value: 1024,
|
||||
},
|
||||
{
|
||||
label: '2048',
|
||||
value: 2048,
|
||||
},
|
||||
{
|
||||
label: '4096',
|
||||
value: 4096,
|
||||
},
|
||||
])
|
||||
|
||||
defineExpose({ vFormRef })
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<v-text-field
|
||||
v-model="domain.name"
|
||||
:label="$gettext('Domain name (ex: domain.tld)')"
|
||||
:rules="[rules.required]"
|
||||
variant="outlined"
|
||||
class="mb-5"
|
||||
/>
|
||||
|
||||
<ChoiceField
|
||||
v-model="domain.type"
|
||||
:label="$gettext('Type')"
|
||||
:choices="domainTypes"
|
||||
@update:model-value="cleanTransport"
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="domain.enabled"
|
||||
:label="$gettext('Enabled')"
|
||||
:hint="
|
||||
$gettext(
|
||||
'Control if this domain will be allowed to send and receive messages'
|
||||
)
|
||||
"
|
||||
color="primary"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import ChoiceField from '@/components/tools/ChoiceField'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, computed } from 'vue'
|
||||
import rules from '@/plugins/rules.js'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({ modelValue: { type: Object, default: null } })
|
||||
|
||||
const domain = computed(() => props.modelValue)
|
||||
|
||||
const vFormRef = ref()
|
||||
|
||||
function cleanTransport(value) {
|
||||
if (value === 'relaydomain' && domain.value.transport == null) {
|
||||
domain.value.transport = {}
|
||||
}
|
||||
}
|
||||
const domainTypes = [
|
||||
{
|
||||
label: 'Domain',
|
||||
icon: 'mdi-earth',
|
||||
value: $gettext('domain'),
|
||||
},
|
||||
{
|
||||
label: 'Relay domain',
|
||||
icon: 'mdi-earth',
|
||||
value: $gettext('relaydomain'),
|
||||
},
|
||||
]
|
||||
|
||||
defineExpose({ vFormRef })
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<v-text-field
|
||||
v-model="domain.quota"
|
||||
:label="$gettext('Quota')"
|
||||
:hint="
|
||||
$gettext(
|
||||
'Quota shared between mailboxes. Can be expressed in KB, MB (default) or GB. A value of 0 means no quota.'
|
||||
)
|
||||
"
|
||||
persistent-hint
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="domain.default_mailbox_quota"
|
||||
:label="$gettext('Default mailbox quota')"
|
||||
:hint="
|
||||
$gettext(
|
||||
'Default quota applied to mailboxes. Can be expressed in KB, MB (default) or GB. A value of 0 means no quota.'
|
||||
)
|
||||
"
|
||||
persistent-hint
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="domain.message_limit"
|
||||
:label="$gettext('Message sending limit')"
|
||||
:hint="
|
||||
$gettext(
|
||||
'Number of messages this domain can send per day. Leave empty for no limit.'
|
||||
)
|
||||
"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
:rules="[rules.numericOrNull]"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import rules from '@/plugins/rules.js'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({ modelValue: { type: Object, default: null } })
|
||||
|
||||
const vFormRef = ref()
|
||||
|
||||
const domain = computed(() => props.modelValue)
|
||||
|
||||
defineExpose({ vFormRef })
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<v-switch
|
||||
v-model="createAdmin"
|
||||
:label="$gettext('Create a domain administrator')"
|
||||
color="primary"
|
||||
@update:model-value="updateCreateAdmin"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="domain.domain_admin.username"
|
||||
:label="$gettext('Name')"
|
||||
:hint="$gettext('Name of the administrator')"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
:disabled="!createAdmin"
|
||||
:suffix="`@${domain.name}`"
|
||||
:rules="[!createAdmin || rules.required]"
|
||||
/>
|
||||
<AccountPasswordSubForm
|
||||
v-model="domain.domain_admin"
|
||||
:disabled="!createAdmin"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="domain.domain_admin.with_mailbox"
|
||||
:label="$gettext('With a mailbox')"
|
||||
:disabled="!createAdmin"
|
||||
:hint="$gettext('Create a mailbox for the administrator.')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="domain.domain_admin.with_aliases"
|
||||
:label="$gettext('Create aliases')"
|
||||
:disabled="!createAdmin"
|
||||
:hint="$gettext('Create standard aliases for the domain.')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import AccountPasswordSubForm from '@/components/admin/identities/form_steps/AccountPasswordSubForm.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import rules from '@/plugins/rules.js'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({ modelValue: { type: Object, default: null } })
|
||||
const emit = defineEmits(['createAdmin'])
|
||||
|
||||
const vFormRef = ref()
|
||||
|
||||
const createAdmin = ref(false)
|
||||
|
||||
const domain = computed(() => props.modelValue)
|
||||
|
||||
function updateCreateAdmin(val) {
|
||||
emit('createAdmin', val)
|
||||
}
|
||||
|
||||
defineExpose({ vFormRef })
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<label class="m-label">{{ $gettext('Service') }}</label>
|
||||
<v-autocomplete
|
||||
v-model="service"
|
||||
:items="backends"
|
||||
item-title="name"
|
||||
return-object
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
<template v-if="service">
|
||||
<template v-for="(setting, i) in service.settings" :key="i">
|
||||
<template v-if="setting.type === 'str' || setting.type === 'int'">
|
||||
<label class="m-label">{{ setting.label }}</label>
|
||||
<v-text-field
|
||||
v-model="form.settings[`${service.name}_${setting.name}`]"
|
||||
variant="outlined"
|
||||
:type="form.type === 'int' ? 'number' : 'text'"
|
||||
:rules="[!!setting.required || rules.required]"
|
||||
@update:model-value="update"
|
||||
/>
|
||||
</template>
|
||||
<v-checkbox
|
||||
v-else-if="setting.type === 'boolean'"
|
||||
v-model="form.settings[`${service.name}_${setting.name}`]"
|
||||
:label="setting.label"
|
||||
@update:model-value="update"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import transports from '@/api/transports'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import rules from '@/plugins/rules.js'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({ modelValue: { type: Object, default: () => {} } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const vFormRef = ref()
|
||||
const form = ref({})
|
||||
const service = ref()
|
||||
const backends = ref([])
|
||||
|
||||
function checkSettingTypes(data) {
|
||||
for (const setting of service.value.settings) {
|
||||
if (setting.type === 'int') {
|
||||
const fullName = `${service.value.name}_${setting.name}`
|
||||
data.transport.settings[fullName] = parseInt(
|
||||
data.transport.settings[fullName]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
form.value.service = service.value.name
|
||||
emit('update:modelValue', form.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
form.value = { ...props.modelValue }
|
||||
transports.getAll().then((resp) => {
|
||||
backends.value = resp.data
|
||||
if (form.value.service) {
|
||||
service.value = backends.value.find(
|
||||
(backend) => backend.name === form.value.service
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({ checkSettingTypes, service, vFormRef })
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Aliases') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-chip v-for="alias in account.aliases" :key="alias" class="mr-2 mt-2">
|
||||
{{ alias }}
|
||||
</v-chip>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
defineProps({ account: { type: Object, default: null } })
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<CreationForm
|
||||
ref="form"
|
||||
:title="$gettext('New account')"
|
||||
:steps="steps"
|
||||
:form-getter="getForm"
|
||||
:get-v-form-ref="getVFormRef"
|
||||
:validate-object="validateAccount"
|
||||
:summary-sections="summarySections"
|
||||
@close="close"
|
||||
@create="submit"
|
||||
@validation-error="setFormErrors"
|
||||
>
|
||||
<template #[`form.role`]>
|
||||
<AccountRoleForm ref="role" v-model="account" />
|
||||
</template>
|
||||
<template #[`form.identification`]>
|
||||
<AccountGeneralForm ref="identification" v-model="account" />
|
||||
</template>
|
||||
<template #[`form.mailbox`]>
|
||||
<AccountMailboxForm ref="mailbox" v-model="account" />
|
||||
</template>
|
||||
<template #[`form.aliases`]>
|
||||
<AccountAliasForm ref="aliases" v-model="account" />
|
||||
</template>
|
||||
<template #[`item.random_password`]="{ item }">
|
||||
<template v-if="item.value">
|
||||
<v-col cols="12" class="highligth text-white">
|
||||
<v-row>
|
||||
<v-col
|
||||
><span>{{ item.key }}</span></v-col
|
||||
>
|
||||
<v-col v-if="item.type === 'yesno'" class="text-right">
|
||||
{{ $yesno(item.value) }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-right py-1">
|
||||
<v-btn
|
||||
append-icon="mdi-clipboard-multiple-outline"
|
||||
color="white"
|
||||
variant="text"
|
||||
:title="$gettext('Copy to clipboard')"
|
||||
@click="copyPassword"
|
||||
>
|
||||
{{ account.password }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-col
|
||||
><span class="grey--text">{{ item.key }}</span></v-col
|
||||
>
|
||||
<v-col v-if="item.type === 'yesno'" class="text-right">{{
|
||||
$yesno(item.value)
|
||||
}}</v-col>
|
||||
</template>
|
||||
</template>
|
||||
</CreationForm>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import accountsApi from '@/api/accounts'
|
||||
import CreationForm from '@/components/tools/CreationForm.vue'
|
||||
import AccountRoleForm from './form_steps/AccountRoleForm.vue'
|
||||
import AccountGeneralForm from './form_steps/AccountGeneralForm.vue'
|
||||
import AccountMailboxForm from './form_steps/AccountMailboxForm.vue'
|
||||
import AccountAliasForm from './form_steps/AccountAliasForm.vue'
|
||||
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useBusStore, useDomainsStore, useIdentitiesStore } from '@/stores'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const domainsStore = useDomainsStore()
|
||||
const identitiesStore = useIdentitiesStore()
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
const needsMailbox = computed(
|
||||
() =>
|
||||
account.value.role !== 'SuperAdmins' &&
|
||||
account.value.username &&
|
||||
account.value.username.indexOf('@') !== -1
|
||||
)
|
||||
|
||||
const summarySections = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
title: $gettext('Role'),
|
||||
items: [{ key: $gettext('Role'), value: account.value.role }],
|
||||
},
|
||||
{
|
||||
title: $gettext('Identification'),
|
||||
items: [
|
||||
{ key: $gettext('Username'), value: account.value.username },
|
||||
{ key: $gettext('First name'), value: account.value.first_name },
|
||||
{ key: $gettext('Last name'), value: account.value.last_name },
|
||||
{
|
||||
name: 'random_password',
|
||||
key: $gettext('Random password'),
|
||||
value: account.value.random_password,
|
||||
type: 'yesno',
|
||||
},
|
||||
{
|
||||
key: $gettext('Enabled'),
|
||||
value: account.value.is_active,
|
||||
type: 'yesno',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
if (needsMailbox.value) {
|
||||
return result.concat([
|
||||
{
|
||||
title: $gettext('Mailbox'),
|
||||
items: [
|
||||
{ key: $gettext('Email'), value: account.value.mailbox.full_address },
|
||||
{
|
||||
key: $gettext('Quota'),
|
||||
value: account.value.mailbox.use_domain_quota
|
||||
? $gettext('Domain default value')
|
||||
: account.value.mailbox.quota,
|
||||
},
|
||||
{
|
||||
key: $gettext('Message sending limit'),
|
||||
value: account.value.mailbox.message_limit,
|
||||
},
|
||||
{
|
||||
key: $gettext('Send only account'),
|
||||
value: account.value.mailbox.is_send_only,
|
||||
type: 'yesno',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $gettext('Aliases'),
|
||||
items: [
|
||||
{
|
||||
key: $gettext('Aliases'),
|
||||
value: account.value.aliases,
|
||||
type: 'list',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const steps = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
name: 'role',
|
||||
title: $gettext('Role'),
|
||||
},
|
||||
{
|
||||
name: 'identification',
|
||||
title: $gettext('Identification'),
|
||||
},
|
||||
]
|
||||
if (needsMailbox.value) {
|
||||
return result.concat([
|
||||
{
|
||||
name: 'mailbox',
|
||||
title: $gettext('Mailbox'),
|
||||
},
|
||||
{
|
||||
name: 'aliases',
|
||||
title: $gettext('Aliases'),
|
||||
},
|
||||
])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const account = ref({
|
||||
aliases: [],
|
||||
is_active: true,
|
||||
role: 'SimpleUsers',
|
||||
mailbox: {
|
||||
use_domain_quota: true,
|
||||
},
|
||||
random_password: false,
|
||||
})
|
||||
|
||||
// Form refs
|
||||
const role = ref()
|
||||
const identification = ref()
|
||||
const mailbox = ref()
|
||||
const aliases = ref()
|
||||
|
||||
const formStepsComponents = {
|
||||
role: role,
|
||||
identification: identification,
|
||||
mailbox: mailbox,
|
||||
aliases: aliases,
|
||||
}
|
||||
|
||||
function copyPassword() {
|
||||
navigator.clipboard.writeText(account.value.password).then(() => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Password copied to clipboard'),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getForm(step) {
|
||||
return formStepsComponents.step[step.name]
|
||||
}
|
||||
|
||||
function getVFormRef(step) {
|
||||
return formStepsComponents[step.name].value.vFormRef
|
||||
}
|
||||
|
||||
function setFormErrors(step, errors) {
|
||||
const stepName = steps.value[step - 1].name
|
||||
if (formStepsComponents[stepName].value.formErrors) {
|
||||
formStepsComponents[stepName].value.formErrors.value = errors
|
||||
}
|
||||
}
|
||||
|
||||
function preparePayload(payload) {
|
||||
const cleaned_payload = { ...payload }
|
||||
if (
|
||||
cleaned_payload.role === 'SuperAdmins' &&
|
||||
cleaned_payload.username &&
|
||||
cleaned_payload.username.indexOf('@') === -1
|
||||
) {
|
||||
delete cleaned_payload.mailbox
|
||||
}
|
||||
if (cleaned_payload.mailbox && cleaned_payload.mailbox.message_limit === '') {
|
||||
cleaned_payload.mailbox.message_limit = null
|
||||
}
|
||||
return cleaned_payload
|
||||
}
|
||||
|
||||
function validateAccount() {
|
||||
const payload = preparePayload({ ...account.value })
|
||||
return accountsApi.validate(payload)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const data = preparePayload({ ...account.value })
|
||||
identitiesStore.createIdentity('account', data).then(() => {
|
||||
emit('created')
|
||||
close()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
domainsStore.getDomains()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.highligth {
|
||||
background-color: #515d78;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<LoadingData v-if="!accountsStore.accountsLoaded || working" />
|
||||
<div v-else>
|
||||
<v-expansion-panels v-model="panel" :multiple="formErrors">
|
||||
<v-expansion-panel eager value="roleForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Role') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-medium-emphasis">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-col cols="6">
|
||||
{{ roleForm.roleLabel }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<AccountRoleForm ref="roleForm" v-model="editedAccount" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel eager value="generalForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Identification') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-secondary">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Username:') }} {{ editedAccount.username }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Enabled') }}
|
||||
<v-icon v-if="editedAccount.is_active" color="success"
|
||||
>mdi-check-circle-outline</v-icon
|
||||
>
|
||||
<v-icon v-else>mdi-close-circle-outline</v-icon>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<AccountGeneralForm
|
||||
ref="generalForm"
|
||||
v-model="editedAccount"
|
||||
editing
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="usernameIsEmail" eager value="mailboxForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Mailbox') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-secondary">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<template v-if="editedAccount.mailbox">
|
||||
<v-col
|
||||
v-if="editedAccount.mailbox.use_domain_quota"
|
||||
cols="6"
|
||||
>
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Quota: ') }}
|
||||
{{ $gettext("domain's default value") }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col v-else cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Quota: ') }}
|
||||
{{ editedAccount.mailbox.quota }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Send only') }}
|
||||
<v-icon
|
||||
v-if="editedAccount.mailbox.is_send_only"
|
||||
color="success"
|
||||
>mdi-check-circle-outline</v-icon
|
||||
>
|
||||
<v-icon v-else>mdi-close-circle-outline</v-icon>
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<AccountMailboxForm ref="mailboxForm" v-model="editedAccount" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="usernameIsEmail" eager value="aliasForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Alias') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-secondary">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<template v-if="editedAccount.aliases">
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Aliases:') }}
|
||||
{{ $gettext('Number of associated alias:') }}
|
||||
{{ editedAccount.aliases.length }}
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<AccountAliasForm ref="aliasForm" v-model="editedAccount" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="needsResources" eager value="resourcesForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Resources') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-secondary">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<template
|
||||
v-if="
|
||||
editedAccount.resources != undefined &&
|
||||
editedAccount.resources.length == 2
|
||||
"
|
||||
>
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Mailbox:') }}
|
||||
{{ $gettext('Number of allowed mailboxes:') }}
|
||||
{{ editedAccount.resources[0].max_value }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">
|
||||
{{ $gettext('Mailbox aliases:') }}
|
||||
{{ $gettext('Number of allowed mailbox aliases:') }}
|
||||
{{ editedAccount.resources[1].max_value }}
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<ResourcesForm
|
||||
ref="resourcesForm"
|
||||
v-model="editedAccount.resources"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
<div class="mt-4 d-flex justify-end">
|
||||
<v-btn :loading="working" @click="$router.go(-1)">
|
||||
{{ $gettext('Cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-4"
|
||||
color="primary darken-1"
|
||||
:loading="working"
|
||||
@click="save"
|
||||
>
|
||||
{{ $gettext('Save') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useAccountsStore, useIdentitiesStore } from '@/stores'
|
||||
import AccountGeneralForm from './form_steps/AccountGeneralForm.vue'
|
||||
import AccountMailboxForm from './form_steps/AccountMailboxForm.vue'
|
||||
import AccountAliasForm from './form_steps/AccountAliasForm.vue'
|
||||
import AccountRoleForm from './form_steps/AccountRoleForm.vue'
|
||||
import ResourcesForm from '@/components/tools/ResourcesForm.vue'
|
||||
import LoadingData from '@/components/tools/LoadingData.vue'
|
||||
import parametersApi from '@/api/parameters'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const accountsStore = useAccountsStore()
|
||||
const identitiesStore = useIdentitiesStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const editedAccount = ref({ pk: route.params.id })
|
||||
|
||||
const usernameIsEmail = computed(() => {
|
||||
return (
|
||||
editedAccount.value.username &&
|
||||
editedAccount.value.role !== 'SuperAdmins' &&
|
||||
editedAccount.value.username.indexOf('@') !== -1
|
||||
)
|
||||
})
|
||||
|
||||
const needsResources = computed(() => {
|
||||
const isNeeded =
|
||||
limitsConfig.value.params &&
|
||||
limitsConfig.value.params.enable_admin_limits &&
|
||||
editedAccount.value.role !== 'SimpleUsers' &&
|
||||
editedAccount.value.role !== 'SuperAdmins'
|
||||
if (isNeeded === undefined) {
|
||||
return false
|
||||
}
|
||||
return isNeeded
|
||||
})
|
||||
|
||||
const limitsConfig = ref({})
|
||||
const panel = ref(0)
|
||||
const working = ref(false)
|
||||
const formErrors = ref(false)
|
||||
|
||||
//refs
|
||||
const roleForm = ref()
|
||||
const generalForm = ref()
|
||||
const mailboxForm = ref()
|
||||
const aliasForm = ref()
|
||||
const resourcesForm = ref()
|
||||
|
||||
//formMap
|
||||
const formMap = computed(() => {
|
||||
const map = {
|
||||
roleForm: roleForm,
|
||||
generalForm: generalForm,
|
||||
}
|
||||
if (usernameIsEmail.value) {
|
||||
map.aliasForm = aliasForm
|
||||
map.mailboxForm = mailboxForm
|
||||
}
|
||||
if (needsResources.value) {
|
||||
map.resourcesForm = resourcesForm
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
async function save() {
|
||||
formErrors.value = false
|
||||
panel.value = []
|
||||
for (const [panelName, formRef] of Object.entries(formMap.value)) {
|
||||
if (formRef.value != null) {
|
||||
const { valid } = await formRef.value.vFormRef.validate()
|
||||
if (!valid) {
|
||||
formErrors.value = true
|
||||
panel.value.push(panelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (formErrors.value) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
try {
|
||||
const data = { ...editedAccount.value }
|
||||
if (usernameIsEmail.value) {
|
||||
data.mailbox.full_address = data.username
|
||||
} else {
|
||||
delete data.mailbox.full_address
|
||||
}
|
||||
if (data.random_password || !data.password) {
|
||||
delete data.password
|
||||
delete data.password_confirmation
|
||||
}
|
||||
if (needsResources.value && resourcesForm.value !== null) {
|
||||
data.resources = resourcesForm.value.getPayload()
|
||||
}
|
||||
if (data.aliases === null) {
|
||||
delete data.aliases
|
||||
}
|
||||
identitiesStore.updateIdentity('account', data).then(() => {
|
||||
router.push({ name: 'AccountDetail', params: { id: route.params.id } })
|
||||
})
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
accountsStore
|
||||
.getAccount(route.params.id)
|
||||
.then((response) => (editedAccount.value = { ...response.data }))
|
||||
|
||||
parametersApi.getApplication('limits').then((response) => {
|
||||
limitsConfig.value = response.data
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
editedAccount,
|
||||
() => {
|
||||
delete editedAccount.value.domains
|
||||
if (editedAccount.value.mailbox === null) {
|
||||
delete editedAccount.value.mailbox
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
Ac
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Sender addresses') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="addresses"
|
||||
:no-data-text="$gettext('No sender address defined for this account')"
|
||||
>
|
||||
<template #[`item.address`]="{ item }">
|
||||
<span v-if="item.pk">{{ item.address }}</span>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="newAddress"
|
||||
:label="$gettext('Specify an email address')"
|
||||
>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="newAddress"
|
||||
icon="mdi-content-save"
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="submit"
|
||||
>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<v-btn
|
||||
v-if="item.pk"
|
||||
icon="mdi-delete-outline"
|
||||
color="red"
|
||||
:title="$gettext('Remove this address')"
|
||||
variant="text"
|
||||
@click="deleteSenderAddress(item)"
|
||||
>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #bottom></template>
|
||||
</v-data-table>
|
||||
<v-btn
|
||||
v-if="!hideAddBtn"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="addRow"
|
||||
>
|
||||
<v-icon size="small" icon="mdi-plus"></v-icon>
|
||||
{{ $gettext('Add sender address') }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
// import { bus } from '@/main'
|
||||
import { ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import senderAddresses from '@/api/senderAddresses'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const props = defineProps({ account: { type: Object, default: null } })
|
||||
|
||||
const addresses = ref([])
|
||||
const hideAddBtn = ref(false)
|
||||
const newAddress = ref('')
|
||||
|
||||
const headers = [
|
||||
{ title: $gettext('Address'), key: 'address' },
|
||||
{ title: '', key: 'actions', align: 'end', sortable: false },
|
||||
]
|
||||
|
||||
function addRow() {
|
||||
addresses.value.push({})
|
||||
hideAddBtn.value = true
|
||||
}
|
||||
|
||||
function fetchSenderAddresses() {
|
||||
senderAddresses.getAllForMailbox(props.account.mailbox.pk).then((resp) => {
|
||||
addresses.value = resp.data
|
||||
})
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const data = {
|
||||
address: newAddress.value,
|
||||
mailbox: props.account.mailbox.pk,
|
||||
}
|
||||
senderAddresses.create(data).then(() => {
|
||||
fetchSenderAddresses()
|
||||
newAddress.value = null
|
||||
hideAddBtn.value = false
|
||||
// bus.$emit('notification', { msg: this.$gettext('Sender address added') })
|
||||
})
|
||||
}
|
||||
|
||||
function deleteSenderAddress(senderAddress) {
|
||||
senderAddresses.delete(senderAddress.pk).then(() => {
|
||||
fetchSenderAddresses()
|
||||
// bus.$emit('notification', { msg: this.$gettext('Sender address deleted') })
|
||||
})
|
||||
}
|
||||
|
||||
fetchSenderAddresses()
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('General information') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Creation date') }}</v-col>
|
||||
<v-col cols="6">{{ $date(account.date_joined) }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Last login date') }}</v-col>
|
||||
<v-col v-if="account.last_login" cols="6">
|
||||
{{ $date(account.last_login) }}
|
||||
</v-col>
|
||||
<v-col v-else cols="6">{{ $gettext('Never logged-in') }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Full name') }}</v-col>
|
||||
<v-col cols="6">{{ account.first_name }} {{ account.last_name }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Role') }} </v-col>
|
||||
<v-col cols="6">{{ account.role }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Secondary email') }}</v-col>
|
||||
<v-col cols="6">{{ account.secondary_email }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Phone number') }}</v-col>
|
||||
<v-col cols="6">{{ account.phone_number }}</v-col>
|
||||
</v-row>
|
||||
<template v-if="account.mailbox">
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Quota') }} </v-col>
|
||||
<v-col v-if="account.mailbox.quota === '0'" cols="6">
|
||||
{{ $gettext('Unlimited') }}
|
||||
</v-col>
|
||||
<v-col v-else cols="6">
|
||||
<v-progress-linear
|
||||
:color="account.mailbox.quota_usage < 80 ? 'primary' : 'warning'"
|
||||
:value="account.mailbox.quota_usage"
|
||||
height="25"
|
||||
class="white--text"
|
||||
>
|
||||
<template #default="{ value }">
|
||||
{{ Math.ceil(value) }}% ({{ account.mailbox.quota }}
|
||||
{{ $gettext('MB') }})
|
||||
</template>
|
||||
</v-progress-linear>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="account.mailbox.message_limit">
|
||||
<v-col cols="6">{{ $gettext('Daily sending limit') }}</v-col>
|
||||
<v-col cols="6">{{ account.mailbox.message_limit }}</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
defineProps({ account: { type: Object, default: null } })
|
||||
</script>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<CreationForm
|
||||
ref="form"
|
||||
:title="$gettext('New alias')"
|
||||
:steps="steps"
|
||||
:form-getter="getForm"
|
||||
:get-v-form-ref="getVFormRef"
|
||||
:validate-object="validateAlias"
|
||||
:summary-sections="summarySections"
|
||||
@close="close"
|
||||
@create="submit"
|
||||
>
|
||||
<template #[`form.general`]>
|
||||
<AliasGeneralForm ref="general" v-model="alias" />
|
||||
</template>
|
||||
<template #[`form.recipients`]>
|
||||
<AliasRecipientForm ref="recipients" v-model="alias.recipients" />
|
||||
</template>
|
||||
</CreationForm>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useBusStore, useIdentitiesStore } from '@/stores'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import aliasesApi from '@/api/aliases'
|
||||
|
||||
import AliasGeneralForm from './form_steps/AliasGeneralForm.vue'
|
||||
import AliasRecipientForm from './form_steps/AliasRecipientForm.vue'
|
||||
import CreationForm from '@/components/tools/CreationForm.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const identitiesStore = useIdentitiesStore()
|
||||
const router = useRouter()
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
||||
//Forms refs
|
||||
const general = ref()
|
||||
const recipients = ref()
|
||||
|
||||
const formStepsComponenents = {
|
||||
general: general,
|
||||
recipients: recipients,
|
||||
}
|
||||
|
||||
const summarySections = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
title: $gettext('General'),
|
||||
items: [
|
||||
{
|
||||
key: $gettext('Random address'),
|
||||
value: alias.value.random,
|
||||
type: 'yesno',
|
||||
},
|
||||
{ key: $gettext('Address'), value: alias.value.address },
|
||||
],
|
||||
},
|
||||
]
|
||||
if (alias.value.expire_at !== undefined) {
|
||||
result[0].items.push({
|
||||
key: $gettext('Expire at'),
|
||||
value: alias.value.expire_at,
|
||||
})
|
||||
}
|
||||
if (alias.value.description !== undefined) {
|
||||
result[0].items.push({
|
||||
key: $gettext('Description'),
|
||||
value: alias.value.description,
|
||||
})
|
||||
}
|
||||
result.push({
|
||||
title: $gettext('Recipients'),
|
||||
items: [
|
||||
{
|
||||
key: $gettext('Recipients'),
|
||||
value: alias.value.recipients.join(', '),
|
||||
},
|
||||
],
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const alias = ref(getInitialForm())
|
||||
|
||||
const steps = ref([
|
||||
{
|
||||
name: 'general',
|
||||
title: $gettext('General'),
|
||||
},
|
||||
{
|
||||
name: 'recipients',
|
||||
title: $gettext('Recipients'),
|
||||
},
|
||||
])
|
||||
|
||||
function close() {
|
||||
alias.value = getInitialForm()
|
||||
emit('close')
|
||||
}
|
||||
function getInitialForm() {
|
||||
return {
|
||||
random: false,
|
||||
enabled: true,
|
||||
recipients: [],
|
||||
}
|
||||
}
|
||||
|
||||
function getForm(step) {
|
||||
return formStepsComponenents.step[step.name]
|
||||
}
|
||||
|
||||
function getVFormRef(step) {
|
||||
return formStepsComponenents[step.name].value.vFormRef
|
||||
}
|
||||
|
||||
function validateAlias() {
|
||||
const data = { ...alias.value }
|
||||
if (data.recipients.length === 0) {
|
||||
delete data.recipients
|
||||
}
|
||||
const validation = aliasesApi.validate(data)
|
||||
validation.catch((error) => {
|
||||
if (error.response.status === 409 && error.response.data.id !== undefined) {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Alias already exists, redirecting to edit page'),
|
||||
type: 'warning',
|
||||
})
|
||||
router.push({
|
||||
name: 'AliasEdit',
|
||||
params: { id: error.response.data.id },
|
||||
})
|
||||
}
|
||||
})
|
||||
return validation
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (alias.value.domain !== undefined) {
|
||||
delete alias.value.domain
|
||||
}
|
||||
identitiesStore.createIdentity('alias', alias.value).then(() => {
|
||||
emit('created')
|
||||
close()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<LoadingData v-if="!aliasesStore.aliasesLoaded || working" />
|
||||
<div v-else>
|
||||
<v-expansion-panels v-model="panel" :multiple="formErrors">
|
||||
<v-expansion-panel eager value="generalForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('General') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-medium-emphasis">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-col cols="6">
|
||||
{{ $gettext('Address') }}: {{ editedAlias.address }}
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="mr-2">{{ $gettext('Enabled') }}</div>
|
||||
<v-icon v-if="editedAlias.enabled" color="success"
|
||||
>mdi-check-circle-outline</v-icon
|
||||
>
|
||||
<v-icon v-else>mdi-close-circle-outline</v-icon>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<AliasGeneralForm ref="generalForm" v-model="editedAlias" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel eager value="recipientForm">
|
||||
<v-expansion-panel-title v-slot="{ expanded }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
{{ $gettext('Recipients') }}
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-medium-emphasis">
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="expanded"></span>
|
||||
<v-row v-else no-gutters style="width: 100%">
|
||||
<v-chip
|
||||
v-for="(rcpt, index) in editedAlias.recipients"
|
||||
:key="index"
|
||||
size="small"
|
||||
>
|
||||
{{ rcpt }}
|
||||
</v-chip>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<AliasRecipientForm
|
||||
ref="recipientForm"
|
||||
v-model="editedAlias.recipients"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
<div class="mt-4 d-flex justify-end">
|
||||
<v-btn :loading="working" @click="$router.go(-1)">
|
||||
{{ $gettext('Cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-4"
|
||||
color="primary darken-1"
|
||||
:loading="working"
|
||||
@click="save"
|
||||
>
|
||||
{{ $gettext('Save') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useIdentitiesStore, useAliasesStore } from '@/stores'
|
||||
import AliasGeneralForm from './form_steps/AliasGeneralForm.vue'
|
||||
import AliasRecipientForm from './form_steps/AliasRecipientForm'
|
||||
import LoadingData from '@/components/tools/LoadingData.vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const identitiesStore = useIdentitiesStore()
|
||||
const aliasesStore = useAliasesStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const editedAlias = ref({ pk: route.params.id })
|
||||
|
||||
const panel = ref(0)
|
||||
const working = ref(false)
|
||||
const formErrors = ref(false)
|
||||
|
||||
// refs
|
||||
const generalForm = ref()
|
||||
const recipientForm = ref()
|
||||
|
||||
//formMap
|
||||
const formMap = { generalForm: generalForm, recipientForm: recipientForm }
|
||||
|
||||
async function save() {
|
||||
formErrors.value = false
|
||||
panel.value = []
|
||||
for (const [panelName, formRef] of Object.entries(formMap)) {
|
||||
if (formRef.value != null) {
|
||||
const { valid } = await formRef.value.vFormRef.validate()
|
||||
if (!valid) {
|
||||
formErrors.value = true
|
||||
panel.value.push(panelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (formErrors.value) {
|
||||
return
|
||||
}
|
||||
|
||||
working.value = true
|
||||
|
||||
identitiesStore
|
||||
.updateIdentity('alias', editedAlias.value)
|
||||
.then(() =>
|
||||
router.push({ name: 'AliasDetail', params: { id: route.params.id } })
|
||||
)
|
||||
.finally(() => (working.value = false))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
aliasesStore.getAlias(route.params.id).then((response) => {
|
||||
editedAlias.value = { ...response.data }
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<div class="headline">{{ $gettext('Recipients') }}</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p v-for="recipient in alias.recipients" :key="recipient">
|
||||
{{ recipient }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
defineProps({ alias: { type: Object, default: null } })
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<div class="headline">{{ $gettext('General information') }}</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Creation date') }}</v-col>
|
||||
<v-col cols="6">{{ $date(alias.creation) }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6">{{ $gettext('Modified') }}</v-col>
|
||||
<v-col cols="6">{{ $date(alias.last_modification) }}</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="alias.expiry">
|
||||
<v-col cols="6">{{ $gettext('Expire at') }}</v-col>
|
||||
<v-col cols="6"></v-col>
|
||||
</v-row>
|
||||
<v-row v-if="alias.description">
|
||||
<v-col cols="6">{{ $gettext('Description') }}</v-col>
|
||||
<v-col cols="6">{{ alias.description }}</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
defineProps({ alias: { type: Object, default: null } })
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Administered domains') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="account.domains !== undefined && account.domains.length">
|
||||
<v-row v-for="domain in account.domains" :key="domain">
|
||||
<v-col cols="12">{{ domain }}</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
<div v-else>
|
||||
{{ $gettext('No domain managed') }}
|
||||
</div>
|
||||
<div class="text-disabled">
|
||||
{{ $gettext('Add/Remove from the domain page') }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
defineProps({ account: { type: Object, default: null } })
|
||||
</script>
|
||||
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<v-card class="mt-6">
|
||||
<v-data-table
|
||||
v-model="selected"
|
||||
:headers="headers"
|
||||
:items="identities"
|
||||
:search="search"
|
||||
:loading="!identitiesLoaded"
|
||||
item-value="identity"
|
||||
class="elevation-0"
|
||||
show-select
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar flat color="white">
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
append-icon="mdi-chevron-down"
|
||||
v-bind="props"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ $gettext('Actions') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<MenuItems :items="getActionMenuItems()" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
variant="text"
|
||||
icon="mdi-reload"
|
||||
@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 }">
|
||||
<template v-if="item.type === 'account'">
|
||||
<router-link :to="{ name: 'AccountDetail', params: { id: item.pk } }">
|
||||
{{ item.identity }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link :to="{ name: 'AliasDetail', params: { id: item.pk } }">
|
||||
{{ item.identity }}
|
||||
</router-link>
|
||||
</template>
|
||||
</template>
|
||||
<template #[`item.tags`]="{ item }">
|
||||
<v-chip
|
||||
v-for="(tag, index) in item.tags"
|
||||
:key="tag.name"
|
||||
:color="tag.type !== 'idt' ? 'primary' : 'default'"
|
||||
:class="index > 0 ? 'ml-2' : ''"
|
||||
size="small"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<template
|
||||
v-if="
|
||||
item.possible_actions !== undefined &&
|
||||
item.possible_actions.length !== 0
|
||||
"
|
||||
>
|
||||
<v-icon size="2.5em" color="blue">mdi-circle-small</v-icon>
|
||||
</template>
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
size="small"
|
||||
icon="mdi-dots-horizontal"
|
||||
variant="text"
|
||||
>
|
||||
</v-btn>
|
||||
</template>
|
||||
<MenuItems :items="getMenuItems(item)" :obj="item" />
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-data-table>
|
||||
<ConfirmDialog ref="confirmAlias" />
|
||||
<ConfirmDialog ref="confirmAccount">
|
||||
<v-checkbox
|
||||
v-model="keepAccountFolder"
|
||||
:label="$gettext('Do not delete account folder')"
|
||||
hide-details
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useIdentitiesStore } from '@/stores'
|
||||
import MenuItems from '@/components/tools/MenuItems.vue'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const identitiesStore = useIdentitiesStore()
|
||||
const router = useRouter()
|
||||
|
||||
const headers = ref([
|
||||
{ title: $gettext('Name'), key: 'identity' },
|
||||
{ title: $gettext('Fullname/recipient'), key: 'name_or_rcpt' },
|
||||
{ title: $gettext('Tags'), key: 'tags' },
|
||||
{
|
||||
title: $gettext('Actions'),
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
align: 'end',
|
||||
},
|
||||
])
|
||||
|
||||
const identities = computed(() => identitiesStore.identities)
|
||||
const identitiesLoaded = computed(() => identitiesStore.identitiesLoaded)
|
||||
const search = ref('')
|
||||
const selected = ref([])
|
||||
const keepAccountFolder = ref(false)
|
||||
const confirmAlias = ref()
|
||||
const confirmAccount = ref()
|
||||
|
||||
function fetchIdentities() {
|
||||
identitiesStore.getIdentities().then(() => {
|
||||
selected.value = []
|
||||
})
|
||||
}
|
||||
|
||||
function getMenuItems(item) {
|
||||
const result = []
|
||||
if (item.type === 'account') {
|
||||
item.possible_actions.forEach((element) => {
|
||||
result.push({
|
||||
label: element.label,
|
||||
icon: element.icon,
|
||||
onClick: () => {
|
||||
if (element.type === 'download') {
|
||||
//TODO : downloadFile(element)
|
||||
}
|
||||
fetchIdentities()
|
||||
},
|
||||
})
|
||||
})
|
||||
result.push({
|
||||
label: $gettext('Edit'),
|
||||
icon: 'mdi-circle-edit-outline',
|
||||
onClick: editAccount,
|
||||
})
|
||||
result.push({
|
||||
label: $gettext('Delete'),
|
||||
icon: 'mdi-delete-outline',
|
||||
onClick: deleteAccount,
|
||||
color: 'red',
|
||||
})
|
||||
} else if (item.type === 'alias') {
|
||||
result.push({
|
||||
label: $gettext('Edit'),
|
||||
icon: 'mdi-circle-edit-outline',
|
||||
onClick: editAlias,
|
||||
})
|
||||
result.push({
|
||||
label: $gettext('Delete'),
|
||||
icon: 'mdi-delete-outline',
|
||||
onClick: deleteAlias,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getActionMenuItems() {
|
||||
const result = []
|
||||
if (selected.value.length > 0) {
|
||||
result.push({
|
||||
label: $gettext('Delete'),
|
||||
icon: 'mdi-delete-outline',
|
||||
onClick: deleteIdentities,
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function editAccount(account) {
|
||||
router.push({ name: 'AccountEdit', params: { id: account.pk } })
|
||||
}
|
||||
|
||||
async function deleteAccount(account) {
|
||||
const result = await confirmAccount.value.open(
|
||||
$gettext('Warning'),
|
||||
$gettext('Are you sure you want to delete this identity ?'),
|
||||
{
|
||||
color: 'error',
|
||||
agreeLabel: $gettext('Yes'),
|
||||
cancelLabel: $gettext('No'),
|
||||
}
|
||||
)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
identitiesStore
|
||||
.deleteIdentity(account.type, account.pk, {
|
||||
keepdir: keepAccountFolder.value,
|
||||
})
|
||||
.then(() => {
|
||||
keepAccountFolder.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function editAlias() {}
|
||||
|
||||
function deleteIdentities() {}
|
||||
|
||||
async function deleteAlias(alias) {
|
||||
const result = await confirmAlias.value.open(
|
||||
$gettext('Warning'),
|
||||
$gettext('Are you sure you want to delete this alias?'),
|
||||
{
|
||||
color: 'error',
|
||||
agreeLabel: $gettext('Yes'),
|
||||
cancelLabel: $gettext('No'),
|
||||
}
|
||||
)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
identitiesStore.deleteIdentity(alias.type, alias.pk)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (identities.value.length < 1) {
|
||||
fetchIdentities()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<EmailField
|
||||
ref="aliasField"
|
||||
v-model="currentAlias"
|
||||
:placeholder="$gettext('Start typing a name here...')"
|
||||
:hint="
|
||||
$gettext(
|
||||
'Alias(es) of this mailbox. To create a catchall alias, just enter the domain name (@domain.tld).'
|
||||
)
|
||||
"
|
||||
persistent-hint
|
||||
:loading="isValidating"
|
||||
:error-msg="errors"
|
||||
@domain-selected="addAlias"
|
||||
/>
|
||||
<v-chip
|
||||
v-for="(alias, index) in account.aliases"
|
||||
:key="index"
|
||||
class="mr-2 mt-2"
|
||||
closable
|
||||
@click:close="removeAlias(index)"
|
||||
>
|
||||
{{ alias }}
|
||||
</v-chip>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import accountsApi from '@/api/accounts'
|
||||
import EmailField from '@/components/tools/EmailField'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const props = defineProps({ modelValue: { type: Object, default: null } })
|
||||
|
||||
const account = computed(() => props.modelValue)
|
||||
|
||||
const currentAlias = ref('')
|
||||
const aliasField = ref()
|
||||
const vFormRef = ref()
|
||||
const isValidating = ref(false)
|
||||
const errors = ref([])
|
||||
|
||||
async function addAlias() {
|
||||
errors.value = []
|
||||
isValidating.value = true
|
||||
if (account.value.aliases.indexOf(currentAlias.value) !== -1) {
|
||||
errors.value = [$gettext('Alias already added')]
|
||||
currentAlias.value = ''
|
||||
isValidating.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await accountsApi.validate({
|
||||
aliases: [currentAlias.value],
|
||||
mailbox: account.value.mailbox,
|
||||
})
|
||||
account.value.aliases.push(currentAlias.value)
|
||||
currentAlias.value = ''
|
||||
vFormRef.value.resetValidation()
|
||||
} catch (error) {
|
||||
let errorMsg = null
|
||||
if (error.response.data.aliases) {
|
||||
errorMsg = error.response.data.aliases
|
||||
} else if (error.response.data.non_field_errors) {
|
||||
errorMsg = error.response.data.non_field_errors
|
||||
}
|
||||
//TODO : v-alert !!
|
||||
} finally {
|
||||
isValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeAlias(index) {
|
||||
account.value.aliases.splice(index, 1)
|
||||
}
|
||||
|
||||
defineExpose({ vFormRef })
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<label class="m-label">{{ $gettext('Username') }}</label>
|
||||
<EmailField
|
||||
ref="username"
|
||||
v-model="account.username"
|
||||
:placeholder="usernamePlaceholder"
|
||||
:type="usernameInputType"
|
||||
:rules="
|
||||
usernameInputType === 'email'
|
||||
? [rules.required, rules.email]
|
||||
: [rules.required]
|
||||
"
|
||||
:role="account.role"
|
||||
:error-msg="formErrors.value ? formErrors.value.username : []"
|
||||
@update:model-value="updateUsername"
|
||||
/>
|
||||
<label class="m-label">{{ $gettext('First name') }}</label>
|
||||
<v-text-field
|
||||
v-model="account.first_name"
|
||||
autocomplete="new-password"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<label class="m-label">{{ $gettext('Last name') }}</label>
|
||||
<v-text-field
|
||||
v-model="account.last_name"
|
||||
autocomplete="new-password"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
|
||||
<AccountPasswordSubForm
|
||||
ref="passwordForm"
|
||||
v-model="account"
|
||||
:editing="editing"
|
||||
:form-errors="formErrors"
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="account.is_active"
|
||||
:label="$gettext('Enabled')"
|
||||
density="compact"
|
||||
color="primary"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import AccountPasswordSubForm from './AccountPasswordSubForm.vue'
|
||||
import EmailField from '@/components/tools/EmailField.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: null },
|
||||
editing: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const vFormRef = ref()
|
||||
const formErrors = ref({})
|
||||
|
||||
const account = computed(() => props.modelValue)
|
||||
|
||||
const usernamePlaceholder = computed(() => {
|
||||
if (account.value.role === 'SimpleUsers') {
|
||||
return $gettext('Enter an email address')
|
||||
}
|
||||
return $gettext('Enter a simple username or an email address')
|
||||
})
|
||||
|
||||
const usernameInputType = computed(() => {
|
||||
return account.value.role === 'SimpleUsers' ? 'email' : 'text'
|
||||
})
|
||||
|
||||
function updateUsername() {
|
||||
if (
|
||||
account.value.role != 'SuperAdmins' &&
|
||||
account.value.username.indexOf('@') !== -1
|
||||
) {
|
||||
account.value.mailbox.full_address = account.value.username
|
||||
account.value.mailbox.message_limit = null
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ vFormRef, formErrors })
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<v-form ref="vFormRef">
|
||||
<label class="m-label">{{ $gettext('Quota') }}</label>
|
||||
<v-switch
|
||||
v-model="form.mailbox.use_domain_quota"
|
||||
:label="useDomainQuotaLabel"
|
||||
color="primary"
|
||||
@update:model-value="cleanQuota"
|
||||
/>
|
||||
<v-text-field
|
||||
v-if="!form.mailbox.use_domain_quota"
|
||||
v-model="form.mailbox.quota"
|
||||
:placeholder="$gettext('Ex: 10MB. Leave empty for no limit')"
|
||||
:hint="
|
||||
$gettext(
|
||||
'Quota for this mailbox, can be expressed in KB, MB (default) or GB. Define a custom value or use domain\'s default one. Leave empty to define an unlimited value (not allowed for domain administrators).'
|
||||
)
|
||||
"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<label class="m-label">{{ $gettext('Daily message sending limit') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.mailbox.message_limit"
|
||||
:placeholder="$gettext('Leave empty for no limit')"
|
||||
:hint="
|
||||
$gettext(
|
||||
'Number of messages this mailbox can send per day. Leave empty for no limit.'
|
||||
)
|
||||
"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.numericOrNull]"
|
||||
@update:model-value="cleanMessageLimit"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="form.mailbox.is_send_only"
|
||||
:label="$gettext('Send only account')"
|
||||
density="compact"
|
||||
color="primary"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useDomainsStore } from '@/stores'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const props = defineProps({ modelValue: { type: Object, default: null } })
|
||||
const domainsStore = useDomainsStore()
|
||||
|
||||
const vFormRef = ref()
|
||||
const form = ref({
|
||||
mailbox: {},
|
||||
})
|
||||
|
||||
watch(
|
||||
props.modelValue,
|
||||
(value) => {
|
||||
if (value) {
|
||||
form.value = { ...value }
|
||||
if (form.value.role === 'SimpleUsers') {
|
||||
if (!form.value.mailbox) {
|
||||
form.value.mailbox = {}
|
||||
}
|
||||
form.value.mailbox.full_address = form.value.username
|
||||
}
|
||||
if (form.value.mailbox.message_limit === '') {
|
||||
form.value.mailbox.message_limit = null
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
mailbox: {},
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
form,
|
||||
(value) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const domainQuota = computed(() => {
|
||||
const email = form.value.mailbox.full_address
|
||||
if (email && email.indexOf('@') !== -1) {
|
||||
const domain = domainsStore.getDomainByName(email.split('@')[1])
|
||||
if (domain) {
|
||||
return parseInt(domain.default_mailbox_quota)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const useDomainQuotaLabel = computed(() => {
|
||||
let result = $gettext('Use domain default value')
|
||||
if (domainQuota.value !== undefined) {
|
||||
if (domainQuota.value === 0) {
|
||||
result += ` (${$gettext('unlimited')})`
|
||||
} else {
|
||||
result += ` (${domainQuota.value} MB)`
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function cleanQuota(value) {
|
||||
if (value) {
|
||||
delete form.value.mailbox.quota
|
||||
}
|
||||
}
|
||||
|
||||
function cleanMessageLimit(value) {
|
||||
if (value === '') {
|
||||
delete form.value.mailbox.message_limit
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ vFormRef })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.quota {
|
||||
max-width: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="withPasswordCheck">
|
||||
<label class="m-label">{{ $gettext('Current password') }}</label>
|
||||
<v-text-field
|
||||
v-model="account.currentPassword"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
density="compact"
|
||||
validate-on="submit"
|
||||
:rules="disabled ? [] : [rules.required]"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
<v-row justify="center">
|
||||
<v-col cols="auto">
|
||||
<v-switch
|
||||
v-model="account.random_password"
|
||||
:label="$gettext('Random password')"
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="text-center"
|
||||
:disabled="disabled"
|
||||
@update:model-value="updatePassword"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-alert
|
||||
v-if="account.random_password"
|
||||
style="background-color: #515d78"
|
||||
class="ml-6"
|
||||
density="compact"
|
||||
>
|
||||
<span class="text-white mr-4">{{ account.password }}</span>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="white"
|
||||
variant="text"
|
||||
density="compact"
|
||||
:disabled="disabled"
|
||||
icon="mdi-clipboard-multiple-outline"
|
||||
:title="$gettext('Copy to clipboard')"
|
||||
@click="copyPassword"
|
||||
/>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<template v-if="!account.random_password">
|
||||
<v-text-field
|
||||
v-model="account.password"
|
||||
variant="outlined"
|
||||
:label="$gettext('Password')"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
density="compact"
|
||||
:disabled="disabled"
|
||||
:rules="isRuleActive ? [rules.required] : []"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="account.password_confirmation"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
:label="$gettext('Confirmation')"
|
||||
density="compact"
|
||||
:disabled="disabled"
|
||||
:rules="isRuleActive ? [rules.required, passwordConfirmationRules] : []"
|
||||
:error-messages="
|
||||
formErrors && formErrors.value ? formErrors.value.password : []
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useBusStore } from '@/stores'
|
||||
import accountsApi from '@/api/accounts'
|
||||
import accountApi from '@/api/account'
|
||||
import { computed } from 'vue'
|
||||
import rules from '@/plugins/rules'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: null },
|
||||
withPasswordCheck: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
editing: { type: Boolean, default: false },
|
||||
formErrors: { type: Object, required: false, default: () => {} },
|
||||
})
|
||||
|
||||
const account = computed(() => props.modelValue)
|
||||
|
||||
const isRuleActive = computed(
|
||||
() => !props.disabled && (!props.editing || account.value.password)
|
||||
)
|
||||
|
||||
const passwordConfirmationRules = (value) =>
|
||||
value === account.value.password || $gettext('Password mismatch')
|
||||
|
||||
function copyPassword() {
|
||||
navigator.clipboard.writeText(account.value.password).then(() => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Password copied to clipboard'),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function updatePassword(value) {
|
||||
if (value) {
|
||||
accountsApi.getRandomPassword().then((resp) => {
|
||||
account.value.password = resp.data.password
|
||||
})
|
||||
} else {
|
||||
account.value.password = null
|
||||
account.value.password_confirmation = null
|
||||
}
|
||||
}
|
||||
|
||||
async function validPassword() {
|
||||
if (account.value.password) {
|
||||
return accountApi
|
||||
.checkPassword(account.value.password)
|
||||
.then(() => true)
|
||||
.catch(() => $gettext('Invalid password'))
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user