Imported to main repo

This commit is contained in:
Spitap
2022-12-05 16:57:38 +01:00
parent e6d9833012
commit 39af0485f8
19 changed files with 892 additions and 13 deletions
+15
View File
@@ -183,6 +183,8 @@ To work properly, the system user which runs modoboa (``www-data``,
``apache``, whatever) must have write access to this directory.
.. _customization:
*************
Customization
*************
@@ -202,6 +204,19 @@ relative URL of your logo under ``MEDIA_URL``. For example::
Then copy your logo file into the directory indicated by
``MEDIA_ROOT``.
Custom new admin sub-url
========================
You can edit the new admin sub-url (by default it is `/new-admin`).
To do so, open the :file:`settings.py` file and add (or uncomment)
``NEW_ADMIN_URL`` variable. This variable must contain a relative url
without initial and last `'/'`. For example::
NEW_ADMIN_URL = 'test_admin'
You will need to change you web server accordingly and rebuild the frontend.
******************
Host configuration
******************
+35
View File
@@ -91,6 +91,41 @@ Rebuild Virtual Environment
Specific instructions
*********************
2.0.4
=====
* Add ``'modoboa.core.context_processors.new_admin_url',`` at the end of `context_processors` list:
.. sourcecode:: python
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
'modoboa.core.context_processors.top_notifications',
'modoboa.core.context_processors.new_admin_url',
],
'debug': False,
},
},
]
* You now have the possibility to customize the url of the new-admin interface.
To do so please head up to :ref:`the custom configuration chapter <customization>` (advanced user).
2.0.3
=====
+12
View File
@@ -3,5 +3,17 @@ import repository from './repository'
export default {
requestToken (data) {
return repository.post('/token/', data)
},
recoverPassword (data) {
return repository.post('/password_reset/', data)
},
checkSmsTotp (data) {
return repository.post('/confirm_sms_totp/', data)
},
changePassword (data) {
return repository.post('/reset_confirm/', data)
},
resendSmsTotp (data) {
return repository.post('/reset_sms_resend/', data)
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
import Vue from 'vue'
import { ValidationObserver, ValidationProvider, extend } from 'vee-validate'
import { confirmed, numeric, required } from 'vee-validate/dist/rules'
import { confirmed, email, numeric, required } from 'vee-validate/dist/rules'
import { translate } from 'vue-gettext'
import account from '@/api/account'
@@ -19,6 +19,10 @@ extend('required', {
...required,
message: () => $gettext('This field is required')
})
extend('email', {
...email,
message: () => $gettext('Specify an email address')
})
// Add custom rules here
extend('validPassword', {
+15
View File
@@ -172,6 +172,21 @@ const routes = [
requiresAuth: true,
layout: 'user'
}
},
{
path: '/password_recovery',
name: 'PasswordRecoveryForm',
component: () => import('../views/user/PasswordRecoveryForm.vue')
},
{
path: '/password_recovery/confirm/:id?/:token?/',
name: 'PasswordRecoveryChangeForm',
component: () => import('../views/user/PasswordRecoveryChangeForm.vue')
},
{
path: '/password_recovery/sms_confirm',
name: 'PasswordRecoverySms',
component: () => import('../views/user/PasswordRecoverySmsTotpForm.vue')
}
]
+4 -1
View File
@@ -34,7 +34,7 @@
outlined
/>
</validation-provider>
<a href="" class="float-right primary--text"><translate>Forgot password?</translate></a>
<a @click="recoverPass" class="float-right primary--text"><translate>Forgot password?</translate></a>
<v-checkbox
v-model="rememberMe"
:label="'Remember me'|translate"
@@ -70,6 +70,9 @@ export default {
}
},
methods: {
recoverPass () {
this.$router.push({ name: 'PasswordRecoveryForm' })
},
async authenticate () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
@@ -0,0 +1,139 @@
<template>
<v-row style="height: 100%; margin: 0 auto">
<v-col cols="0" sm="6" class="primary" style="height: 100%">
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="300"
class="mt-10 ml-8"
/>
</v-col>
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="changePassword">
<span class="primary--text mb-6 text-h4"><translate>Forgot password?</translate></span>
<validation-observer ref="observer">
<validation-provider
vid="password"
v-slot="{ errors }"
rules="required"
>
<p id="password_validation"></p>
<label class="m-label"><translate>Change password</translate></label>
<v-text-field
v-model="password"
:error-messages="errors"
type="password"
outlined
/>
</validation-provider>
<validation-provider
vid="password_confirm"
v-slot="{ errors }"
rules="required|samePassword:password"
>
<v-text-field
v-model="password_confirmed"
:error-messages="errors"
type="password"
outlined
/>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center">
<v-btn
class="flex-grow-1"
color="primary"
large
@click="changePassword"
:loading="loading"
>
<translate>Submit</translate>
</v-btn>
</div>
<div>
<a @click="returnLogin" class="float-right primary--text"><translate>Return to login?</translate></a>
</div>
</v-col>
</v-row>
</template>
<script>
import auth from '@/api/auth'
export default {
data () {
return {
loading: false,
password: '',
password_confirmed: '',
errors: {},
token: '',
id: ''
}
},
methods: {
checkUrl: function () {
if (this.$route.params.token === undefined) {
if (this.token === '' || this.id === '') {
this.$router.push({ name: 'PasswordRecoveryForm' })
}
} else {
const decodedId = atob(this.$route.params.id)
if (!/^\d+$/.test(decodedId)) {
console.error('Received ID is invalid')
this.$router.push({ name: 'PasswordRecoveryForm' })
} else {
this.id = this.$route.params.id
this.token = this.$route.params.token
this.$router.push({ name: 'PasswordRecoveryChangeForm' })
}
}
},
returnLogin () {
this.$router.push({ name: 'Login' })
},
async changePassword () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
const payload = {
new_password1: this.password,
new_password2: this.password_confirmed,
token: this.token,
id: this.id
}
this.loading = true
auth.changePassword(payload).then(resp => {
document.getElementById('password_validation').innerHTML = ''
this.loading = false
this.returnLogin()
}).catch(err => {
this.loading = false
if (err.response.status === 401) {
this.$refs.observer.setErrors({
password_confirmed: this.$gettext('Invalid reset token.')
})
} else if (err.response.status === 404) {
this.$refs.observer.setErrors({
password_confirmed: this.$gettext('User unknown.')
})
} else if (err.response.status === 455) {
let message = ''
err.response.data.forEach(element => {
message += this.$gettext(element) + '<br>'
})
document.getElementById('password_validation').innerHTML = message
}
})
}
},
beforeMount () {
this.checkUrl()
}
}
</script>
<style>
#password_validation {
color:crimson;
}
</style>
@@ -0,0 +1,130 @@
<template>
<v-row style="height: 100%; margin: 0 auto">
<v-col cols="0" sm="6" class="primary" style="height: 100%">
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="300"
class="mt-10 ml-8"
/>
</v-col>
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="recoverPassword">
<span class="primary--text mb-6 text-h4"><translate>Forgot password?</translate></span>
<validation-observer ref="observer">
<validation-provider
vid="email"
v-slot="{ errors }"
rules="required|email"
>
<label class="m-label"><translate>Please fill-in your primary email address</translate></label>
<v-text-field
v-model="email"
:error-messages="errors"
outlined
/>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center">
<v-btn
class="flex-grow-1"
color="primary"
large
@click="recoverPassword"
:loading="loading"
>
<translate>Submit</translate>
</v-btn>
</div>
<div>
<a @click="returnLogin" class="float-right primary--text"><translate>Return to login?</translate></a>
</div>
</v-col>
<confirm-dialog ref="dialog_response">
</confirm-dialog>
</v-row>
</template>
<script>
import auth from '@/api/auth'
import ConfirmDialog from '@/components/layout/ConfirmDialog'
export default {
components: {
ConfirmDialog
},
data () {
return {
loading: false,
email: '',
errors: {}
}
},
methods: {
returnLogin () {
this.$router.push({ name: 'Login' })
},
async showDialogEmail () {
const confirm = await this.$refs.dialog_response.open(
this.$gettext('Info'),
this.$gettext(
'Email sent, please check your inbox'
),
{
color: 'info',
cancelLabel: this.$gettext('Ok'),
agreeLabel: this.$gettext('Return to login')
})
if (!confirm) {
return
}
this.returnLogin()
},
async emailFailedToSend () {
const confirm = await this.$refs.dialog_response.open(
this.$gettext('Error'),
this.$gettext(
'Failed to send email, please check with you administrator'
),
{
color: 'error',
cancelLabel: this.$gettext('Ok'),
agreeLabel: this.$gettext('Return to login')
})
if (!confirm) {
return
}
this.returnLogin()
},
async recoverPassword () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
const payload = {
email: this.email
}
this.loading = true
auth.recoverPassword(payload).then(resp => {
this.loading = false
if (resp.status === 233) {
this.$router.push({ name: 'PasswordRecoverySmsTotpForm' })
} else if (resp.status === 210) {
this.showDialogEmail()
}
}).catch(err => {
this.loading = false
if (err.response.status === 404) {
// User not found
this.$refs.observer.setErrors({
email: this.$gettext('Invalid email')
})
} else if (err.response.status === 502) {
this.emailFailedToSend()
}
})
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,127 @@
<template>
<v-row style="height: 100%; margin: 0 auto">
<v-col cols="0" sm="6" class="primary" style="height: 100%">
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="300"
class="mt-10 ml-8"
/>
</v-col>
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="changePassword">
<confirm-dialog ref="confirm_wrong_user">
</confirm-dialog>
<span class="primary--text mb-6 text-h4"><translate>Password recovert</translate></span>
<validation-observer ref="observer">
<validation-provider
vid="sms_totp"
v-slot="{ errors }"
rules="required|length:6"
>
<label class="m-label"><translate>Enter the code you've just received by SMS</translate></label>
<v-text-field
v-model="sms_totp"
:error-messages="errors"
type="totp"
outlined
/>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center">
<v-btn
class="flex-grow-1"
color="primary"
large
@click="changePassword"
:loading="loading"
>
<translate>Submit</translate>
</v-btn>
</div>
<div>
<a @click="resendSms" class="float-left primary--text"><translate>Resend SMS.</translate></a>
<a @click="returnLogin" class="float-right primary--text"><translate>Return to login?</translate></a>
</div>
</v-col>
</v-row>
</template>
<script>
import auth from '@/api/auth'
import ConfirmDialog from '@/components/layout/ConfirmDialog'
export default {
components: {
ConfirmDialog
},
data () {
return {
loading: false,
sms_totp: '',
password: '',
password_confirmed: '',
user_pk: -1,
errors: {}
}
},
methods: {
returnLogin () {
this.$router.push({ name: 'Login' })
},
async userPkError () {
const confirm = await this.$refs.confirm.open(
this.$gettext('Warning'),
this.$gettext(
'User seems wrong, return to login or restart reset process?'
),
{
color: 'error',
cancelLabel: this.$gettext('Return to login'),
agreeLabel: this.$gettext('Retry reset')
}
)
if (!confirm) {
this.returnLogin()
} else {
this.$router.push({ name: 'PasswordRecoveryForm' })
}
},
async resendSms () {
auth.resendSmsTotp().then(resp => {
this.loading = false
// Reset done !
}).catch(err => {
console.error(err)
this.loading = false
this.userPkError()
})
},
async checkSmsTotp () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
const payload = {
sms_totp: this.sms_totp
}
this.loading = true
auth.checkSmsTotp(payload).then(resp => {
this.loading = false
if (resp.response.status === 200) {
// Code is good, redirecting to change password form.
this.$router.push({ name: 'PasswordRecoveryChangeForm', data: resp.response.data })
}
}).catch(err => {
this.loading = false
if (err.response.status === 500) {
this.$refs.observer.setErrors({
password_confirmed: this.$gettext('Invalid totp, try resend')
})
}
})
}
}
}
</script>
<style>
</style>
+234 -8
View File
@@ -1,19 +1,56 @@
"""Core API v2 serializers."""
import django_otp
import oath
from django.utils import formats
from django.utils.translation import ugettext_lazy, ugettext as _
from django.db.models import Q
from django.contrib.auth import password_validation
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.core.validators import validate_email
from django.core.exceptions import ValidationError as djangoValidationError
from django.template import loader
from django.utils import formats
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
from django.utils.translation import ugettext_lazy, ugettext as _
import django_otp
from rest_framework import serializers
from modoboa.lib import fields as lib_fields
from modoboa.core import constants, models, sms_backends, app_settings, context_processors
from modoboa.core.models import User
from modoboa.lib import fields as lib_fields, cryptutils
from ... import constants
from ... import models
from ... import sms_backends
from ... import app_settings
class NoSMSAvailable(Exception):
""" Raised when no sms totp is availible for password reset (to try email)."""
pass
class UserNotFound(Exception):
""" Raised when no valid user has been found (to have a proper 404 http code instead of 400."""
pass
class EmailSendingFailure(Exception):
""" Raised when a reset email has failed to send."""
pass
class PasswordRequirementsFailure(Exception):
""" Raised when django is not happy with password provided."""
def __init__(self, message_list, *args: object) -> None:
super().__init__(*args)
self.message_list = message_list
class InvalidToken(Exception):
""" Raised during password reset if token is not valid."""
pass
class CoreGlobalParametersSerializer(serializers.Serializer):
@@ -242,3 +279,192 @@ class UserAPITokenSerializer(serializers.Serializer):
"""Serializer used by API access routes."""
token = serializers.CharField()
class EmailPasswordRecoveryInitSerializer(serializers.Serializer):
"""Serializer used by password recovery route."""
email = serializers.EmailField()
def validate(self, data):
clean_email = data["email"]
self.context["user"] = (User.objects.filter(
email__iexact=clean_email, is_active=True)
.exclude(Q(secondary_email__isnull=True) | Q(secondary_email=""))
).first()
if self.context["user"] is None:
raise UserNotFound()
return data
def save(self):
user = self.context["user"]
to_email = user.secondary_email
current_site = get_current_site(self.context["request"])
site_name = current_site.name
domain = current_site.domain
context = {
'email': to_email,
'domain': domain,
'site_name': site_name,
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user,
'token': default_token_generator.make_token(user),
'protocol': 'https',
}
context.update(context_processors.new_admin_url())
subject = loader.render_to_string(
"registration/password_reset_subject.txt", context)
# Email subject *must not* contain newlines
subject = ''.join(subject.splitlines())
body = loader.render_to_string(
"registration/password_reset_email_v2.html", context)
try:
if send_mail(subject, body, None, [to_email]) == 0:
raise EmailSendingFailure()
except:
raise EmailSendingFailure()
class SMSPasswordRecoveryInitSerializer(serializers.Serializer):
email = serializers.EmailField()
def validate(self, data, *args, **kwargs):
request = self.context["request"]
clean_email = data["email"]
self.context["user"] = (User.objects.filter(
email__iexact=clean_email, is_active=True)
.exclude(Q(phone_number__isnull=True) | Q(phone_number=""))
).first()
if self.context["user"] is None:
raise NoSMSAvailable()
if not request.localconfig.parameters.get_value("sms_password_recovery"):
raise NoSMSAvailable()
user = self.context["user"]
if not user:
raise NoSMSAvailable()
backend = sms_backends.get_active_backend(
request.localconfig.parameters)
secret = cryptutils.random_hex_key(20)
code = oath.totp(secret)
text = _(
"Please use the following code to recover your Modoboa password: {}"
.format(code)
)
if not backend.send(text, [str(user.phone_number)]):
raise NoSMSAvailable()
request.session["totp_secret"] = secret
request.session["user_pk"] = user.pk
return data
class PasswordRecoverySmsSerializer(serializers.Serializer):
sms_totp = serializers.CharField(min_lenght=6, max_length=6)
def validate(self, data):
try:
totp_secret = self.context["request"].session["totp_secret"]
self.context["request"].session.pop("totp_secret")
except KeyError:
raise serializers.ValidationError(
"totp secret not set in session", 403)
if not oath.accept_totp(totp_secret, data["sms_totp"]):
raise serializers.ValidationError("Wrong totp", 403)
# Attempt to get user, will raise an error if pk is not valid
self.user = User.objects.get(
pk=self.context["user_pk"])
return data
def save(self):
self.context["request"].session.pop("totp_secret")
self.context["request"].session.pop("user")
token = default_token_generator.make_token(self.user)
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
self.context["response"] = (token, uid)
return True
class PasswordRecoveryConfirmSerializer(serializers.Serializer):
new_password1 = serializers.CharField()
new_password2 = serializers.CharField()
token = serializers.CharField()
id = serializers.CharField()
def get_user(self, user_id):
try:
# urlsafe_base64_decode() decodes to bytestring
uid = urlsafe_base64_decode(user_id).decode()
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None
return user
def validate(self, data):
# Validate password
if data["new_password1"] == "" or data["new_password1"] != data["new_password2"]:
raise serializers.ValidationError(
"Password is empty or does not match")
user = self.get_user(data["id"])
if user is None:
raise UserNotFound
if not default_token_generator.check_token(user, data["token"]):
raise InvalidToken
# Check that the password works with set conditions
try:
password_validation.validate_password(data["new_password1"], user)
except djangoValidationError as e:
raise PasswordRequirementsFailure(e.error_list)
self.context["user"] = user
return super().validate(data)
def save(self):
user = self.context["user"]
user.set_password(self.validated_data["new_password1"])
user.save()
return True
class PasswordRecoverySmsResendSerializer(serializers.Serializer):
def validate(self, data):
try:
self.context["session"].user_pk
self.context["session"].totp_token
return data
except KeyError:
raise serializers.ValidationError(
"No previous reset attempt recorded", 403)
def save(self):
user = User.objects.get(
pk=self.context["session"].user_pk)
request = self.context["request"]
backend = sms_backends.get_active_backend(
request.localconfig.parameters)
secret = cryptutils.random_hex_key(20)
code = oath.totp(secret)
text = _(
"Please use the following code to recover your Modoboa password: {}"
.format(code)
)
if not backend.send(text, [str(user.phone_number)]):
return False
request.session["totp_secret"] = secret
request.session["user_pk"] = user.pk
+45
View File
@@ -5,8 +5,10 @@ from unittest import mock
from django.urls import reverse
from modoboa.admin import factories
from modoboa.core import models
from modoboa.lib.tests import ModoAPITestCase
from rest_framework.authtoken.models import Token
CORE_SETTINGS = {
"authentication_type": "local",
@@ -112,6 +114,14 @@ class ParametersAPITestCase(ModoAPITestCase):
class AccountViewSetTestCase(ModoAPITestCase):
@classmethod
def setUpTestData(cls): # NOQA:N802
"""Create test data."""
super().setUpTestData()
factories.populate_database()
cls.da_token = Token.objects.create(
user=models.User.objects.get(username="admin@test.com"))
def test_me(self):
url = reverse("v2:account-me")
resp = self.client.get(url)
@@ -199,6 +209,41 @@ class AccountViewSetTestCase(ModoAPITestCase):
resp = self.client.post(url)
self.assertEqual(resp.status_code, 201)
@mock.patch("ovh.Client.get")
@mock.patch("ovh.Client.post")
def test_reset_password(self, client_post, client_get):
url = reverse("v2:password_reset")
# No payload provided
response = self.client.post(url)
self.assertEqual(response.status_code, 400)
# SMS parameter
self.set_global_parameters({
"sms_password_recovery": True,
"sms_provider": "ovh",
"sms_ovh_application_key": "key",
"sms_ovh_application_secret": "secret",
"sms_ovh_consumer_key": "consumer"
}, app="core")
account = models.User.objects.get(username="user@test.com")
data = {"email": account.email}
# Provide a user w/o phone or secondary
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 404)
# Secondary email for reset
account.secondary_email = "toto@test.com"
account.is_active = True
account.save()
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 204)
# Phone number
account.phone_number = "+33612345678"
account.save()
client_get.return_value = ["service"]
client_post.return_value = {"totalCreditsRemoved": 1}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 233)
class AuthenticationTestCase(ModoAPITestCase):
+9 -1
View File
@@ -21,5 +21,13 @@ urlpatterns += [
path('token/', views.TokenObtainPairView.as_view(),
name="token_obtain_pair"),
path('token/refresh/', TokenRefreshView.as_view(),
name='token_refresh'),
name="token_refresh"),
path('password_reset/', views.DefaultPasswordResetView.as_view(),
name="password_reset"),
path('reset_confirm/', views.PasswordResetConfirmView.as_view(),
name="password_reset_confirm_v2"),
path('confirm_sms_totp/', views.PasswordResetConfirmSmsCodeView.as_view(),
name="confirm_sms_totp"),
path('reset_sms_resend', views.PasswordResetResendSmsCodeView.as_view(),
name='reset_sms_resend'),
]
+87
View File
@@ -10,10 +10,13 @@ from django.contrib.auth import login
from rest_framework import response, status
from rest_framework_simplejwt import views as jwt_views
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework.views import APIView
from modoboa.core.password_hashers import get_password_hasher
from modoboa.parameters import tools as param_tools
from .serializers import *
logger = logging.getLogger("modoboa.auth")
@@ -70,3 +73,87 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView):
return response.Response(
serializer.validated_data, status=status.HTTP_200_OK)
class EmailPasswordResetView(APIView):
"""
An Api View which provides a method to request a password reset token based on an e-mail address.
"""
def post(self, request, *args, **kwargs):
serializer = EmailPasswordRecoveryInitSerializer(
data=request.data, context={'request': request})
try:
serializer.is_valid(raise_exception=True)
except UserNotFound:
return response.Response({"no valid user found"}, 404)
try:
serializer.save()
except EmailSendingFailure:
return response.Response({"Email failed to send, possibly a misconfiguration?"}, 502)
# Email response
return response.Response(status=204)
class DefaultPasswordResetView(EmailPasswordResetView):
"""
Works with PasswordRecoveryForm.vue.
First checks if SMS recovery is available, else switch to super (Email recovery [with seconday email]).
"""
def post(self, request, *args, **kwargs):
"""Recover password."""
serializer = SMSPasswordRecoveryInitSerializer(
data=request.data, context={'request': request})
try:
serializer.is_valid(raise_exception=True)
except NoSMSAvailable:
return super().post(request, *args, **kwargs)
# SMS response
return response.Response(status=233)
class PasswordResetConfirmSmsCodeView(APIView):
""" Check SMS Totp code. """
def post(self, request, *args, **kwargs):
serializer = PasswordRecoverySmsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
payload = serializer.context["response"]
payload = {"token": payload[0], "id": payload[1]}
return response.Response(payload, 200)
class PasswordResetConfirmView(APIView):
""" Get and set new user password. """
def post(self, request, *args, **kwargs):
serializer = PasswordRecoveryConfirmSerializer(data=request.data)
try:
serializer.is_valid(raise_exception=True)
except (PasswordRequirementsFailure, UserNotFound, InvalidToken) as e:
if type(e) == PasswordRequirementsFailure:
errors = []
for element in e.message_list:
errors.append(element)
return response.Response(errors, 455)
elif type(e) == UserNotFound:
return response.Response(status=404)
elif type(e) == InvalidToken:
return response.Response(status=401)
serializer.save()
return response.Response(status=200)
class PasswordResetResendSmsCodeView(APIView):
def post(self, request, *args, **kwargs):
serializer = PasswordRecoverySmsResendSerializer(
data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True)
serializer.save()
return response.Response(status=200)
@@ -141,6 +141,7 @@ TEMPLATES = [
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
'modoboa.core.context_processors.top_notifications',
'modoboa.core.context_processors.new_admin_url',
],
'debug': {{ devmode }},
},
@@ -202,6 +203,9 @@ REST_FRAMEWORK = {
}
# Uncomment if you need a custom sub url for new_admin interface
# NEW_ADMIN_URL = 'new_admin'
SPECTACULAR_SETTINGS = {
'SCHEMA_PATH_PREFIX': r'/api/v[0-9]',
'TITLE': 'Modoboa API',
+8
View File
@@ -1,6 +1,7 @@
"""Custom context processors."""
from functools import reduce
from django.conf import settings
from . import signals
@@ -19,3 +20,10 @@ def top_notifications(request):
"notifications_check_interval": interval * 1000,
"top_notifications": notifications
}
def new_admin_url(*args):
"""A context processor to include new admin url."""
return {
"new_admin": getattr(settings, "NEW_ADMIN_URL", "new_admin")
}
+1 -1
View File
@@ -8,7 +8,7 @@
{% if user.is_admin %}
<div class="alert alert-warning text-center">
<strong>Want to check the new admin interface?</strong>
<a class="btn btn-default" style="margin-left: 20px;" href="/new-admin" target="_blank">Go to Modoboa v2</a>
<a class="btn btn-default" style="margin-left: 20px;" href="/{{ new_admin }}" target="_blank">Go to Modoboa v2</a>
</div>
{% endif %}
+1 -1
View File
@@ -53,7 +53,7 @@
<li class="divider"></li>
{% if user.is_admin %}
<li>
<a name="newadmin" href="/new-admin" target="_blank">
<a name="newadmin" href="/{{ new_admin }}" target="_blank">
{% trans "New admin" %}
</a>
</li>
@@ -0,0 +1,17 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans with username=user.get_username trimmed %}
To initiate the password reset process for your {{ username }} Modoboa account, click the link below:
{% endblocktrans %}
{{ protocol }}://{{ domain }}/{{ new_admin }}/password_recovery/confirm/{{ uid }}/{{ token }}
{% blocktrans trimmed %}
If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead.
{% endblocktrans %}
{% blocktrans trimmed %}
Sincerely, The Modoboa Team.
{% endblocktrans %}
{% endautoescape %}
+4
View File
@@ -147,6 +147,7 @@ TEMPLATES = [
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
'modoboa.core.context_processors.top_notifications',
'modoboa.core.context_processors.new_admin_url'
],
'debug': DEBUG,
},
@@ -194,6 +195,9 @@ REST_FRAMEWORK = {
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
}
# Uncomment if you need a custom sub url for new_admin interface
# NEW_ADMIN_URL = 'new_admin'
SPECTACULAR_SETTINGS = {
'SCHEMA_PATH_PREFIX': r'/api/v[0-9]',
'TITLE': 'Modoboa API',