mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Changes:
* Updated parameter forms * Updated layout * Updated login page * Added 2FA authentication
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<v-app v-if="!isAuthenticated">
|
||||
<v-main>
|
||||
<v-container fluid fill-height>
|
||||
<router-view />
|
||||
</v-container>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-app>
|
||||
<v-app v-else>
|
||||
|
||||
@@ -5,5 +5,9 @@ const resource = 'account'
|
||||
export default {
|
||||
getMe () {
|
||||
return repository.get(`/${resource}/me/`)
|
||||
},
|
||||
verifyTFACode (code) {
|
||||
const payload = { code }
|
||||
return repository.post(`${resource}/tfa/verify/`, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,17 @@ _axios.interceptors.response.use(
|
||||
return response
|
||||
},
|
||||
function (error) {
|
||||
if (error.response.status !== 401) {
|
||||
if (error.response.status === 418) {
|
||||
router.push({ name: 'TwoFA' })
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (error.response.status !== 401 || router.currentRoute.path === '/login/') {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
const refreshToken = Cookies.get('refreshToken')
|
||||
if (error.config.url.endsWith('/token/refresh/') || !refreshToken) {
|
||||
store.dispatch('auth/logout')
|
||||
router.push('/login/')
|
||||
router.push({ name: 'Login' })
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return _axios.post('/token/refresh/', { refresh: refreshToken }).then(resp => {
|
||||
|
||||
@@ -20,6 +20,28 @@
|
||||
<template v-slot:form.options="{ step }">
|
||||
<domain-options-form :ref="`form_${step}`" :domain="domain" @createAdmin="updateCreateAdmin" />
|
||||
</template>
|
||||
<template v-slot:item.with_random_password="{ item }">
|
||||
<template v-if="item.value">
|
||||
<v-col cols="12" class="highligth white--text">
|
||||
<v-row>
|
||||
<v-col><span>{{ item.key }}</span></v-col>
|
||||
<v-col class="text-right">{{ item.value|yesno }}</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-right py-1">
|
||||
{{ domain.domain_admin.password }}
|
||||
<v-btn icon color="white" :title="'Copy to clipboard'|translate" @click="copyPassword">
|
||||
<v-icon>mdi-clipboard-multiple-outline</v-icon>
|
||||
</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 class="text-right">{{ item.value|yesno }}</v-col>
|
||||
</template>
|
||||
</template>
|
||||
</creation-form>
|
||||
</template>
|
||||
|
||||
@@ -80,6 +102,7 @@ export default {
|
||||
if (this.createAdmin) {
|
||||
result[3].items.push({ key: this.$gettext('Administrator name'), value: this.domain.domain_admin.username })
|
||||
result[3].items.push({
|
||||
name: 'with_random_password',
|
||||
key: this.$gettext('Random password'),
|
||||
value: this.domain.domain_admin.with_random_password,
|
||||
type: 'yesno'
|
||||
@@ -117,6 +140,11 @@ export default {
|
||||
this.initDomain()
|
||||
},
|
||||
methods: {
|
||||
copyPassword () {
|
||||
navigator.clipboard.writeText(this.domain.domain_admin.password).then(() => {
|
||||
bus.$emit('notification', { msg: this.$gettext('Password copied to clipboard') })
|
||||
})
|
||||
},
|
||||
initDomain () {
|
||||
this.domain = {
|
||||
name: '',
|
||||
@@ -154,6 +182,7 @@ export default {
|
||||
delete data.domain_admin
|
||||
}
|
||||
this.$store.dispatch('domains/createDomain', data).then(resp => {
|
||||
this.$router.push({ name: 'DomainDetail', params: { id: resp.data.pk } })
|
||||
bus.$emit('notification', { msg: this.$gettext('Domain created') })
|
||||
this.close()
|
||||
})
|
||||
@@ -161,3 +190,9 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.highligth {
|
||||
background-color: #515D78;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
:label="'Random password' | translate"
|
||||
:disabled="!createAdmin"
|
||||
:hint="'Generate a random password for the administrator.' | translate"
|
||||
@change="updatePassword"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-switch v-model="domain.domain_admin.with_mailbox"
|
||||
@@ -41,6 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import accounts from '@/api/accounts'
|
||||
|
||||
export default {
|
||||
props: ['domain'],
|
||||
data () {
|
||||
@@ -49,6 +52,16 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updatePassword (value) {
|
||||
if (value) {
|
||||
accounts.getRandomPassword().then(resp => {
|
||||
this.$set(this.domain.domain_admin, 'password', resp.data.password)
|
||||
})
|
||||
} else {
|
||||
delete this.domain.domain_admin.password
|
||||
}
|
||||
},
|
||||
|
||||
update (val) {
|
||||
this.$emit('createAdmin', val)
|
||||
}
|
||||
|
||||
@@ -21,22 +21,28 @@
|
||||
<account-alias-form v-if="step >= 4" :ref="`form_${step}`" :account="account" />
|
||||
</template>
|
||||
<template v-slot:item.random_password="{ item }">
|
||||
<v-col cols="12" class="highligth white--text">
|
||||
<v-row>
|
||||
<v-col><span>{{ item.key }}</span></v-col>
|
||||
<v-col class="text-right" v-if="item.type === 'yesno'">
|
||||
{{ item.value|yesno }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-right py-1">
|
||||
{{ account.password }}
|
||||
<v-btn icon color="white" :title="'Copy to clipboard'|translate" @click="copyPassword">
|
||||
<v-icon>mdi-clipboard-multiple-outline</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
<template v-if="item.value">
|
||||
<v-col cols="12" class="highligth white--text">
|
||||
<v-row>
|
||||
<v-col><span>{{ item.key }}</span></v-col>
|
||||
<v-col class="text-right" v-if="item.type === 'yesno'">
|
||||
{{ item.value|yesno }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-right py-1">
|
||||
{{ account.password }}
|
||||
<v-btn icon color="white" :title="'Copy to clipboard'|translate" @click="copyPassword">
|
||||
<v-icon>mdi-clipboard-multiple-outline</v-icon>
|
||||
</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 class="text-right" v-if="item.type === 'yesno'">{{ item.value|yesno }}</v-col>
|
||||
</template>
|
||||
</template>
|
||||
</creation-form>
|
||||
</template>
|
||||
|
||||
@@ -14,62 +14,64 @@
|
||||
|
||||
<v-list>
|
||||
<template v-for="item in mainMenuItems">
|
||||
<v-list-item
|
||||
v-if="!item.children"
|
||||
class="menu-item"
|
||||
:to="item.to"
|
||||
link
|
||||
:key="item.title"
|
||||
>
|
||||
<v-list-item-icon v-if="item.icon">
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<template v-if="item.roles === undefined || item.roles.indexOf(authUser.role) !== -1">
|
||||
<v-list-item
|
||||
v-if="!item.children"
|
||||
class="menu-item"
|
||||
:to="item.to"
|
||||
link
|
||||
:key="item.title"
|
||||
:exact="item.exact"
|
||||
>
|
||||
<v-list-item-icon v-if="item.icon">
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-group v-else
|
||||
color="white"
|
||||
:key="item.text"
|
||||
:prepend-icon="item.icon"
|
||||
no-action>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</template>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-group v-else
|
||||
color="white"
|
||||
:key="item.text"
|
||||
:prepend-icon="item.icon"
|
||||
no-action>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</template>
|
||||
|
||||
<template v-for="subitem in item.children">
|
||||
<v-list-item v-if="!subitem.children"
|
||||
:key="subitem.text"
|
||||
:to="subitem.to"
|
||||
link>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-group v-else
|
||||
:key="subitem.text"
|
||||
:value="true"
|
||||
no-action
|
||||
sub-group>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
|
||||
</template>
|
||||
<v-list-item v-for="subsubitem in subitem.children"
|
||||
:key="subsubitem.text"
|
||||
:to="subsubitem.to"
|
||||
<template v-for="subitem in item.children">
|
||||
<v-list-item v-if="!subitem.children"
|
||||
:key="subitem.text"
|
||||
:to="subitem.to"
|
||||
link>
|
||||
<v-list-item-title>{{ subsubitem.text }}</v-list-item-title>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
</template>
|
||||
</v-list-group>
|
||||
<v-list-group v-else
|
||||
:key="subitem.text"
|
||||
:value="true"
|
||||
no-action
|
||||
sub-group>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
|
||||
</template>
|
||||
<v-list-item v-for="subsubitem in subitem.children"
|
||||
:key="subsubitem.text"
|
||||
:to="subsubitem.to"
|
||||
link>
|
||||
<v-list-item-title>{{ subsubitem.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
</template>
|
||||
</v-list-group>
|
||||
</template>
|
||||
</template>
|
||||
</v-list>
|
||||
<template v-slot:append>
|
||||
<v-menu rounded="lg" offset-y top>
|
||||
<template v-slot:activator="{ attrs, on }">
|
||||
<div class="user-box justify-end white--text" v-bind="attrs" v-on="on">
|
||||
<div class="d-flex user-box justify-center align-center white--text py-2" v-bind="attrs" v-on="on">
|
||||
<v-avatar size="40" color="primary">
|
||||
<span class="white--text headline">AN</span>
|
||||
</v-avatar>
|
||||
@@ -113,27 +115,33 @@ export default {
|
||||
mainMenuItems: [
|
||||
{
|
||||
text: this.$gettext('Dashboard'),
|
||||
icon: 'mdi-view-dashboard-outline'
|
||||
to: { name: 'Dashboard' },
|
||||
icon: 'mdi-view-dashboard-outline',
|
||||
exact: true
|
||||
},
|
||||
{
|
||||
text: this.$gettext('Domains'),
|
||||
to: { name: 'DomainList' },
|
||||
icon: 'mdi-domain'
|
||||
icon: 'mdi-domain',
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
},
|
||||
{
|
||||
text: this.$gettext('Identities'),
|
||||
to: { name: 'Identities' },
|
||||
icon: 'mdi-account'
|
||||
icon: 'mdi-account',
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
},
|
||||
{
|
||||
icon: 'mdi-history',
|
||||
text: this.$gettext('Logs'),
|
||||
to: { name: 'LogList' }
|
||||
to: { name: 'AuditTrail' },
|
||||
roles: ['SuperAdmins']
|
||||
},
|
||||
{
|
||||
icon: 'mdi-cog',
|
||||
text: this.$gettext('Parameters'),
|
||||
children: []
|
||||
children: [],
|
||||
roles: ['SuperAdmins']
|
||||
}
|
||||
],
|
||||
userMenuItems: [
|
||||
@@ -158,7 +166,7 @@ export default {
|
||||
methods: {
|
||||
logout () {
|
||||
this.$store.dispatch('auth/logout').then(() => {
|
||||
this.$router.push('/login')
|
||||
this.$router.push({ name: 'Login' })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -178,10 +186,6 @@ export default {
|
||||
}
|
||||
|
||||
.user-box {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #979797;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<v-card class="mt-6">
|
||||
<v-toolbar flat>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="'Search'|translate"
|
||||
filled
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
/>
|
||||
</v-toolbar>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="logs"
|
||||
:search="search"
|
||||
class="elevation-1"
|
||||
:options.sync="pagination"
|
||||
>
|
||||
<template v-slot:item.level="{ item }">
|
||||
<v-chip :color="getLevelColor(item.level)" text-color="white" small>
|
||||
{{ item.level }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logs from '@/api/logs'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
logs: [],
|
||||
headers: [
|
||||
{ text: 'Date', value: 'date_created' },
|
||||
{ text: 'Level', value: 'level' },
|
||||
{ text: 'Logger', value: 'logger' },
|
||||
{ text: 'Message', value: 'message' }
|
||||
],
|
||||
loading: true,
|
||||
pagination: {
|
||||
},
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
created () {
|
||||
logs.getAll().then(response => {
|
||||
this.logs = response.data
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
getLevelColor (level) {
|
||||
if (level === 'INFO') {
|
||||
return 'primary'
|
||||
}
|
||||
if (level === 'WARNING') {
|
||||
return 'orange'
|
||||
}
|
||||
if (level === 'CRITICAL') {
|
||||
return 'red'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<v-layout>
|
||||
<v-flex>
|
||||
<v-toolbar flat color="white">
|
||||
<v-toolbar-title>Logs</v-toolbar-title>
|
||||
<v-divider class="mx-2" inset vertical></v-divider>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
append-icon="mdi-magnify"
|
||||
label="Search"
|
||||
single-line
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-toolbar>
|
||||
<v-data-table :headers="headers"
|
||||
:items="logs"
|
||||
:search="search"
|
||||
class="elevation-1"
|
||||
:options.sync="pagination"
|
||||
>
|
||||
<template slot="items" slot-scope="props">
|
||||
<td>{{ props.item.date_created }}</td>
|
||||
<td>
|
||||
<v-chip v-if="props.item.level === 'INFO'"
|
||||
color="primary" text-color="white" small>
|
||||
{{ props.item.level }}
|
||||
</v-chip>
|
||||
<v-chip v-if="props.item.level === 'WARNING'"
|
||||
color="orange" text-color="white" small>
|
||||
{{ props.item.level }}
|
||||
</v-chip>
|
||||
<v-chip v-if="props.item.level === 'CRITICAL'"
|
||||
color="red" text-color="white" small>
|
||||
{{ props.item.level }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ props.item.logger }}</td>
|
||||
<td>{{ props.item.message }}</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logs from '@/api/logs'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
logs: [],
|
||||
headers: [
|
||||
{ text: 'Date', value: 'date_created' },
|
||||
{ text: 'Level', value: 'level' },
|
||||
{ text: 'Logger', value: 'logger' },
|
||||
{ text: 'Message', value: 'message' }
|
||||
],
|
||||
loading: true,
|
||||
pagination: {
|
||||
},
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
created () {
|
||||
logs.getAll().then(response => {
|
||||
this.logs = response.data
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,28 @@
|
||||
<template>
|
||||
<v-layout>
|
||||
<v-flex>
|
||||
<v-tabs v-model="active">
|
||||
<v-tab v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
>
|
||||
{{ element.label }}
|
||||
</v-tab>
|
||||
<v-tabs-items v-model="active">
|
||||
<v-tab-item
|
||||
v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<template v-for="(param, index) in displayableParams(element.parameters)">
|
||||
<div class="my-4" :key="index">
|
||||
<template v-if="param.widget === 'BooleanField'">
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<v-tabs v-model="active">
|
||||
<v-tab v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
>
|
||||
{{ element.label }}
|
||||
</v-tab>
|
||||
<v-tabs-items v-model="active">
|
||||
<v-tab-item
|
||||
v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<template v-for="(param, index) in displayableParams(element.parameters)">
|
||||
<div class="my-4" :key="index">
|
||||
<template v-if="param.widget === 'SeparatorField'">
|
||||
<h2>{{ param.label }}</h2>
|
||||
</template>
|
||||
<template v-else-if="param.widget === 'BooleanField'">
|
||||
<v-switch
|
||||
v-model="parameters[param.name]"
|
||||
:label="param.label"
|
||||
@@ -42,30 +48,30 @@
|
||||
v-model="parameters[param.name]"
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
:error-messages="formErrors[param.name]"
|
||||
:type="param.widget === 'PasswordField' ? 'password' : 'text'"
|
||||
v-else
|
||||
dense
|
||||
outlined
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</v-tabs>
|
||||
<v-btn fixed
|
||||
dark
|
||||
fab
|
||||
bottom
|
||||
right
|
||||
color="green"
|
||||
@click="save"
|
||||
>
|
||||
<v-icon>mdi-content-save</v-icon>
|
||||
</v-btn>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</v-tabs>
|
||||
<v-btn fixed
|
||||
dark
|
||||
fab
|
||||
bottom
|
||||
right
|
||||
color="green"
|
||||
@click="save"
|
||||
>
|
||||
<v-icon>mdi-content-save</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -85,6 +91,17 @@ export default {
|
||||
displayableElements () {
|
||||
const result = this.structure.filter(element => this.display(element))
|
||||
return result
|
||||
},
|
||||
title () {
|
||||
let result = this.$gettext('Parameters: ')
|
||||
if (this.$route.params.app === 'core') {
|
||||
result += this.$gettext('General')
|
||||
} else if (this.$route.params.app === 'admin') {
|
||||
result += this.$gettext('Administration')
|
||||
} else if (this.$route.params.app === 'limits') {
|
||||
result += this.$gettext('Limitations')
|
||||
}
|
||||
return result
|
||||
}
|
||||
},
|
||||
created () {
|
||||
@@ -108,11 +125,15 @@ export default {
|
||||
if (element.display === '') {
|
||||
return true
|
||||
}
|
||||
var [field, value] = element.display.split('=')
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
return this.parameters[field] === value
|
||||
let result = true
|
||||
element.display.split('&').forEach(rule => {
|
||||
var [field, value] = rule.split('=')
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
result &= this.parameters[field] === value
|
||||
})
|
||||
return result
|
||||
},
|
||||
displayableParams (params) {
|
||||
return params.filter(param => this.display(param))
|
||||
@@ -127,3 +148,9 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,18 @@ import VueRouter from 'vue-router'
|
||||
import store from '@/store'
|
||||
|
||||
import ParametersForm from '@/components/parameters/ParametersForm'
|
||||
import LogList from '@/components/logs/LogList'
|
||||
|
||||
Vue.use(VueRouter)
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ '../views/Dashboard.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
@@ -17,28 +24,36 @@ const routes = [
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "about" */ '../views/Login.vue')
|
||||
},
|
||||
{
|
||||
path: '/twofa',
|
||||
name: 'TwoFA',
|
||||
component: () => import('../views/TwoFA.vue')
|
||||
},
|
||||
{
|
||||
path: '/domains',
|
||||
name: 'DomainList',
|
||||
component: () => import('../views/Domains.vue'),
|
||||
component: () => import('../views/domains/Domains.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/domains/:id',
|
||||
name: 'DomainDetail',
|
||||
component: () => import('../views/Domain.vue'),
|
||||
component: () => import('../views/domains/Domain.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/domains/:id/edit',
|
||||
name: 'DomainEdit',
|
||||
component: () => import('../views/DomainEdit.vue'),
|
||||
component: () => import('../views/domains/DomainEdit.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -46,7 +61,8 @@ const routes = [
|
||||
name: 'Identities',
|
||||
component: () => import('../views/identities/Identities.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -54,7 +70,8 @@ const routes = [
|
||||
name: 'AccountEdit',
|
||||
component: () => import('../views/identities/AccountEdit.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -62,7 +79,8 @@ const routes = [
|
||||
name: 'AliasEdit',
|
||||
component: () => import('../views/identities/AliasEdit.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -70,15 +88,17 @@ const routes = [
|
||||
name: 'ParametersEdit',
|
||||
component: ParametersForm,
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/logs/',
|
||||
name: 'LogList',
|
||||
component: LogList,
|
||||
name: 'AuditTrail',
|
||||
component: () => import('../views/logs/AuditTrail.vue'),
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['SuperAdmins']
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -93,8 +113,14 @@ router.beforeEach((to, from, next) => {
|
||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||
store.dispatch('auth/initialize').then(() => {
|
||||
if (!store.getters['auth/isAuthenticated']) {
|
||||
next('/login/')
|
||||
next({ name: 'Login' })
|
||||
} else {
|
||||
if (to.meta.allowedRoles !== undefined) {
|
||||
if (to.meta.allowedRoles.indexOf(store.getters['auth/authUser'].role) === -1) {
|
||||
next({ name: 'Dashboard' })
|
||||
return
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,8 +40,13 @@ const actions = {
|
||||
})
|
||||
},
|
||||
createDomain ({ commit }, data) {
|
||||
return domainApi.createDomain(data).then(response => {
|
||||
commit(ADD_DOMAIN, { domain: response.data })
|
||||
return new Promise((resolve, reject) => {
|
||||
domainApi.createDomain(data).then(response => {
|
||||
commit(ADD_DOMAIN, { domain: response.data })
|
||||
resolve(response)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateDomain ({ commit }, data) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title><translate>Welcome to Modoboa</translate></v-toolbar-title>
|
||||
</v-toolbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,58 +1,73 @@
|
||||
<template>
|
||||
<v-layout align-center justify-center>
|
||||
<v-flex xs12 sm8 md4>
|
||||
<v-card class="elevation-12" @keyup.enter="authenticate">
|
||||
<v-toolbar dark color="primary">
|
||||
<v-toolbar-title>Modoboa</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-text-field label="Utilisateur"
|
||||
v-model="username"
|
||||
:error="errors.username !== undefined"
|
||||
:error-messages="errors.username"
|
||||
prepend-icon="mdi-account"
|
||||
>
|
||||
</v-text-field>
|
||||
<v-text-field label="Mot de passe"
|
||||
type="password"
|
||||
v-model="password"
|
||||
:error="errors.password !== undefined"
|
||||
:error-messages="errors.password"
|
||||
prepend-icon="mdi-lock"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" text @click="authenticate">
|
||||
Connect
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<v-row style="height: 100%; width: 80%; margin: 0 auto">
|
||||
<v-col cols="0" sm="6" class="primary" style="height: 100%">
|
||||
<v-img
|
||||
src="../assets/Modoboa_RVB-BLANC-SANS.png"
|
||||
max-width="300"
|
||||
class="mt-10 ml-8"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="authenticate">
|
||||
<span class="primary--text mb-6 text-h4"><translate>Identification</translate></span>
|
||||
<validation-observer ref="observer">
|
||||
<validation-provider
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
>
|
||||
<label class="m-label"><translate>Username</translate></label>
|
||||
<v-text-field
|
||||
v-model="username"
|
||||
:error-messages="errors"
|
||||
outlined
|
||||
/>
|
||||
</validation-provider>
|
||||
<validation-provider
|
||||
vid="password"
|
||||
v-slot="{ errors }"
|
||||
rules="required"
|
||||
>
|
||||
<label class="m-label"><translate>Password</translate></label>
|
||||
<v-text-field
|
||||
type="password"
|
||||
v-model="password"
|
||||
:error-messages="errors"
|
||||
outlined
|
||||
/>
|
||||
</validation-provider>
|
||||
<a href="" class="float-right primary--text"><translate>Forgot password?</translate></a>
|
||||
<v-checkbox
|
||||
v-model="rememberMe"
|
||||
:label="'Remember me'|translate"
|
||||
/>
|
||||
</validation-observer>
|
||||
<div class="d-flex justify-center">
|
||||
<v-btn class="flex-grow-1" color="primary" large @click="authenticate">
|
||||
<translate>Connect</translate>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cookies from 'js-cookie'
|
||||
import { mapGetters } from 'vuex'
|
||||
import auth from '@/api/auth'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
endpoints: 'auth/endpoints'
|
||||
})
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
rememberMe: false,
|
||||
username: '',
|
||||
password: '',
|
||||
errors: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
authenticate () {
|
||||
async authenticate () {
|
||||
const isValid = await this.$refs.observer.validate()
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
@@ -61,12 +76,20 @@ export default {
|
||||
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
|
||||
Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
|
||||
this.$store.dispatch('auth/initialize').then(() => {
|
||||
this.$router.push('/')
|
||||
this.$router.push({ name: 'Dashboard' })
|
||||
})
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
if (err.response.status === 401) {
|
||||
this.$refs.observer.setErrors({
|
||||
password: this.$gettext('Invalid username and/or password')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<v-row style="height: 100%; width: 80%; margin: 0 auto">
|
||||
<v-col cols="0" sm="6" class="primary" style="height: 100%">
|
||||
<v-img
|
||||
src="../assets/Modoboa_RVB-BLANC-SANS.png"
|
||||
max-width="300"
|
||||
class="mt-10 ml-8"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="verifyCode">
|
||||
<span class="primary--text mb-6 text-h4"><translate>Verification</translate></span>
|
||||
<validation-observer ref="observer">
|
||||
<validation-provider
|
||||
v-slot="{ errors }"
|
||||
vid="code"
|
||||
rules="required"
|
||||
>
|
||||
<label class="m-label"><translate>Two-factor authentication code</translate></label>
|
||||
<v-text-field
|
||||
v-model="code"
|
||||
prepend-icon="mdi-numeric"
|
||||
:error-messages="errors"
|
||||
:hint="'Enter the code from the two-factor app on your mobile device. If you have lost your device, you may enter one of your recovery codes.'|translate"
|
||||
persistent-hint
|
||||
dense
|
||||
outlined
|
||||
/>
|
||||
</validation-provider>
|
||||
</validation-observer>
|
||||
<div class="d-flex justify-center mt-6">
|
||||
<v-btn class="flex-grow-1" color="primary" large @click="verifyCode">
|
||||
<translate>Verify code</translate>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cookies from 'js-cookie'
|
||||
import account from '@/api/account'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
code: null,
|
||||
errors: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async verifyCode () {
|
||||
const isValid = await this.$refs.observer.validate()
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
account.verifyTFACode(this.code).then(resp => {
|
||||
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
|
||||
Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
|
||||
this.$store.dispatch('auth/initialize').then(() => {
|
||||
this.$router.push({ name: 'Dashboard' })
|
||||
})
|
||||
}).catch(error => {
|
||||
this.$refs.observer.setErrors(error.response.data)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -57,7 +57,7 @@ export default {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
domain: {},
|
||||
domain: { pk: this.$route.params.id },
|
||||
showEditForm: false,
|
||||
showAdminList: false
|
||||
}
|
||||
@@ -79,3 +79,9 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title><translate>Audit trail</translate></v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<audit-trail-list />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AuditTrailList from '@/components/logs/AuditTrailList.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AuditTrailList
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
</style>
|
||||
@@ -194,7 +194,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
fields = (
|
||||
"pk", "username", "first_name", "last_name", "is_active",
|
||||
"master_user", "mailbox", "role", "language", "phone_number",
|
||||
"secondary_email", "domains",
|
||||
"secondary_email", "domains"
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Admin API v2 serializers."""
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext as _
|
||||
@@ -13,6 +15,7 @@ from modoboa.core import models as core_models, signals as core_signals
|
||||
from modoboa.lib import exceptions as lib_exceptions
|
||||
from modoboa.lib import fields as lib_fields
|
||||
from modoboa.lib import validators, web_utils
|
||||
from modoboa.lib.sysutils import exec_cmd
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from ... import constants, lib, models
|
||||
@@ -22,7 +25,7 @@ class CreateDomainAdminSerializer(serializers.Serializer):
|
||||
"""Sub serializer for domain administrator creation."""
|
||||
|
||||
username = serializers.CharField()
|
||||
with_random_password = serializers.BooleanField(default=False)
|
||||
password = serializers.CharField(required=False)
|
||||
with_mailbox = serializers.BooleanField(default=False)
|
||||
with_aliases = serializers.BooleanField(default=False)
|
||||
|
||||
@@ -57,9 +60,8 @@ class DomainSerializer(v1_serializers.DomainSerializer):
|
||||
core_signals.can_create_object.send(
|
||||
self.__class__, context=user, klass=models.Mailbox)
|
||||
da = core_models.User(username=username, email=username, is_active=True)
|
||||
if domain_admin["with_random_password"]:
|
||||
password = lib.make_password()
|
||||
else:
|
||||
password = domain_admin.get("password")
|
||||
if password is None:
|
||||
password = param_tools.get_global_parameter(
|
||||
"default_password", app="core")
|
||||
da.set_password(password)
|
||||
@@ -132,6 +134,48 @@ class AdminGlobalParametersSerializer(serializers.Serializer):
|
||||
auto_create_domain_and_mailbox = serializers.BooleanField(default=True)
|
||||
create_alias_on_mbox_rename = serializers.BooleanField(default=False)
|
||||
|
||||
def validate_default_domain_quota(self, value):
|
||||
"""Ensure quota is a positive integer."""
|
||||
if value < 0:
|
||||
raise serializers.ValidationError(
|
||||
_("Must be a positive integer")
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_default_mailbox_quota(self, value):
|
||||
"""Ensure quota is a positive integer."""
|
||||
if value < 0:
|
||||
raise serializers.ValidationError(
|
||||
_("Must be a positive integer")
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_dkim_keys_storage_dir(self, value):
|
||||
"""Check that directory exists."""
|
||||
if value:
|
||||
if not os.path.isdir(value):
|
||||
raise serializers.ValidationError(
|
||||
_("Directory not found.")
|
||||
)
|
||||
code, output = exec_cmd("which openssl")
|
||||
if code:
|
||||
raise serializers.ValidationError(
|
||||
_("openssl not found, please make sure it is installed.")
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
"""Check MX options."""
|
||||
condition = (
|
||||
data.get("enable_mx_checks") and
|
||||
data.get("domains_must_have_authorized_mx") and
|
||||
not data.get("valid_mxs"))
|
||||
if condition:
|
||||
raise serializers.ValidationError({
|
||||
"valid_mxs": _("Define at least one authorized network / address")
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
class DomainAdminSerializer(serializers.ModelSerializer):
|
||||
"""Serializer used for administrator related routes."""
|
||||
@@ -184,8 +228,9 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
"""Check if quota is required."""
|
||||
method = self.context["request"].method
|
||||
if not data.get("use_domain_quota", False):
|
||||
if "quota" not in data:
|
||||
if "quota" not in data and method != "PATCH":
|
||||
raise serializers.ValidationError({
|
||||
"quota": _("This field is required")
|
||||
})
|
||||
|
||||
@@ -137,7 +137,7 @@ class AccountViewSet(v1_viewsets.AccountViewSet):
|
||||
filterset_class = AccountFilterSet
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ["create", "validate"]:
|
||||
if self.action in ["create", "validate", "update", "partial_update"]:
|
||||
return serializers.WritableAccountSerializer
|
||||
if self.action == "delete":
|
||||
return serializers.DeleteAccountSerializer
|
||||
|
||||
@@ -5,12 +5,14 @@ from django.utils.translation import ugettext_lazy, ugettext as _
|
||||
|
||||
from django.contrib.auth import password_validation
|
||||
|
||||
import django_otp
|
||||
from rest_framework import serializers
|
||||
|
||||
from modoboa.lib import fields as lib_fields
|
||||
|
||||
from ... import constants
|
||||
from ... import models
|
||||
from ... import sms_backends
|
||||
|
||||
|
||||
class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
@@ -34,16 +36,39 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
default="sha512crypt"
|
||||
)
|
||||
rounds_number = serializers.IntegerField(default=70000)
|
||||
update_scheme = serializers.BooleanField(default=True)
|
||||
default_password = serializers.CharField(default="password")
|
||||
random_password_length = serializers.IntegerField(min_value=8, default=8)
|
||||
update_password_url = serializers.URLField(required=False)
|
||||
password_recovery_msg = serializers.CharField(
|
||||
required=False, allow_blank=True)
|
||||
sms_password_recovery = serializers.BooleanField(default=False)
|
||||
sms_provider = serializers.ChoiceField(
|
||||
choices=constants.SMS_BACKENDS, required=False)
|
||||
|
||||
# LDAP settings
|
||||
ldap_server_address = serializers.CharField(default="localhost")
|
||||
ldap_server_port = serializers.IntegerField(default=389)
|
||||
ldap_enable_secondary_server = serializers.BooleanField(default=False)
|
||||
ldap_secondary_server_address = serializers.CharField(required=False)
|
||||
ldap_secondary_server_port = serializers.IntegerField(
|
||||
default=389, required=False)
|
||||
ldap_secured = serializers.ChoiceField(
|
||||
choices=constants.LDAP_SECURE_MODES,
|
||||
default="none"
|
||||
)
|
||||
ldap_is_active_directory = serializers.BooleanField(default=False)
|
||||
ldap_admin_groups = serializers.CharField(
|
||||
default="", required=False, allow_blank=True)
|
||||
ldap_group_type = serializers.ChoiceField(
|
||||
default="posixgroup",
|
||||
choices=constants.LDAP_GROUP_TYPES
|
||||
)
|
||||
ldap_groups_search_base = serializers.CharField(
|
||||
default="", required=False, allow_blank=True)
|
||||
ldap_password_attribute = serializers.CharField(default="userPassword")
|
||||
|
||||
# LDAP auth settings
|
||||
ldap_auth_method = serializers.ChoiceField(
|
||||
choices=constants.LDAP_AUTH_METHODS,
|
||||
default="searchbind",
|
||||
@@ -59,16 +84,24 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
allow_blank=True)
|
||||
ldap_user_dn_template = serializers.CharField(
|
||||
default="", required=False, allow_blank=True)
|
||||
ldap_password_attribute = serializers.CharField(default="userPassword")
|
||||
ldap_is_active_directory = serializers.BooleanField(default=False)
|
||||
ldap_admin_groups = serializers.CharField(
|
||||
default="", required=False, allow_blank=True)
|
||||
ldap_group_type = serializers.ChoiceField(
|
||||
default="posixgroup",
|
||||
choices=constants.LDAP_GROUP_TYPES
|
||||
|
||||
# LDAP sync settings
|
||||
ldap_sync_bind_dn = serializers.CharField(required=False)
|
||||
ldap_sync_bind_password = serializers.CharField(required=False)
|
||||
ldap_enable_sync = serializers.BooleanField(default=False)
|
||||
ldap_sync_delete_remote_account = serializers.BooleanField(default=False)
|
||||
ldap_sync_account_dn_template = serializers.CharField(
|
||||
required=False, allow_blank=True)
|
||||
ldap_enable_import = serializers.BooleanField(default=False)
|
||||
ldap_import_search_base = serializers.CharField(required=False)
|
||||
ldap_import_search_filter = serializers.CharField(
|
||||
default="(cn=*)", required=False
|
||||
)
|
||||
ldap_import_username_attr = serializers.CharField(default="cn")
|
||||
ldap_dovecot_sync = serializers.BooleanField(default=False)
|
||||
ldap_dovecot_conf_file = serializers.CharField(
|
||||
default="/etc/dovecot/dovecot-modoboa.conf", required=False
|
||||
)
|
||||
ldap_groups_search_base = serializers.CharField(
|
||||
default="", required=False, allow_blank=True)
|
||||
|
||||
# Dashboard settings
|
||||
rss_feed_url = serializers.URLField(allow_blank=True)
|
||||
@@ -81,6 +114,8 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
# API settings
|
||||
enable_api_communication = serializers.BooleanField(default=True)
|
||||
check_new_versions = serializers.BooleanField(default=True)
|
||||
send_new_versions_email = serializers.BooleanField(default=False)
|
||||
new_versions_email_rcpt = lib_fields.DRFEmailFieldUTF8(required=False)
|
||||
send_statistics = serializers.BooleanField(default=True)
|
||||
|
||||
# Misc settings
|
||||
@@ -98,6 +133,20 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
raise serializers.ValidationError(_("Invalid syntax"))
|
||||
return value
|
||||
|
||||
def validate_ldap_sync_account_dn_template(self, value):
|
||||
try:
|
||||
value % {"user": "toto"}
|
||||
except (KeyError, ValueError):
|
||||
raise serializers.ValidationError(_("Invalid syntax"))
|
||||
return value
|
||||
|
||||
def validate_ldap_search_filter(self, value):
|
||||
try:
|
||||
value % {"user": "toto"}
|
||||
except (KeyError, ValueError, TypeError):
|
||||
raise serializers.ValidationError(_("Invalid syntax"))
|
||||
return value
|
||||
|
||||
def validate_rounds_number(self, value):
|
||||
if value < 1000 or value > 999999999:
|
||||
raise serializers.ValidationError(_("Invalid rounds number"))
|
||||
@@ -114,13 +163,24 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
Depending on 'ldap_auth_method' value, we check for different
|
||||
required parameters.
|
||||
"""
|
||||
errors = {}
|
||||
if data["sms_password_recovery"]:
|
||||
provider = data.get("sms_provider")
|
||||
if provider:
|
||||
sms_settings = sms_backends.get_backend_settings(provider)
|
||||
if sms_settings:
|
||||
for name in sms_settings.keys():
|
||||
if not data.get(name):
|
||||
errors[name] = _("This field is required")
|
||||
else:
|
||||
errors["sms_provider"] = _("This field is required")
|
||||
|
||||
if data["authentication_type"] != "ldap":
|
||||
return data
|
||||
if data["ldap_auth_method"] == "searchbind":
|
||||
required_fields = ["ldap_search_base", "ldap_search_filter"]
|
||||
else:
|
||||
required_fields = ["ldap_user_dn_template"]
|
||||
errors = {}
|
||||
for f in required_fields:
|
||||
if data.get(f, u"") == u"":
|
||||
errors[f] = _("This field is required")
|
||||
@@ -140,3 +200,15 @@ class LogSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_date_created(self, log) -> str:
|
||||
return formats.date_format(log.date_created, "SHORT_DATETIME_FORMAT")
|
||||
|
||||
|
||||
class VerifyTFACodeSerializer(serializers.Serializer):
|
||||
"""Serializer used to verify 2FA code validity."""
|
||||
|
||||
code = serializers.CharField()
|
||||
|
||||
def validate_code(self, value):
|
||||
device = django_otp.match_token(self.context["user"], value)
|
||||
if device is None:
|
||||
raise serializers.ValidationError(_("This code is invalid"))
|
||||
return device
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Core API v2 viewsets."""
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import permissions, response, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from modoboa.admin.api.v1 import serializers as admin_v1_serializers
|
||||
from modoboa.core.api.v1 import viewsets as core_v1_viewsets
|
||||
|
||||
from ... import constants
|
||||
from ... import models
|
||||
from . import serializers
|
||||
|
||||
@@ -14,11 +17,26 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
|
||||
"""Account viewset."""
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@extend_schema(responses=admin_v1_serializers.AccountSerializer)
|
||||
def me(self, request):
|
||||
"""Return information about connected user."""
|
||||
serializer = admin_v1_serializers.AccountSerializer(request.user)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
@action(methods=["post"], detail=False, url_path="tfa/verify")
|
||||
def tfa_verify_code(self, request):
|
||||
"""Verify given code validity."""
|
||||
serializer = serializers.VerifyTFACodeSerializer(
|
||||
data=request.data, context={"user": request.user})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
refresh = RefreshToken.for_user(request.user)
|
||||
refresh[constants.TFA_DEVICE_TOKEN_KEY] = (
|
||||
serializer.validated_data["code"].persistent_id)
|
||||
return response.Response({
|
||||
"refresh": str(refresh),
|
||||
"access": str(refresh.access_token)
|
||||
})
|
||||
|
||||
|
||||
class LogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""Log viewset."""
|
||||
|
||||
+222
-53
@@ -737,6 +737,13 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
|
||||
"inclusive."
|
||||
)
|
||||
}),
|
||||
("update_scheme", {
|
||||
"label": ugettext_lazy("Update password scheme at login"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Update user password at login to use the default "
|
||||
"password scheme"
|
||||
)
|
||||
}),
|
||||
("default_password", {
|
||||
"label": ugettext_lazy("Default password"),
|
||||
"help_text": ugettext_lazy(
|
||||
@@ -746,12 +753,41 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
|
||||
"label": ugettext_lazy("Random password length"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Length of randomly generated passwords.")
|
||||
})
|
||||
}),
|
||||
("update_password_url", {
|
||||
"label": ugettext_lazy("Update password service URL"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The URL of an external page where users will be able"
|
||||
" to update their password. It applies only to non local"
|
||||
" users, ie. those automatically created after a successful"
|
||||
" external authentication (LDAP, SMTP)."
|
||||
)
|
||||
}),
|
||||
("password_recovery_msg", {
|
||||
"label": ugettext_lazy("Password recovery announcement"),
|
||||
"help_text": ugettext_lazy(
|
||||
"A temporary message that will be displayed on the "
|
||||
"reset password page."
|
||||
)
|
||||
}),
|
||||
("sms_password_recovery", {
|
||||
"label": ugettext_lazy("Enable password recovery by SMS"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Enable password recovery by SMS for users who filled "
|
||||
"a phone number."
|
||||
)
|
||||
}),
|
||||
("sms_provider", {
|
||||
"label": ugettext_lazy("SMS provider"),
|
||||
"display": "sms_password_recovery=true",
|
||||
"help_text": ugettext_lazy(
|
||||
"Choose a provider to send password recovery SMS"
|
||||
)
|
||||
}),
|
||||
])
|
||||
}),
|
||||
("ldap", {
|
||||
"label": _("LDAP"),
|
||||
"display": "authentication_type=ldap",
|
||||
"params": collections.OrderedDict([
|
||||
("ldap_server_address", {
|
||||
"label": ugettext_lazy("Server address"),
|
||||
@@ -763,61 +799,32 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
|
||||
"help_text": ugettext_lazy(
|
||||
"The TCP port number used by the LDAP server")
|
||||
}),
|
||||
("ldap_enable_secondary_server", {
|
||||
"label": ugettext_lazy("Enable secondary server (fallback)"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Enable a secondary LDAP server which will be used "
|
||||
"if the primary one fails"
|
||||
)
|
||||
}),
|
||||
("ldap_secondary_server_address", {
|
||||
"label": ugettext_lazy("Secondary server address"),
|
||||
"display": "ldap_enable_secondary_server=true",
|
||||
"help_text": ugettext_lazy(
|
||||
"The IP address or the DNS name of the seondary LDAP server"
|
||||
)
|
||||
}),
|
||||
("ldap_secondary_server_port", {
|
||||
"label": ugettext_lazy("Secondary server port"),
|
||||
"display": "ldap_enable_secondary_server=true",
|
||||
"help_text": ugettext_lazy(
|
||||
"The TCP port number used by the LDAP secondary server"
|
||||
)
|
||||
}),
|
||||
("ldap_secured", {
|
||||
"label": ugettext_lazy("Use a secured connection"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Use an SSL/STARTTLS connection to access the LDAP server")
|
||||
}),
|
||||
("ldap_auth_method", {
|
||||
"label": ugettext_lazy("Authentication method"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Choose the authentication method to use"),
|
||||
}),
|
||||
("ldap_bind_dn", {
|
||||
"label": ugettext_lazy("Bind DN"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The distinguished name to use when binding to the LDAP "
|
||||
"server. Leave empty for an anonymous bind"
|
||||
),
|
||||
"display": "ldap_auth_method=searchbind"
|
||||
}),
|
||||
("ldap_bind_password", {
|
||||
"label": ugettext_lazy("Bind password"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The password to use when binding to the LDAP server "
|
||||
"(with 'Bind DN')"
|
||||
),
|
||||
"display": "ldap_auth_method=searchbind"
|
||||
}),
|
||||
("ldap_search_base", {
|
||||
"label": ugettext_lazy("Users search base"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The distinguished name of the search base used to find "
|
||||
"users"
|
||||
),
|
||||
"display": "ldap_auth_method=searchbind"
|
||||
}),
|
||||
("ldap_search_filter", {
|
||||
"label": ugettext_lazy("Search filter"),
|
||||
"help_text": ugettext_lazy(
|
||||
"An optional filter string (e.g. '(objectClass=person)'). "
|
||||
"In order to be valid, it must be enclosed in parentheses."
|
||||
),
|
||||
"display": "ldap_auth_method=searchbind"
|
||||
}),
|
||||
("ldap_user_dn_template", {
|
||||
"label": ugettext_lazy("User DN template"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The template used to construct a user's DN. It should "
|
||||
"contain one placeholder (ie. %(user)s)"
|
||||
),
|
||||
"display": "ldap_auth_method=directbind"
|
||||
}),
|
||||
("ldap_password_attribute", {
|
||||
"label": ugettext_lazy("Password attribute"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The attribute used to store user passwords"),
|
||||
}),
|
||||
("ldap_is_active_directory", {
|
||||
"label": ugettext_lazy("Active Directory"),
|
||||
"help_text": ugettext_lazy(
|
||||
@@ -843,7 +850,154 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
|
||||
"The distinguished name of the search base used to find "
|
||||
"groups"
|
||||
)
|
||||
})
|
||||
}),
|
||||
("ldap_password_attribute", {
|
||||
"label": ugettext_lazy("Password attribute"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The attribute used to store user passwords"),
|
||||
}),
|
||||
("ldap_auth_sep", {
|
||||
"label": ugettext_lazy("LDAP authentication settings"),
|
||||
"display": "authentication_type=ldap",
|
||||
"separator": True
|
||||
}),
|
||||
("ldap_auth_method", {
|
||||
"label": ugettext_lazy("Authentication method"),
|
||||
"display": "authentication_type=ldap",
|
||||
"help_text": ugettext_lazy(
|
||||
"Choose the authentication method to use"),
|
||||
}),
|
||||
("ldap_bind_dn", {
|
||||
"label": ugettext_lazy("Bind DN"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The distinguished name to use when binding to the LDAP "
|
||||
"server. Leave empty for an anonymous bind"
|
||||
),
|
||||
"display": "authentication_type=ldap&ldap_auth_method=searchbind"
|
||||
}),
|
||||
("ldap_bind_password", {
|
||||
"label": ugettext_lazy("Bind password"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The password to use when binding to the LDAP server "
|
||||
"(with 'Bind DN')"
|
||||
),
|
||||
"display": "authentication_type=ldap&ldap_auth_method=searchbind",
|
||||
"password": True
|
||||
}),
|
||||
("ldap_search_base", {
|
||||
"label": ugettext_lazy("Users search base"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The distinguished name of the search base used to find "
|
||||
"users"
|
||||
),
|
||||
"display": "authentication_type=ldap&ldap_auth_method=searchbind"
|
||||
}),
|
||||
("ldap_search_filter", {
|
||||
"label": ugettext_lazy("Search filter"),
|
||||
"help_text": ugettext_lazy(
|
||||
"An optional filter string (e.g. '(objectClass=person)'). "
|
||||
"In order to be valid, it must be enclosed in parentheses."
|
||||
),
|
||||
"display": "authentication_type=ldap&ldap_auth_method=searchbind"
|
||||
}),
|
||||
("ldap_user_dn_template", {
|
||||
"label": ugettext_lazy("User DN template"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The template used to construct a user's DN. It should "
|
||||
"contain one placeholder (ie. %(user)s)"
|
||||
),
|
||||
"display": "authentication_type=ldap&ldap_auth_method=directbind"
|
||||
}),
|
||||
("ldap_sync_sep", {
|
||||
"label": ugettext_lazy("LDAP synchronization settings"),
|
||||
"separator": True
|
||||
}),
|
||||
("ldap_sync_bind_dn", {
|
||||
"label": ugettext_lazy("Bind DN"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The distinguished name to use when binding to the LDAP server. "
|
||||
"Leave empty for an anonymous bind"
|
||||
)
|
||||
}),
|
||||
("ldap_sync_bind_password", {
|
||||
"label": ugettext_lazy("Bind password"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The password to use when binding to the LDAP server "
|
||||
"(with 'Bind DN')"
|
||||
),
|
||||
"password": True
|
||||
}),
|
||||
("ldap_enable_sync", {
|
||||
"label": ugettext_lazy("Enable export to LDAP"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Enable automatic synchronization between local database "
|
||||
"and LDAP directory"
|
||||
)
|
||||
}),
|
||||
("ldap_sync_delete_remote_account", {
|
||||
"label": ugettext_lazy(
|
||||
"Delete remote LDAP account when local account is deleted"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Delete remote LDAP account when local account is deleted, "
|
||||
"otherwise it will be disabled."
|
||||
),
|
||||
"display": "ldap_enable_sync=true"
|
||||
}),
|
||||
("ldap_sync_account_dn_template", {
|
||||
"label": ugettext_lazy("Account DN template"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The template used to construct an account's DN. It should "
|
||||
"contain one placeholder (ie. %(user)s)"
|
||||
),
|
||||
"display": "ldap_enable_sync=true"
|
||||
}),
|
||||
("ldap_enable_import", {
|
||||
"label": ugettext_lazy("Enable import from LDAP"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Enable account synchronization from LDAP directory to "
|
||||
"local database"
|
||||
)
|
||||
}),
|
||||
("ldap_import_search_base", {
|
||||
"label": ugettext_lazy("Users search base"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The distinguished name of the search base used to find "
|
||||
"users"
|
||||
),
|
||||
"display": "ldap_enable_import=true"
|
||||
}),
|
||||
("ldap_import_search_filter", {
|
||||
"label": ugettext_lazy("Search filter"),
|
||||
"help_text": ugettext_lazy(
|
||||
"An optional filter string (e.g. '(objectClass=person)'). "
|
||||
"In order to be valid, it must be enclosed in parentheses."
|
||||
),
|
||||
"display": "ldap_enable_import=true"
|
||||
}),
|
||||
("ldap_import_username_attr", {
|
||||
"label": ugettext_lazy("Username attribute"),
|
||||
"help_text": ugettext_lazy(
|
||||
"The name of the LDAP attribute where the username can be "
|
||||
"found."
|
||||
),
|
||||
"display": "ldap_enable_import=true"
|
||||
}),
|
||||
("ldap_dovecot_sync", {
|
||||
"label": ugettext_lazy("Enable Dovecot LDAP sync"),
|
||||
"help_text": ugettext_lazy(
|
||||
"LDAP authentication settings will be applied to Dovecot "
|
||||
"configuration."
|
||||
)
|
||||
}),
|
||||
("ldap_dovecot_conf_file", {
|
||||
"label": ugettext_lazy("Dovecot LDAP config file"),
|
||||
"help_text": ugettext_lazy(
|
||||
"Location of the configuration file which contains "
|
||||
"Dovecot LDAP settings."
|
||||
),
|
||||
"display": "ldap_dovecot_sync=true"
|
||||
}),
|
||||
|
||||
])
|
||||
}),
|
||||
("dashboard", {
|
||||
@@ -889,6 +1043,21 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
|
||||
"help_text": ugettext_lazy(
|
||||
"Automatically checks if a newer version is available")
|
||||
}),
|
||||
("send_new_versions_email", {
|
||||
"label": ugettext_lazy(
|
||||
"Send an email when new versions are found"),
|
||||
"display": "check_new_versions=true",
|
||||
"help_text": ugettext_lazy(
|
||||
"Send an email to notify admins about new versions"
|
||||
)
|
||||
}),
|
||||
("new_versions_email_rcpt", {
|
||||
"label": ugettext_lazy("Recipient"),
|
||||
"display": "check_new_versions=true",
|
||||
"help_text": ugettext_lazy(
|
||||
"Recipient of new versions notification emails."
|
||||
)
|
||||
}),
|
||||
("send_statistics", {
|
||||
"label": ugettext_lazy("Send statistics"),
|
||||
"display": "enable_api_communication=true",
|
||||
|
||||
@@ -110,3 +110,5 @@ SMS_BACKENDS = [
|
||||
|
||||
if settings.DEBUG:
|
||||
SMS_BACKENDS.insert(1, ("dummy", ugettext_lazy("Dummy")))
|
||||
|
||||
TFA_DEVICE_TOKEN_KEY = "otp_device_id"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Custom authentication for DRF."""
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from django_otp.models import Device
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
from . import constants
|
||||
|
||||
|
||||
class NotVerified(APIException):
|
||||
status_code = status.HTTP_418_IM_A_TEAPOT
|
||||
default_detail = _('Missing 2FA verification.')
|
||||
default_code = 'not_verified'
|
||||
|
||||
|
||||
class JWTAuthenticationWith2FA(JWTAuthentication):
|
||||
"""Add 2FA support."""
|
||||
|
||||
def verify_user(self, request, user):
|
||||
header = self.get_header(request)
|
||||
raw_token = self.get_raw_token(header)
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
url_exceptions = (
|
||||
reverse("v2:account-tfa-verify-code"),
|
||||
)
|
||||
if request.path in url_exceptions:
|
||||
return
|
||||
if constants.TFA_DEVICE_TOKEN_KEY in validated_token:
|
||||
device_id = validated_token[constants.TFA_DEVICE_TOKEN_KEY]
|
||||
device = Device.from_persistent_id(device_id)
|
||||
if device is not None and device.user_id == user.pk:
|
||||
return
|
||||
raise NotVerified
|
||||
|
||||
def authenticate(self, request):
|
||||
result = super().authenticate(request)
|
||||
if result is None:
|
||||
return None
|
||||
user, token = result
|
||||
if user.tfa_enabled:
|
||||
self.verify_user(request, user)
|
||||
return user, token
|
||||
@@ -120,19 +120,25 @@ class Registry(object):
|
||||
"parameters": []
|
||||
}
|
||||
for name, config in list(sconfig["params"].items()):
|
||||
field = serializer.fields[name]
|
||||
data = {
|
||||
"name": name,
|
||||
"label": config["label"],
|
||||
"help_text": config.get("help_text", ""),
|
||||
"display": config.get("display", ""),
|
||||
"widget": field.__class__.__name__,
|
||||
}
|
||||
if data["widget"] == "ChoiceField":
|
||||
data["choices"] = [
|
||||
{"text": value, "value": key}
|
||||
for key, value in field.choices.items()
|
||||
]
|
||||
if config.get("separator"):
|
||||
data["widget"] = "SeparatorField"
|
||||
else:
|
||||
field = serializer.fields[name]
|
||||
if config.get("password"):
|
||||
data["widget"] = "PasswordField"
|
||||
else:
|
||||
data["widget"] = field.__class__.__name__
|
||||
if data["widget"] == "ChoiceField":
|
||||
data["choices"] = [
|
||||
{"text": value, "value": key}
|
||||
for key, value in field.choices.items()
|
||||
]
|
||||
item["parameters"].append(data)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user