Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change password #1625

Merged
merged 10 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -221,6 +222,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 @@ -204,6 +205,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 @@ -599,6 +602,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 @@ -692,6 +696,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
Loading