Skip to content

Commit c798f73

Browse files
authored
Merge pull request #495 from amahuli03/auth-login-ux-token-refresh
[auth] Update login page, add token refresh, and fix password reset flow
2 parents 50fad9f + 8dd740a commit c798f73

7 files changed

Lines changed: 260 additions & 126 deletions

File tree

frontend/src/api/apiClient.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Conversation } from "../components/Header/Chat";
44
import {
55
V1_API_ENDPOINTS,
66
CONVERSATION_ENDPOINTS,
7+
AUTH_ENDPOINTS,
78
endpoints,
89
} from "./endpoints";
910

@@ -31,6 +32,70 @@ adminApi.interceptors.request.use(
3132
(error) => Promise.reject(error),
3233
);
3334

35+
// Response interceptor to handle token refresh on 401
36+
let isRefreshing = false;
37+
let failedQueue: { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }[] = [];
38+
39+
const processQueue = (error: unknown, token: string | null = null) => {
40+
failedQueue.forEach((prom) => {
41+
if (error) {
42+
prom.reject(error);
43+
} else {
44+
prom.resolve(token);
45+
}
46+
});
47+
failedQueue = [];
48+
};
49+
50+
adminApi.interceptors.response.use(
51+
(response) => response,
52+
async (error) => {
53+
const originalRequest = error.config;
54+
55+
if (error.response?.status === 401 && !originalRequest._retry) {
56+
if (isRefreshing) {
57+
return new Promise((resolve, reject) => {
58+
failedQueue.push({ resolve, reject });
59+
}).then((token) => {
60+
originalRequest.headers.Authorization = `JWT ${token}`;
61+
return adminApi(originalRequest);
62+
}).catch((err) => Promise.reject(err));
63+
}
64+
65+
originalRequest._retry = true;
66+
isRefreshing = true;
67+
68+
const refreshToken = localStorage.getItem("refresh");
69+
70+
if (!refreshToken) {
71+
localStorage.removeItem("access");
72+
localStorage.removeItem("refresh");
73+
window.location.href = "/login";
74+
return Promise.reject(error);
75+
}
76+
77+
try {
78+
const response = await axios.post(AUTH_ENDPOINTS.JWT_REFRESH, { refresh: refreshToken });
79+
const newAccessToken = response.data.access;
80+
localStorage.setItem("access", newAccessToken);
81+
processQueue(null, newAccessToken);
82+
originalRequest.headers.Authorization = `JWT ${newAccessToken}`;
83+
return adminApi(originalRequest);
84+
} catch (refreshError) {
85+
processQueue(refreshError, null);
86+
localStorage.removeItem("access");
87+
localStorage.removeItem("refresh");
88+
window.location.href = "/login";
89+
return Promise.reject(refreshError);
90+
} finally {
91+
isRefreshing = false;
92+
}
93+
}
94+
95+
return Promise.reject(error);
96+
},
97+
);
98+
3499
const handleSubmitFeedback = async (
35100
feedbackType: FormValues["feedbackType"],
36101
name: FormValues["name"],

frontend/src/api/endpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const AUTH_ENDPOINTS = {
2222
USERS_CREATE: `${API_BASE}/auth/users/`,
2323
USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`,
2424
USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`,
25+
JWT_REFRESH: `${API_BASE}/auth/jwt/refresh/`,
2526
} as const;
2627

2728
/**

frontend/src/components/Header/Header.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,14 @@ const Header: React.FC<LoginFormProps> = ({ isAuthenticated, isSuperuser }) => {
207207
Balancer
208208
</span>
209209
<Chat showChat={showChat} setShowChat={setShowChat} />
210-
{isAuthenticated && authLinks()}
210+
{isAuthenticated ? authLinks() : (
211+
<Link
212+
to="/login"
213+
className="font-satoshi flex cursor-pointer items-center text-black hover:text-blue-600"
214+
>
215+
Log In
216+
</Link>
217+
)}
211218
</div>
212219
<MdNavBar handleForm={handleForm} isAuthenticated={isAuthenticated} />
213220
</header>

frontend/src/components/Header/MdNavBar.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,25 @@ const MdNavBar = (props: LoginFormProps) => {
127127
Support Development
128128
</a>
129129
</li>
130-
{isAuthenticated &&
130+
{isAuthenticated ? (
131131
<li className="border-b border-gray-300 p-4">
132132
<Link
133-
to="/logout"
134-
className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline"
135-
>Sign Out
133+
to="/logout"
134+
className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline"
135+
>
136+
Sign Out
137+
</Link>
138+
</li>
139+
) : (
140+
<li className="border-b border-gray-300 p-4">
141+
<Link
142+
to="/login"
143+
className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline"
144+
>
145+
Log In
136146
</Link>
137147
</li>
138-
}
148+
)}
139149
</ul>
140150
</div>
141151
<Chat showChat={showChat} setShowChat={setShowChat}/>

frontend/src/pages/Login/LoginForm.tsx

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { RootState } from "../../services/actions/types";
66
import { useState, useEffect } from "react";
77
import ErrorMessage from "../../components/ErrorMessage";
88
import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner";
9-
import { FaExclamationTriangle } from "react-icons/fa";
109

1110
interface LoginFormProps {
1211
isAuthenticated: boolean | null;
@@ -60,19 +59,9 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
6059
className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12"
6160
>
6261
<div className="flex flex-col items-center justify-center">
63-
{/* {errorMessage && <div className="text-red-500">{errorMessage}</div>} */}
6462
<h2 className="blue_gradient mb-6 font-satoshi text-3xl font-bold text-gray-600">
65-
Welcome
63+
Log in
6664
</h2>
67-
68-
<blockquote className="p-4 mb-4 border-s-4 border-yellow-500 bg-amber-50 flex gap-5 items-center">
69-
<div className="mb-2 text-yellow-500">
70-
<FaExclamationTriangle size={24} />
71-
</div>
72-
<div>
73-
<p className="text-gray-800">This login is for Code for Philly administrators. Providers can use all site features without logging in. <Link to="/" className="underline hover:text-blue-600 hover:no-underline" style={{ 'whiteSpace': 'nowrap' }}>Return to Homepage</Link></p>
74-
</div>
75-
</blockquote>
7665
</div>
7766
<ErrorMessage errors={errors} />
7867
<div className="mb-4 mt-5">
@@ -113,18 +102,17 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
113102
Sign In
114103
</button>
115104
</div>
105+
<div className="mt-4 flex justify-between text-sm">
106+
<Link to="/register" className="text-blue-600 hover:underline">
107+
Don't have an account? Sign up
108+
</Link>
109+
<Link to="/resetPassword" className="text-blue-600 hover:underline">
110+
Forgot password?
111+
</Link>
112+
</div>
116113
</form>
117114
</section>
118-
{ loading && <LoadingSpinner /> }
119-
120-
{/* <p>
121-
Don't have an account?{" "}
122-
<Link to="/register" className="font-bold hover:text-blue-600">
123-
{" "}
124-
Register here
125-
</Link>
126-
.
127-
</p> */}
115+
{ loading && <LoadingSpinner /> }
128116
</>
129117
);
130118
}
Lines changed: 75 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useFormik } from "formik";
2-
import { useNavigate } from "react-router-dom";
2+
import { useNavigate, Link } from "react-router-dom";
33
import { reset_password, AppDispatch } from "../../services/actions/auth";
44
import { connect, useDispatch } from "react-redux";
55
import { RootState } from "../../services/actions/types";
66
import { useEffect, useState } from "react";
7+
import axios from "axios";
8+
import { AUTH_ENDPOINTS } from "../../api/endpoints";
79
import Layout from "../Layout/Layout";
810

