From b4d70f53f1b03e212e9326d565df724dca9f45b9 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 14:25:16 -0400 Subject: [PATCH 01/12] fix isSuperuser hardcoded to true on page reload --- frontend/src/services/reducers/auth.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 769f3071..9cc5d278 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -68,12 +68,15 @@ const initialState: StateType = { export default function authReducer(state = initialState, action: ActionType): StateType { switch(action.type) { - case AUTHENTICATED_SUCCESS: + case AUTHENTICATED_SUCCESS: { + const token = localStorage.getItem('access'); + const decoded: TokenClaims = token ? jwtDecode(token) : { is_superuser: false }; return { ...state, isAuthenticated: true, - isSuperuser: true + isSuperuser: decoded.is_superuser } + } case LOGIN_SUCCESS: case GOOGLE_AUTH_SUCCESS: case FACEBOOK_AUTH_SUCCESS:{ From 23a045b3e9994eb8404128aa92e2be736d490777 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 14:53:22 -0400 Subject: [PATCH 02/12] add IsSuperuser permission class and apply to admin-only endpoints --- server/api/permissions.py | 6 ++++++ server/api/views/ai_settings/views.py | 4 ++-- server/api/views/listMeds/views.py | 3 +++ server/api/views/medRules/views.py | 4 ++-- server/api/views/text_extraction/views.py | 6 +++--- server/api/views/uploadFile/views.py | 7 ++++--- 6 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 server/api/permissions.py diff --git a/server/api/permissions.py b/server/api/permissions.py new file mode 100644 index 00000000..0dbe0597 --- /dev/null +++ b/server/api/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsSuperUser(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated and request.user.is_superuser) diff --git a/server/api/views/ai_settings/views.py b/server/api/views/ai_settings/views.py index 9ee6aad7..7f453200 100644 --- a/server/api/views/ai_settings/views.py +++ b/server/api/views/ai_settings/views.py @@ -1,6 +1,6 @@ from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from drf_spectacular.utils import extend_schema from .models import AI_Settings @@ -9,7 +9,7 @@ @extend_schema(request=AISettingsSerializer, responses={200: AISettingsSerializer(many=True), 201: AISettingsSerializer}) @api_view(['GET', 'POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsSuperUser]) def settings_view(request): if request.method == 'GET': settings = AI_Settings.objects.all() diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 1b199a7e..4321615d 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,5 +1,6 @@ from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema, inline_serializer @@ -127,6 +128,7 @@ class AddMedication(APIView): """ API endpoint to add a medication to the database with its risks and benefits. """ + permission_classes = [IsSuperUser] serializer_class = MedicationSerializer def post(self, request): @@ -158,6 +160,7 @@ class DeleteMedication(APIView): """ API endpoint to delete medication if medication in database. """ + permission_classes = [IsSuperUser] @extend_schema( request=inline_serializer(name='DeleteMedicationRequest', fields={ diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index 2f80f8f3..7e4ecae5 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -1,7 +1,7 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers +from api.permissions import IsSuperUser from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from drf_spectacular.utils import extend_schema, inline_serializer @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class MedRules(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = MedRuleSerializer def get(self, request, format=None): diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index 020740ad..35abe976 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -97,7 +97,7 @@ def anthropic_citations(client: anthropic.Client, user_prompt: str, content_chun @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ @@ -155,7 +155,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index eda43b76..6da092ce 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,5 +1,6 @@ from rest_framework.views import APIView -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers from rest_framework.generics import UpdateAPIView @@ -24,7 +25,7 @@ class UploadFileView(APIView): def get_permissions(self): if self.request.method == 'GET': return [AllowAny()] # Public access - return [IsAuthenticated()] # Auth required for other methods + return [IsSuperUser()] # Superuser required for write methods def get(self, request, format=None): print("UploadFileView, get list") @@ -217,7 +218,7 @@ def get(self, request, guid, format=None): class EditFileMetadataView(UpdateAPIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = UploadFileSerializer lookup_field = 'guid' From 21a42058624901963ca2bd037942d80a4f082080 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 15:13:21 -0400 Subject: [PATCH 03/12] add AdminRoute component and applly to admin-only pages --- .../components/ProtectedRoute/AdminRoute.tsx | 38 +++++++++++++++++++ frontend/src/routes/routes.tsx | 13 ++++--- 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ProtectedRoute/AdminRoute.tsx diff --git a/frontend/src/components/ProtectedRoute/AdminRoute.tsx b/frontend/src/components/ProtectedRoute/AdminRoute.tsx new file mode 100644 index 00000000..61195cb8 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/AdminRoute.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../services/actions/types'; +import { AppDispatch, checkAuthenticated } from '../../services/actions/auth'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; + +interface AdminRouteProps { + children: ReactNode; +} + +const AdminRoute = ({ children }: AdminRouteProps) => { + const location = useLocation(); + const dispatch = useDispatch(); + const { isAuthenticated, isSuperuser } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + if (isAuthenticated === null) { + dispatch(checkAuthenticated()); + } + }, [dispatch, isAuthenticated]); + + if (isAuthenticated === null) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (!isSuperuser) { + return ; + } + + return children; +}; + +export default AdminRoute; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index dc974e85..9dd99e97 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -19,6 +19,7 @@ import ListofFiles from "../pages/Files/ListOfFiles.tsx"; import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; +import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; const routes = [ { @@ -28,17 +29,17 @@ const routes = [ }, { path: "listoffiles", - element: , + element: , errorElement: , }, { path: "rulesmanager", - element: , + element: , errorElement: , }, { path: "uploadfile", - element: , + element: , }, { path: "drugSummary", @@ -86,11 +87,11 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -98,7 +99,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; From 8879f34d71eedccce2d467be39d4bf8d01fa9af2 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 15:35:51 -0400 Subject: [PATCH 04/12] lock down CORS to environment-driven allowlist (only dev right now) --- config/env/dev.env.example | 1 + server/balancer_backend/settings.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 4b40294b..59713f64 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -31,6 +31,7 @@ SQL_PORT=5432 # SQL_SSL_MODE=require LOGIN_REDIRECT_URL= +CORS_ALLOWED_ORIGINS=http://localhost:3000 OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..fdd31c10 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,7 +67,7 @@ ROOT_URLCONF = "balancer_backend.urls" -CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") TEMPLATES = [ { From 06ce32e5d43a33fa14d74a35f7dbec9a91fab879 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 13:52:52 -0400 Subject: [PATCH 05/12] configure console email backend for local development --- server/balancer_backend/settings.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index fdd31c10..1eb68d0c 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -139,12 +139,15 @@ "default": db_config, } -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" -EMAIL_PORT = 587 -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = True +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +else: + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = "smtp.gmail.com" + EMAIL_PORT = 587 + EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") + EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") + EMAIL_USE_TLS = True # Password validation From e53291515d1a46cd71a3f08da3f3dc96c4c5930f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 14:19:23 -0400 Subject: [PATCH 06/12] wire up signup form and email activation Redux actions --- frontend/src/api/endpoints.ts | 3 + frontend/src/services/actions/auth.tsx | 106 ++++++++++++------------- 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 3f8585f0..edc044b0 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -19,6 +19,9 @@ export const AUTH_ENDPOINTS = { USER_ME: `${API_BASE}/auth/users/me/`, RESET_PASSWORD: `${API_BASE}/auth/users/reset_password/`, RESET_PASSWORD_CONFIRM: `${API_BASE}/auth/users/reset_password_confirm/`, + USERS_CREATE: `${API_BASE}/auth/users/`, + USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, + USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, } as const; /** diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index a6a30ff3..43c95fd7 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -233,64 +233,58 @@ export const reset_password_confirm = } }; -// export const signup = -// (first_name, last_name, email, password, re_password) => -// async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; - -// const body = JSON.stringify({ -// first_name, -// last_name, -// email, -// password, -// re_password, -// }); - -// try { -// const res = await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/`, -// body, -// config -// ); +export const signup = + (first_name: string, last_name: string, email: string, password: string, re_password: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// dispatch({ -// type: SIGNUP_SUCCESS, -// payload: res.data, -// }); -// } catch (err) { -// dispatch({ -// type: SIGNUP_FAIL, -// }); -// } -// }; + const body = JSON.stringify({ first_name, last_name, email, password, re_password }); -// export const verify = -// (uid, token) => async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; + try { + const res = await axios.post(AUTH_ENDPOINTS.USERS_CREATE, body, config); + dispatch({ + type: SIGNUP_SUCCESS, + payload: res.data, + }); + } catch (err) { + let errorMessage = "Registration failed"; + if (isAxiosError(err) && err.response) { + const messages = Object.values(err.response.data as Record).flat(); + if (messages.length > 0) errorMessage = messages.join(" "); + } + dispatch({ + type: SIGNUP_FAIL, + payload: errorMessage, + }); + throw err; + } + }; -// const body = JSON.stringify({ uid, token }); +export const verify = + (uid: string, token: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// try { -// await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/activation/`, -// body, -// config -// ); + const body = JSON.stringify({ uid, token }); -// dispatch({ -// type: ACTIVATION_SUCCESS, -// }); -// } catch (err) { -// dispatch({ -// type: ACTIVATION_FAIL, -// }); -// } -// }; + try { + await axios.post(AUTH_ENDPOINTS.USERS_ACTIVATION, body, config); + dispatch({ + type: ACTIVATION_SUCCESS, + payload: "", + }); + } catch (err) { + dispatch({ + type: ACTIVATION_FAIL, + }); + throw err; + } + }; From 99ac0777f6bcbda8bca8f56c4c517746a9aeda76 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 14:37:27 -0400 Subject: [PATCH 07/12] build registration form with validation and email sent success state --- .../src/pages/Register/RegistrationForm.tsx | 256 ++++++++++++++---- 1 file changed, 198 insertions(+), 58 deletions(-) diff --git a/frontend/src/pages/Register/RegistrationForm.tsx b/frontend/src/pages/Register/RegistrationForm.tsx index c1745b3d..8134c521 100644 --- a/frontend/src/pages/Register/RegistrationForm.tsx +++ b/frontend/src/pages/Register/RegistrationForm.tsx @@ -1,71 +1,211 @@ import { useFormik } from "formik"; +import * as Yup from "yup"; import { Link } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { signup, AppDispatch } from "../../services/actions/auth"; +import { RootState } from "../../services/actions/types"; +import { useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; + +const validationSchema = Yup.object({ + first_name: Yup.string().required("First name is required"), + last_name: Yup.string().required("Last name is required"), + email: Yup.string().email("Enter a valid email").required("Email is required"), + password: Yup.string() + .min(8, "Password must be at least 8 characters") + .required("Password is required"), + re_password: Yup.string() + .oneOf([Yup.ref("password")], "Passwords must match") + .required("Please confirm your password"), +}); + +const RegistrationForm = () => { + const dispatch = useDispatch(); + const signupError = useSelector((state: RootState) => state.auth.error); + const [submitted, setSubmitted] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); + + const { handleSubmit, handleChange, handleBlur, values, errors, touched, isSubmitting } = + useFormik({ + initialValues: { + first_name: "", + last_name: "", + email: "", + password: "", + re_password: "", + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch(signup(values.first_name, values.last_name, values.email, values.password, values.re_password)); + setSubmittedEmail(values.email); + setSubmitted(true); + } catch { + // error is stored in Redux state and displayed via signupError + } finally { + setSubmitting(false); + } + }, + }); + + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.USERS_RESEND_ACTIVATION, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + + if (submitted) { + return ( +
+
+

+ Check your email +

+

+ We sent an activation link to {submittedEmail}. Click the link to activate your account. +

+
+ + Go to log in + + +
+
+
+ ); + } -const LoginForm = () => { - const { handleSubmit, handleChange, values } = useFormik({ - initialValues: { - email: "", - password: "", - }, - onSubmit: (values) => { - console.log("values", values); - // make registration post request here. - }, - }); return ( - <> -
-

- Register +
+
+

+ Create account

- -
- - -
-
- - -
- -
-
-

+ {signupError && ( +

{signupError}

+ )} + +
+ + + {touched.first_name && errors.first_name && ( +

{errors.first_name}

+ )} +
+ +
+ + + {touched.last_name && errors.last_name && ( +

{errors.last_name}

+ )} +
+ +
+ + + {touched.email && errors.email && ( +

{errors.email}

+ )} +
+ +
+ + + {touched.password && errors.password && ( +

{errors.password}

+ )} +
+ +
+ + + {touched.re_password && errors.re_password && ( +

{errors.re_password}

+ )} +
+ + + +

Already have an account?{" "} - {" "} - Login here. + Log in

- +

); }; -export default LoginForm; +export default RegistrationForm; From 3d64d76aba32c197510bd59900d838941697336c Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 15:30:52 -0400 Subject: [PATCH 08/12] build email activation page and add route --- config/env/dev.env.example | 2 + frontend/src/pages/Activate/Activate.tsx | 76 ++++++++++++++++++++++++ frontend/src/routes/routes.tsx | 5 ++ server/balancer_backend/settings.py | 6 ++ 4 files changed, 89 insertions(+) create mode 100644 frontend/src/pages/Activate/Activate.tsx diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 59713f64..b8e195cf 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -32,6 +32,8 @@ SQL_PORT=5432 LOGIN_REDIRECT_URL= CORS_ALLOWED_ORIGINS=http://localhost:3000 +# Domain used by Djoser for activation and password reset email links (should be the frontend URL) +FRONTEND_DOMAIN=localhost:3000 OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= diff --git a/frontend/src/pages/Activate/Activate.tsx b/frontend/src/pages/Activate/Activate.tsx new file mode 100644 index 00000000..391ec04b --- /dev/null +++ b/frontend/src/pages/Activate/Activate.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { verify, AppDispatch } from "../../services/actions/auth"; +import Layout from "../Layout/Layout"; +import Spinner from "../../components/LoadingSpinner/LoadingSpinner"; + +const Activate = () => { + const { uid, token } = useParams<{ uid: string; token: string }>(); + const dispatch = useDispatch(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + + useEffect(() => { + if (!uid || !token) { + setStatus("error"); + return; + } + + (async () => { + try { + await dispatch(verify(uid, token)); + setStatus("success"); + } catch { + setStatus("error"); + } + })(); + }, [dispatch, uid, token]); + + if (status === "loading") { + return ( + + + + ); + } + + if (status === "error") { + return ( + +
+
+

+ Activation failed +

+

+ This activation link is invalid or has already been used. Please register again or request a new activation email. +

+ + Back to register + +
+
+
+ ); + } + + return ( + +
+
+

+ Email verified +

+

+ Your account has been activated. You can now log in. +

+ + Continue to log in + +
+
+
+ ); +}; + +export default Activate; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 9dd99e97..b94cb64f 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -20,6 +20,7 @@ import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; +import Activate from "../pages/Activate/Activate.tsx"; const routes = [ { @@ -49,6 +50,10 @@ const routes = [ path: "register", element: , }, + { + path: "activate/:uid/:token", + element: , + }, { path: "login", element: , diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 1eb68d0c..070ac581 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -221,6 +221,12 @@ "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } +# Domain used by Djoser to build activation and password reset links in emails. +# Should point to the frontend, not the backend, since the frontend handles these routes. +# Override in production via environment variable. +DOMAIN = os.environ.get("FRONTEND_DOMAIN", "localhost:3000") +SITE_NAME = "Balancer" + DJOSER = { "LOGIN_FIELD": "email", "USER_CREATE_PASSWORD_RETYPE": True, From bf2a6013c1359334cebe72f5174406c8d27b598f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 12:59:25 -0400 Subject: [PATCH 09/12] update login page for non-admin users login page no longer says "This login page is for Code for philly administrators" page now includes options to reset password and create an account --- frontend/src/pages/Login/LoginForm.tsx | 32 ++++++++------------------ 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index d0d08184..1d27aac5 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -6,7 +6,6 @@ import { RootState } from "../../services/actions/types"; import { useState, useEffect } from "react"; import ErrorMessage from "../../components/ErrorMessage"; import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; -import { FaExclamationTriangle } from "react-icons/fa"; interface LoginFormProps { isAuthenticated: boolean | null; @@ -60,19 +59,9 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12" >
- {/* {errorMessage &&
{errorMessage}
} */}

- Welcome + Log in

- -
-
- -
-
-

This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

-
-
@@ -113,18 +102,17 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { Sign In
+
+ + Don't have an account? Sign up + + + Forgot password? + +
- { loading && } - - {/*

- Don't have an account?{" "} - - {" "} - Register here - - . -

*/} + { loading && } ); } From f55c1c375e4da88fdb443b14f16433ff4d020dea Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:02:27 -0400 Subject: [PATCH 10/12] add success states to password reset flow --- frontend/src/pages/Login/ResetPassword.tsx | 112 ++++++++----- .../src/pages/Login/ResetPasswordConfirm.tsx | 147 ++++++++++-------- 2 files changed, 161 insertions(+), 98 deletions(-) diff --git a/frontend/src/pages/Login/ResetPassword.tsx b/frontend/src/pages/Login/ResetPassword.tsx index 61345aa8..34ffc44b 100644 --- a/frontend/src/pages/Login/ResetPassword.tsx +++ b/frontend/src/pages/Login/ResetPassword.tsx @@ -1,9 +1,11 @@ import { useFormik } from "formik"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import { reset_password, AppDispatch } from "../../services/actions/auth"; import { connect, useDispatch } from "react-redux"; import { RootState } from "../../services/actions/types"; import { useEffect, useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; import Layout from "../Layout/Layout"; interface ResetPasswordProps { @@ -14,6 +16,8 @@ function ResetPassword(props: ResetPasswordProps) { const { isAuthenticated } = props; const dispatch = useDispatch(); const [requestSent, setRequestSent] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); const navigate = useNavigate(); @@ -29,49 +33,86 @@ function ResetPassword(props: ResetPasswordProps) { }, onSubmit: (values) => { dispatch(reset_password(values.email)); + setSubmittedEmail(values.email); setRequestSent(true); }, }); + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.RESET_PASSWORD, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + if (requestSent) { - navigate("/"); - } - return ( - <> + return ( -
-

- Reset Password -

-
-
- - -
-
-
-
+
- + ); + } + + return ( + +
+
+

+ Reset password +

+
+ + +
+ +
+ + Back to log in + +
+
+
+
); } @@ -79,8 +120,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant const ConnectedResetPassword = connect(mapStateToProps)(ResetPassword); - -// Export the named constant export default ConnectedResetPassword; diff --git a/frontend/src/pages/Login/ResetPasswordConfirm.tsx b/frontend/src/pages/Login/ResetPasswordConfirm.tsx index 533669bb..80f36a63 100644 --- a/frontend/src/pages/Login/ResetPasswordConfirm.tsx +++ b/frontend/src/pages/Login/ResetPasswordConfirm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, Link } from "react-router-dom"; import { reset_password_confirm, AppDispatch, @@ -17,7 +17,8 @@ const ResetPasswordConfirm: React.FC = ({ isAuthenticated, }) => { const dispatch = useDispatch(); - const [requestSent, setRequestSent] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); const { uid, token } = useParams<{ uid: string; token: string }>(); const navigate = useNavigate(); @@ -33,66 +34,94 @@ const ResetPasswordConfirm: React.FC = ({ new_password: "", re_new_password: "", }, - onSubmit: (values) => { - dispatch( - reset_password_confirm( - uid!, - token!, - values.new_password, - values.re_new_password - ) - ); - setRequestSent(true); + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch( + reset_password_confirm( + uid!, + token!, + values.new_password, + values.re_new_password + ) + ); + setSuccess(true); + } catch { + setError("This reset link is invalid or has expired. Please request a new one."); + } finally { + setSubmitting(false); + } }, }); - if (requestSent) { - navigate("/"); - } - return ( - <> + if (success) { + return ( -
-

- Reset Password -

-
-
- - - -
-
- -
-
+
+
+

+ Password updated +

+

+ Your password has been reset. You can now log in with your new password. +

+ + Log in now + +
- + ); + } + + return ( + +
+
+

+ Set new password +

+ {error &&

{error}

} +
+ + +
+
+ + +
+ +
+
+
); }; @@ -100,9 +129,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant -const ConnectedResetPasswordConfirm = - connect(mapStateToProps)(ResetPasswordConfirm); - -// Export the named constant +const ConnectedResetPasswordConfirm = connect(mapStateToProps)(ResetPasswordConfirm); export default ConnectedResetPasswordConfirm; From fca027a30c7eeafa83371c5e2833fe9c2a882e89 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:10:01 -0400 Subject: [PATCH 11/12] add "Log In" button to header for unauthenticated users --- frontend/src/components/Header/Header.tsx | 9 ++++++++- frontend/src/components/Header/MdNavBar.tsx | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index c2fe3cfc..488920d8 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -207,7 +207,14 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Balancer - {isAuthenticated && authLinks()} + {isAuthenticated ? authLinks() : ( + + Log In + + )} diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index ccd06fcd..550b74d2 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -127,15 +127,25 @@ const MdNavBar = (props: LoginFormProps) => { Support Development - {isAuthenticated && + {isAuthenticated ? (
  • Sign Out + to="/logout" + className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline" + > + Sign Out + +
  • + ) : ( +
  • + + Log In
  • - } + )} From 52c9efb748377d73d2d4de77d7c8b8250d938c29 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:43:34 -0400 Subject: [PATCH 12/12] add token refresh interceptor to adminApi On 401 responses, attempts a silent token refresh using the refresh token from localStorage. On success, retries the original request. On failure (expired or missing refresh token), clears tokens and redirects to /login. Uses a queue to handle concurrent requests during refresh. --- frontend/src/api/apiClient.ts | 65 +++++++++++++++++++++++++++++++++++ frontend/src/api/endpoints.ts | 1 + 2 files changed, 66 insertions(+) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 856f78a9..545ce5d4 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -4,6 +4,7 @@ import { Conversation } from "../components/Header/Chat"; import { V1_API_ENDPOINTS, CONVERSATION_ENDPOINTS, + AUTH_ENDPOINTS, endpoints, } from "./endpoints"; @@ -31,6 +32,70 @@ adminApi.interceptors.request.use( (error) => Promise.reject(error), ); +// Response interceptor to handle token refresh on 401 +let isRefreshing = false; +let failedQueue: { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }[] = []; + +const processQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + +adminApi.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `JWT ${token}`; + return adminApi(originalRequest); + }).catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem("refresh"); + + if (!refreshToken) { + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(error); + } + + try { + const response = await axios.post(AUTH_ENDPOINTS.JWT_REFRESH, { refresh: refreshToken }); + const newAccessToken = response.data.access; + localStorage.setItem("access", newAccessToken); + processQueue(null, newAccessToken); + originalRequest.headers.Authorization = `JWT ${newAccessToken}`; + return adminApi(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + }, +); + const handleSubmitFeedback = async ( feedbackType: FormValues["feedbackType"], name: FormValues["name"], diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index edc044b0..8e43a239 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -22,6 +22,7 @@ export const AUTH_ENDPOINTS = { USERS_CREATE: `${API_BASE}/auth/users/`, USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, + JWT_REFRESH: `${API_BASE}/auth/jwt/refresh/`, } as const; /**