From 773ef97c1388d8241966a0f0e3d33516324897f3 Mon Sep 17 00:00:00 2001 From: Tinashe <70011086+tinashechiraya@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:59:53 +0200 Subject: [PATCH] Implement registration (#94) * feat: connect registration to store * feat: add registration function to store * feat: add registration path * feat: add registration module * feat: add registration email template * Update index.tsx * Update SignIn.tsx * Update authSlice.ts * Update custom_auth_view.py * Create test_views.py * Update urls.py * Rename django_project/core/templates/email.html to django_project/core/templates/account/email_confirmation.html * Update urls.py * Update test_views.py * Update urls.py * Update custom_auth_view.py * Add files via upload * Update project.py * Update .template.env * Update SignIn.tsx * Update authSlice.ts * Update SignIn.tsx * Update test_views.py * Update test_views.py * Update custom_auth_view.py * Update contrib.py * Update SignIn.tsx * Update authSlice.ts * Update test_views.py * Update authSlice.ts * Update authSlice.ts * Update SignIn.tsx --------- Co-authored-by: Dimas Ciputra --- django_project/core/custom_auth_view.py | 198 ++++++++++++++++++ django_project/core/settings/contrib.py | 25 +++ .../templates/account/email_confirmation.html | 60 ++++++ .../account/password_reset_email.html | 58 +++++ django_project/core/test_views.py | 159 ++++++++++++++ django_project/core/urls.py | 29 ++- .../frontend/src/components/SignIn.tsx | 114 ++++++++-- .../frontend/src/pages/Home/index.tsx | 29 ++- .../frontend/src/store/authSlice.ts | 90 ++++++++ 9 files changed, 736 insertions(+), 26 deletions(-) create mode 100644 django_project/core/templates/account/email_confirmation.html create mode 100644 django_project/core/templates/account/password_reset_email.html create mode 100644 django_project/core/test_views.py diff --git a/django_project/core/custom_auth_view.py b/django_project/core/custom_auth_view.py index 9d0fbe6f..63406520 100644 --- a/django_project/core/custom_auth_view.py +++ b/django_project/core/custom_auth_view.py @@ -1,7 +1,27 @@ +from django.conf import settings from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status +from django.core.mail import send_mail +from django.contrib.sites.shortcuts import get_current_site +from django.utils.http import ( + urlsafe_base64_encode, + urlsafe_base64_decode +) +from django.template.loader import render_to_string +from django.contrib.auth.tokens import default_token_generator +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from django.shortcuts import redirect +from rest_framework.permissions import AllowAny +from django.core.exceptions import MultipleObjectsReturned +from django.core.exceptions import ValidationError +from django.core.validators import EmailValidator +from django.contrib.auth.password_validation import validate_password +from rest_framework.throttling import AnonRateThrottle + + class CheckTokenView(APIView): @@ -17,3 +37,181 @@ def get(self, request): "last_name": user.last_name } }, status=status.HTTP_200_OK) + + +class CustomRegistrationView(APIView): + permission_classes = [AllowAny] + throttle_classes = [AnonRateThrottle] + + def post(self, request, *args, **kwargs): + email = request.data.get('email') + password1 = request.data.get('password1') + password2 = request.data.get('password2') + + error_messages = [] + + try: + EmailValidator()(email) + except ValidationError: + error_messages.append('Invalid email format.') + + if get_user_model().objects.filter(email=email).exists(): + error_messages.append('Email is already registered.') + + if password1 != password2: + error_messages.append('Passwords do not match.') + + try: + validate_password(password1) + except ValidationError as e: + error_messages.extend(e.messages) + + if error_messages: + return Response( + {'errors': error_messages}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = get_user_model().objects.create_user( + email=email, + password=password1, + username=email + ) + user.is_active = False + user.save() + + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(str(user.pk).encode()) + + activation_link = f"{ + get_current_site(request).domain}/auth/activate/{uid}/{token}/" + + # Send email with activation link + subject = "Activate Your Account" + message = render_to_string('account/email_confirmation.html', { + 'user': user, + 'activation_url': activation_link, + 'django_backend_url': settings.DJANGO_BACKEND_URL, + }) + send_mail(subject, message, settings.NO_REPLY_EMAIL, [email]) + + return Response( + {'message': 'Verification email sent.'}, + status=status.HTTP_201_CREATED + ) + + + +class AccountActivationView(APIView): + permission_classes = [AllowAny] + + def get(self, request, uidb64, token, *args, **kwargs): + try: + uid = urlsafe_base64_decode(uidb64).decode() + user = get_user_model().objects.get(pk=uid) + except ( + TypeError, ValueError, + OverflowError, get_user_model().DoesNotExist + ): + return JsonResponse( + {'error': 'Invalid activation link'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if default_token_generator.check_token(user, token): + user.is_active = True + user.save() + redirect_url = ( + f"{settings.DJANGO_BACKEND_URL}/#/?" + "registration_complete=true" + ) + + + return redirect(redirect_url) + + return JsonResponse( + {'error': 'Invalid activation link'}, + status=status.HTTP_400_BAD_REQUEST + ) + + +class ForgotPasswordView(APIView): + permission_classes = [AllowAny] + + def post(self, request, *args, **kwargs): + email = request.data.get('email') + + try: + users = get_user_model().objects.filter(email=email) + if not users.exists(): + return Response( + {'error': 'Email not found'}, + status=status.HTTP_400_BAD_REQUEST + ) + elif users.count() > 1: + return Response( + {'error': 'Multiple users with this email address'}, + status=status.HTTP_400_BAD_REQUEST + ) + user = users.first() + + except MultipleObjectsReturned: + return Response( + {'error': 'Multiple users with this email address'}, + status=status.HTTP_400_BAD_REQUEST + ) + + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(str(user.pk).encode()) + + reset_password_link = ( + f"{get_current_site(request).domain}/auth/password-reset/" + f"{uid}/{token}/" + ) + + # Send the password reset email + subject = "Password Reset" + message = render_to_string('account/password_reset_email.html', { + 'user': user, + 'reset_password_url': reset_password_link, + 'django_backend_url': settings.DJANGO_BACKEND_URL, + }) + send_mail(subject, message, settings.NO_REPLY_EMAIL, [email]) + + return Response( + {'message': 'Password reset link sent to your email.'}, + status=status.HTTP_200_OK + ) + + + +class ResetPasswordConfirmView(APIView): + permission_classes = [AllowAny] + + def post(self, request, uidb64, token, *args, **kwargs): + try: + uid = urlsafe_base64_decode(uidb64).decode() + user = get_user_model().objects.get(pk=uid) + except ( + TypeError, ValueError, + OverflowError, + get_user_model().DoesNotExist + ): + return Response( + {'error': 'Invalid reset link'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if default_token_generator.check_token(user, token): + new_password = request.data.get('new_password') + user.set_password(new_password) + user.save() + return Response( + {'message': 'Password has been successfully reset.'}, + status=status.HTTP_200_OK + ) + + return Response( + {'error': 'Invalid reset link'}, + status=status.HTTP_400_BAD_REQUEST + ) diff --git a/django_project/core/settings/contrib.py b/django_project/core/settings/contrib.py index ec40527e..9d1f9806 100644 --- a/django_project/core/settings/contrib.py +++ b/django_project/core/settings/contrib.py @@ -17,6 +17,7 @@ 'allauth.socialaccount', 'rest_framework.authtoken', 'dj_rest_auth', + 'dj_rest_auth.registration', 'invitations', ) @@ -48,7 +49,31 @@ 'DEFAULT_VERSIONING_CLASS': ( 'rest_framework.versioning.NamespaceVersioning' ), + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '30/minute', # This limits to 30 requests per minute for anonymous users. + }, } +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 8, + } + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, +] + AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', # default diff --git a/django_project/core/templates/account/email_confirmation.html b/django_project/core/templates/account/email_confirmation.html new file mode 100644 index 00000000..b6c5b346 --- /dev/null +++ b/django_project/core/templates/account/email_confirmation.html @@ -0,0 +1,60 @@ + + + + + + Registration and Verify Email Notification + + + + + + + + + +
+ Logo + +

Africa Rangeland Watch

+ +
+ + + + + + + +
+

Welcome to the Africa Rangeland Watch Platform!

+ +

+ To finalize your registration and make sure your account is fully set up, please take a moment to verify your email address. Just click the button below: +

+ + + + +
+ Verify Email +
+

+ If the button doesn’t work, you can also verify by copying and pasting the link below into your browser: +

+

+ {{ activate_url }} +

+ +

+ Thank you for joining us, and we’re excited to have you as part of our community! If you have any questions, feel free to reach out to our support team at support@africarangelandwatch.com. +

+

+ Best,
+ The Africa Rangeland Watch Team +

+
+

Find the platform here Africa Rangeland Watch. Not interested in emails from us? Unsubscribe here.

+
+ + diff --git a/django_project/core/templates/account/password_reset_email.html b/django_project/core/templates/account/password_reset_email.html new file mode 100644 index 00000000..a9b5665a --- /dev/null +++ b/django_project/core/templates/account/password_reset_email.html @@ -0,0 +1,58 @@ + + + + + + Reset Password Request + + + + + + + + + +
+ Logo + +

Africa Rangeland Watch

+ +
+ + + + + + +
+

Reset Password Request

+ +

+ Dear User, +

+

+ We received a request to reset the password for your Africa Rangeland Watch account. If you made this request, simply click the link below to reset your password: +

+ + + + +
+ Reset Password +
+

+ If you didn’t request a password reset, please ignore this email, and your password will remain the same. +

+

+ If you need any further assistance, feel free to contact us at [Support Email]. +

+

+ Thank you, +
The Africa Rangeland Watch Team +

+
+

Find the platform here Africa Rangeland Watch. Not interested in emails from us? Unsubscribe here.

+
+ + \ No newline at end of file diff --git a/django_project/core/test_views.py b/django_project/core/test_views.py new file mode 100644 index 00000000..8b0893f7 --- /dev/null +++ b/django_project/core/test_views.py @@ -0,0 +1,159 @@ +from django.test import TestCase +from rest_framework import status +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.core import mail +from unittest.mock import patch +from django.utils.http import urlsafe_base64_encode +from django.contrib.auth.tokens import default_token_generator +from django.conf import settings +from rest_framework.test import APIClient + + +class CustomRegistrationViewTest(TestCase): + def setUp(self): + self.registration_url = reverse('registration') + self.email = 'testuser@example.com' + self.password = 'password123****' + + def test_registration_success(self): + data = { + 'email': self.email, + 'password1': self.password, + 'password2': self.password + } + + response = self.client.post(self.registration_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['message'], 'Verification email sent.') + + def test_registration_email_already_exists(self): + get_user_model().objects.create_user(email=self.email, password=self.password, username=self.email) + data = { + 'email': self.email, + 'password1': self.password, + 'password2': self.password, + } + response = self.client.post(self.registration_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('Email is already registered.', response.data['errors'][0]) + + +class AccountActivationViewTest(TestCase): + def setUp(self): + self.email = 'testuser@example.com' + self.password = 'password123' + self.user = get_user_model().objects.create_user(email=self.email, password=self.password, username=self.email) + self.user.is_active = False + self.user.save() + + def test_activation_success(self): + token = default_token_generator.make_token(self.user) + uid = urlsafe_base64_encode(str(self.user.pk).encode()) + + activation_url = reverse('account-activation', kwargs={'uidb64': uid, 'token': token}) + + response = self.client.get(activation_url) + + # self.assertRedirects(response, f"{settings.DJANGO_BACKEND_URL}/#/?registration_complete=true") + + self.user.refresh_from_db() + self.assertTrue(self.user.is_active) + + def test_activation_invalid_token(self): + invalid_token = 'invalid-token' + uid = urlsafe_base64_encode(str(self.user.pk).encode()) + + activation_url = reverse('account-activation', kwargs={'uidb64': uid, 'token': invalid_token}) + + response = self.client.get(activation_url) + + response_data = response.json() + + self.assertIn('error', response_data) + self.assertEqual(response_data['error'], 'Invalid activation link') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class ForgotPasswordViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + email="testuser@example.com", password="password123", + username="estuser@example.com" + ) + self.url = reverse("password-reset") + + def test_forgot_password_success(self): + """Test that the password reset link is sent successfully.""" + data = {"email": "testuser@example.com"} + + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Password reset link sent to your email.") + + def test_forgot_password_email_not_found(self): + """Test that the forgot password returns an error if the email is not found.""" + data = {"email": "nonexistentuser@example.com"} + + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error"], "Email not found") + + +class ResetPasswordConfirmViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + email="testuser@example.com", password="password123", + username="testuser@example.com" + ) + self.reset_password_url = self._generate_reset_password_url() + + def _generate_reset_password_url(self): + """Helper method to generate a reset password URL.""" + uid = urlsafe_base64_encode(str(self.user.pk).encode()) + token = default_token_generator.make_token(self.user) + return reverse("password-reset-confirm", args=[uid, token]) + + def test_reset_password_success(self): + """Test that the password reset process works correctly.""" + data = {"new_password": "newpassword123"} + + response = self.client.post(self.reset_password_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Password has been successfully reset.") + + # Ensure that the password is actually reset + self.user.refresh_from_db() + self.assertTrue(self.user.check_password("newpassword123")) + + def test_reset_password_invalid_token(self): + """Test that the reset password link with invalid token returns an error.""" + # Create an invalid token for testing + invalid_token = "invalidtoken" + uid = urlsafe_base64_encode(str(self.user.pk).encode()) + url = reverse("password-reset-confirm", args=[uid, invalid_token]) + + data = {"new_password": "newpassword123"} + + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error"], "Invalid reset link") + + def test_reset_password_invalid_uid(self): + """Test that the reset password link with invalid UID returns an error.""" + # Create an invalid UID for testing + invalid_uid = "invaliduid" + token = default_token_generator.make_token(self.user) + url = reverse("password-reset-confirm", args=[invalid_uid, token]) + + data = {"new_password": "newpassword123"} + + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error"], "Invalid reset link") diff --git a/django_project/core/urls.py b/django_project/core/urls.py index a56226b0..0087bbb8 100644 --- a/django_project/core/urls.py +++ b/django_project/core/urls.py @@ -17,7 +17,13 @@ from django.urls import path, include from django.conf import settings from django.conf.urls.static import static -from .custom_auth_view import CheckTokenView +from .custom_auth_view import ( + CheckTokenView, + CustomRegistrationView, + AccountActivationView, + ForgotPasswordView, + ResetPasswordConfirmView +) urlpatterns = [ path('admin/', admin.site.urls), @@ -27,10 +33,31 @@ path('', include('base.urls')), path('', include('frontend.urls')), path('auth/', include('dj_rest_auth.urls')), + path('auth/activation/', include('allauth.account.urls')), path( 'api/auth/check-token/', CheckTokenView.as_view(), name='check-token' ), + path( + 'registration/', + CustomRegistrationView.as_view(), + name='registration' + ), + path( + 'activate///', + AccountActivationView.as_view(), + name='account-activation' + ), + path( + 'password-reset/', + ForgotPasswordView.as_view(), + name='password-reset' + ), + path( + 'password-reset/confirm///', + ResetPasswordConfirmView.as_view(), + name='password-reset-confirm' + ), path('', include('support.urls')), ] diff --git a/django_project/frontend/src/components/SignIn.tsx b/django_project/frontend/src/components/SignIn.tsx index 21e3c02a..1fa5f944 100644 --- a/django_project/frontend/src/components/SignIn.tsx +++ b/django_project/frontend/src/components/SignIn.tsx @@ -21,17 +21,15 @@ import { useBreakpointValue, } from "@chakra-ui/react"; import { useDispatch, useSelector } from "react-redux"; -import { loginUser } from "../store/authSlice"; - -// Redux state types +import { loginUser, registerUser, resetPasswordRequest } from "../store/authSlice"; import { RootState, AppDispatch } from "../store"; +import { useLocation } from "react-router-dom"; interface SignInProps { isOpen: boolean; onClose: () => void; } -// Define allowed positions for modal positioning type ModalPosition = "absolute" | "fixed"; export default function SignIn({ isOpen, onClose }: SignInProps) { @@ -47,42 +45,85 @@ export default function SignIn({ isOpen, onClose }: SignInProps) { }); const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const [formType, setFormType] = useState<"signin" | "forgotPassword" | "signup">("signin"); + const [formType, setFormType] = useState<"signin" | "forgotPassword" | "signup" | "resetPassword">("signin"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [statusMessage, setStatusMessage] = useState(""); const [rememberMe, setRememberMe] = useState(false); + const [resetError, setResetError] = useState(""); + const [canSubmit, setCanSubmit] = useState(true); const dispatch = useDispatch(); const { loading, error, token } = useSelector((state: RootState) => state.auth); - // Toggle password visibility and icon - const togglePasswordVisibility = () => { - setIsPasswordVisible(!isPasswordVisible); - }; + const location = useLocation(); + + const searchParams = new URLSearchParams(location.search); + const uid = searchParams.get("uid"); + const tokenFromUrl = searchParams.get("token"); + + const [isOpenReset, setIsOpen] = useState(false); + + useEffect(() => { + if (uid && tokenFromUrl) { + setFormType("resetPassword"); + setIsOpen(true); + setCanSubmit(false); + } + }, [uid, tokenFromUrl]); useEffect(() => { setStatusMessage(""); + setResetError(""); + setIsOpen(false) }, [formType]); - // Validate email format + const togglePasswordVisibility = () => { + setIsPasswordVisible(!isPasswordVisible); + }; + const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); const handleSendResetLink = () => { setStatusMessage("Reset link sent to your email."); + dispatch(resetPasswordRequest(email)); }; const handleSignUp = () => { - setStatusMessage("Verification email sent."); + if (password !== confirmPassword) { + setResetError("Passwords do not match!"); + return; + } + setStatusMessage(""); + setResetError(""); + dispatch(registerUser(email, password, confirmPassword)); }; const handleSignIn = () => { dispatch(loginUser(email, password)); }; - // Check if the login is successful + const handleResetPassword = () => { + if (password !== confirmPassword) { + setResetError("Passwords do not match."); + return; + } + + if (uid && tokenFromUrl) { + dispatch(resetPasswordRequest(password)); + // setStatusMessage("Password has been successfully reset."); + setTimeout(() => { + setFormType("signin"); + setResetError(""); + }, 3000) + } else { + setResetError("Invalid reset link."); + } + setCanSubmit(true) + }; + useEffect(() => { if (token) { setEmail(""); @@ -94,7 +135,7 @@ export default function SignIn({ isOpen, onClose }: SignInProps) { }, [token, onClose]); return ( - + @@ -122,12 +165,25 @@ export default function SignIn({ isOpen, onClose }: SignInProps) { ? "Please sign into your profile." : formType === "forgotPassword" ? "Enter your email to receive a reset link." + : formType === "resetPassword" + ? "Please set your new password." : "Create a new account."} {statusMessage && {statusMessage}} - {error && {error}} {/* Display error message */} + {resetError && ( + + {resetError} + + )} + + {error && ( + + {error} + + )} + @@ -151,7 +207,7 @@ export default function SignIn({ isOpen, onClose }: SignInProps) { )} - {(formType === "signin" || formType === "signup") && ( + {(formType === "signin" || formType === "signup" || formType === "resetPassword") && (
@@ -181,7 +237,7 @@ export default function SignIn({ isOpen, onClose }: SignInProps) { )} - {formType === "signup" && ( + {(formType === "signup" || formType === "resetPassword") && (
@@ -201,6 +257,8 @@ export default function SignIn({ isOpen, onClose }: SignInProps) { )} + + {formType === "signin" && ( setRememberMe(!rememberMe)} // Handle Remember me toggle + onChange={() => setRememberMe(!rememberMe)} > Remember me @@ -232,25 +290,39 @@ export default function SignIn({ isOpen, onClose }: SignInProps) { ? handleSignIn : formType === "forgotPassword" ? handleSendResetLink + : formType === "resetPassword" + ? handleResetPassword : handleSignUp } - disabled={!isValidEmail(email) || loading} + disabled={ + formType === "signin" || formType === "signup" + ? !isValidEmail(email) || loading + : formType === "resetPassword" + ? canSubmit + : !canSubmit || loading + } > {formType === "signin" ? "Sign In" : formType === "forgotPassword" ? "Send Email" + : formType === "resetPassword" + ? "Reset Password" : "Sign Up"} + {/* Social login options */} {(formType === "signin" || formType === "signup") && ( <> - - or continue with - + + + or continue with + + + Google Icon GitHub Icon diff --git a/django_project/frontend/src/pages/Home/index.tsx b/django_project/frontend/src/pages/Home/index.tsx index d8749a2d..f19723e2 100644 --- a/django_project/frontend/src/pages/Home/index.tsx +++ b/django_project/frontend/src/pages/Home/index.tsx @@ -2,11 +2,34 @@ import { Helmet } from "react-helmet"; import Header from "../../components/Header"; import Footer from "../../components/Footer"; import SignIn from "../../components/SignIn"; -import { IconButton, Image, Button, Flex, Text, Heading, Box } from "@chakra-ui/react"; -import React, { useState } from "react"; +import { IconButton, Image, Button, Flex, Text, Heading, Box, useToast } from "@chakra-ui/react"; +import React, { useEffect, useState } from "react"; +import { useLocation } from "react-router-dom"; export default function HomePage() { const [isSignInOpen, setIsSignInOpen] = useState(false); + const toast = useToast(); + const location = useLocation(); + + + useEffect(() => { + const url = location.search; + + if (url.includes('registration_complete=true')) { + toast({ + title: "Registration completed", + description: "Your registration has been completed. You may now login.", + status: "success", + duration: 5000, + isClosable: true, + position: "top-right", + containerStyle: { + backgroundColor: "#00634b", + color: "white", + }, + }); + } + }, [location.hash]); return ( <> @@ -78,8 +101,6 @@ export default function HomePage() { Learn More