diff --git a/django_project_base/account/rest/profile.py b/django_project_base/account/rest/profile.py index d4d4235d..a830622e 100644 --- a/django_project_base/account/rest/profile.py +++ b/django_project_base/account/rest/profile.py @@ -10,6 +10,7 @@ from django.db.models import ForeignKey, Model, QuerySet from django.template.loader import render_to_string from django.utils import timezone +from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse from dynamicforms import fields @@ -19,6 +20,7 @@ from dynamicforms.template_render.layout import Column, Layout, Row from dynamicforms.template_render.responsive_table_layout import ResponsiveTableLayout, ResponsiveTableLayouts from dynamicforms.viewsets import ModelViewSet +from natural.date import compress from rest_framework import exceptions, filters, status from rest_framework.decorators import action from rest_framework.exceptions import APIException, ValidationError @@ -34,7 +36,7 @@ from django_project_base.account.rest.project_profiles_utils import get_project_members from django_project_base.base.event import UserRegisteredEvent from django_project_base.constants import NOTIFY_NEW_USER_SETTING_NAME -from django_project_base.notifications.email_notification import EMailNotification +from django_project_base.notifications.email_notification import EMailNotification, EMailNotificationWithListOfEmails from django_project_base.notifications.models import DjangoProjectBaseMessage from django_project_base.permissions import BasePermissions from django_project_base.rest.project import ProjectSerializer, ProjectViewSet @@ -417,10 +419,53 @@ def get_current_profile(self, request: Request, **kwargs) -> Response: @get_current_profile.mapping.post def update_current_profile(self, request: Request, **kwargs) -> Response: user: Model = request.user + new_email = request.data.pop("email", None) serializer = self.get_serializer(user, data=request.data, many=False) serializer.is_valid(raise_exception=True) serializer.save() - return Response(serializer.data) + email_changed = new_email and user.email != new_email + email_changed_cookie = "verify-email" + response = Response(serializer.data) + if email_changed: + code = get_random_string(length=6) + response.set_cookie(email_changed_cookie, user.pk, samesite="Lax") + request.session[f"email-changed-{code}-{user.pk}"] = new_email + # TODO: Use system email + # TODO: SEND THIS AS SYSTEM MSG WHEN PR IS MERGED + # TODO: https://taiga.velis.si/project/velis-django-project-admin/issue/728 + EMailNotificationWithListOfEmails( + message=DjangoProjectBaseMessage( + subject=f"{_('Email change for account on')} {request.META['HTTP_HOST']}", + body=f"{_('You requested an email change for your account at')} {request.META['HTTP_HOST']}. " + f"\n\n{_('Your verification code is')}: " + f"{code} \n\n {_('Code is valid for')} {compress(settings.CONFIRMATION_CODE_TIMEOUT)}.\n", + footer="", + content_type=DjangoProjectBaseMessage.PLAIN_TEXT, + ), + recipients=[new_email], + project=self.request.selected_project.slug, + user=user.pk, + ).send() + return response + + @extend_schema(exclude=True) + @action( + methods=["POST"], + detail=False, + url_path="confirm-new-email", + url_name="confirm-new-email", + ) + @transaction.atomic() + def confirm_new_email(self, request: Request, **kwargs) -> Response: + user = request.user + if not request.data.get("code"): + raise ValidationError(dict(code=[_("Code required")])) + new_email = request.session.get(f"email-changed-{request.data['code']}-{user.pk}") + if email := new_email: + user.email = email + user.save(update_fields=["email"]) + return Response() + raise ValidationError(dict(code=[_("Invalid code")])) @extend_schema( description="Marks profile of calling user for deletion in future. Future date is determined " "by settings", diff --git a/vue/components/notifications-editor.vue b/vue/components/notifications-editor.vue index 45a0fba2..3354400b 100644 --- a/vue/components/notifications-editor.vue +++ b/vue/components/notifications-editor.vue @@ -3,7 +3,7 @@ import { APIConsumer, ComponentDisplay, ConsumerLogicApi, - FormConsumerOneShotApi, gettext, + FormConsumerApiOneShot, gettext, useActionHandler, } from '@velis/dynamicforms'; import { onMounted, onUnmounted, ref } from 'vue'; @@ -50,12 +50,12 @@ const notificationLogic = ref(new ConsumerLogicApi( notificationLogic.value.getFullDefinition(); const actionViewLicense = async (): Promise => { - await FormConsumerOneShotApi({ url: licenseConsumerUrl, trailingSlash: licenseConsumerUrlTrailingSlash, pk: 'new' }); + await FormConsumerApiOneShot({ url: licenseConsumerUrl, trailingSlash: licenseConsumerUrlTrailingSlash, pk: 'new' }); return true; }; const actionAddNotification = async (): Promise => { - await FormConsumerOneShotApi({ + await FormConsumerApiOneShot({ url: consumerUrl, trailingSlash: consumerTrailingSlash, pk: 'new', diff --git a/vue/components/profile-search.vue b/vue/components/profile-search.vue index d7ff627f..675356af 100644 --- a/vue/components/profile-search.vue +++ b/vue/components/profile-search.vue @@ -91,9 +91,12 @@ function customLabel(profile: UserDataJSON) { @select="onSelect" > diff --git a/vue/components/user-session/login-dialog.vue b/vue/components/user-session/login-dialog.vue index 9c2345ec..e78ab395 100644 --- a/vue/components/user-session/login-dialog.vue +++ b/vue/components/user-session/login-dialog.vue @@ -7,7 +7,7 @@ import { } from '@velis/dynamicforms'; import { ref, watch } from 'vue'; -import useLogin from './login'; +import { useLogin } from './login'; import SocialLogos from './social-logos.vue'; import { showLoginDialog } from './use-login-dialog'; // TODO: needs to be moved to /rest/about or to some configuration. definitely needs to be app-specific diff --git a/vue/components/user-session/login-inline.vue b/vue/components/user-session/login-inline.vue index 9095e8ab..0c2f5d82 100644 --- a/vue/components/user-session/login-inline.vue +++ b/vue/components/user-session/login-inline.vue @@ -46,7 +46,7 @@ import _ from 'lodash'; import { onMounted } from 'vue'; import { useCookies } from 'vue3-cookies'; -import useLogin from './login'; +import { useLogin } from './login'; import SocialLogos from './social-logos.vue'; import useUserSessionStore from './state'; diff --git a/vue/components/user-session/login.ts b/vue/components/user-session/login.ts index a98da564..224691e0 100644 --- a/vue/components/user-session/login.ts +++ b/vue/components/user-session/login.ts @@ -3,7 +3,7 @@ import { ConsumerLogicApi, dfModal as dfModalApi, dfModal, DialogSize, DisplayMode, - FilteredActions, FormConsumerOneShotApi, + FilteredActions, FormConsumerApiOneShot, FormPayload, gettext, } from '@velis/dynamicforms'; @@ -25,21 +25,21 @@ const resetPasswordErrors = reactive({} as { [key: string]: any[] }); let resetPasswordData = { user_id: 0, timestamp: 0, signature: '' }; -export default function useLogin() { +function parseErrors(apiErr: AxiosError, errsStore: { [key: string]: any[] }) { + Object.keys(errsStore).forEach((key: string) => { + delete errsStore[key]; + }); + if (apiErr.response?.data?.detail) { + errsStore.non_field_errors = [apiErr.response.data.detail]; + } else { + Object.assign(errsStore, apiErr.response?.data); + } +} + +function useLogin() { const userSession = useUserSessionStore(); const loginConsumer = new ConsumerLogicApi(userSession.apiEndpointLogin); - function parseErrors(apiErr: AxiosError, errsStore: { [key: string]: any[] }) { - Object.keys(errsStore).forEach((key: string) => { - delete errsStore[key]; - }); - if (apiErr.response?.data?.detail) { - errsStore.non_field_errors = [apiErr.response.data.detail]; - } else { - Object.assign(errsStore, apiErr.response?.data); - } - } - async function enterResetPasswordData() { // eslint-disable-next-line vue/max-len if (_.includes(window.location.hash, '#reset-user-password') || _.includes(window.location.hash, '#/reset-user-password')) { @@ -221,7 +221,7 @@ export default function useLogin() { socialAuth.value = formDef.payload.social_auth_providers; } - const openRegistration = async () => FormConsumerOneShotApi( + const openRegistration = async () => FormConsumerApiOneShot( { url: '/account/profile/register', trailingSlash: false }, ); @@ -243,3 +243,5 @@ export default function useLogin() { openRegistration, }; } + +export { useLogin, parseErrors }; diff --git a/vue/components/user-session/project-list.vue b/vue/components/user-session/project-list.vue index 7596fc02..f3cddc19 100644 --- a/vue/components/user-session/project-list.vue +++ b/vue/components/user-session/project-list.vue @@ -3,7 +3,7 @@ import { Action, apiClient, dfModal, - FormConsumerOneShotApi, + FormConsumerApiOneShot, FormPayload, } from '@velis/dynamicforms'; import slugify from 'slugify'; @@ -46,7 +46,6 @@ async function loadData() { } async function addNewProject() { - // const addProjectModal = await FormConsumerOneShotApi({ url: '/project', trailingSlash: false, pk: 'new' }); let slugChanged = false; let ignoreSlugChange = false; const valueChangedHandler = (action: Action, payload: FormPayload, context: any) => { @@ -60,7 +59,7 @@ async function addNewProject() { } return false; }; - const addProjectModal = await FormConsumerOneShotApi( + const addProjectModal = await FormConsumerApiOneShot( { url: '/project', trailingSlash: false, diff --git a/vue/components/user-session/user-profile.vue b/vue/components/user-session/user-profile.vue index a5012236..c1564fec 100644 --- a/vue/components/user-session/user-profile.vue +++ b/vue/components/user-session/user-profile.vue @@ -1,8 +1,9 @@