mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
New user profile section
WIP
This commit is contained in:
@@ -6,8 +6,24 @@ 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/`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -28,51 +28,13 @@
|
||||
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 +44,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 +65,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,127 @@
|
||||
<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.account.random_password) {
|
||||
this.updatePassword(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
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,7 +23,7 @@
|
||||
</div>
|
||||
|
||||
<v-list>
|
||||
<template v-for="item in mainMenuItems">
|
||||
<template v-for="item in menuItems">
|
||||
<template v-if="item.roles === undefined || item.roles.indexOf(authUser.role) !== -1">
|
||||
<v-list-item
|
||||
v-if="!item.children"
|
||||
@@ -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'),
|
||||
to: '',
|
||||
icon: 'mdi-tune',
|
||||
exact: true
|
||||
},
|
||||
{
|
||||
text: this.$gettext('Forward'),
|
||||
to: '',
|
||||
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',
|
||||
@@ -228,4 +274,8 @@ export default {
|
||||
.user-box {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.logo {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<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 () {
|
||||
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,135 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<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 color="grey lighten-2">
|
||||
<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 color="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 color="grey lighten-2">
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import account from '@/api/account'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
account: Object
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
pinCode: null,
|
||||
qrCode: null,
|
||||
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 () {
|
||||
|
||||
}
|
||||
},
|
||||
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,24 @@ 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'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -344,6 +344,28 @@ 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 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)
|
||||
else:
|
||||
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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,19 @@
|
||||
"""Core API v2 viewsets."""
|
||||
|
||||
import io
|
||||
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
|
||||
import django_otp
|
||||
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 +32,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 +59,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 +120,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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user