From 2454c020bfa69b562333c406b9f51975d1657163 Mon Sep 17 00:00:00 2001 From: Amine Date: Thu, 23 Apr 2026 18:54:38 +0200 Subject: [PATCH 1/6] Add register page --- .gitignore | 2 + .../apply/src/app/register/login.module.css | 96 ++++++++++ frontend/apply/src/app/register/page.tsx | 171 ++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 frontend/apply/src/app/register/login.module.css create mode 100644 frontend/apply/src/app/register/page.tsx 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/frontend/apply/src/app/register/login.module.css b/frontend/apply/src/app/register/login.module.css new file mode 100644 index 0000000..d577fd8 --- /dev/null +++ b/frontend/apply/src/app/register/login.module.css @@ -0,0 +1,96 @@ +.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; +} + +.form { + display: grid; + grid-template-columns: 1fr 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; +} + +.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: 5rem; + 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/register/page.tsx b/frontend/apply/src/app/register/page.tsx new file mode 100644 index 0000000..e0159ec --- /dev/null +++ b/frontend/apply/src/app/register/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import TextInput from "@/components/TextInput"; +import styles from "../register/login.module.css"; +import { useTranslation } from "react-i18next"; +import "@/i18n/config"; + +export default function Signup() { + const { t } = useTranslation(); + const router = useRouter(); + + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + + const [personalIdentityNumber, setPersonalIdentityNumber] = useState(""); + const [sectionValue, setSectionValue] = useState(""); // placeholder until dropdown/select + const [phoneNumberValue, setPhoneNumberValue] = useState(""); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + + if (name === "username") setUsername(value); + if (name === "email") setEmail(value); + + if (name === "password") setPassword(value); + if (name === "passwordConfirmation") setPasswordConfirmation(value); + + if (name === "personalIdentityNumber") setPersonalIdentityNumber(value); + if (name === "section") setSectionValue(value); + if (name === "phoneNumber") setPhoneNumberValue(value); + }; + + // you said ignore handleSubmit logic for now + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + // minimal client-side check + if (password !== passwordConfirmation) { + setError(t("passwordsDoNotMatch")); + setLoading(false); + return; + } + + // TODO: connect real register API later + setLoading(false); + // router.push("/account"); // example + }; + + return ( +
+
+

{t("registerTitle")}

+ +
+ + + + + + + + + + + {/* Placeholder until dropdown/select */} + + + + + {error &&
{error}
} + + + +
+ + + {t("login")} + +
+
+ +
+ ); +} \ No newline at end of file From b867fca597705e5e9653b54327ed8201cf1f8f56 Mon Sep 17 00:00:00 2001 From: Ebin Bellini Date: Sun, 3 May 2026 12:17:07 +0200 Subject: [PATCH 2/6] Add /register translations - Add translations for the register account page - Add loading state to button - Fix link to register page - Removed unused router --- frontend/apply/src/app/components/Button.tsx | 2 +- frontend/apply/src/app/i18n/locales/en.json | 25 +++++++++ frontend/apply/src/app/i18n/locales/sv.json | 25 +++++++++ frontend/apply/src/app/login/page.tsx | 2 +- frontend/apply/src/app/register/page.tsx | 56 ++++++++++---------- 5 files changed, 80 insertions(+), 30 deletions(-) diff --git a/frontend/apply/src/app/components/Button.tsx b/frontend/apply/src/app/components/Button.tsx index d3a28fe..a72fc31 100644 --- a/frontend/apply/src/app/components/Button.tsx +++ b/frontend/apply/src/app/components/Button.tsx @@ -39,7 +39,7 @@ export default function Button({ ref={button} disabled={disabled} > - {buttonWidth && ( + {buttonWidth !== 0 && (
- + {t("loginPage.noAccount")} diff --git a/frontend/apply/src/app/register/page.tsx b/frontend/apply/src/app/register/page.tsx index e0159ec..03cb746 100644 --- a/frontend/apply/src/app/register/page.tsx +++ b/frontend/apply/src/app/register/page.tsx @@ -1,15 +1,14 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; import TextInput from "@/components/TextInput"; import styles from "../register/login.module.css"; import { useTranslation } from "react-i18next"; import "@/i18n/config"; +import Button from "@/components/Button"; export default function Signup() { const { t } = useTranslation(); - const router = useRouter(); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); @@ -59,68 +58,68 @@ export default function Signup() { return (
From 9114c4d3da3db78b634786fad003061325b96995 Mon Sep 17 00:00:00 2001 From: Amine Date: Tue, 12 May 2026 18:52:48 +0200 Subject: [PATCH 3/6] Adding translation and missing error messages - Fixing ppositioning of the error message --- frontend/apply/src/app/i18n/locales/en.json | 93 +++++++++++----- frontend/apply/src/app/i18n/locales/sv.json | 15 ++- frontend/apply/src/app/register/page.tsx | 112 +++++++++++++------- 3 files changed, 155 insertions(+), 65 deletions(-) diff --git a/frontend/apply/src/app/i18n/locales/en.json b/frontend/apply/src/app/i18n/locales/en.json index 9c82781..3c344d9 100644 --- a/frontend/apply/src/app/i18n/locales/en.json +++ b/frontend/apply/src/app/i18n/locales/en.json @@ -89,11 +89,48 @@ "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 8 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", + "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" +}, +"passwordsDoNotMatch": "Passwords do not match", "applyPage": { "failedToLoadPosition": "Failed to load position", "failedToLoadApplication": "Failed to load application", "positionNotFound": "Position not found", - "applicationNotFound": "Application not found" + "applicationNotFound": "Application not found", + "loginRequiredMessage": "Log in to apply for this position", + "goToLogin": "Go to login" }, "accountPage": { "accountTitle": "Account", @@ -125,7 +162,10 @@ "newPassword": "New Password", "confirmNewPassword": "Confirm New Password", "passwordsDoNotMatch": "New passwords do not match", - "passwordTooShort": "Password must be at least 8 characters", + "passwordCannotBeSame": "New password cannot be the same as current password", + "passwordTooShort": "Password must be at least 10 characters", + "passwordMustContainNumber": "Password must contain at least one number", + "passwordMustContainSpecialCharacter": "Password must contain at least one special character", "passwordChanged": "Password changed successfully! You will now be logged out.", "passwordChangeError": "Failed to change password. Please check your current password.", "deleteAccount": "Delete Account", @@ -144,6 +184,9 @@ "failedToDeleteApplication": "Failed to delete application", "teamLabel": "Team", "applicationDeadline": "Application Deadline", + "applicationDeadlineInDays_one": "in {{count}} day", + "applicationDeadlineInDays_other": "in {{count}} days", + "deadlineToday": "today", "viewRoleDescription": "View full role description", "contactEmail": "Contact email", "coverLetterTitle": "Cover letter", @@ -168,29 +211,25 @@ "applicationSubmittedSuccess": "Your application has been submitted.", "submitApplicationConfirmation": "Are you sure you want to submit this application? Once submitted, you will not be able to edit it anymore." }, - "registerPage": { - "registerTitle": "Register", - "username": "Username", - "usernamePlaceholder": "Choose a username", - "usernameRequired": "Username is required", - "email": "Email", - "emailPlaceholder": "your.email@example.com", - "emailRequired": "Email is required", - "password": "Password", - "passwordPlaceholder": "Enter a password", - "passwordRequired": "Password is required", - "passwordConfirmation": "Confirm password", - "passwordConfirmationPlaceholder": "Confirm your password", - "passwordConfirmationRequired": "Password confirmation is required", - "personalIdentityNumber": "Personal identity number", - "PersonNumberPlaceholder": "YYYYMMDD-XXXX", - "PersonNumberRequired": "Personal identity number is required", - "section": "Section", - "sectionPlaceholder": "Select your section", - "sectionRequired": "Section is required", - "phoneNumber": "Phone number", - "phoneNumberPlaceholder": "+46 12 345 67 89", - "phoneNumberRequired": "Phone number is required", - "registerAccount": "Create account" + "forgotPasswordPage": { + "title": "Forgot password", + "description": "Enter your email and we'll send you a reset link.", + "submit": "Send reset link", + "link": "Forgot your password?", + "checkEmail": "Check your email", + "emailSent": "If an account exists for that email, a reset link has been sent.", + "backToLogin": "Back to login", + "error": "Something went wrong. Please try again." + }, + "resetPasswordPage": { + "title": "Set new password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "submit": "Reset password", + "passwordMismatch": "Passwords do not match.", + "invalidOrExpired": "This link is invalid or has expired.", + "invalidLink": "Invalid reset link.", + "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 8791eaa..e28f475 100644 --- a/frontend/apply/src/app/i18n/locales/sv.json +++ b/frontend/apply/src/app/i18n/locales/sv.json @@ -173,24 +173,35 @@ "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 8 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", "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" - } -} + }, + "passwordsDoNotMatch": "Lösenorden stämmer inte överens" + +} \ No newline at end of file diff --git a/frontend/apply/src/app/register/page.tsx b/frontend/apply/src/app/register/page.tsx index 03cb746..3c30297 100644 --- a/frontend/apply/src/app/register/page.tsx +++ b/frontend/apply/src/app/register/page.tsx @@ -12,15 +12,13 @@ export default function Signup() { const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState(""); - const [personalIdentityNumber, setPersonalIdentityNumber] = useState(""); - const [sectionValue, setSectionValue] = useState(""); // placeholder until dropdown/select + const [sectionValue, setSectionValue] = useState(""); const [phoneNumberValue, setPhoneNumberValue] = useState(""); - const [error, setError] = useState(""); + const [errors, setErrors] = useState>({}); const [loading, setLoading] = useState(false); const handleChange = (e: React.ChangeEvent) => { @@ -28,31 +26,86 @@ export default function Signup() { if (name === "username") setUsername(value); if (name === "email") setEmail(value); - if (name === "password") setPassword(value); if (name === "passwordConfirmation") setPasswordConfirmation(value); - if (name === "personalIdentityNumber") setPersonalIdentityNumber(value); if (name === "section") setSectionValue(value); if (name === "phoneNumber") setPhoneNumberValue(value); + + // Clear field error on change + setErrors((prev) => ({ ...prev, [name]: "" })); }; - // you said ignore handleSubmit logic for now - const handleSubmit = async (e: React.FormEvent) => { + const validate = (): Record => { + const newErrors: Record = {}; + + if (!username.trim()) { + newErrors.username = t("registerPage.usernameRequired"); + } else if (username.trim().length < 3) { + newErrors.username = t("registerPage.usernameTooShort"); // "Username must be at least 3 characters" + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!email.trim()) { + newErrors.email = t("registerPage.emailRequired"); + } else if (!emailRegex.test(email)) { + newErrors.email = t("registerPage.emailInvalid"); // "Please enter a valid email address" + } + + if (!password) { + newErrors.password = t("registerPage.passwordRequired"); + } else if (password.length < 8) { + newErrors.password = t("registerPage.passwordTooShort"); // "Password must be at least 8 characters" + } else if (!/[A-Z]/.test(password)) { + newErrors.password = t("registerPage.passwordNeedsUppercase"); // "Password must contain at least one uppercase letter" + } else if (!/[0-9]/.test(password)) { + newErrors.password = t("registerPage.passwordNeedsNumber"); // "Password must contain at least one number" + } + + if (!passwordConfirmation) { + newErrors.passwordConfirmation = t("registerPage.passwordConfirmationRequired"); + } else if (password !== passwordConfirmation) { + newErrors.passwordConfirmation = t("passwordsDoNotMatch"); // "Passwords do not match" + } + + // Swedish personal identity number: YYYYMMDD-XXXX or YYYYMMDDXXXX + const pinRegex = /^\d{8}[-]?\d{4}$/; + if (!personalIdentityNumber.trim()) { + newErrors.personalIdentityNumber = t("registerPage.PersonNumberRequired"); + } else if (!pinRegex.test(personalIdentityNumber.trim())) { + newErrors.personalIdentityNumber = t("registerPage.PersonNumberInvalid"); // "Enter a valid personal identity number (YYYYMMDD-XXXX)" + } + + // Section + if (!sectionValue.trim()) { + newErrors.section = t("registerPage.sectionRequired"); + } + + // Phone number: allows +, spaces, dashes, digits; min 7 digits + const phoneRegex = /^[+\d][\d\s\-()]{6,}$/; + if (!phoneNumberValue.trim()) { + newErrors.phoneNumber = t("registerPage.phoneNumberRequired"); + } else if (!phoneRegex.test(phoneNumberValue.trim())) { + newErrors.phoneNumber = t("registerPage.phoneNumberInvalid"); // "Enter a valid phone number" + } + + return newErrors; + }; + + const handleSubmit = async (e: React.FormEvent | React.MouseEvent) => { e.preventDefault(); - setError(""); - setLoading(true); - // minimal client-side check - if (password !== passwordConfirmation) { - setError(t("passwordsDoNotMatch")); - setLoading(false); + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); return; } - // TODO: connect real register API later + setErrors({}); + setLoading(true); + + // TODO: Register API setLoading(false); - // router.push("/account"); // example }; return ( @@ -69,7 +122,7 @@ export default function Signup() { name="username" type="text" placeholder={t("registerPage.usernamePlaceholder")} - error={error && username === "" ? t("registerPage.usernameRequired") : ""} + error={errors.username} /> - {/* Placeholder until dropdown/select */} - - {error &&
{error}
} -
@@ -165,7 +206,6 @@ export default function Signup() {
-
); } \ No newline at end of file From 639f4a4d445d46828452507511e23d4f00f15189 Mon Sep 17 00:00:00 2001 From: Amine Date: Tue, 12 May 2026 19:03:22 +0200 Subject: [PATCH 4/6] Fixing translation --- frontend/apply/src/app/i18n/locales/en.json | 4 ++-- frontend/apply/src/app/i18n/locales/sv.json | 7 ++++--- frontend/apply/src/app/register/page.tsx | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/apply/src/app/i18n/locales/en.json b/frontend/apply/src/app/i18n/locales/en.json index 3c344d9..5673daa 100644 --- a/frontend/apply/src/app/i18n/locales/en.json +++ b/frontend/apply/src/app/i18n/locales/en.json @@ -121,9 +121,9 @@ "phoneNumberPlaceholder": "Enter your phone number", "phoneNumberRequired": "Phone number is required", "phoneNumberInvalid": "Enter a valid phone number", - "registerAccount": "Create account" + "registerAccount": "Create account", + "passwordsDoNotMatch": "Passwords do not match" }, -"passwordsDoNotMatch": "Passwords do not match", "applyPage": { "failedToLoadPosition": "Failed to load position", "failedToLoadApplication": "Failed to load application", diff --git a/frontend/apply/src/app/i18n/locales/sv.json b/frontend/apply/src/app/i18n/locales/sv.json index e28f475..32594c3 100644 --- a/frontend/apply/src/app/i18n/locales/sv.json +++ b/frontend/apply/src/app/i18n/locales/sv.json @@ -200,8 +200,9 @@ "phoneNumberPlaceholder": "+46 12 345 67 89", "phoneNumberRequired": "Telefonnummer krävs", "phoneNumberInvalid": "Ange ett giltigt telefonnummer", - "registerAccount": "Skapa konto" - }, - "passwordsDoNotMatch": "Lösenorden stämmer inte överens" + "registerAccount": "Skapa konto", + "passwordsDoNotMatch": "Lösenorden stämmer inte överens" + + } } \ No newline at end of file diff --git a/frontend/apply/src/app/register/page.tsx b/frontend/apply/src/app/register/page.tsx index 3c30297..edfc4f9 100644 --- a/frontend/apply/src/app/register/page.tsx +++ b/frontend/apply/src/app/register/page.tsx @@ -65,7 +65,7 @@ export default function Signup() { if (!passwordConfirmation) { newErrors.passwordConfirmation = t("registerPage.passwordConfirmationRequired"); } else if (password !== passwordConfirmation) { - newErrors.passwordConfirmation = t("passwordsDoNotMatch"); // "Passwords do not match" + newErrors.passwordConfirmation = t("registerPage.passwordsDoNotMatch"); // "Passwords do not match" } // Swedish personal identity number: YYYYMMDD-XXXX or YYYYMMDDXXXX From ddb55001d606587fede47c242f0f4abb6e7ede57 Mon Sep 17 00:00:00 2001 From: Ebin Bellini Date: Fri, 15 May 2026 13:12:11 +0200 Subject: [PATCH 5/6] Registration and email verification - Add email verification flow after account registration with hashed codes, expiry, resend cooldown - New verify-email page - Add section and program selection and per-field errors to registration page - Disable editing email in account page since that would require re-verifying - Add MailHog support for local testing Note: make sure to copy the new lines in .env.example to your .env file --- .env.example | 16 +- .../0008_alter_member_is_superuser.py | 18 ++ .../0009_add_email_verification_code.py | 50 ++++ backend/backend/models.py | 27 ++- backend/backend/send_email.py | 64 ++++-- backend/backend/serializers.py | 35 ++- backend/backend/tests.py | 54 ++++- backend/backend/utils/validators.py | 4 +- backend/backend/views.py | 61 ++++- backend/config/settings.py | 14 +- docker-compose.yml | 9 + frontend/apply/src/app/account/page.tsx | 6 +- .../forgot-password.module.css | 2 +- frontend/apply/src/app/i18n/locales/en.json | 85 ++++--- frontend/apply/src/app/i18n/locales/sv.json | 26 ++- frontend/apply/src/app/login/page.tsx | 21 +- frontend/apply/src/app/register/page.tsx | 213 ++++++++++++++++-- .../{login.module.css => register.module.css} | 10 +- .../apply/src/app/reset-password/page.tsx | 5 +- frontend/apply/src/app/styles/textinput.css | 5 + frontend/apply/src/app/utils/auth.ts | 18 +- frontend/apply/src/app/verify-email/page.tsx | 182 +++++++++++++++ .../verify-email/reset-password.module.css | 53 +++++ 23 files changed, 864 insertions(+), 114 deletions(-) create mode 100644 backend/backend/migrations/0008_alter_member_is_superuser.py create mode 100644 backend/backend/migrations/0009_add_email_verification_code.py rename frontend/apply/src/app/register/{login.module.css => register.module.css} (90%) create mode 100644 frontend/apply/src/app/verify-email/page.tsx create mode 100644 frontend/apply/src/app/verify-email/reset-password.module.css 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/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 dd79478..e74fecc 100644 --- a/backend/backend/models.py +++ b/backend/backend/models.py @@ -70,6 +70,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, @@ -79,7 +102,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 f1097e9..d571a5d 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/account/page.tsx b/frontend/apply/src/app/account/page.tsx index 34cb3dc..6b12a07 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 5673daa..2b4d87c 100644 --- a/frontend/apply/src/app/i18n/locales/en.json +++ b/frontend/apply/src/app/i18n/locales/en.json @@ -82,6 +82,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", @@ -90,40 +102,45 @@ "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 8 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", - "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", - "passwordsDoNotMatch": "Passwords do not match" -}, + "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", diff --git a/frontend/apply/src/app/i18n/locales/sv.json b/frontend/apply/src/app/i18n/locales/sv.json index 9645719..fe77ecb 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", @@ -82,6 +82,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", @@ -189,7 +201,7 @@ "password": "Lösenord", "passwordPlaceholder": "Ange ett lösenord", "passwordRequired": "Lösenord krävs", - "passwordTooShort": "Lösenordet måste vara minst 8 tecken", + "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", @@ -201,6 +213,9 @@ "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", @@ -209,8 +224,10 @@ "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.", @@ -232,5 +249,4 @@ "requestNewLink": "Begär en ny länk", "error": "Något gick fel, var snäll prova igen." } - -} \ No newline at end of file +} diff --git a/frontend/apply/src/app/login/page.tsx b/frontend/apply/src/app/login/page.tsx index cd99568..712be05 100644 --- a/frontend/apply/src/app/login/page.tsx +++ b/frontend/apply/src/app/login/page.tsx @@ -1,25 +1,32 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import TextInput from "@/components/TextInput"; 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, setLoading] = 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); @@ -38,7 +45,7 @@ export default function Login() { } catch { setError(t("loginPage.networkError")); } finally { - setLoading(false); + setLoadingForm(false); } }; @@ -81,9 +88,9 @@ export default function Login() { diff --git a/frontend/apply/src/app/register/page.tsx b/frontend/apply/src/app/register/page.tsx index edfc4f9..19f5462 100644 --- a/frontend/apply/src/app/register/page.tsx +++ b/frontend/apply/src/app/register/page.tsx @@ -1,14 +1,35 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState, type ChangeEvent } from "react"; +import { useRouter } from "next/navigation"; import TextInput from "@/components/TextInput"; -import styles from "../register/login.module.css"; +import styles from "../register/register.module.css"; import { useTranslation } from "react-i18next"; import "@/i18n/config"; import Button from "@/components/Button"; +import { signUp } from "@/utils/auth"; +import { request, Method } from "@/utils/request"; + +interface Program { + id: string; + name_en: string; + name_sv: string; + name: string; + value: string; +} + +interface Section { + id: string; + abbreviation: string; + section_en: string; + section_sv: string; + name: string; + value: string; + programs: Array; +} export default function Signup() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); @@ -16,12 +37,18 @@ export default function Signup() { const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [personalIdentityNumber, setPersonalIdentityNumber] = useState(""); const [sectionValue, setSectionValue] = useState(""); + const [programValue, setProgramValue] = useState(""); const [phoneNumberValue, setPhoneNumberValue] = useState(""); + const [sections, setSections] = useState>([]); const [errors, setErrors] = useState>({}); + const [registerError, setRegisterError] = useState(""); const [loading, setLoading] = useState(false); + const router = useRouter(); - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: ChangeEvent, + ) => { const { name, value } = e.target; if (name === "username") setUsername(value); @@ -29,7 +56,13 @@ export default function Signup() { if (name === "password") setPassword(value); if (name === "passwordConfirmation") setPasswordConfirmation(value); if (name === "personalIdentityNumber") setPersonalIdentityNumber(value); - if (name === "section") setSectionValue(value); + if (name === "section") { + setSectionValue(value); + const section = sections.find((section) => section.id === value); + const firstProgram = section?.programs?.[0]?.id || ""; + setProgramValue(firstProgram); + } + if (name === "program") setProgramValue(value); if (name === "phoneNumber") setPhoneNumberValue(value); // Clear field error on change @@ -42,30 +75,32 @@ export default function Signup() { if (!username.trim()) { newErrors.username = t("registerPage.usernameRequired"); } else if (username.trim().length < 3) { - newErrors.username = t("registerPage.usernameTooShort"); // "Username must be at least 3 characters" + newErrors.username = t("registerPage.usernameTooShort"); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!email.trim()) { newErrors.email = t("registerPage.emailRequired"); } else if (!emailRegex.test(email)) { - newErrors.email = t("registerPage.emailInvalid"); // "Please enter a valid email address" + newErrors.email = t("registerPage.emailInvalid"); } if (!password) { newErrors.password = t("registerPage.passwordRequired"); - } else if (password.length < 8) { - newErrors.password = t("registerPage.passwordTooShort"); // "Password must be at least 8 characters" + } else if (password.length < 10) { + newErrors.password = t("registerPage.passwordTooShort"); } else if (!/[A-Z]/.test(password)) { - newErrors.password = t("registerPage.passwordNeedsUppercase"); // "Password must contain at least one uppercase letter" + newErrors.password = t("registerPage.passwordNeedsUppercase"); } else if (!/[0-9]/.test(password)) { - newErrors.password = t("registerPage.passwordNeedsNumber"); // "Password must contain at least one number" + newErrors.password = t("registerPage.passwordNeedsNumber"); } if (!passwordConfirmation) { - newErrors.passwordConfirmation = t("registerPage.passwordConfirmationRequired"); + newErrors.passwordConfirmation = t( + "registerPage.passwordConfirmationRequired", + ); } else if (password !== passwordConfirmation) { - newErrors.passwordConfirmation = t("registerPage.passwordsDoNotMatch"); // "Passwords do not match" + newErrors.passwordConfirmation = t("registerPage.passwordsDoNotMatch"); } // Swedish personal identity number: YYYYMMDD-XXXX or YYYYMMDDXXXX @@ -73,7 +108,7 @@ export default function Signup() { if (!personalIdentityNumber.trim()) { newErrors.personalIdentityNumber = t("registerPage.PersonNumberRequired"); } else if (!pinRegex.test(personalIdentityNumber.trim())) { - newErrors.personalIdentityNumber = t("registerPage.PersonNumberInvalid"); // "Enter a valid personal identity number (YYYYMMDD-XXXX)" + newErrors.personalIdentityNumber = t("registerPage.PersonNumberInvalid"); } // Section @@ -81,6 +116,11 @@ export default function Signup() { newErrors.section = t("registerPage.sectionRequired"); } + // Program + if (!programValue.trim()) { + newErrors.program = t("registerPage.programRequired"); + } + // Phone number: allows +, spaces, dashes, digits; min 7 digits const phoneRegex = /^[+\d][\d\s\-()]{6,}$/; if (!phoneNumberValue.trim()) { @@ -92,6 +132,48 @@ export default function Signup() { return newErrors; }; + const mapServerErrors = (data: unknown) => { + const fieldErrors: Record = {}; + let globalError = ""; + + const fieldMap: Record = { + name: "username", + email: "email", + password: "password", + phone_number: "phoneNumber", + personal_identity_number: "personalIdentityNumber", + ssn: "personalIdentityNumber", + section_id: "section", + study_program_id: "program", + section: "section", + program: "program", + }; + + if (typeof data === "string") { + globalError = data; + return { fieldErrors, globalError }; + } + + if (typeof data === "object" && data !== null) { + for (const [key, value] of Object.entries(data)) { + const text = Array.isArray(value) ? value.join(" ") : String(value); + const fieldName = fieldMap[key]; + + if (fieldName) { + fieldErrors[fieldName] = text; + } else if (key === "message" || key === "detail") { + globalError = text; + } else if (key === "non_field_errors") { + globalError = text; + } else if (text) { + globalError = globalError ? `${globalError} ${text}`.trim() : text; + } + } + } + + return { fieldErrors, globalError }; + }; + const handleSubmit = async (e: React.FormEvent | React.MouseEvent) => { e.preventDefault(); @@ -102,12 +184,65 @@ export default function Signup() { } setErrors({}); + setRegisterError(""); setLoading(true); - // TODO: Register API - setLoading(false); + try { + const response = await signUp({ + email, + ssn: personalIdentityNumber, + password, + name: username, + phone_number: phoneNumberValue, + study_program_id: programValue, + section_id: sectionValue, + }); + + if (response.status === 201) { + router.push(`/verify-email?email=${encodeURIComponent(email)}`); + return; + } + + const data = await response.json(); + const { fieldErrors, globalError } = mapServerErrors(data); + setErrors((prev) => ({ ...prev, ...fieldErrors })); + setRegisterError(globalError || t("registerPage.registerError")); + } catch { + setRegisterError(t("registerPage.networkError")); + } finally { + setLoading(false); + } }; + useEffect(() => { + const normalizeSections = (data: Section[]): Section[] => { + const isSwedish = i18n.language === "sv"; + return data.map((section) => ({ + ...section, + value: section.id, + name: isSwedish ? section.section_sv : section.section_en, + programs: section.programs.map((program) => ({ + ...program, + value: program.id, + name: isSwedish ? program.name_sv : program.name_en, + })), + })); + }; + + request(Method.GET, "/sections/").then(async (res) => { + if (res.ok) { + const data = (await res.json()) as Section[]; + setSections(normalizeSections(data)); + } else { + const err = await res.text(); + console.error("Failed to fetch sections", err); + } + }); + }, [i18n.language]); + + const programs_in_section = + sections.find((section) => section.id == sectionValue)?.programs || []; + return (
@@ -169,29 +304,57 @@ export default function Signup() { error={errors.personalIdentityNumber} /> + + ({ + value: program.id, + name: program.name, + })), + ] + } + error={errors.program} /> + {registerError && ( +
{registerError}
+ )} +
); -} \ No newline at end of file +} diff --git a/frontend/apply/src/app/register/login.module.css b/frontend/apply/src/app/register/register.module.css similarity index 90% rename from frontend/apply/src/app/register/login.module.css rename to frontend/apply/src/app/register/register.module.css index d577fd8..7a74a2e 100644 --- a/frontend/apply/src/app/register/login.module.css +++ b/frontend/apply/src/app/register/register.module.css @@ -21,11 +21,16 @@ font-size: 2rem; font-weight: 700; color: #1a202c; + line-height: 16px; +} + +h1.title::after { + margin-bottom: 16px; } .form { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 1.5rem; } @@ -41,6 +46,7 @@ border-radius: 6px; font-size: 0.9rem; text-align: center; + margin-top: 1rem; } .links { @@ -59,7 +65,7 @@ text-decoration: none; font-size: 0.9rem; text-align: center; - padding-left: 5rem; + padding-left: 2rem; transition: color 0.2s ease; } 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..c0b8fd2 --- /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]); + + 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 From 313eebff90b7566590ba5462f9b326ea2beeba54 Mon Sep 17 00:00:00 2001 From: Ebin Bellini Date: Fri, 15 May 2026 13:23:17 +0200 Subject: [PATCH 6/6] Fix eslint errors --- frontend/apply/src/app/about/page.tsx | 8 +++++++- frontend/apply/src/app/login/page.tsx | 2 +- frontend/apply/src/app/verify-email/page.tsx | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) 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/login/page.tsx b/frontend/apply/src/app/login/page.tsx index e0e4a50..5fd910e 100644 --- a/frontend/apply/src/app/login/page.tsx +++ b/frontend/apply/src/app/login/page.tsx @@ -16,7 +16,7 @@ export default function Login() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); - const [loadingForm, setLoading] = useState(false); + const [loadingForm, setLoadingForm] = useState(false); useEffect(() => { if (!loading && isLoggedIn) { diff --git a/frontend/apply/src/app/verify-email/page.tsx b/frontend/apply/src/app/verify-email/page.tsx index c0b8fd2..77241ef 100644 --- a/frontend/apply/src/app/verify-email/page.tsx +++ b/frontend/apply/src/app/verify-email/page.tsx @@ -125,7 +125,7 @@ function VerifyEmailForm() { lastAutoSubmittedCode.current = normalizedCode; setVerificationCode(normalizedCode); void handleSubmit(undefined, normalizedCode); - }, [codeFromQuery, t]); + }, [codeFromQuery, t, handleSubmit]); return (