Skip to content

Commit

Permalink
Merge branch 'main' of github.com:velis74/django-project-base
Browse files Browse the repository at this point in the history
  • Loading branch information
KlemenSpruk committed Oct 13, 2023
2 parents 3a8f0b4 + ec32db9 commit 8c090e7
Show file tree
Hide file tree
Showing 9 changed files with 163 additions and 34 deletions.
52 changes: 50 additions & 2 deletions django_project_base/account/rest/profile.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
from random import randrange

import django
import swapper
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -417,10 +419,56 @@ 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
response = Response(serializer.data)
if email_changed:
code = randrange(100001, 999999)
response.set_cookie("verify-email", 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")]))
key = f"email-changed-{request.data['code']}-{user.pk}"
new_email = request.session.get(key)
if email := new_email:
user.email = email
user.save(update_fields=["email"])
request.session.pop(key, None)
response = Response()
response.delete_cookie("verify-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",
Expand Down
6 changes: 3 additions & 3 deletions vue/components/notifications-editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
APIConsumer,
ComponentDisplay,
ConsumerLogicApi,
FormConsumerOneShotApi, gettext,
FormConsumerApiOneShot, gettext,
useActionHandler,
} from '@velis/dynamicforms';
import { onMounted, onUnmounted, ref } from 'vue';
Expand Down Expand Up @@ -50,12 +50,12 @@ const notificationLogic = ref(new ConsumerLogicApi(
notificationLogic.value.getFullDefinition();
const actionViewLicense = async (): Promise<boolean> => {
await FormConsumerOneShotApi({ url: licenseConsumerUrl, trailingSlash: licenseConsumerUrlTrailingSlash, pk: 'new' });
await FormConsumerApiOneShot({ url: licenseConsumerUrl, trailingSlash: licenseConsumerUrlTrailingSlash, pk: 'new' });
return true;
};
const actionAddNotification = async (): Promise<boolean> => {
await FormConsumerOneShotApi({
await FormConsumerApiOneShot({
url: consumerUrl,
trailingSlash: consumerTrailingSlash,
pk: 'new',
Expand Down
9 changes: 6 additions & 3 deletions vue/components/profile-search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,12 @@ function customLabel(profile: UserDataJSON) {
@select="onSelect"
>
<template slot="singleLabel" slot-scope="props">
{{ props.option.email }}
{{ props.option.first_name }}
{{ props.option.last_name }}
{{ //@ts-ignore
props.option.email }}
{{ //@ts-ignore
props.option.first_name }}
{{ //@ts-ignore
props.option.last_name }}
</template>
</multiselect>
</div>
Expand Down
2 changes: 1 addition & 1 deletion vue/components/user-session/login-dialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion vue/components/user-session/login-inline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
30 changes: 16 additions & 14 deletions vue/components/user-session/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
ConsumerLogicApi, dfModal as dfModalApi,
dfModal, DialogSize,
DisplayMode,
FilteredActions, FormConsumerOneShotApi,
FilteredActions, FormConsumerApiOneShot,
FormPayload,
gettext,
} from '@velis/dynamicforms';
Expand All @@ -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<any>, 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<any>, 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')) {
Expand Down Expand Up @@ -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 },
);

Expand All @@ -243,3 +243,5 @@ export default function useLogin() {
openRegistration,
};
}

export { useLogin, parseErrors };
5 changes: 2 additions & 3 deletions vue/components/user-session/project-list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
Action,
apiClient,
dfModal,
FormConsumerOneShotApi,
FormConsumerApiOneShot,
FormPayload,
} from '@velis/dynamicforms';
import slugify from 'slugify';
Expand Down Expand Up @@ -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) => {
Expand All @@ -60,7 +59,7 @@ async function addNewProject() {
}
return false;
};
const addProjectModal = await FormConsumerOneShotApi(
const addProjectModal = await FormConsumerApiOneShot(
{
url: '/project',
trailingSlash: false,
Expand Down
83 changes: 77 additions & 6 deletions vue/components/user-session/user-profile.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
<script setup lang="ts">
import { Action, dfModal, DialogSize, FilteredActions, FormConsumerOneShotApi, gettext } from '@velis/dynamicforms';
import axios from 'axios';
import { Action, dfModal, DialogSize, FilteredActions, FormConsumerApiOneShot, gettext } from '@velis/dynamicforms';
import axios, { AxiosRequestConfig } from 'axios';
import _ from 'lodash';
import { computed, h, onMounted, watch } from 'vue';
import { computed, h, onMounted, reactive, watch } from 'vue';
import { useCookies } from 'vue3-cookies';
import { useDisplay } from 'vuetify';
import { apiClient } from '../../apiClient';
import { HTTP_401_UNAUTHORIZED } from '../../apiConfig';
import { showGeneralErrorNotification } from '../../notifications';
import { SocialAccItem } from '../../socialIntegrations';
import { PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME, UserDataJSON } from './data-types';
import icons from './icons';
import { parseErrors } from './login';
import ProjectList from './project-list.vue'; // eslint-disable-line @typescript-eslint/no-unused-vars
import useUserSessionStore from './state';
Expand All @@ -20,6 +23,8 @@ interface UserProfileProps {
projectListComponent: string;
}
const { cookies } = useCookies();
const props = withDefaults(defineProps<UserProfileProps>(), { projectListComponent: 'ProjectList' });
const display = useDisplay();
const userSession = useUserSessionStore();
Expand All @@ -28,9 +33,57 @@ let availableSocialConnections = [] as Array<SocialAccItem>;
let socialConnectionsModalPromise = null as any;
const showProjectList = computed(() => (props.projectListComponent && userSession.loggedIn && display.smAndDown.value));
const changePasswordErrors = reactive({} as { [key: string]: any[] });
async function verifyEmailChanged(userData: UserDataJSON) {
const verifyMailCookie = cookies.get('verify-email');
if (_.size(verifyMailCookie) && userData[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME] &&
userData[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME].toString() === verifyMailCookie.toString()) {
const enterEmailConfirmationCode = await dfModal.message('Update email', () => [
h(
'h4',
{ class: 'mt-n6 mb-4' },
`${gettext('We have sent an email to the new email address you provided. ' +
'Please enter the code from the message')}:`,
),
h('div', { style: 'display: flex; justify-content: center;' }, [h('input', {
type: 'text',
id: 'input-change-email',
placeholder: changePasswordErrors.code ? changePasswordErrors.code : gettext('Email code'),
style: 'padding: 0.1em;',
class: 'w-50 mb-4 p-2 rounded border-lightgray',
}, {})]),
], new FilteredActions({
cancel: new Action({
name: 'cancel',
label: gettext('Cancel'),
displayStyle: { asButton: true, showLabel: true, showIcon: true },
position: 'FORM_FOOTER',
}),
confirm: new Action({
name: 'confirm',
label: gettext('Confirm'),
displayStyle: { asButton: true, showLabel: true, showIcon: true },
position: 'FORM_FOOTER',
}),
}));
if (enterEmailConfirmationCode.action.name === 'confirm') {
apiClient.post(
'/account/profile/confirm-new-email',
{ code: (<HTMLInputElement> document.getElementById('input-change-email')).value },
{ hideErrorNotice: true } as AxiosRequestConfig,
).then(() => {
dfModal.message('', gettext('Your new email is now verified, thank you. '));
}).catch((err) => {
parseErrors(err, changePasswordErrors);
verifyEmailChanged(userData);
});
}
}
}
async function changePassword() {
await FormConsumerOneShotApi({ url: '/account/change-password/', trailingSlash: true, pk: 'new' });
await FormConsumerApiOneShot({ url: '/account/change-password/', trailingSlash: true, pk: 'new' });
}
async function checkResetPassword() {
Expand All @@ -40,19 +93,36 @@ async function checkResetPassword() {
}
async function loadData(force: boolean = false) {
const userData: UserDataJSON = {
full_name: '',
email: '',
username: '',
avatar: '',
password_invalid: false,
is_superuser: false,
permissions: [],
is_impersonated: false,
groups: [],
delete_at: '',
[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME]: '',
};
userData[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME] = userSession.userData[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME];
userData.email = userSession.userData.email;
if (userSession.loggedIn && !force) {
await checkResetPassword();
await verifyEmailChanged(userData);
return;
}
await userSession.checkLogin(false);
await checkResetPassword();
await verifyEmailChanged(userData);
}
watch(() => userSession.impersonated, () => window.location.reload());
onMounted(() => loadData());
async function showImpersonateLogin() {
await FormConsumerOneShotApi({ url: '/account/impersonate', trailingSlash: false });
await FormConsumerApiOneShot({ url: '/account/impersonate', trailingSlash: false });
await userSession.checkLogin(false);
}
Expand All @@ -62,8 +132,9 @@ async function stopImpersonation() {
}
async function userProfile() {
await FormConsumerOneShotApi({ url: '/account/profile/current', trailingSlash: false });
const savedData = await FormConsumerApiOneShot({ url: '/account/profile/current', trailingSlash: false });
await userSession.checkLogin(false);
await verifyEmailChanged(savedData);
}
function isSocialConnectionEnabled(name: String) {
Expand Down
8 changes: 7 additions & 1 deletion vue/profile-search-add-user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Action, dfModal, DialogSize, FilteredActions, gettext } from '@velis/dynamicforms';
import { h } from 'vue';

// @ts-ignore
import ProfileSearch from './components/profile-search.vue';
import { UserDataJSON } from './components/user-session/data-types';

Expand All @@ -14,7 +15,12 @@ function selected(profile: UserDataJSON) {
async function showAddProfileModal(addCallback: (profile: UserDataJSON | undefined) => any, searchUrl: string) {
const modal = await dfModal.message(
gettext('Add new user'),
() => [h('div', [h(ProfileSearch, { onSelected: selected, searchUrl })])],
() => [h('div', [h(
// @ts-ignore
ProfileSearch,
{ onSelected: selected, searchUrl },
)]),
],
new FilteredActions({
cancel: new Action({
name: 'cancel',
Expand Down

0 comments on commit 8c090e7

Please sign in to comment.