From bf2a6013c1359334cebe72f5174406c8d27b598f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 12:59:25 -0400 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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; /** From ab498c0846324e2ca8784c1e9173d01a18da27d6 Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Wed, 27 May 2026 12:37:55 -0400 Subject: [PATCH 5/6] Clean up CORS_ALLOWED_ORIGINS configuration Refactor CORS_ALLOWED_ORIGINS to remove empty strings. --- server/balancer_backend/settings.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 40422a99..15dfbfd2 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,14 +67,12 @@ ROOT_URLCONF = "balancer_backend.urls" -<<<<<<< auth-login-ux-token-refresh -CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") -======= + # CORS configuration CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") # Ensure no empty strings if input was empty or trailing comma CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] ->>>>>>> develop + TEMPLATES = [ { From 8dd740a93cf8d92ddd6621b81243383b96ee2ffe Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Wed, 27 May 2026 12:38:47 -0400 Subject: [PATCH 6/6] Clean up CORS_ALLOWED_ORIGINS configuration Refactor CORS_ALLOWED_ORIGINS to remove empty strings. --- server/balancer_backend/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 15dfbfd2..7c2c9e67 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,13 +67,11 @@ ROOT_URLCONF = "balancer_backend.urls" - # CORS configuration CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") # Ensure no empty strings if input was empty or trailing comma CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] - TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates",