Merge pull request #2437 from modoboa/feature/user_profile_v2

Feature/user profile v2
This commit is contained in:
Antoine Nguyen
2022-02-04 14:44:19 +01:00
committed by GitHub
38 changed files with 1204 additions and 135 deletions
+25
View File
@@ -6,8 +6,33 @@ export default {
getMe () {
return repository.get(`/${resource}/me/`)
},
checkPassword (value) {
return repository.post(`/${resource}/me/password/`, { password: value })
},
verifyTFACode (code) {
const payload = { code }
return repository.post(`${resource}/tfa/verify/`, payload)
},
startTFASetup () {
return repository.post(`${resource}/tfa/setup/`)
},
getQRCodeForTFASetup () {
return repository.get(`${resource}/tfa/setup/qr_code/`)
},
finalizeTFASetup (pinCode) {
const payload = { pin_code: pinCode }
return repository.post(`${resource}/tfa/setup/check/`, payload)
},
disableTFA () {
return repository.post(`${resource}/tfa/disable/`)
},
resetRecoveryCodes () {
return repository.post(`${resource}/tfa/reset_codes/`)
},
getForward () {
return repository.get(`${resource}/forward/`)
},
setForward (data) {
return repository.post(`${resource}/forward/`, data)
}
}
+9
View File
@@ -0,0 +1,9 @@
import repository from './repository'
const resource = 'languages'
export default {
getAll () {
return repository.get(`/${resource}/`)
}
}
@@ -31,6 +31,19 @@ mail.{{ domain.name }}. IN A <strong>[<translate>IP address of your Modoboa serv
@ IN TXT "v=spf1 mx ~all"</pre>
</v-alert>
<v-alert
v-if="domain.enable_dkim && domain.dkim_public_key"
border="left"
colored-border
color="primary lighten-3"
elevation="2"
dense
>
<div class="title">DKIM</div>
<pre>
{{ domain.dkim_key_selector }}._domain_key.{{ domain.name }}. IN TXT (
{{ splitKey(`v=DKIM1;k=rsa;p=${domain.dkim_public_key}`) }}</pre>
</v-alert>
<v-alert
border="left"
colored-border
@@ -50,8 +63,8 @@ _dmarc.{{ domain.name }}. IN TXT "v=DMARC1; p=quarantine; pct=100;"</pre>
dense
>
<div class="title">Auto configuration</div>
<pre>
autoconfig.{{ domain.name }}. IN CNAME <strong>[<translate>hostname of your automx server</translate>]</strong>
<pre>
autoconfig.{{ domain.name }}. IN CNAME <strong>[<translate>hostname of your automx server</translate>]</strong>
autodiscover.{{ domain.name }}. IN CNAME <strong>[<translate>hostname of your automx server</translate>]</strong></pre>
</v-alert>
</v-card-text>
@@ -70,6 +83,19 @@ export default {
methods: {
close () {
this.$emit('close')
},
splitKey (value) {
let key = value
const result = []
while (key.length > 0) {
if (key.length > 74) {
result.push(` "${key.substring(0, 74)}"`)
key = key.substring(74)
} else {
result.push(` "${key}"`)
}
}
return result.join('\n')
}
}
}
@@ -173,7 +173,11 @@ export default {
this.$emit('close')
},
submit () {
accounts.create(this.account).then(resp => {
const data = { ...this.account }
if (this.account.role === 'SuperAdmins' && this.account.username.indexOf('@') === -1) {
delete data.mailbox
}
accounts.create(data).then(resp => {
bus.$emit('notification', { msg: this.$gettext('Account created') })
this.$emit('created')
this.close()
@@ -139,7 +139,7 @@ export default {
methods: {
async save () {
if (this.$refs.generalForm !== undefined) {
const valid = await this.$refs.generalForm.$refs.observer.validate()
const valid = await this.$refs.generalForm.validateForm()
if (!valid) {
return
}
@@ -178,7 +178,7 @@ export default {
account: {
handler: function (val) {
if (val) {
this.editedAccount = JSON.parse(JSON.stringify(val))
this.editedAccount = { ...val }
delete this.editedAccount.domains
if (this.editedAccount.mailbox === null) {
this.editedAccount.mailbox = {}
@@ -3,7 +3,6 @@
<validation-provider
v-slot="{ errors }"
rules="required"
vid="username"
>
<label class="m-label">{{ $gettext('Username') }}</label>
<email-field
@@ -12,67 +11,29 @@
:error-messages="errors"
:placeholder="usernamePlaceholder"
:type="usernameInputType"
outlined
dense
/>
</validation-provider>
<label class="m-label">{{ $gettext('First name') }}</label>
<v-text-field
v-model="account.first_name"
autocomplete="new-password"
outlined
dense
/>
<label class="m-label">{{ $gettext('Last name') }}</label>
<v-text-field
v-model="account.last_name"
autocomplete="new-password"
outlined
dense
/>
<div class="d-flex align-center">
<v-switch
v-model="account.random_password"
:label="'Random password'|translate"
@change="updatePassword"
dense
/>
<template v-if="account.pk && account.random_password">
<v-alert style="background-color: #515D78" class="ml-6" dense>
<span class="white--text mr-4">{{ account.password }}</span>
<v-btn small color="white" icon :title="'Copy to clipboard'|translate" @click="copyPassword">
<v-icon>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</v-alert>
</template>
</div>
<validation-provider
v-if="!account.random_password"
v-slot="{ errors }"
:rules="passwordRules"
vid="password"
>
<label class="m-label">{{ $gettext('Password') }}</label>
<v-text-field
v-model="account.password"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<validation-provider
v-if="!account.random_password"
v-slot="{ errors }"
:rules="passwordConfirmationRules"
>
<label class="m-label">{{ $gettext('Confirmation') }}</label>
<v-text-field
v-model="account.password_confirmation"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<account-password-form
ref="passwordForm"
v-model="account.password"
:account="account"
/>
<v-switch
v-model="account.is_active"
:label="'Enabled' | translate"
@@ -82,22 +43,16 @@
</template>
<script>
import { bus } from '@/main'
import accounts from '@/api/accounts'
import AccountPasswordForm from './AccountPasswordForm'
import EmailField from '@/components/tools/EmailField'
export default {
components: {
AccountPasswordForm,
EmailField
},
props: ['account'],
computed: {
passwordRules () {
return (this.account.pk) ? '' : 'required'
},
passwordConfirmationRules () {
return (this.account.password) ? 'required' : ''
},
usernamePlaceholder () {
if (this.account.role === 'SimpleUsers') {
return this.$gettext('Enter an email address')
@@ -109,25 +64,8 @@ export default {
}
},
methods: {
copyPassword () {
navigator.clipboard.writeText(this.account.password).then(() => {
bus.$emit('notification', { msg: this.$gettext('Password copied to clipboard') })
})
},
updatePassword (value) {
if (value) {
accounts.getRandomPassword().then(resp => {
this.$set(this.account, 'password', resp.data.password)
})
} else {
this.$set(this.account, 'password', null)
this.$set(this.account, 'password_confirmation', null)
}
}
},
mounted () {
if (this.account.random_password) {
this.updatePassword(true)
async validateForm () {
return await this.$refs.observer.validate() && await this.$refs.passwordForm.validate()
}
}
}
@@ -0,0 +1,130 @@
<template>
<div>
<validation-observer ref="observer">
<validation-provider
v-if="withPasswordCheck"
v-slot="{ errors }"
rules="required|validPassword"
mode="lazy"
>
<label class="m-label">{{ $gettext('Current password') }}</label>
<v-text-field
v-model="form.currentPassword"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<div class="d-flex align-center">
<v-switch
v-model="form.random_password"
:label="'Random password'|translate"
@change="updatePassword"
dense
/>
<template v-if="form.random_password">
<v-alert style="background-color: #515D78" class="ml-6" dense>
<span class="white--text mr-4">{{ form.password }}</span>
<v-btn small color="white" icon :title="'Copy to clipboard'|translate" @click="copyPassword">
<v-icon>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</v-alert>
</template>
</div>
<validation-provider
v-if="!form.random_password"
v-slot="{ errors }"
:rules="passwordRules"
vid="password"
>
<label class="m-label">{{ $gettext('Password') }}</label>
<v-text-field
:value="value"
:error-messages="errors"
outlined
type="password"
dense
@input="(value) => $emit('input', value)"
/>
</validation-provider>
<validation-provider
v-if="!form.random_password"
v-slot="{ errors }"
:rules="passwordConfirmationRules"
vid="confirmation"
>
<label class="m-label">{{ $gettext('Confirmation') }}</label>
<v-text-field
v-model="form.password_confirmation"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
</validation-observer>
</div>
</template>
<script>
import { bus } from '@/main'
import accounts from '@/api/accounts'
export default {
props: {
value: String,
withPasswordCheck: {
type: Boolean,
default: false
},
account: {
type: Object,
required: false
}
},
data () {
return {
form: {}
}
},
computed: {
passwordRules () {
return (this.account && this.account.pk) ? '' : 'required'
},
passwordConfirmationRules () {
return (this.value) ? 'required|samePassword:password' : ''
}
},
methods: {
copyPassword () {
navigator.clipboard.writeText(this.form.password).then(() => {
bus.$emit('notification', { msg: this.$gettext('Password copied to clipboard') })
})
},
updatePassword (value) {
if (value) {
accounts.getRandomPassword().then(resp => {
this.$set(this.form, 'password', resp.data.password)
this.$emit('input', this.form.password)
})
} else {
this.$set(this.form, 'password', null)
this.$set(this.form, 'password_confirmation', null)
}
},
async validate () {
return await this.$refs.observer.validate()
}
},
mounted () {
if (this.account) {
this.form.random_password = this.account.random_password
if (this.form.random_password) {
this.updatePassword(true)
}
}
}
}
</script>
+57 -4
View File
@@ -1,9 +1,9 @@
<template>
<v-navigation-drawer
<v-navigation-drawer
v-model="drawer"
:mini-variant.sync="mini"
permanent
color="primary"
:color="mainColor"
app
dark
>
@@ -11,6 +11,8 @@
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="190"
class="logo"
@click="$router.push('/')"
/>
<v-btn
icon
@@ -21,8 +23,8 @@
</div>
<v-list>
<template v-for="item in mainMenuItems">
<template v-if="item.roles === undefined || item.roles.indexOf(authUser.role) !== -1">
<template v-for="item in menuItems">
<template v-if="displayMenuItem(item)">
<v-list-item
v-if="!item.children"
class="menu-item"
@@ -93,6 +95,7 @@
<v-list-item
v-for="item in userMenuItems"
:key="item.text"
:to="item.to"
@click="item.click"
link
>
@@ -135,6 +138,18 @@ export default {
return (this.authUser.first_name || this.authUser.last_name)
? `${this.authUser.first_name} ${this.authUser.last_name}`
: this.authUser.username
},
menuItems () {
if (this.$route.meta.layout === 'user') {
return this.userSettingsMenuItems
}
return this.mainMenuItems
},
mainColor () {
if (this.$route.meta.layout === 'user') {
return 'grey'
}
return 'primary'
}
},
data () {
@@ -183,8 +198,39 @@ export default {
roles: ['SuperAdmins']
}
],
userSettingsMenuItems: [
{
text: this.$gettext('Profile'),
to: { name: 'UserProfile' },
icon: 'mdi-account-circle-outline',
exact: true
},
{
text: this.$gettext('Security'),
to: { name: 'UserSecurity' },
icon: 'mdi-lock-outline',
exact: true
},
// {
// text: this.$gettext('Preferences'),
// icon: 'mdi-tune',
// exact: true
// },
{
text: this.$gettext('Forward'),
condition: () => this.authUser.mailbox !== null,
to: { name: 'UserForward' },
icon: 'mdi-forward',
exact: true
}
],
mini: false,
userMenuItems: [
{
text: this.$gettext('Profile'),
icon: 'mdi-account-circle-outline',
to: { name: 'UserProfile' }
},
{
text: this.$gettext('Logout'),
icon: 'mdi-logout',
@@ -204,6 +250,9 @@ export default {
})
},
methods: {
displayMenuItem (item) {
return (item.roles === undefined || item.roles.indexOf(this.authUser.role) !== -1) && (item.condition === undefined || item.condition())
},
logout () {
this.$store.dispatch('auth/logout').then(() => {
this.$router.push({ name: 'Login' })
@@ -228,4 +277,8 @@ export default {
.user-box {
background: rgba(0, 0, 0, 0.25);
}
.logo {
cursor: pointer;
}
</style>
@@ -107,7 +107,6 @@ export default {
return
}
}
this.$emit('close')
this.currentStep = 1
this.steps.forEach((item, index) => {
const observer = this.formObserverGetter(index + 1)
@@ -115,6 +114,7 @@ export default {
observer.reset()
}
})
this.$emit('close')
},
async goToNextStep (current, next) {
const observer = this.formObserverGetter(current)
@@ -5,6 +5,7 @@
v-model="input"
outlined
dense
autocomplete="new-password"
v-bind="$attrs"
v-on="$listeners"
@input="update"
@@ -150,6 +151,7 @@ export default {
},
watch: {
value (newValue) {
console.log(newValue)
this.input = newValue
}
}
@@ -0,0 +1,62 @@
<template>
<div>
<v-card flat>
<v-card-text>
<label class="m-label"><translate>Recipient(s)</translate></label>
<v-textarea
v-model="form.recipients"
:hint="'Indicate one or more recipients separated by a comma (,)'|translate"
persistent-hint
rows="3"
outlined
/>
<v-checkbox
v-model="form.keepcopies"
:label="'Keep local copies'|translate"
:hint="'Forward messages and store copies into your local mailbox'|translate"
persistent-hint
/>
</v-card-text>
<v-card-actions class="pa-4">
<v-btn
color="success"
:loading="loading"
@click="submit"
>
<translate>Update</translate>
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
import account from '@/api/account'
import { bus } from '@/main'
export default {
props: {
account: Object
},
data () {
return {
form: {},
loading: false
}
},
methods: {
submit () {
this.loading = true
account.setForward(this.form).then(resp => {
this.loading = false
bus.$emit('notification', { type: 'success', msg: this.$gettext('Forward updated') })
})
}
},
mounted () {
account.getForward().then(resp => {
this.form = resp.data
})
}
}
</script>
@@ -0,0 +1,134 @@
<template>
<v-expansion-panels
v-model="panel"
flat
>
<v-expansion-panel>
<v-expansion-panel-header><translate>General settings</translate></v-expansion-panel-header>
<v-expansion-panel-content>
<label class="m-label">{{ $gettext('First name') }}</label>
<v-text-field
v-model="form.first_name"
outlined
dense
/>
<label class="m-label">{{ $gettext('Last name') }}</label>
<v-text-field
v-model="form.last_name"
outlined
dense
/>
<label class="m-label">{{ $gettext('Language') }}</label>
<v-autocomplete
v-model="form.language"
:items="languages"
item-text="label"
item-value="code"
outlined
dense
/>
<label class="m-label">{{ $gettext('Phone number') }}</label>
<v-text-field
v-model="form.phone_number"
:label="'Phone number' | translate"
outlined
dense
/>
<label class="m-label">{{ $gettext('Secondary email') }}</label>
<v-text-field
v-model="form.secondary_email"
outlined
dense
/>
<v-btn
color="success"
@click="updateGeneralSettings"
>
<translate>Update settings</translate>
</v-btn>
</v-expansion-panel-content>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-header><translate>Password</translate></v-expansion-panel-header>
<v-expansion-panel-content>
<account-password-form
ref="passwordForm"
v-model="password"
with-password-check
/>
<v-btn
color="success"
@click="updatePassword"
>
<translate>Update Password</translate>
</v-btn>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</template>
<script>
import accounts from '@/api/accounts'
import { bus } from '@/main'
import languages from '@/api/languages'
import AccountPasswordForm from '@/components/identities/AccountPasswordForm'
export default {
props: {
account: Object
},
components: {
AccountPasswordForm
},
data () {
return {
form: {},
languages: [],
panel: 0,
password: null
}
},
methods: {
initFormFromAccount (account) {
this.form = {
first_name: account.first_name,
last_name: account.last_name,
language: account.language,
phone_number: account.phone_number,
secondary_email: account.secondary_email
}
},
updateGeneralSettings () {
accounts.patch(this.account.pk, this.form).then(resp => {
bus.$emit('notification', { msg: this.$gettext('Profile updated') })
})
},
async updatePassword () {
const valid = await this.$refs.passwordForm.validate()
if (!valid) {
return
}
accounts.patch(this.account.pk, { password: this.password }).then(resp => {
bus.$emit('notification', { msg: this.$gettext('Password updated') })
this.$refs.passwordObserver.reset()
})
}
},
mounted () {
console.log('profile')
this.initFormFromAccount(this.account)
languages.getAll().then(resp => {
this.languages = resp.data
})
},
watch: {
account (value) {
if (value) {
this.initFormFromAccount(value)
} else {
this.form = {}
}
}
}
}
</script>
@@ -0,0 +1,45 @@
<template>
<v-card>
<v-card-title>
<span class="headline"><translate>New recovery codes</translate></span>
</v-card-title>
<v-card-text>
<v-alert type="info">
<translate>
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.
</translate>
</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"
>
<translate>Close</translate>
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
export default {
props: {
tokens: Array
},
methods: {
close () {
this.$emit('close')
}
}
}
</script>
@@ -0,0 +1,160 @@
<template>
<div>
<v-card flat>
<v-card-title>
<span class="text-subtitle-1"><translate>Two factor authentication</translate></span>
</v-card-title>
<v-card-text>
<template v-if="qrCode">
<v-row>
<v-col cols="4">
<div v-html="qrCode"></div>
</v-col>
<v-col cols="6">
<v-alert type="info">
<translate>
Install a soft token authenticator like FreeOTP or Google
Authenticator from your application repository and use that
app to scan this QR code.
</translate>
</v-alert>
<label class="m-label"><translate>Pin code</translate></label>
<validation-observer ref="observer">
<validation-provider
v-slot="{ errors }"
rules="required"
>
<v-text-field
v-model="pinCode"
outlined
:error-messages="errors"
/>
</validation-provider>
</validation-observer>
<v-btn color="primary" @click="finalizeTFASetup">
<translate>Register</translate>
</v-btn>
</v-col>
</v-row>
</template>
<template v-else-if="tokens.length">
<v-alert type="success">
<translate>
Congratulations! Two-Factor Authentication is now enabled for your account.
</translate>
</v-alert>
<p>
<translate>
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.
</translate>
</p>
<ul>
<li v-for="token in tokens" :key="token">
{{ token }}
</li>
</ul>
</template>
<template v-else-if="account.tfa_enabled">
<v-alert type="info">
Two-Factor Authentication is enabled for your account.
</v-alert>
<v-btn color="error" @click="disableTFA" class="mr-2">
<translate>Disable 2FA</translate>
</v-btn>
<v-btn @click="resetRecoveryCodes">
<translate>Reset recovery codes</translate>
</v-btn>
</template>
<template v-else>
<translate tag="p" class="my-4">
Two-Factor Authentication (2FA) is not yet activated for your account. Enabling this feature will increase your account's security.
</translate>
<v-btn color="success" @click="startTFASetup">
<translate>Enable 2FA</translate>
</v-btn>
</template>
</v-card-text>
</v-card>
<v-dialog
v-model="showCodesResetDialog"
max-width="800px"
persistent
>
<recovery-codes-reset-dialog
:tokens="newTokens"
@close="closeRecoveryCodesResetDialog"
/>
</v-dialog>
</div>
</template>
<script>
import account from '@/api/account'
import Cookies from 'js-cookie'
import RecoveryCodesResetDialog from './RecoveryCodesResetDialog'
export default {
components: {
RecoveryCodesResetDialog
},
props: {
account: Object
},
data () {
return {
newTokens: [],
pinCode: null,
qrCode: null,
showCodesResetDialog: false,
tokens: []
}
},
methods: {
getQRCode () {
account.getQRCodeForTFASetup().then(resp => {
this.qrCode = resp.data
})
},
startTFASetup () {
account.startTFASetup().then(resp => {
this.getQRCode()
})
},
async finalizeTFASetup () {
const valid = await this.$refs.observer.validate()
if (!valid) {
return
}
const resp = await account.finalizeTFASetup(this.pinCode)
this.qrCode = null
this.tokens = resp.data.tokens
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
this.$store.dispatch('auth/initialize')
},
disableTFA () {
account.disableTFA().then(resp => {
this.$store.dispatch('auth/fetchUser')
})
},
resetRecoveryCodes () {
account.resetRecoveryCodes().then(resp => {
this.newTokens = resp.data.tokens
this.showCodesResetDialog = true
})
},
closeRecoveryCodesResetDialog () {
this.newTokens = []
this.showCodesResetDialog = false
}
},
created () {
if (!this.account.tfa_enabled) {
this.getQRCode()
}
}
}
</script>
+17 -1
View File
@@ -1,11 +1,16 @@
import Vue from 'vue'
import { ValidationObserver, ValidationProvider, extend } from 'vee-validate'
import { numeric, required } from 'vee-validate/dist/rules'
import { confirmed, numeric, required } from 'vee-validate/dist/rules'
import { translate } from 'vue-gettext'
import account from '@/api/account'
const { gettext: $gettext } = translate
// Declare builtin rules here
extend('samePassword', {
...confirmed,
message: () => $gettext('Passwords mismatch')
})
extend('numeric', {
...numeric,
message: () => $gettext('This field must be a valid number')
@@ -16,6 +21,17 @@ extend('required', {
})
// Add custom rules here
extend('validPassword', {
validate: async (value) => {
try {
await account.checkPassword(value)
return true
} catch (error) {
return false
}
},
message: $gettext('Invalid password')
})
// Register components globally
Vue.component('ValidationObserver', ValidationObserver)
+27
View File
@@ -109,6 +109,33 @@ const routes = [
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'SuperAdmins']
}
},
{
path: '/user/profile',
name: 'UserProfile',
component: () => import('../views/user/Profile.vue'),
meta: {
requiresAuth: true,
layout: 'user'
}
},
{
path: '/user/security',
name: 'UserSecurity',
component: () => import('../views/user/Security.vue'),
meta: {
requiresAuth: true,
layout: 'user'
}
},
{
path: '/user/forward',
name: 'UserForward',
component: () => import('../views/user/Forward.vue'),
meta: {
requiresAuth: true,
layout: 'user'
}
}
]
+7 -11
View File
@@ -9,12 +9,6 @@ function setupAxios (token) {
repository.defaults.headers.post['Content-Type'] = 'application/json'
}
function fetchUser (commit) {
return account.getMe().then(resp => {
commit('SET_AUTH_USER', { authUser: resp.data, isAuthenticated: true })
})
}
const state = () => ({
authUser: {},
isAuthenticated: false
@@ -38,16 +32,18 @@ const mutations = {
}
const actions = {
initialize ({ commit, state }) {
if (state.isAuthenticated) {
return
}
fetchUser ({ commit }) {
return account.getMe().then(resp => {
commit('SET_AUTH_USER', { authUser: resp.data, isAuthenticated: true })
})
},
initialize ({ commit, dispatch, state }) {
const token = Cookies.get('token')
if (!token) {
return
}
setupAxios(token)
return fetchUser(commit)
return dispatch('fetchUser')
},
logout ({ commit }) {
return new Promise((resolve, reject) => {
+12 -10
View File
@@ -15,16 +15,18 @@
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
/>
<v-row>
<v-col cols="6" offset="3">
<label class="m-label"><translate>Two-factor authentication code</translate></label>
<v-otp-input
v-model="code"
length="6"
: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
/>
</v-col>
</v-row>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center mt-6">
+32
View File
@@ -0,0 +1,32 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title><translate>Forward</translate></v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<forward-form :account="authUser" />
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import ForwardForm from '@/components/user/ForwardForm'
export default {
components: {
ForwardForm
},
computed: {
...mapGetters({
authUser: 'auth/authUser'
})
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title><translate>Profile</translate></v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<profile-form :account="authUser" />
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import ProfileForm from '@/components/user/ProfileForm'
export default {
components: {
ProfileForm
},
computed: {
...mapGetters({
authUser: 'auth/authUser'
})
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title><translate>Security</translate></v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<two-factor-auth-form :account="authUser" />
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import TwoFactorAuthForm from '@/components/user/TwoFactorAuthForm'
export default {
components: {
TwoFactorAuthForm
},
computed: {
...mapGetters({
authUser: 'auth/authUser'
})
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+12 -7
View File
@@ -2110,9 +2110,9 @@ aws4@^1.8.0:
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
axios@^0.21.2:
version "0.21.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.2.tgz#21297d5084b2aeeb422f5d38e7be4fbb82239017"
integrity sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==
version "0.21.4"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
dependencies:
follow-redirects "^1.14.0"
@@ -4591,11 +4591,16 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3"
readable-stream "^2.3.6"
follow-redirects@^1.0.0, follow-redirects@^1.14.0:
follow-redirects@^1.0.0:
version "1.14.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
follow-redirects@^1.14.0:
version "1.14.7"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -9772,9 +9777,9 @@ vuetify-loader@^1.3.0:
loader-utils "^2.0.0"
vuetify@^2.2.11:
version "2.5.6"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-2.5.6.tgz#9cbb1eacece6c42028216312b9be23e35a7f5cf4"
integrity sha512-2T8ML5PYuJ/AdMVH3ZIvzHnsM0nX8t4Xzj+0HFFGfLT7jLlptCFpG+JE8+kyrgGZlbUgulyOwCUu8hJkVsReFA==
version "2.6.2"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-2.6.2.tgz#1ed4d3c9e734d55202bd566b789cce0e0013279a"
integrity sha512-nx3uZkO8MZNMshUEh1xKaQ1hQYepNwWFn3FVxKt+XBVf7ZFscd0GS/a3KZo4B3baXQmziCQAZKNIQF5IWeaIUw==
vuex@^3.4.0:
version "3.6.2"
+2 -2
View File
@@ -195,12 +195,12 @@ 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", "tfa_enabled"
)
def __init__(self, *args, **kwargs):
"""Adapt fields to current user."""
super(AccountSerializer, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
request = self.context.get("request")
if not request:
return
+42
View File
@@ -4,6 +4,7 @@ import os
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core import validators as dj_validators
from django.utils.translation import ugettext as _
from django.contrib.auth import password_validation
@@ -344,6 +345,30 @@ class WritableAccountSerializer(v1_serializers.WritableAccountSerializer):
)
return user
def update(self, instance, validated_data):
"""Update account and associated objects."""
mailbox_data = validated_data.pop("mailbox", None)
password = validated_data.pop("password", None)
domains = validated_data.pop("domains", [])
for key, value in validated_data.items():
setattr(instance, key, value)
if password:
instance.set_password(password)
if mailbox_data:
creator = self.context["request"].user
if hasattr(instance, "mailbox"):
if "username" in validated_data:
mailbox_data["email"] = validated_data["username"]
instance.email = validated_data["username"]
instance.mailbox.update_from_dict(creator, mailbox_data)
elif "username" in validated_data:
mailbox_data["full_address"] = validated_data["username"]
instance.email = validated_data["username"]
self._create_mailbox(creator, instance, mailbox_data)
instance.save()
self.set_permissions(instance, domains)
return instance
class DeleteAccountSerializer(serializers.Serializer):
"""Serializer used with delete operation."""
@@ -361,3 +386,20 @@ class AliasSerializer(v1_serializers.AliasSerializer):
for field in v1_serializers.AliasSerializer.Meta.fields
if field != "internal"
) + ("expire_at", "description")
class UserForwardSerializer(serializers.Serializer):
"""Serializer to define user forward."""
recipients = serializers.CharField(required=False)
keepcopies = serializers.BooleanField(default=False)
def validate_recipients(self, value):
value = value.strip()
recipients = []
if not value:
return recipients
for recipient in value.split(","):
dj_validators.validate_email(recipient)
recipients.append(recipient)
return recipients
+77
View File
@@ -158,6 +158,38 @@ class AccountViewSetTestCase(ModoAPITestCase):
with self.assertRaises(core_models.User.DoesNotExist):
account.refresh_from_db()
def test_update(self):
account = core_models.User.objects.get(username="user@test.com")
url = reverse("v2:account-detail", args=[account.pk])
data = {
"username": "user@test.com",
"role": "SimpleUsers",
"password": "Toto12345",
"mailbox": {
"quota": 10
}
}
resp = self.client.patch(url, data, format="json")
self.assertEqual(resp.status_code, 200)
def test_update_admin(self):
account = core_models.User.objects.get(username="admin")
url = reverse("v2:account-detail", args=[account.pk])
data = {
"username": "superadmin@test.com",
"role": "SuperAdmins",
"password": "Toto12345",
"mailbox": {
"quota": 10
}
}
resp = self.client.patch(url, data, format="json")
self.assertEqual(resp.status_code, 200)
account.refresh_from_db()
self.assertEqual(account.email, data["username"])
self.assertEqual(account.mailbox.full_address, data["username"])
class IdentityViewSetTestCase(ModoAPITestCase):
@@ -201,3 +233,48 @@ class AliasViewSetTestCase(ModoAPITestCase):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn("address", resp.json())
class UserAccountViewSetTestCase(ModoAPITestCase):
@classmethod
def setUpTestData(cls): # NOQA:N802
"""Create test data."""
super().setUpTestData()
factories.populate_database()
cls.da = core_models.User.objects.get(username="admin@test.com")
cls.da_token = Token.objects.create(user=cls.da)
def test_forward(self):
self.client.credentials(
HTTP_AUTHORIZATION="Token " + self.da_token.key)
url = reverse("v2:account-forward")
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertNotIn("recipients", resp.json())
data = {"recipients": "user@domain.ext"}
resp = self.client.post(url, data, format="json")
self.assertEqual(resp.status_code, 200)
self.assertEqual(self.da.mailbox.aliasrecipient_set.count(), 1)
data = {"recipients": "user@domain.ext", "keepcopies": True}
resp = self.client.post(url, data, format="json")
self.assertEqual(resp.status_code, 200)
self.assertEqual(
models.Alias.objects.filter(address=self.da.username).count(),
2
)
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["keepcopies"])
data = {"keepcopies": False}
resp = self.client.post(url, data, format="json")
self.assertEqual(resp.status_code, 200)
self.assertEqual(
models.Alias.objects.filter(address=self.da.username).count(),
1
)
+2
View File
@@ -17,5 +17,7 @@ router.register(
basename="sender_address")
router.register(
r"identities", viewsets.IdentityViewSet, basename="identities")
router.register(
r"account", viewsets.UserAccountViewSet, basename="account")
urlpatterns = router.urls
+53
View File
@@ -212,3 +212,56 @@ class AliasViewSet(v1_viewsets.AliasViewSet):
return response.Response({
"address": models.Alias.generate_random_address()
})
class UserAccountViewSet(viewsets.ViewSet):
"""Viewset for current user operations."""
@action(methods=["get", "post"], detail=False)
def forward(self, request, **kwargs):
"""Get or define user forward."""
mb = request.user.mailbox
alias = models.Alias.objects.filter(
address=mb.full_address, internal=False).first()
data = {}
if request.method == "GET":
if alias is not None and alias.recipients:
recipients = list(alias.recipients)
if alias.aliasrecipient_set.filter(r_mailbox=mb).exists():
data["keepcopies"] = True
recipients.remove(mb.full_address)
data["recipients"] = "\n".join(recipients)
serializer = serializers.UserForwardSerializer(data)
return response.Response(serializer.data)
serializer = serializers.UserForwardSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
recipients = serializer.validated_data.get("recipients")
if not recipients:
models.Alias.objects.filter(
address=mb.full_address, internal=False).delete()
# Make sure internal self-alias is enabled
models.Alias.objects.filter(
address=mb.full_address, internal=True
).update(enabled=True)
else:
if alias is None:
alias = models.Alias.objects.create(
address=mb.full_address,
domain=mb.domain,
enabled=mb.user.is_active
)
alias.post_create(request.user)
if serializer.validated_data["keepcopies"]:
# Make sure internal self-alias is enabled
models.Alias.objects.filter(
address=mb.full_address, internal=True
).update(enabled=True)
recipients.append(mb.full_address)
else:
# Deactivate internal self-alias to avoid storing
# local copies...
models.Alias.objects.filter(
address=mb.full_address, internal=True
).update(enabled=False)
alias.set_recipients(recipients)
return response.Response(serializer.validated_data)
+2 -2
View File
@@ -279,7 +279,7 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject):
def post_create(self, creator):
from modoboa.lib.permissions import grant_access_to_object
super(Mailbox, self).post_create(creator)
super().post_create(creator)
conditions = (
creator.has_perm("admin.add_mailbox"),
not self.user.has_perm("admin.add_domain")
@@ -341,7 +341,7 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject):
if self.quota_value is None:
self.quota_value, created = Quota.objects.get_or_create(
username=self.full_address)
super(Mailbox, self).save(*args, **kwargs)
super().save(*args, **kwargs)
reversion.register(Mailbox)
+12
View File
@@ -220,3 +220,15 @@ class VerifyTFACodeSerializer(serializers.Serializer):
if device is None:
raise serializers.ValidationError(_("This code is invalid"))
return device
class CheckPasswordSerializer(serializers.Serializer):
"""Simple serializer to check user password."""
password = serializers.CharField()
def validate_password(self, value):
user = self.context["user"]
if not user.check_password(value):
raise serializers.ValidationError(_("Invalid password"))
return value
+52
View File
@@ -119,6 +119,13 @@ class AccountViewSetTestCase(ModoAPITestCase):
me = resp.json()
self.assertEqual(me["username"], "admin")
def test_me_password(self):
url = reverse("v2:account-check-me-password")
resp = self.client.post(url, {"password": "Toto1234"}, format="json")
self.assertEqual(resp.status_code, 400)
resp = self.client.post(url, {"password": "password"}, format="json")
self.assertEqual(resp.status_code, 200)
@mock.patch("django_otp.match_token")
def test_tfa_verify_code(self, match_mock):
user = models.User.objects.get(username="admin")
@@ -133,6 +140,43 @@ class AccountViewSetTestCase(ModoAPITestCase):
self.assertEqual(resp.status_code, 200)
self.assertIn("access", resp.json())
def test_tfa_setup_get_qr_code(self):
url = reverse("v2:account-tfa-setup-get-qr-code")
resp = self.client.get(url)
self.assertEqual(resp.status_code, 404)
user = models.User.objects.get(username="admin")
user.totpdevice_set.create(name="Device")
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content_type, "application/xml")
user.tfa_enabled = True
user.save()
resp = self.client.get(url)
self.assertEqual(resp.status_code, 404)
@mock.patch("django_otp.plugins.otp_totp.models.TOTPDevice.verify_token")
def test_tfa_setup_check(self, verify_mock):
user = models.User.objects.get(username="admin")
user.totpdevice_set.create(name="Device")
url = reverse("v2:account-tfa-setup-check")
data = {"pin_code": 1234}
verify_mock.side_effect = [False]
resp = self.client.post(url, data, format="json")
self.assertEqual(resp.status_code, 400)
verify_mock.side_effect = [True]
resp = self.client.post(url, data, format="json")
self.assertEqual(resp.status_code, 200)
self.assertIn("tokens", resp.json())
verify_mock.side_effect = [True]
resp = self.client.post(url, data, format="json")
self.assertEqual(resp.status_code, 400)
class AuthenticationTestCase(ModoAPITestCase):
@@ -171,3 +215,11 @@ class AuthenticationTestCase(ModoAPITestCase):
HTTP_AUTHORIZATION="Bearer {}".format(resp.json()["access"]))
resp = self.client.get(me_url)
self.assertEqual(resp.status_code, 200)
class LanguageViewSetTestCase(ModoAPITestCase):
def test_list(self):
url = reverse("v2:language-list")
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
+1
View File
@@ -13,6 +13,7 @@ from . import viewsets
router = routers.SimpleRouter()
router.register(r"account", viewsets.AccountViewSet, basename="account")
router.register(r"languages", viewsets.LanguageViewSet, basename="language")
router.register(r"logs/audit-trail", viewsets.LogViewSet)
urlpatterns = router.urls
+80
View File
@@ -1,11 +1,18 @@
"""Core API v2 viewsets."""
import io
import qrcode
import qrcode.image.svg
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from drf_spectacular.utils import extend_schema
from rest_framework import filters, 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 serializers as core_v1_serializers
from modoboa.core.api.v1 import viewsets as core_v1_viewsets
from modoboa.lib import pagination
@@ -24,6 +31,19 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
serializer = admin_v1_serializers.AccountSerializer(request.user)
return response.Response(serializer.data)
@action(
methods=['post'],
detail=False,
url_path='me/password',
serializer_class=serializers.CheckPasswordSerializer
)
def check_me_password(self, request):
"""Check current user password."""
serializer = serializers.CheckPasswordSerializer(
data=request.data, context={"user": request.user})
serializer.is_valid(raise_exception=True)
return response.Response()
@action(methods=["post"], detail=False, url_path="tfa/verify")
def tfa_verify_code(self, request):
"""Verify given code validity."""
@@ -38,6 +58,52 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
"access": str(refresh.access_token)
})
@action(methods=["get"], detail=False, url_path="tfa/setup/qr_code")
def tfa_setup_get_qr_code(self, request):
"""Get a QR code to finalize the setup process."""
if request.user.tfa_enabled:
return response.Response(status=404)
device = request.user.totpdevice_set.first()
if not device:
return response.Response(status=404)
factory = qrcode.image.svg.SvgPathImage
img = qrcode.make(device.config_url, image_factory=factory)
buf = io.BytesIO()
img.save(buf)
return response.Response(buf.getvalue(), content_type="application/xml")
@extend_schema(
request=core_v1_serializers.CheckTFASetupSerializer
)
@action(methods=["post"], detail=False, url_path="tfa/setup/check")
def tfa_setup_check(self, request):
"""Check TFA setup."""
serializer = core_v1_serializers.CheckTFASetupSerializer(
data=request.data, context={"user": request.user})
serializer.is_valid(raise_exception=True)
# create static device for recovery purposes
device = StaticDevice.objects.create(
user=request.user,
name="{} static device".format(request.user)
)
tokens = []
for cpt in range(10):
token = StaticToken.random_token()
device.token_set.create(token=token)
tokens.append(token)
# Set enable flag to True so we can't go here anymore
request.user.tfa_enabled = True
request.user.save()
# Generate new tokens
device = request.user.totpdevice_set.first()
refresh = RefreshToken.for_user(request.user)
refresh[constants.TFA_DEVICE_TOKEN_KEY] = device.persistent_id
return response.Response({
"tokens": tokens,
"refresh": str(refresh),
"access": str(refresh.access_token)
})
class LogViewSet(viewsets.ReadOnlyModelViewSet):
"""Log viewset."""
@@ -53,3 +119,17 @@ class LogViewSet(viewsets.ReadOnlyModelViewSet):
queryset = models.Log.objects.all()
search_fields = ["logger", "level", "message"]
serializer_class = serializers.LogSerializer
class LanguageViewSet(viewsets.ViewSet):
"""Language viewset."""
permission_classes = (
permissions.IsAuthenticated,
)
def list(self, request, *args, **kwargs):
languages = [
{"code": lang[0], "label": lang[1]} for lang in constants.LANGUAGES
]
return response.Response(languages)
+2 -1
View File
@@ -16,7 +16,7 @@ class LocalConfigMiddleware(MiddlewareMixin):
class TwoFAMiddleware:
"""Custom 2FA middleware to enforce verification is used has TFA enabled."""
"""Custom 2FA middleware to enforce verification if user has TFA enabled."""
def __init__(self, get_response=None):
self.get_response = get_response
@@ -32,6 +32,7 @@ class TwoFAMiddleware:
user and
not user.is_anonymous and
user.tfa_enabled and
not request.path.startswith('/api/') and
request.path not in url_exceptions and
not user.is_verified()
)
+3 -2
View File
@@ -29,7 +29,7 @@ class AccountViewSetTestCase(ModoAPITestCase):
self.assertIn("not yet activated for", response.json()["content"])
# Start setup sequence
url = reverse("api:account-tfa-setup")
url = reverse("v1:account-tfa-setup")
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
@@ -40,7 +40,7 @@ class AccountViewSetTestCase(ModoAPITestCase):
# Continue setup sequence
verify_mock.side_effect = [True]
url = reverse("api:account-tfa-setup-check")
url = reverse("v1:account-tfa-setup-check")
data = {"pin_code": "1234"}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 200)
@@ -48,6 +48,7 @@ class AccountViewSetTestCase(ModoAPITestCase):
# Display recovery tokens
security_url = reverse("core:user_security")
response = self.client.get(security_url)
self.assertIn("recovery codes", response.json()["content"])
admin.refresh_from_db()
self.assertTrue(admin.tfa_enabled)
+4 -1
View File
@@ -251,7 +251,10 @@ class Email(object):
add_nofollow=True,
safe_attrs=safe_attrs
)
mail_text = lxml.html.tostring(cleaner.clean_html(html))
mail_text = lxml.html.tostring(
cleaner.clean_html(html), encoding="unicode")
with open("/tmp/output.txt", "w") as fp:
fp.write(mail_text)
return smart_text(mail_text)
def _map_cid(self, url):
@@ -12,9 +12,9 @@
someone
<a class="moz-txt-link-abbreviated" href="mailto:someone@example.net" target="_blank" rel="nofollow">someone@example.net</a></pre>
</body>
<span><b>Some <i>HTML</i> text</b>, a <a href="http://example.net" target="_blank" rel="nofollow">link</a>, an&#13;&#13;
embedded image and an external image.<br>&#13;&#13;
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAF0lEQVQY02NgYGD4//8/ThKfHASMRN0AfaqVa6MQxIQAAAAASUVORK5CYII=" target="_blank"><br>&#13;&#13;
<img src="http://example.net/image.png" target="_blank"><br>&#13;&#13;
Nifty!</span>&#13;
<span><b>Some <i>HTML</i> text</b>, a <a href="http://example.net" target="_blank" rel="nofollow">link</a>, an␍
embedded image and an external image.<br>␍
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAF0lEQVQY02NgYGD4//8/ThKfHASMRN0AfaqVa6MQxIQAAAAASUVORK5CYII=" target="_blank"><br>␍
<img src="http://example.net/image.png" target="_blank"><br>␍
Nifty!</span>
</div>
@@ -12,9 +12,9 @@
someone
<a class="moz-txt-link-abbreviated">someone@example.net</a></pre>
</body>
<span><b>Some <i>HTML</i> text</b>, a <a target="_blank" rel="nofollow">link</a>, an&#13;&#13;
embedded image and an external image.<br>&#13;&#13;
<img target="_blank"><br>&#13;&#13;
<img target="_blank"><br>&#13;&#13;
Nifty!</span>&#13;
<span><b>Some <i>HTML</i> text</b>, a <a target="_blank" rel="nofollow">link</a>, an␍
embedded image and an external image.<br>␍
<img target="_blank"><br>␍
<img target="_blank"><br>␍
Nifty!</span>
</div>
+18 -3
View File
@@ -158,7 +158,7 @@ WSGI_APPLICATION = 'test_project.wsgi.application'
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'en'
TIME_ZONE = 'UTC'
@@ -189,10 +189,11 @@ REST_FRAMEWORK = {
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
}
SPECTACULAR_SETTINGS = {
'SCHEMA_PATH_PREFIX': r'/api/v1',
'SCHEMA_PATH_PREFIX': r'/api/v[0-9]',
'TITLE': 'Modoboa API',
'VERSION': None,
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'],
@@ -279,6 +280,10 @@ LOGGING = {
'syslog': {
'format': '%(name)s: %(levelname)s %(message)s'
},
'django.server': {
'()': 'django.utils.log.ServerFormatter',
'format': '[%(server_time)s] %(message)s',
}
},
'handlers': {
'mail-admins': {
@@ -296,7 +301,12 @@ LOGGING = {
},
'console': {
'class': 'logging.StreamHandler'
}
},
'django.server': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'django.server',
},
},
'loggers': {
'django': {
@@ -314,6 +324,11 @@ LOGGING = {
'level': 'INFO',
'propagate': False
},
'django.server': {
'handlers': ['django.server'],
'level': 'INFO',
'propagate': False,
},
# 'django_auth_ldap': {
# 'level': 'DEBUG',
# 'handlers': ['console']