mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
Merge pull request #2437 from modoboa/feature/user_profile_v2
Feature/user profile v2
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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>
|
||||
<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
|
||||
embedded image and an external image.<br>
|
||||
<img target="_blank"><br>
|
||||
<img target="_blank"><br>
|
||||
Nifty!</span>
|
||||
<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>
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user