diff --git a/.env.example b/.env.example index 0892687..41a5811 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,17 @@ POSTGRES_USER= POSTGRES_PASSWORD= -POSTGRES_DB= \ No newline at end of file +POSTGRES_DB= + +UNICORE_USER= +UNICORE_PASSWORD= +UNICORE_ORG_ID= +UNICORE_URL=https://unicorecustomapi.mecenat.com/utn + +# Email settings for local testing with MailHog +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=mailhog +EMAIL_PORT=1025 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=False +EMAIL_USE_SSL=False diff --git a/.gitignore b/.gitignore index 4cd9f8b..eac977e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ venv .DS_Store node_modules team_logos +.venv/ +backend/.venv/ diff --git a/backend/backend/migrations/0008_alter_member_is_superuser.py b/backend/backend/migrations/0008_alter_member_is_superuser.py new file mode 100644 index 0000000..7006da7 --- /dev/null +++ b/backend/backend/migrations/0008_alter_member_is_superuser.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2026-05-14 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backend', '0007_merge_0006_alter_appointment_member_0006_role_role_description_url'), + ] + + operations = [ + migrations.AlterField( + model_name='member', + name='is_superuser', + field=models.BooleanField(default=False, help_text='Designates whether the user is a superuser'), + ), + ] diff --git a/backend/backend/migrations/0009_add_email_verification_code.py b/backend/backend/migrations/0009_add_email_verification_code.py new file mode 100644 index 0000000..b8b27b4 --- /dev/null +++ b/backend/backend/migrations/0009_add_email_verification_code.py @@ -0,0 +1,50 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend", "0008_alter_member_is_superuser"), + ] + + operations = [ + migrations.AddField( + model_name="member", + name="email_verification_code", + field=models.CharField( + blank=True, + default=None, + help_text="One-time email verification code", + max_length=128, + null=True, + ), + ), + migrations.AddField( + model_name="member", + name="email_verification_code_expires_at", + field=models.DateTimeField( + blank=True, + default=None, + help_text="When the email verification code expires", + null=True, + ), + ), + migrations.AddField( + model_name="member", + name="email_verification_attempts", + field=models.PositiveSmallIntegerField( + default=0, + help_text="Failed email verification attempts", + ), + ), + migrations.AddField( + model_name="member", + name="email_verification_sent_at", + field=models.DateTimeField( + blank=True, + default=None, + help_text="When the last email verification code was sent", + null=True, + ), + ), + ] diff --git a/backend/backend/models.py b/backend/backend/models.py index a134981..2f0095e 100644 --- a/backend/backend/models.py +++ b/backend/backend/models.py @@ -73,6 +73,29 @@ class Member(AbstractBaseUser, PermissionsMixin): ) verified_email = models.BooleanField(default=False) + email_verification_code = models.CharField( + max_length=128, + null=True, + blank=True, + default=None, + help_text=_("One-time email verification code"), + ) + email_verification_code_expires_at = models.DateTimeField( + null=True, + blank=True, + default=None, + help_text=_("When the email verification code expires"), + ) + email_verification_attempts = models.PositiveSmallIntegerField( + default=0, + help_text=_("Failed email verification attempts"), + ) + email_verification_sent_at = models.DateTimeField( + null=True, + blank=True, + default=None, + help_text=_("When the last email verification code was sent"), + ) phone_number = models.CharField( max_length=20, @@ -82,7 +105,9 @@ class Member(AbstractBaseUser, PermissionsMixin): ) is_superuser = models.BooleanField( - help_text=("Designates whether the user is a superuser")) + default=False, + help_text=("Designates whether the user is a superuser"), + ) is_staff = models.BooleanField( _("Staff status"), diff --git a/backend/backend/send_email.py b/backend/backend/send_email.py index e134a44..688c9e2 100644 --- a/backend/backend/send_email.py +++ b/backend/backend/send_email.py @@ -1,23 +1,58 @@ +import secrets +import string +from datetime import timedelta + +from django.contrib.auth.hashers import make_password from django.contrib.auth.tokens import default_token_generator -from django.contrib.sites.shortcuts import get_current_site from django.core.mail import send_mail -from django.template.loader import render_to_string -from django.utils.html import strip_tags +from django.utils import timezone + +VERIFICATION_CODE_LENGTH = 6 +VERIFICATION_CODE_TTL_MINUTES = 15 +VERIFICATION_RESEND_COOLDOWN_SECONDS = 60 + + +def generate_verification_code(length=VERIFICATION_CODE_LENGTH): + characters = string.ascii_uppercase + string.digits + return "".join(secrets.choice(characters) for _ in range(length)) # This needs rate limiting -def send_verification_email(user): +def send_verification_email(user, *, allow_rate_limit=True): """ - TEMPORARY FUNCTION - This function sends an email verification link to the user. - The link contains a token that the user can use to verify their email address. - + Sends an email verification code to the user. """ - token = default_token_generator.make_token(user) + now = timezone.now() + if ( + allow_rate_limit + and user.email_verification_sent_at + and (now - user.email_verification_sent_at).total_seconds() + < VERIFICATION_RESEND_COOLDOWN_SECONDS + ): + return False + + code = generate_verification_code() + user.email_verification_code = make_password(code) + user.email_verification_code_expires_at = now + timedelta( + minutes=VERIFICATION_CODE_TTL_MINUTES + ) + user.email_verification_attempts = 0 + user.email_verification_sent_at = now + user.save( + update_fields=[ + "email_verification_code", + "email_verification_code_expires_at", + "email_verification_attempts", + "email_verification_sent_at", + ] + ) subject = "Verify your email address" - message = f"Sign in with the secure link: http://localhost:3000/auth/verify-email?id={user.id}&token={token}" + message = ( + f"Verify your email address for Apply using the verification code {code}\n\n" + f"Or you can verify by visiting http://localhost:3000/verify-email?email={user.email}&code={code}" + ) send_mail( subject, @@ -26,19 +61,22 @@ def send_verification_email(user): [user.email], ) + return True + # This needs rate limiting def send_password_reset_email(user): """ - TEMPORARY FUNCTION This function sends a password reset email to the user. The email contains a link to reset the user's password. - """ + if not user.verified_email: + return + token = default_token_generator.make_token(user) subject = "Reset your password" - message = f"Click to reset your password: http://localhost:3000/reset-password?id={user.id}&token={token}" + message = f"Follow this link to reset your password:\nhttp://localhost:3000/reset-password?id={user.id}&token={token}" send_mail( subject, message, diff --git a/backend/backend/serializers.py b/backend/backend/serializers.py index a00e78c..0314efa 100644 --- a/backend/backend/serializers.py +++ b/backend/backend/serializers.py @@ -12,6 +12,7 @@ Application, Reference, ) +from .send_email import send_verification_email def get_language_from_request(request): @@ -63,6 +64,24 @@ class MemberSerializer(ModelSerializer): """ study_program = StudyProgramSerializer(read_only=True) + study_program_id = serializers.PrimaryKeyRelatedField( + queryset=StudyProgram.objects.all(), + source="study_program", + write_only=True, + required=False, + allow_null=True, + ) + section_id = serializers.PrimaryKeyRelatedField( + queryset=Section.objects.all(), + write_only=True, + required=False, + allow_null=True, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance is not None: + self.fields["email"].read_only = True class Meta: model = Member @@ -70,6 +89,8 @@ class Meta: "name", "phone_number", "study_program", + "study_program_id", + "section_id", "registration_year", "status", "ssn", @@ -94,7 +115,19 @@ def validate_password(self, value): raise serializers.ValidationError(err.messages) return value + def validate(self, attrs): + section = attrs.get("section_id") + study_program = attrs.get("study_program") + + if section and study_program and study_program.section_id != section.id: + raise serializers.ValidationError( + {"section_id": ["Section does not match selected program."]} + ) + + return attrs + def create(self, validated_data): + validated_data.pop("section_id", None) password = validated_data.pop("password", None) if password is None: raise serializers.ValidationError({"password": ["Password must be set"]}) @@ -102,7 +135,7 @@ def create(self, validated_data): user.set_password(password) user.save() - # TODO: Email verification here + send_verification_email(user) return user diff --git a/backend/backend/tests.py b/backend/backend/tests.py index 7ce503c..99da6c0 100644 --- a/backend/backend/tests.py +++ b/backend/backend/tests.py @@ -1,3 +1,55 @@ +from django.contrib.auth import get_user_model +from django.core import mail +from django.urls import reverse +from rest_framework.test import APIClient from django.test import TestCase -# Create your tests here. + +class PasswordResetTests(TestCase): + def setUp(self): + self.client = APIClient() + User = get_user_model() + + self.verified_user = User.objects.create( + email="verified@example.com", + name="Verified User", + phone_number="+4600000000", + ssn="199001010001", + verified_email=True, + is_active=True, + ) + self.verified_user.set_password("securepass123") + self.verified_user.save() + + self.unverified_user = User.objects.create( + email="unverified@example.com", + name="Unverified User", + phone_number="+4600000001", + ssn="199001010002", + verified_email=False, + is_active=True, + ) + self.unverified_user.set_password("securepass123") + self.unverified_user.save() + + def test_password_reset_email_sent_for_verified_user(self): + response = self.client.post( + reverse("reset-password"), + {"email": self.verified_user.email}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + self.assertIn("reset-password", mail.outbox[0].body) + self.assertEqual(mail.outbox[0].to, [self.verified_user.email]) + + def test_password_reset_email_not_sent_for_unverified_user(self): + response = self.client.post( + reverse("reset-password"), + {"email": self.unverified_user.email}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 0) diff --git a/backend/backend/utils/validators.py b/backend/backend/utils/validators.py index 9dfd6d1..a0e9a95 100644 --- a/backend/backend/utils/validators.py +++ b/backend/backend/utils/validators.py @@ -36,7 +36,7 @@ class NumberValidator: def validate(self, password, user=None): if not any(char.isdigit() for char in password): raise validators.ValidationError( - _("This password must contain at least one number."), + _("The password must contain at least one number."), code="password_no_number", ) @@ -50,7 +50,7 @@ class SpecialCharacterValidator: def validate(self, password, user=None): if not any(char in self.SPECIAL_CHARACTERS for char in password): raise validators.ValidationError( - _("This password must contain at least one special character."), + _("The password must contain at least one special character."), code="password_no_special_character", ) diff --git a/backend/backend/views.py b/backend/backend/views.py index 29ceb41..ce49dfe 100644 --- a/backend/backend/views.py +++ b/backend/backend/views.py @@ -4,6 +4,7 @@ from django.contrib.auth.tokens import default_token_generator from django.core.exceptions import ValidationError from django.middleware.csrf import get_token +from django.utils import timezone from .managers import MemberManager from rest_framework import status from rest_framework.decorators import action @@ -180,7 +181,8 @@ def post(self, request): status=200, ) - send_password_reset_email(user) + if user.verified_email: + send_password_reset_email(user) return Response( { @@ -296,7 +298,7 @@ def post(self, request): class EmailVerificationAPIView(APIView): """ EmailVerificationAPIView handles email verification through the API. - This view processes GET requests to verify user email addresses using a token and ID. + This view processes GET requests to verify user email addresses using a code and ID. DO NOT CHANGE UNLESS YOU KNOW WHAT YOU ARE DOING @@ -316,20 +318,54 @@ class EmailVerificationAPIView(APIView): permission_classes = [AllowAny] def get(self, request): - user_id = request.GET.get("id") - token = request.GET.get("token") + code = (request.GET.get("code") or "").strip().upper() + email = (request.GET.get("email") or "").strip() try: - user = get_user_model().objects.get(pk=user_id) + user = get_user_model().objects.get(email=email) except get_user_model().DoesNotExist: user = None - if default_token_generator.check_token(user, token): - login(request, user) + if user is None: + return Response({"message": "No account found for this email."}, status=400) + + if not code: + return Response({"message": "Verification code is required."}, status=400) + + if not user.email_verification_code or not user.email_verification_code_expires_at: + return Response({"message": "No active verification code. Please request a new one."}, status=400) + + if user.email_verification_code_expires_at < timezone.now(): + return Response({"message": "Verification code has expired. Please request a new one."}, status=400) - return Response({"message": "Email verified successfully"}, status=200) + if user.email_verification_attempts >= 5: + return Response({"message": "Too many failed attempts. Please request a new code."}, status=400) - return Response({"message": "Invalid verification link"}, status=400) + if not check_password(code, user.email_verification_code): + user.email_verification_attempts += 1 + user.save(update_fields=["email_verification_attempts"]) + remaining_attempts = max(0, 5 - user.email_verification_attempts) + return Response( + {"message": f"Invalid verification code. Attempts remaining: {remaining_attempts}."}, + status=400, + ) + + user.verified_email = True + user.email_verification_code = None + user.email_verification_code_expires_at = None + user.email_verification_attempts = 0 + user.email_verification_sent_at = None + user.save( + update_fields=[ + "verified_email", + "email_verification_code", + "email_verification_code_expires_at", + "email_verification_attempts", + "email_verification_sent_at", + ] + ) + login(request, user) + return Response({"message": "Email verified successfully"}, status=200) class ResendVerificationEmailAPIView(APIView): @@ -369,7 +405,12 @@ def post(self, request): if user.verified_email: return Response({"message": "Email already verified"}, status=400) - send_verification_email(user) + sent = send_verification_email(user, allow_rate_limit=True) + if not sent: + return Response( + {"message": "Verification email sent recently. Try again shortly."}, + status=429, + ) return Response({"message": "Verification email resent"}, status=200) diff --git a/backend/config/settings.py b/backend/config/settings.py index 25aef8a..73c3153 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -29,9 +29,17 @@ if DEBUG: SECRET_KEY = "django-insecure-ljadjxsm&naahk*kduro)1es8l7#d65msgqdq65o*pd)7hu+m&" -if DEBUG: - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - ALLOWED_HOSTS = ["localhost", "backend"] +EMAIL_BACKEND = os.getenv( + "EMAIL_BACKEND", + "django.core.mail.backends.console.EmailBackend", +) +EMAIL_HOST = os.getenv("EMAIL_HOST", "localhost") +EMAIL_PORT = int(os.getenv("EMAIL_PORT", 25)) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False").lower() in ("true", "1", "yes") +EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "False").lower() in ("true", "1", "yes") +ALLOWED_HOSTS = ["localhost", "backend"] AUTH_USER_MODEL = "backend.Member" diff --git a/docker-compose.yml b/docker-compose.yml index 0260db0..a46507a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,14 @@ services: volumes: - pg_data:/var/lib/postgresql/data + mailhog: + image: mailhog/mailhog:latest + container_name: mailhog + restart: always + ports: + - "1025:1025" + - "8025:8025" + backend: build: context: ./backend @@ -17,6 +25,7 @@ services: container_name: django_backend depends_on: - db + - mailhog ports: - "8000:8000" env_file: diff --git a/frontend/apply/src/app/about/page.tsx b/frontend/apply/src/app/about/page.tsx index 7109c13..6743f84 100644 --- a/frontend/apply/src/app/about/page.tsx +++ b/frontend/apply/src/app/about/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useTranslation } from "react-i18next"; +import Image from "next/image"; +import { getImageUrl } from "@/utils/imageUrl"; export default function About() { const { t } = useTranslation(); @@ -8,7 +10,11 @@ export default function About() {

