From c5a9ca822843a8af54cf8303cd61cc522cb70bd7 Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Sun, 8 Dec 2024 05:32:11 +0100 Subject: [PATCH] Change password (#1625) * Add frontend for changing password * Add to admin sidebar. Improve current route matching logic * Add change password backend * Change path, update routes * Add handleServerFormErrors util * Send change password request to backend * Undo unintentional formatting * Remove unused import * ruff --- backend/root/utils/routes.py | 1 + backend/samfundet/serializers.py | 17 +++ backend/samfundet/urls.py | 1 + backend/samfundet/views.py | 17 ++- .../ChangePasswordForm.module.scss | 17 +++ .../ChangePasswordForm.tsx | 113 ++++++++++++++++++ .../UserChangePasswordPage.tsx | 17 +++ .../src/Pages/UserChangePasswordPage/index.ts | 1 + frontend/src/Pages/index.ts | 1 + .../PagesAdmin/AdminLayout/AdminLayout.tsx | 19 +-- frontend/src/api.ts | 5 + frontend/src/i18n/constants.ts | 3 + frontend/src/i18n/translations.ts | 6 + frontend/src/router/router.tsx | 7 ++ frontend/src/routes/backend.ts | 3 +- frontend/src/routes/frontend.ts | 6 + frontend/src/utils.ts | 56 +++++++++ 17 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.module.scss create mode 100644 frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.tsx create mode 100644 frontend/src/Pages/UserChangePasswordPage/UserChangePasswordPage.tsx create mode 100644 frontend/src/Pages/UserChangePasswordPage/index.ts diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 30079e0fb..fc1b27776 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -573,6 +573,7 @@ samfundet__login = 'samfundet:login' samfundet__register = 'samfundet:register' samfundet__logout = 'samfundet:logout' +samfundet__change_password = 'samfundet:change-password' samfundet__user = 'samfundet:user' samfundet__groups = 'samfundet:groups' samfundet__users = 'samfundet:users' diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 371910607..d19f08ce7 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -15,6 +15,7 @@ from django.core.exceptions import ValidationError from django.core.files.images import ImageFile from django.contrib.auth.models import Group, Permission +from django.contrib.auth.password_validation import validate_password from root.constants import PHONE_NUMBER_REGEX from root.utils.mixins import CustomBaseSerializer @@ -235,6 +236,22 @@ class Meta: fields = '__all__' +class ChangePasswordSerializer(serializers.Serializer): + current_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True) + + def validate_current_password(self, value: str) -> str: + user = self.context['request'].user + if not user.check_password(value): + raise serializers.ValidationError('Incorrect current password') + return value + + def validate_new_password(self, value: str) -> str: + user = self.context['request'].user + validate_password(value, user) + return value + + class LoginSerializer(serializers.Serializer): """ This serializer defines two fields for authentication: diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 0fab9f6d5..940ac346f 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -63,6 +63,7 @@ path('login/', views.LoginView.as_view(), name='login'), path('register/', views.RegisterView.as_view(), name='register'), path('logout/', views.LogoutView.as_view(), name='logout'), + path('password/change/', views.ChangePasswordView.as_view(), name='change-password'), path('user/', views.UserView.as_view(), name='user'), path('groups/', views.AllGroupsView.as_view(), name='groups'), path('users/', views.AllUsersView.as_view(), name='users'), diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index a566688b0..59666f8aa 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -25,7 +25,7 @@ from django.core.mail import EmailMessage from django.db.models import Q, Count, QuerySet from django.shortcuts import get_object_or_404 -from django.contrib.auth import login, logout +from django.contrib.auth import login, logout, update_session_auth_hash from django.utils.encoding import force_bytes from django.middleware.csrf import get_token from django.utils.decorators import method_decorator @@ -76,6 +76,7 @@ UserFeedbackSerializer, UserGangRoleSerializer, InterviewRoomSerializer, + ChangePasswordSerializer, FoodPreferenceSerializer, UserPreferenceSerializer, InformationPageSerializer, @@ -481,6 +482,20 @@ def post(self, request: Request) -> Response: return res +class ChangePasswordView(APIView): + permission_classes = (IsAuthenticated,) + + def post(self, request: Request) -> Response: + serializer = ChangePasswordSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + new_password = serializer.validated_data['new_password'] + user = request.user + user.set_password(new_password) + user.save() + update_session_auth_hash(request, user) + return Response({'message': 'Successfully updated password'}, status=status.HTTP_200_OK) + + class UserView(APIView): permission_classes = [IsAuthenticated] diff --git a/frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.module.scss b/frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.module.scss new file mode 100644 index 000000000..849d932b7 --- /dev/null +++ b/frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.module.scss @@ -0,0 +1,17 @@ +@import 'src/constants'; + +@import 'src/mixins'; + +.form { + width: 100%; + + @include for-desktop-up { + width: 400px; + } +} + +.action_row { + display: flex; + justify-content: flex-end; + margin: 1rem 0; +} diff --git a/frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.tsx b/frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.tsx new file mode 100644 index 000000000..21d389640 --- /dev/null +++ b/frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.tsx @@ -0,0 +1,113 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import i18next from 'i18next'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { z } from 'zod'; +import { Button, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input } from '~/Components'; +import { changePassword } from '~/api'; +import { KEY } from '~/i18n/constants'; +import { PASSWORD } from '~/schema/user'; +import { handleServerFormErrors, lowerCapitalize } from '~/utils'; +import styles from './ChangePasswordForm.module.scss'; + +const schema = z + .object({ + current_password: PASSWORD, + new_password: PASSWORD, + repeat_password: PASSWORD, + }) + .refine((data) => data.new_password === data.repeat_password, { + message: i18next.t(KEY.loginpage_passwords_must_match), + path: ['repeat_password'], + }); + +type SchemaType = z.infer; + +export function ChangePasswordForm() { + const { t } = useTranslation(); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + current_password: '', + new_password: '', + repeat_password: '', + }, + }); + + const { mutate, isPending } = useMutation({ + mutationFn: ({ current_password, new_password }: { current_password: string; new_password: string }) => + changePassword(current_password, new_password), + onSuccess: () => { + form.reset(); + toast.success(t(KEY.common_update_successful)); + }, + onError: (error) => + handleServerFormErrors(error, form, { + 'Incorrect current': 'Ugyldig nåværende passord', + 'too short': 'Passordet er for kort. Det må inneholde minst 8 karakterer', + 'too common': 'Passordet er for vanlig.', + }), + }); + + function onSubmit(values: SchemaType) { + mutate({ current_password: values.current_password, new_password: values.new_password }); + } + + return ( +
+ + ( + + {lowerCapitalize(`${t(KEY.common_current)} ${t(KEY.common_password)}`)} + + + + + + )} + /> + ( + + {lowerCapitalize(t(KEY.new_password))} + + + + + + )} + /> + ( + + {lowerCapitalize(`${t(KEY.common_repeat)} ${t(KEY.new_password)}`)} + + + + + + )} + /> + +
+ +
+ + + ); +} diff --git a/frontend/src/Pages/UserChangePasswordPage/UserChangePasswordPage.tsx b/frontend/src/Pages/UserChangePasswordPage/UserChangePasswordPage.tsx new file mode 100644 index 000000000..9f85b87ea --- /dev/null +++ b/frontend/src/Pages/UserChangePasswordPage/UserChangePasswordPage.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next'; +import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout'; +import { useTitle } from '~/hooks'; +import { KEY } from '~/i18n/constants'; +import { ChangePasswordForm } from './ChangePasswordForm'; + +export function UserChangePasswordPage() { + const { t } = useTranslation(); + const title = t(KEY.change_password); + useTitle(title); + + return ( + + + + ); +} diff --git a/frontend/src/Pages/UserChangePasswordPage/index.ts b/frontend/src/Pages/UserChangePasswordPage/index.ts new file mode 100644 index 000000000..06f99c31f --- /dev/null +++ b/frontend/src/Pages/UserChangePasswordPage/index.ts @@ -0,0 +1 @@ +export { UserChangePasswordPage } from './UserChangePasswordPage'; diff --git a/frontend/src/Pages/index.ts b/frontend/src/Pages/index.ts index 1dd719188..89625e6d2 100644 --- a/frontend/src/Pages/index.ts +++ b/frontend/src/Pages/index.ts @@ -24,5 +24,6 @@ export { RecruitmentPage } from './RecruitmentPage'; export { RouteOverviewPage } from './RouteOverviewPage'; export { SaksdokumenterPage } from './SaksdokumenterPage'; export { SignUpPage } from './SignUpPage'; +export { UserChangePasswordPage } from './UserChangePasswordPage'; export { VenuePage } from './VenuePage'; export { OrganizationRecruitmentPage } from './OrganizationRecruitmentPage'; diff --git a/frontend/src/PagesAdmin/AdminLayout/AdminLayout.tsx b/frontend/src/PagesAdmin/AdminLayout/AdminLayout.tsx index 5960969da..6331157fd 100644 --- a/frontend/src/PagesAdmin/AdminLayout/AdminLayout.tsx +++ b/frontend/src/PagesAdmin/AdminLayout/AdminLayout.tsx @@ -31,7 +31,7 @@ export function AdminLayout() { if (applet.url === undefined) return <>; // Create panel item - const selected = location.pathname.toLowerCase().indexOf(applet.url) !== -1; + const selected = location.pathname === applet.url; return ( { if (!isMobile) { setPanelOpen(true); } }, [isMobile]); + const userApplets: Applet[] = [ + { url: ROUTES_FRONTEND.admin, icon: 'mdi:person', title_nb: 'Profil', title_en: 'Profile' }, + { + url: ROUTES_FRONTEND.user_change_password, + icon: 'mdi:password', + title_nb: 'Bytt passord', + title_en: 'Change password', + }, + ]; + const panel = (