Skip to content

Commit

Permalink
Change password (#1625)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
robines authored Dec 8, 2024
1 parent f691f78 commit c5a9ca8
Show file tree
Hide file tree
Showing 17 changed files with 281 additions and 9 deletions.
1 change: 1 addition & 0 deletions backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
17 changes: 17 additions & 0 deletions backend/samfundet/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/samfundet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
17 changes: 16 additions & 1 deletion backend/samfundet/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,6 +76,7 @@
UserFeedbackSerializer,
UserGangRoleSerializer,
InterviewRoomSerializer,
ChangePasswordSerializer,
FoodPreferenceSerializer,
UserPreferenceSerializer,
InformationPageSerializer,
Expand Down Expand Up @@ -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]

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
113 changes: 113 additions & 0 deletions frontend/src/Pages/UserChangePasswordPage/ChangePasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof schema>;

export function ChangePasswordForm() {
const { t } = useTranslation();

const form = useForm<SchemaType>({
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
<FormField
name="current_password"
control={form.control}
disabled={isPending}
render={({ field }) => (
<FormItem>
<FormLabel>{lowerCapitalize(`${t(KEY.common_current)} ${t(KEY.common_password)}`)}</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="new_password"
control={form.control}
disabled={isPending}
render={({ field }) => (
<FormItem>
<FormLabel>{lowerCapitalize(t(KEY.new_password))}</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="repeat_password"
control={form.control}
disabled={isPending}
render={({ field }) => (
<FormItem>
<FormLabel>{lowerCapitalize(`${t(KEY.common_repeat)} ${t(KEY.new_password)}`)}</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className={styles.action_row}>
<Button type="submit" theme="green" disabled={isPending}>
{t(KEY.common_save)}
</Button>
</div>
</form>
</Form>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<AdminPageLayout title={title}>
<ChangePasswordForm />
</AdminPageLayout>
);
}
1 change: 1 addition & 0 deletions frontend/src/Pages/UserChangePasswordPage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { UserChangePasswordPage } from './UserChangePasswordPage';
1 change: 1 addition & 0 deletions frontend/src/Pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
19 changes: 12 additions & 7 deletions frontend/src/PagesAdmin/AdminLayout/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Link
key={index}
Expand All @@ -48,14 +48,22 @@ export function AdminLayout() {
[location, isMobile, panelOpen],
);

const selectedIndex = window.location.href.endsWith(ROUTES_FRONTEND.admin);

useEffect(() => {
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 = (
<div className={classNames(styles.panel, !panelOpen && styles.mobile_panel_closed)}>
<button type="button" className={styles.mobile_panel_close_btn} onClick={() => setPanelOpen(false)}>
Expand All @@ -65,10 +73,7 @@ export function AdminLayout() {
{/* Header */}
<div className={styles.panel_header}>{t(KEY.control_panel_title)}</div>
{/* Index */}
<Link className={classNames(styles.panel_item, selectedIndex && styles.selected)} url={ROUTES_FRONTEND.admin}>
<Icon icon="mdi:person" />
{t(KEY.common_profile)}
</Link>
{userApplets.map((applet, index) => makeAppletShortcut(applet, index))}
<br />
{/* Applets */}
{appletCategories.map((category) => {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export async function register(data: RegistrationDto): Promise<number> {
return response.status;
}

export async function changePassword(current_password: string, new_password: string): Promise<AxiosResponse> {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__change_password;
return await axios.post(url, { current_password, new_password }, { withCredentials: true });
}

export async function getUser(): Promise<UserDto> {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__user;
const response = await axios.get<UserDto>(url, { withCredentials: true });
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const KEY = {
common_occasion: 'common_occasion',
common_phonenumber: 'common_phonenumber',
common_password: 'common_password',
common_current: 'common_current',
common_about_us: 'common_about_us',
common_overview: 'common_overview',
common_recruitmentposition: 'common_recruitmentposition',
Expand Down Expand Up @@ -223,6 +224,8 @@ export const KEY = {
// ==================== //

role_content_type: 'role_content_type',
change_password: 'change_password',
new_password: 'new_password',

// LoginPage:
loginpage_register: 'loginpage_register',
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const nb = prepareTranslations({
[KEY.common_phonenumber]: 'Telefonnummer',
[KEY.common_register]: 'Registrer',
[KEY.common_password]: 'passord',
[KEY.common_current]: 'Nåværende',
[KEY.common_about_us]: 'Om oss',
[KEY.common_previous]: 'Forrige',
[KEY.common_required]: 'Påkrevd',
Expand Down Expand Up @@ -206,6 +207,8 @@ export const nb = prepareTranslations({
// Others //
// ==================== //
[KEY.role_content_type]: 'Hierarkinivå',
[KEY.change_password]: 'Bytt passord',
[KEY.new_password]: 'Nytt passord',
[KEY.admin_impersonate]: 'Stjel identitet',
[KEY.admin_stop_impersonate]: 'Stopp identitetstyveri',

Expand Down Expand Up @@ -601,6 +604,7 @@ export const en = prepareTranslations({
[KEY.common_phonenumber]: 'Phone number',
[KEY.common_lastname]: 'Last name',
[KEY.common_password]: 'password',
[KEY.common_current]: 'Current',
[KEY.common_select_all]: 'Select all',
[KEY.common_unselect_all]: 'Unselect all',
[KEY.common_overview]: 'Overview',
Expand Down Expand Up @@ -696,6 +700,8 @@ export const en = prepareTranslations({
// Others //
// ==================== //
[KEY.role_content_type]: 'Hierarchical level',
[KEY.change_password]: 'Change password',
[KEY.new_password]: 'New password',
[KEY.admin_impersonate]: 'Impersonate',
[KEY.admin_stop_impersonate]: 'Stop impersonation',

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
RouteOverviewPage,
SaksdokumenterPage,
SignUpPage,
UserChangePasswordPage,
VenuePage,
} from '~/Pages';
import {
Expand Down Expand Up @@ -148,6 +149,12 @@ export const router = createBrowserRouter(
path={ROUTES.frontend.admin}
element={<PermissionRoute required={[PERM.SAMFUNDET_VIEW_GANG]} element={<AdminPage />} />}
/>
{/* User pages */}
<Route
path={ROUTES.frontend.user_change_password}
element={<UserChangePasswordPage />}
handle={{ crumb: ({ pathname }: UIMatch) => <Link url={pathname}>{t(KEY.change_password)}</Link> }}
/>
{/* Gangs */}
<Route
element={<Outlet />}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/routes/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ export const ROUTES_BACKEND = {
samfundet__login: '/login/',
samfundet__register: '/register/',
samfundet__logout: '/logout/',
samfundet__change_password: '/password/change/',
samfundet__user: '/user/',
samfundet__groups: '/groups/',
samfundet__users: '/users/',
Expand Down Expand Up @@ -610,4 +611,4 @@ export const ROUTES_BACKEND = {
samfundet__gang_application_stats: '/recruitment/:recruitmentId/gang/:gangId/stats/',
static__path: '/static/:path',
media__path: '/media/:path',
} as const;
} as const;
Loading

0 comments on commit c5a9ca8

Please sign in to comment.