{t("aboutPage.title")}

{t("aboutPage.introText")}

- + UTN members in front of our union house in winter

{t("aboutPage.timeRequiredText")}

diff --git a/frontend/apply/src/app/account/page.tsx b/frontend/apply/src/app/account/page.tsx index e152897..7c4192e 100644 --- a/frontend/apply/src/app/account/page.tsx +++ b/frontend/apply/src/app/account/page.tsx @@ -224,7 +224,10 @@ export default function Account() { window.location.href = "/login"; }, 1500); } else { - setPasswordError(t("accountPage.passwordChangeError")); + const errorData = await response.json().catch(() => ({})); + setPasswordError( + errorData?.message || t("accountPage.passwordChangeError"), + ); } } catch { setPasswordError(t("accountPage.passwordChangeError")); @@ -577,6 +580,7 @@ export default function Account() { name="email" icon={} error={errors.email} + disabled />
diff --git a/frontend/apply/src/app/forgot-password/forgot-password.module.css b/frontend/apply/src/app/forgot-password/forgot-password.module.css index df2de61..92a0d68 100644 --- a/frontend/apply/src/app/forgot-password/forgot-password.module.css +++ b/frontend/apply/src/app/forgot-password/forgot-password.module.css @@ -12,7 +12,7 @@ border: 1px solid rgb(239, 239, 239); padding: 40px; width: 100%; - max-width: 420px; + max-width: 450px; display: flex; flex-direction: column; gap: 16px; diff --git a/frontend/apply/src/app/i18n/locales/en.json b/frontend/apply/src/app/i18n/locales/en.json index c648c25..e73cd53 100644 --- a/frontend/apply/src/app/i18n/locales/en.json +++ b/frontend/apply/src/app/i18n/locales/en.json @@ -81,6 +81,18 @@ "emailPlaceholder": "your.email@example.com", "passwordPlaceholder": "Enter your password" }, + "verifyEmailPage": { + "title": "Verify your email", + "instructions": "Enter the six-character code you received in your email to verify your email address.", + "verificationCode": "Verification code", + "codeInvalid": "Enter a valid 6-character code.", + "invalidOrExpired": "The verification code is invalid or expired.", + "error": "Unable to verify your email. Please try again.", + "verifyButton": "Verify", + "resendButton": "Resend verification email", + "resendSuccess": "Verification email sent successfully.", + "resendError": "Failed to resend verification email. Please try again." + }, "logoutPage": { "doYouWantToLogOut": "Do you want to log out?", "yesLogOut": "Yes, log out", @@ -88,6 +100,46 @@ "logoutError": "An error occurred during logout", "networkError": "Network error. Please try again." }, + "registerPage": { + "registerTitle": "Create Account", + "username": "Username", + "usernamePlaceholder": "Enter your username", + "usernameRequired": "Username is required", + "usernameTooShort": "Username must be at least 3 characters", + "email": "Email", + "emailPlaceholder": "Enter your email", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "password": "Password", + "passwordPlaceholder": "Enter your password", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 10 characters", + "passwordNeedsUppercase": "Password must contain at least one uppercase letter", + "passwordNeedsNumber": "Password must contain at least one number", + "passwordConfirmation": "Confirm password", + "passwordConfirmationPlaceholder": "Confirm your password", + "passwordConfirmationRequired": "Please confirm your password", + "personalIdentityNumber": "Personal identity number", + "PersonNumberPlaceholder": "YYYYMMDD-XXXX", + "PersonNumberRequired": "Personal identity number is required", + "PersonNumberInvalid": "Enter a valid personal identity number (YYYYMMDD-XXXX)", + "section": "Section", + "sectionPlaceholder": "Select your section", + "selectSection": "Select a section", + "selectProgram": "Select a program", + "selectSectionFirst": "Select a section to see its programs", + "sectionRequired": "Section is required", + "program": "Program", + "programRequired": "Please select a program", + "phoneNumber": "Phone number", + "phoneNumberPlaceholder": "Enter your phone number", + "phoneNumberRequired": "Phone number is required", + "phoneNumberInvalid": "Enter a valid phone number", + "registerAccount": "Create account", + "registerError": "Unable to create account. Please check your details and try again.", + "networkError": "Network error. Please try again.", + "passwordsDoNotMatch": "Passwords do not match" + }, "applyPage": { "failedToLoadPosition": "Failed to load position", "failedToLoadApplication": "Failed to load application", @@ -195,4 +247,4 @@ "requestNewLink": "Request a new link", "error": "Something went wrong. Please try again." } -} +} \ No newline at end of file diff --git a/frontend/apply/src/app/i18n/locales/sv.json b/frontend/apply/src/app/i18n/locales/sv.json index acce13d..6eac0e6 100644 --- a/frontend/apply/src/app/i18n/locales/sv.json +++ b/frontend/apply/src/app/i18n/locales/sv.json @@ -64,7 +64,7 @@ "moreInfoWebsite": "hemsidan", "moreInfoUrl": "https://utn.se/engagera-dig/att-engagera-sig", "moreInfoContinuationText": ", där vi också har svar på vanliga frågor om att engagera sig.", - "contactText": "Om du undrar något kan du också maila till" + "contactText": "Om du undrar något kan du också mejla till" }, "loginPage": { "loginTitle": "Logga in", @@ -81,6 +81,18 @@ "emailPlaceholder": "din.epost@exempel.se", "passwordPlaceholder": "Ange ditt lösenord" }, + "verifyEmailPage": { + "title": "Verifiera din e-post", + "instructions": "Ange den sex tecken långa koden du fick till din e-post för att verifiera din e-postaddress.", + "verificationCode": "Verifieringskod", + "codeInvalid": "Ange en giltig kod med 6 tecken.", + "invalidOrExpired": "Verifieringskoden är ogiltig eller har gått ut.", + "error": "Kunde inte verifiera din e-post. Försök igen.", + "verifyButton": "Verifiera", + "resendButton": "Skicka verifieringsmejl igen", + "resendSuccess": "Verifieringsmejl skickades.", + "resendError": "Misslyckades med att skicka verifieringsmejl igen. Försök igen." + }, "logoutPage": { "doYouWantToLogOut": "Vill du logga ut?", "yesLogOut": "Ja, logga ut", @@ -174,6 +186,46 @@ "applicationSubmittedSuccess": "Din ansökan har skickats in.", "submitApplicationConfirmation": "Är du säker på att du vill skicka in denna ansökan? När den väl är inskickad kommer du inte kunna redigera den längre." }, + "registerPage": { + "registerTitle": "Registrera", + "username": "Användarnamn", + "usernamePlaceholder": "Välj ett användarnamn", + "usernameRequired": "Användarnamn krävs", + "usernameTooShort": "Användarnamnet måste vara minst 3 tecken", + "email": "E-post", + "emailPlaceholder": "din.epost@exempel.se", + "emailRequired": "E-post krävs", + "emailInvalid": "Ange en giltig e-postadress", + "password": "Lösenord", + "passwordPlaceholder": "Ange ett lösenord", + "passwordRequired": "Lösenord krävs", + "passwordTooShort": "Lösenordet måste vara minst 10 tecken", + "passwordNeedsUppercase": "Lösenordet måste innehålla minst en stor bokstav", + "passwordNeedsNumber": "Lösenordet måste innehålla minst en siffra", + "passwordConfirmation": "Bekräfta lösenord", + "passwordConfirmationPlaceholder": "Bekräfta ditt lösenord", + "passwordConfirmationRequired": "Bekräftelse av lösenord krävs", + "personalIdentityNumber": "Personnummer", + "PersonNumberPlaceholder": "ÅÅÅÅMMDD-XXXX", + "PersonNumberRequired": "Personnummer krävs", + "PersonNumberInvalid": "Ange ett giltigt personnummer (ÅÅÅÅMMDD-XXXX)", + "section": "Sektion", + "sectionPlaceholder": "Välj din sektion", + "selectSection": "Välj en sektion", + "selectProgram": "Välj ett program", + "selectSectionFirst": "Välj en sektion för att se dess program", + "sectionRequired": "Sektion krävs", + "program": "Program", + "programRequired": "Välj ett program", + "phoneNumber": "Telefonnummer", + "phoneNumberPlaceholder": "+46 12 345 67 89", + "phoneNumberRequired": "Telefonnummer krävs", + "phoneNumberInvalid": "Ange ett giltigt telefonnummer", + "registerAccount": "Skapa konto", + "registerError": "Det gick inte att skapa kontot. Kontrollera dina uppgifter och försök igen.", + "networkError": "Nätverksfel. Försök igen.", + "passwordsDoNotMatch": "Lösenorden stämmer inte överens" + }, "forgotPasswordPage": { "title": "Glömt lösenord", "description": "Skriv din mejl så skickar vi en återställningslänk.", diff --git a/frontend/apply/src/app/login/page.tsx b/frontend/apply/src/app/login/page.tsx index 1adcdf3..5fd910e 100644 --- a/frontend/apply/src/app/login/page.tsx +++ b/frontend/apply/src/app/login/page.tsx @@ -1,26 +1,33 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import TextInput from "@/components/TextInput"; import Button from "@/components/Button"; import styles from "./login.module.css"; -import { logIn } from "@/utils/auth"; +import { logIn, useIsLoggedIn } from "@/utils/auth"; import { useTranslation } from "react-i18next"; import "@/i18n/config"; export default function Login() { const { t } = useTranslation(); const router = useRouter(); + const { isLoggedIn, loading } = useIsLoggedIn(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); + const [loadingForm, setLoadingForm] = useState(false); + + useEffect(() => { + if (!loading && isLoggedIn) { + router.push("/"); + } + }, [isLoggedIn, loading, router]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); - setLoading(true); + setLoadingForm(true); try { const response = await logIn(email, password); @@ -39,7 +46,7 @@ export default function Login() { } catch { setError(t("loginPage.networkError")); } finally { - setLoading(false); + setLoadingForm(false); } }; @@ -83,8 +90,8 @@ export default function Login() { + + {t("navbar.login")} + + + + + ); +} diff --git a/frontend/apply/src/app/register/register.module.css b/frontend/apply/src/app/register/register.module.css new file mode 100644 index 0000000..7a74a2e --- /dev/null +++ b/frontend/apply/src/app/register/register.module.css @@ -0,0 +1,102 @@ +.loginContainer { + display: flex; + justify-content: end; + align-items: center; + min-height: calc(100vh - 100px); + padding: 24px 128px 24px 0; + background: url("/Uppsala_University_2023.jpg"); + background-size: cover; +} + +.loginCard { + background: white; + border-radius: 12px; + padding: 3rem; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); + max-width: 870px; + width: 100%; +} + +.title { + font-size: 2rem; + font-weight: 700; + color: #1a202c; + line-height: 16px; +} + +h1.title::after { + margin-bottom: 16px; +} + +.form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 1.5rem; +} + +.formFullWidth { + grid-column: 1 / -1; +} + +.errorMessage { + background-color: #fee; + border: 1px solid #fcc; + color: #c33; + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + text-align: center; + margin-top: 1rem; +} + +.links { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 1.5rem; + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid #e2e8f0; +} + +.link { + color: #667eea; + text-decoration: none; + font-size: 0.9rem; + text-align: center; + padding-left: 2rem; + transition: color 0.2s ease; +} + +.link:hover { + color: #764ba2; + text-decoration: underline; +} + +@media (max-width: 1000px) { + .loginContainer { + padding-right: 48px; + } + .loginCard { + max-width: 500px; + } + .form { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .loginContainer { + justify-content: center; + padding: 24px 0; + } + + .loginCard { + padding: 2rem; + } + + .title { + font-size: 1.75rem; + } +} \ No newline at end of file diff --git a/frontend/apply/src/app/reset-password/page.tsx b/frontend/apply/src/app/reset-password/page.tsx index befd469..a0891d5 100644 --- a/frontend/apply/src/app/reset-password/page.tsx +++ b/frontend/apply/src/app/reset-password/page.tsx @@ -56,7 +56,10 @@ function ResetPasswordForm() { if (res.ok) { router.push("/login?reset=success"); } else { - setError(t("resetPasswordPage.invalidOrExpired")); + const errorData = await res.json().catch(() => ({})); + setError( + errorData?.message || t("resetPasswordPage.invalidOrExpired"), + ); } } catch { setError(t("resetPasswordPage.error")); diff --git a/frontend/apply/src/app/styles/textinput.css b/frontend/apply/src/app/styles/textinput.css index 8044d34..6334582 100644 --- a/frontend/apply/src/app/styles/textinput.css +++ b/frontend/apply/src/app/styles/textinput.css @@ -6,6 +6,10 @@ width: 100%; } +.text-input-container.error { + margin-top: 0; +} + .text-input-container .icon { display: flex; align-items: center; @@ -30,6 +34,7 @@ flex-direction: column; gap: 4px; flex: 1; + max-width: 100%; } .text-input-container .label { diff --git a/frontend/apply/src/app/utils/auth.ts b/frontend/apply/src/app/utils/auth.ts index 03f0d19..761d68d 100644 --- a/frontend/apply/src/app/utils/auth.ts +++ b/frontend/apply/src/app/utils/auth.ts @@ -78,18 +78,24 @@ export function logOut() { } /** - * Sends a sign-up request to the server with the provided email and password. + * Sends a sign-up request to the server with the provided registration data. * - * @param email - The email address of the user - * @param ssn - The national identification number of the user - * @param password - The password of the user + * @param payload - The registration payload * @returns A promise that resolves to the JSON response from the server * @returns status 201: When signup is successful * @returns status 400: When provided credentials are invalid. * @throws Error if the request fails */ -export function signUp(email: string, ssn: string, password: string) { - return request(Method.POST, URLs.SIGNUP, { email, ssn, password }); +export function signUp(payload: { + email: string; + ssn: string; + password: string; + name: string; + phone_number: string; + study_program_id?: string; + section_id?: string; +}) { + return request(Method.POST, URLs.SIGNUP, payload); } /** diff --git a/frontend/apply/src/app/verify-email/page.tsx b/frontend/apply/src/app/verify-email/page.tsx new file mode 100644 index 0000000..77241ef --- /dev/null +++ b/frontend/apply/src/app/verify-email/page.tsx @@ -0,0 +1,182 @@ +"use client"; +import { useEffect, useRef, useState, Suspense, type ChangeEvent, type FormEvent, type MouseEvent } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import styles from "./reset-password.module.css"; +import TextInput from "@/components/TextInput"; +import Button from "@/components/Button"; +import { request, Method } from "@/utils/request"; +import "@/i18n/config"; + +function VerifyEmailForm() { + const { t } = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + const codeFromQuery = searchParams.get("code"); + + const [verificationCode, setVerificationCode] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [resendLoading, setResendLoading] = useState(false); + const [resendMessage, setResendMessage] = useState(""); + const [resendError, setResendError] = useState(false); + const lastAutoSubmittedCode = useRef(null); + + const handleSubmit = async (e?: FormEvent, code?: string) => { + e?.preventDefault(); + setError(""); + + const normalizedCode = code || verificationCode.trim().toUpperCase(); + const isValidCode = /^[A-Z0-9]{6}$/.test(normalizedCode); + + if (!isValidCode) { + setError(t("verifyEmailPage.codeInvalid")); + return; + } + + setLoading(true); + + try { + const query = new URLSearchParams({ + code: normalizedCode, + }); + if (email) { + query.set("email", email); + } + + const res = await request( + Method.GET, + `/auth/verify-email?${query.toString()}`, + ); + + if (res.ok) { + window.dispatchEvent(new CustomEvent("logged-in")); + router.push("/"); + } else { + let message = ""; + try { + const data = await res.json(); + if (data && typeof data.message === "string") { + message = data.message; + } + } catch { + message = ""; + } + setError(message || t("verifyEmailPage.invalidOrExpired")); + } + } catch { + setError(t("verifyEmailPage.error")); + } finally { + setLoading(false); + } + }; + + const handleResend = async (e?: MouseEvent) => { + e?.preventDefault(); + + if (!email) { + setResendMessage(t("verifyEmailPage.resendError")); + setResendError(true); + return; + } + + setResendLoading(true); + setResendMessage(""); + + try { + const res = await request( + Method.POST, + "/auth/resend-verification-email", + { email } + ); + + if (res.ok) { + setResendMessage(t("verifyEmailPage.resendSuccess")); + setResendError(false); + } else { + setResendMessage(t("verifyEmailPage.resendError")); + setResendError(true); + } + } catch { + setResendMessage(t("verifyEmailPage.resendError")); + setResendError(true); + } finally { + setResendLoading(false); + } + }; + + useEffect(() => { + if (!codeFromQuery) { + return; + } + + const normalizedCode = codeFromQuery.trim().toUpperCase(); + if (lastAutoSubmittedCode.current === normalizedCode) { + return; + } + + const isValidCode = /^[A-Z0-9]{6}$/.test(normalizedCode); + if (!isValidCode) { + setError(t("verifyEmailPage.codeInvalid")); + return; + } + + lastAutoSubmittedCode.current = normalizedCode; + setVerificationCode(normalizedCode); + void handleSubmit(undefined, normalizedCode); + }, [codeFromQuery, t, handleSubmit]); + + return ( +
+
+

{t("verifyEmailPage.title")}

+

{t("verifyEmailPage.instructions")}

+ +
+ ) => + setVerificationCode(e.target.value.toUpperCase()) + } + required + /> + + {error &&

{error}

} + + + + +
+ + {t("verifyEmailPage.resendButton")} + + {resendMessage && ( +

+ {resendMessage} +

+ )} +
+
+
+ ); +} + +export default function VerifyEmailPage() { + return ( + + + + ); +} diff --git a/frontend/apply/src/app/verify-email/reset-password.module.css b/frontend/apply/src/app/verify-email/reset-password.module.css new file mode 100644 index 0000000..1d93619 --- /dev/null +++ b/frontend/apply/src/app/verify-email/reset-password.module.css @@ -0,0 +1,53 @@ +.container { + display: flex; + justify-content: center; + align-items: center; + min-height: calc(100vh - 100px); + padding: 20px; +} + +.card { + background: white; + border-radius: 12px; + border: 1px solid rgb(239, 239, 239); + padding: 40px; + width: 100%; + max-width: 450px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.errorMessage { + color: var(--error, #d32f2f); + font-size: 0.875rem; +} + +.links { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.link { + color: var(--text-accent); + font-size: 0.875rem; + text-decoration: none; +} + +.link:hover { + text-decoration: underline; +} \ No newline at end of file