911
interface ResetPasswordProps {
@@ -14,6 +16,8 @@ function ResetPassword(props: ResetPasswordProps) {
1416
const { isAuthenticated } = props;
1517
const dispatch = useDispatch<AppDispatch>();
1618
const [requestSent, setRequestSent] = useState(false);
19+
const [submittedEmail, setSubmittedEmail] = useState("");
20+
const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle");
1721

1822
const navigate = useNavigate();
1923

@@ -29,58 +33,92 @@ function ResetPassword(props: ResetPasswordProps) {
2933
},
3034
onSubmit: (values) => {
3135
dispatch(reset_password(values.email));
36+
setSubmittedEmail(values.email);
3237
setRequestSent(true);
3338
},
3439
});
3540

41+
const handleResend = async () => {
42+
try {
43+
await axios.post(AUTH_ENDPOINTS.RESET_PASSWORD, { email: submittedEmail });
44+
setResendStatus("sent");
45+
} catch {
46+
setResendStatus("error");
47+
}
48+
};
49+
3650
if (requestSent) {
37-
navigate("/");
38-
}
39-
return (
40-
<>
51+
return (
4152
<Layout>
42-
<section className="mx-auto mt-36 w-full max-w-xs">
43-
<h2 className="blue_gradient mb-6 font-satoshi text-xl font-bold text-gray-600">
44-
Reset Password
45-
</h2>
46-
<form
47-
onSubmit={handleSubmit}
48-
className="mb-4 rounded bg-white px-8 pb-8 pt-6 shadow-md"
49-
>
50-
<div className="mb-4">
51-
<label
52-
htmlFor="email"
53-
className="mb-2 block text-sm font-bold text-gray-700"
54-
>
55-
Email
56-
</label>
57-
<input
58-
id="login-email"
59-
name="email"
60-
type="email"
61-
onChange={handleChange}
62-
value={values.email}
63-
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
64-
/>
65-
</div>
66-
<div className="flex items-center justify-between">
67-
<button className="black_btn" type="submit">
68-
Reset Password
53+
<section className="mx-auto mt-24 w-[20rem] md:mt-48 md:w-[32rem] text-center">
54+
<div className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12">
55+
<h2 className="blue_gradient mb-4 font-satoshi text-3xl font-bold text-gray-600">
56+
Check your email
57+
</h2>
58+
<p className="text-gray-600 mb-6">
59+
If an account exists for <strong>{submittedEmail}</strong>, you'll receive a password reset link shortly.
60+
</p>
61+
<div className="flex flex-col gap-3">
62+
<Link to="/login" className="btnBlue w-full text-lg text-center block">
63+
Back to log in
64+
</Link>
65+
<button onClick={handleResend} className="text-sm text-blue-600 hover:underline" type="button">
66+
{resendStatus === "sent"
67+
? "Email resent!"
68+
: resendStatus === "error"
69+
? "Failed to resend. Try again."
70+
: "Resend email"}
6971
</button>
7072
</div>
71-
</form>
73+
</div>
7274
</section>
7375
</Layout>
74-
</>
76+
);
77+
}
78+
79+
return (
80+
<Layout>
81+
<section className="mx-auto mt-24 w-[20rem] md:mt-48 md:w-[32rem]">
82+
<form
83+
onSubmit={handleSubmit}
84+
className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12"
85+
>
86+
<h2 className="blue_gradient mb-6 font-satoshi text-3xl font-bold text-gray-600 text-center">
87+
Reset password
88+
</h2>
89+
<div className="mb-4">
90+
<label
91+
htmlFor="email"
92+
className="mb-2 block text-lg font-bold text-gray-700"
93+
>
94+
Email
95+
</label>
96+
<input
97+
id="login-email"
98+
name="email"
99+
type="email"
100+
onChange={handleChange}
101+
value={values.email}
102+
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-3 leading-tight text-gray-700 shadow focus:outline-none"
103+
/>
104+
</div>
105+
<button className="btnBlue w-full text-lg" type="submit">
106+
Send reset link
107+
</button>
108+
<div className="mt-4 text-center">
109+
<Link to="/login" className="text-sm text-blue-600 hover:underline">
110+
Back to log in
111+
</Link>
112+
</div>
113+
</form>
114+
</section>
115+
</Layout>
75116
);
76117
}
77118

78119
const mapStateToProps = (state: RootState) => ({
79120
isAuthenticated: state.auth.isAuthenticated,
80121
});
81122

82-
// Assign the connected component to a named constant
83123
const ConnectedResetPassword = connect(mapStateToProps)(ResetPassword);
84-
85-
// Export the named constant
86124
export default ConnectedResetPassword;

0 commit comments

Comments
 (0)